From 405b5d8288f0bfc53c42a99f848f32a1af1053a3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 18:36:00 -0700 Subject: [PATCH 01/10] meta(openspec): Plan project management tools Add the OpenSpec proposal, design, requirements, and task list for tightening project creation, splitting metadata updates from team access changes, and documenting the project-management capability. Co-Authored-By: GPT-5 Codex --- .../.openspec.yaml | 2 + .../refine-project-management-tools/design.md | 93 ++++++++++++++ .../proposal.md | 43 +++++++ .../specs/project-management/spec.md | 120 ++++++++++++++++++ .../refine-project-management-tools/tasks.md | 54 ++++++++ 5 files changed, 312 insertions(+) create mode 100644 openspec/changes/refine-project-management-tools/.openspec.yaml create mode 100644 openspec/changes/refine-project-management-tools/design.md create mode 100644 openspec/changes/refine-project-management-tools/proposal.md create mode 100644 openspec/changes/refine-project-management-tools/specs/project-management/spec.md create mode 100644 openspec/changes/refine-project-management-tools/tasks.md diff --git a/openspec/changes/refine-project-management-tools/.openspec.yaml b/openspec/changes/refine-project-management-tools/.openspec.yaml new file mode 100644 index 000000000..43e65ca6e --- /dev/null +++ b/openspec/changes/refine-project-management-tools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/refine-project-management-tools/design.md b/openspec/changes/refine-project-management-tools/design.md new file mode 100644 index 000000000..f691610dd --- /dev/null +++ b/openspec/changes/refine-project-management-tools/design.md @@ -0,0 +1,93 @@ +## Context + +The MCP catalog already includes `create_project`, `update_project`, `create_team`, and DSN management tools under the `project-management` skill. The current project tools need sharper boundaries: + +- `create_project` creates a project, creates an additional DSN, and optionally links a repository. +- `update_project` changes project metadata and can also grant a team access to the project. +- Project-scoped MCP sessions can still discover `create_project`, even though creating sibling projects is outside the active project constraint. + +Sentry's upstream project creation endpoint creates the project under a team and emits normal project creation side effects, including default key creation through Sentry's project post-save hook. Agents still need the DSN in the tool response because creating a Sentry project is usually followed immediately by SDK setup. + +## Goals / Non-Goals + +**Goals:** + +- Make project creation a safe, focused setup workflow that always returns a usable `SENTRY_DSN`. +- Keep project metadata updates separate from project team access changes. +- Add explicit tools for granting and revoking project team access. +- Preserve catalog-only exposure behind the `project-management` skill. +- Document project-management behavior in a durable repo spec. +- Add focused test coverage for success paths, guardrails, constraints, and generated definitions. + +**Non-Goals:** + +- Repository linking during project creation. +- Project deletion, transfer, ownership rule management, alert rule management, or broader project settings. +- Direct top-level MCP exposure for project-management tools. +- Changing Sentry's upstream API semantics. + +## Decisions + +### Keep `create_project` focused on project setup + +`create_project` will call Sentry's team project creation endpoint with only core fields: organization, team, name, optional slug, and optional platform. It will not expose the upstream `default_rules` flag in the first iteration; Sentry's default behavior remains active. + +Alternative considered: keep repository linking in `create_project`. This was rejected because repository linking has separate permissions, integration dependencies, and failure modes. Project creation should not partially succeed with unrelated VCS work hidden in the same tool. + +### Guarantee a DSN without creating duplicates in the normal path + +After project creation, the tool will call `listClientKeys` for the created project. If a key exists, it will return an existing DSN, preferring a key named `Default` when present. If no key exists, it will create a `Default` key and return that DSN. + +Alternative considered: always call `createClientKey` after project creation. This guarantees a DSN, but it creates duplicate keys in normal Sentry deployments where the default key already exists. + +### Make `update_project` metadata-only + +`update_project` will only update `name`, `slug`, and `platform`. Team access changes will move to separate tools. This keeps the tool's destructive annotation and user-facing description aligned with what it actually mutates. + +Alternative considered: keep team assignment as an optional field. This was rejected because changing teams changes access and rule ownership behavior, which is materially different from correcting a project name or platform. + +### Reject project-scoped slug changes + +When a session is constrained to a project, `update_project` will reject `slug` updates. A successful rename would leave the active session constrained to the old slug until reconnect, which makes follow-up tool calls ambiguous or likely to fail. + +Alternative considered: allow the update and report the new slug. This was rejected for the first iteration because the active MCP constraint is session state, not a mutable output from the tool call. + +### Add explicit team access tools + +`add_team_to_project` and `remove_team_from_project` will be separate catalog tools. Adding a team widens access; removing a team revokes access and can clear alert/rule team ownership upstream. The separate names make those effects visible to agents and users. + +Alternative considered: one `update_project_team_access` tool with an action enum. Separate tools are clearer for tool search, safety annotations, examples, and audit-focused responses. + +### Preflight team removal + +`remove_team_from_project` will fetch current project teams before deleting. It will reject removal when the target team is not assigned and when it is the only assigned team. + +Alternative considered: rely entirely on the backend DELETE. This was rejected because the backend path deletes the project-team row and clears ownership references without a last-team guard at that layer. + +### Keep tools catalog-only + +The project-management tools will remain discoverable through `search_sentry_tools` and executable through `execute_sentry_tool`, filtered by skills, scopes, and constraints. They will not be added to `TOP_LEVEL_TOOL_NAMES`. + +Alternative considered: expose `create_project` directly. This was rejected because project management is powerful, less frequent than inspection/search, and the direct surface has a strict tool budget. + +## Risks / Trade-offs + +- Duplicate DSN fallback could still create a key if key listing races with Sentry's default key hook. Mitigation: list first, create only when the list is empty, and keep the fallback explicit in tests. +- Last-team removal guard may reject a backend operation that Sentry technically allows. Mitigation: prefer safe MCP behavior; users can use Sentry directly for exceptional cases until a deliberate override is designed. +- Slug updates can make project-scoped sessions confusing after the mutation. Mitigation: document and test constraint behavior; consider rejecting slug changes in project-scoped sessions if implementation reveals ambiguous constraint state. +- Adding team access tools increases catalog size. Mitigation: keep them catalog-only and behind the existing `project-management` skill. + +## Migration Plan + +1. Update API client support for project creation slug and project team access reads/deletes. +2. Tighten `create_project` and `update_project` schemas and descriptions. +3. Add `add_team_to_project` and `remove_team_from_project`. +4. Add tests covering old behavior removal and new contracts. +5. Add `docs/specs/project-management.md` and link it from `docs/specs/README.md`. +6. Regenerate tool definitions. + +Rollback is straightforward: revert the catalog and API client changes. Existing users of `update_project(..., teamSlug=...)` will need to switch to `add_team_to_project`; this should be called out in the spec and release notes if applicable. + +## Open Questions + +None. diff --git a/openspec/changes/refine-project-management-tools/proposal.md b/openspec/changes/refine-project-management-tools/proposal.md new file mode 100644 index 000000000..a0d74e4f5 --- /dev/null +++ b/openspec/changes/refine-project-management-tools/proposal.md @@ -0,0 +1,43 @@ +## Why + +Project management is already partially exposed through catalog tools, but the current surface mixes unrelated side effects into broad operations. Creating a project must reliably return a usable DSN, while project metadata updates and team access changes need separate contracts so agents do not accidentally change access when renaming or re-platforming a project. + +## What Changes + +- Tighten `create_project` to focus on project creation and immediate SDK setup: + - Accept organization, team, project name, optional project slug, and optional platform. + - Return the created project identity and a usable `SENTRY_DSN`. + - Prefer the default DSN created by Sentry; create a `Default` DSN only as a fallback when no key exists. + - Remove repository linking from this tool. +- Tighten `update_project` to metadata-only updates: + - Allow project name, slug, and platform changes. + - Reject slug changes from project-scoped sessions so the active project constraint does not become stale. + - Remove team assignment from this tool. +- Add explicit team access tools: + - `add_team_to_project` grants an additional team access to a project. + - `remove_team_from_project` revokes a team from a project, with preflight guardrails. +- Keep all project-management operations catalog-only behind the `project-management` skill. +- Add durable project-management documentation under `docs/specs/project-management.md` and link it from the specs index. + +## Capabilities + +### New Capabilities + +- `project-management`: Defines safe project, DSN, and project team access management through MCP catalog tools. + +### Modified Capabilities + +None. + +## Impact + +- `packages/mcp-core/src/tools/catalog/create-project.ts` +- `packages/mcp-core/src/tools/catalog/update-project.ts` +- New catalog tools for adding and removing project teams +- `packages/mcp-core/src/tools/catalog/index.ts` +- `packages/mcp-core/src/api-client/client.ts` +- `packages/mcp-core/src/api-client/schema.ts` +- Catalog tool tests and mocks for project creation, metadata updates, team access changes, and constraints +- Generated definitions from `pnpm run --filter @sentry/mcp-core generate-definitions` +- `docs/specs/project-management.md` +- `docs/specs/README.md` diff --git a/openspec/changes/refine-project-management-tools/specs/project-management/spec.md b/openspec/changes/refine-project-management-tools/specs/project-management/spec.md new file mode 100644 index 000000000..d2ff5b906 --- /dev/null +++ b/openspec/changes/refine-project-management-tools/specs/project-management/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Project management tools are skill-gated and catalog-only +The system SHALL expose project-management tools only through the searchable catalog when the `project-management` skill and required Sentry scopes are granted. + +#### Scenario: Project-management skill granted +- **WHEN** a session has the `project-management` skill and the required scopes for a project-management tool +- **THEN** the tool is discoverable through `search_sentry_tools` and executable through `execute_sentry_tool` + +#### Scenario: Project-management skill absent +- **WHEN** a session does not have the `project-management` skill +- **THEN** project-management tools are not discoverable or executable + +#### Scenario: Direct tool surface +- **WHEN** the MCP server registers top-level tools +- **THEN** project-management tools are not added to the direct `tools/list` surface + +### Requirement: Project creation returns a usable DSN +The `create_project` tool SHALL create a Sentry project and return a usable `SENTRY_DSN` in the same tool response. + +#### Scenario: Default DSN exists after project creation +- **WHEN** Sentry creates a default client key for the new project +- **THEN** `create_project` returns that existing DSN instead of creating another client key + +#### Scenario: Default DSN is missing after project creation +- **WHEN** no client key exists after the project is created +- **THEN** `create_project` creates a `Default` client key and returns its DSN + +#### Scenario: Project creation response +- **WHEN** `create_project` succeeds +- **THEN** the response includes the project ID, project slug, project name, and `SENTRY_DSN` + +### Requirement: Project creation accepts only core setup fields +The `create_project` tool SHALL accept only core project setup fields and SHALL NOT perform repository linking. + +#### Scenario: Caller provides core project fields +- **WHEN** a caller provides `organizationSlug`, `teamSlug`, `name`, and optional `slug` or `platform` +- **THEN** the tool sends the supported project creation fields to Sentry + +#### Scenario: Caller wants repository linking +- **WHEN** a caller asks `create_project` to link a repository +- **THEN** the tool schema does not accept repository linking parameters + +### Requirement: Project creation respects active constraints +The system SHALL prevent project creation from escaping the active MCP constraints. + +#### Scenario: Organization-scoped session +- **WHEN** a session is constrained to an organization +- **THEN** `create_project` uses that organization constraint and does not accept another organization + +#### Scenario: Project-scoped session +- **WHEN** a session is constrained to a specific project +- **THEN** `create_project` is not available or rejects execution + +### Requirement: Project metadata updates are separate from team access changes +The `update_project` tool SHALL only update project metadata fields. + +#### Scenario: Caller updates metadata +- **WHEN** a caller provides at least one of `name`, `slug`, or `platform` +- **THEN** `update_project` updates only those project metadata fields + +#### Scenario: Caller attempts team assignment through update_project +- **WHEN** a caller provides team assignment arguments to `update_project` +- **THEN** the tool schema rejects those arguments + +#### Scenario: Caller renames project in project-scoped session +- **WHEN** a project-scoped session invokes `update_project` with a `slug` value +- **THEN** the tool returns a user input error explaining that slug changes require an organization-scoped or unconstrained session + +#### Scenario: Caller provides no metadata changes +- **WHEN** a caller invokes `update_project` without `name`, `slug`, or `platform` +- **THEN** the tool returns a user input error explaining that at least one metadata field is required + +### Requirement: Team access grants use an explicit tool +The `add_team_to_project` tool SHALL grant a team access to an existing project. + +#### Scenario: Team is not assigned to project +- **WHEN** a caller invokes `add_team_to_project` for a team that is not assigned to the project +- **THEN** the tool adds the team and returns the updated project team list + +#### Scenario: Team is already assigned to project +- **WHEN** a caller invokes `add_team_to_project` for a team that already has access +- **THEN** the tool returns a successful no-op response with the current project team list + +### Requirement: Team access removals are guarded +The `remove_team_from_project` tool SHALL revoke a team's project access only after validating the current project team assignments. + +#### Scenario: Team is assigned and not the last team +- **WHEN** a caller invokes `remove_team_from_project` for an assigned team and at least one other team remains +- **THEN** the tool removes the team and returns the remaining project team list + +#### Scenario: Team is not assigned to project +- **WHEN** a caller invokes `remove_team_from_project` for a team that is not assigned to the project +- **THEN** the tool returns a user input error and does not call the delete endpoint + +#### Scenario: Team is the only assigned team +- **WHEN** a caller invokes `remove_team_from_project` for the project's only assigned team +- **THEN** the tool returns a user input error and does not call the delete endpoint + +### Requirement: Project team access tools respect active constraints +The project team access tools SHALL operate only within the active organization and project constraints. + +#### Scenario: Organization constraint +- **WHEN** a session is constrained to an organization +- **THEN** team access tools use that organization constraint and do not accept another organization + +#### Scenario: Project constraint +- **WHEN** a session is constrained to a project +- **THEN** team access tools use that project constraint and do not accept another project + +### Requirement: Project-management responses are user-facing markdown +Project-management tools SHALL return concise markdown responses with follow-up identifiers and no raw API payloads. + +#### Scenario: Project operation succeeds +- **WHEN** a project-management tool completes successfully +- **THEN** the response includes the relevant organization slug, project slug, IDs needed for follow-up calls, and short response notes + +#### Scenario: Access-changing operation succeeds +- **WHEN** a team access tool completes successfully +- **THEN** the response clearly states whether access was granted or revoked and lists the current project teams diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md new file mode 100644 index 000000000..29ce65e0d --- /dev/null +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -0,0 +1,54 @@ +## 1. API Client Support + +- [ ] 1.1 Re-verify project creation, project details update, project teams, project team details, and project keys endpoint contracts against `~/src/sentry`. +- [ ] 1.2 Extend `createProject` to accept and send optional `slug`. +- [ ] 1.3 Add or update API client methods for listing a project's teams and removing a team from a project. +- [ ] 1.4 Adjust API client return schemas/types only as needed, preserving strict parsing and avoiding `any`. + +## 2. Project Tool Changes + +- [ ] 2.1 Update `create_project` schema and description to remove repository linking and add optional `slug`. +- [ ] 2.2 Change `create_project` DSN handling to list existing keys first, return an existing default/active DSN when available, and create a `Default` key only when no key exists. +- [ ] 2.3 Update `create_project` output and tests so the response always includes project identity and `SENTRY_DSN`. +- [ ] 2.4 Update `update_project` schema and description to remove `teamSlug`. +- [ ] 2.5 Add `update_project` validation requiring at least one metadata field. +- [ ] 2.6 Add `update_project` validation rejecting slug updates in project-scoped sessions. + +## 3. Team Access Tools + +- [ ] 3.1 Add `add_team_to_project` as a catalog-only `project-management` tool. +- [ ] 3.2 Implement `add_team_to_project` as idempotent from the user perspective by returning current teams when the team is already assigned. +- [ ] 3.3 Add `remove_team_from_project` as a catalog-only `project-management` tool with destructive safety annotation. +- [ ] 3.4 Implement `remove_team_from_project` preflight checks for team-not-assigned and last-team removal before calling DELETE. +- [ ] 3.5 Register the new tools in the catalog and confirm they are not added to the direct top-level surface. + +## 4. Constraint and Availability Behavior + +- [ ] 4.1 Hide or reject `create_project` in project-scoped sessions. +- [ ] 4.2 Verify organization constraints are injected for project-management tools and explicit conflicting org inputs are filtered. +- [ ] 4.3 Verify project constraints are injected for `update_project`, `add_team_to_project`, and `remove_team_from_project`. +- [ ] 4.4 Add tests for project-scoped slug update rejection. + +## 5. Tests + +- [ ] 5.1 Update `create-project.test.ts` snapshots for the narrowed schema and DSN-first behavior. +- [ ] 5.2 Add `create_project` tests for existing default DSN, fallback DSN creation, optional slug, and no repository linking. +- [ ] 5.3 Update `update-project.test.ts` snapshots and remove team assignment cases. +- [ ] 5.4 Add `add-team-to-project.test.ts` coverage for add, already-assigned no-op, mixed-case slug preservation, and constraint injection. +- [ ] 5.5 Add `remove-team-from-project.test.ts` coverage for remove, not assigned, last team guard, mixed-case slug preservation, and constraint injection. +- [ ] 5.6 Update registry, tool count, skill gating, and generated-definition tests as needed. + +## 6. Documentation and Generated Definitions + +- [ ] 6.1 Add `docs/specs/project-management.md` covering the durable project-management contract. +- [ ] 6.2 Link the new spec from `docs/specs/README.md`. +- [ ] 6.3 Update any docs that mention `update_project` team assignment or `create_project` repository linking. +- [ ] 6.4 Run `pnpm run --filter @sentry/mcp-core generate-definitions` after tool changes. + +## 7. Verification + +- [ ] 7.1 Run targeted unit tests for project-management tools and catalog availability. +- [ ] 7.2 Run `pnpm run tsc`. +- [ ] 7.3 Run `pnpm run lint`. +- [ ] 7.4 Run `pnpm run test`. +- [ ] 7.5 Run `pnpm run measure-tokens` if generated tool definitions materially change the catalog text size. From 16984a7f21cd65727a37532fa1d697011480f05f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 19:03:56 -0700 Subject: [PATCH 02/10] feat(api-client): Add project team helpers Add API client support for project creation slugs and project team membership reads/removals. Mark the API-client OpenSpec tasks complete after verifying upstream endpoint contracts. Co-Authored-By: GPT-5 Codex --- .../refine-project-management-tools/tasks.md | 8 +- packages/mcp-core/src/api-client/client.ts | 81 ++++++++++++++++++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md index 29ce65e0d..36aff56cd 100644 --- a/openspec/changes/refine-project-management-tools/tasks.md +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -1,9 +1,9 @@ ## 1. API Client Support -- [ ] 1.1 Re-verify project creation, project details update, project teams, project team details, and project keys endpoint contracts against `~/src/sentry`. -- [ ] 1.2 Extend `createProject` to accept and send optional `slug`. -- [ ] 1.3 Add or update API client methods for listing a project's teams and removing a team from a project. -- [ ] 1.4 Adjust API client return schemas/types only as needed, preserving strict parsing and avoiding `any`. +- [x] 1.1 Re-verify project creation, project details update, project teams, project team details, and project keys endpoint contracts against `~/src/sentry`. +- [x] 1.2 Extend `createProject` to accept and send optional `slug`. +- [x] 1.3 Add or update API client methods for listing a project's teams and removing a team from a project. +- [x] 1.4 Adjust API client return schemas/types only as needed, preserving strict parsing and avoiding `any`. ## 2. Project Tool Changes diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 5883c79f1..2696dd4d0 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -240,6 +240,18 @@ type ClientKeyDynamicSdkLoaderOptions = { hasLogsAndMetrics?: boolean; }; +type CreateProjectRequest = { + name: string; + slug?: string; + platform?: string; +}; + +type UpdateProjectRequest = { + name?: string; + slug?: string; + platform?: string; +}; + type UpdateClientKeyRequest = { name?: string; isActive?: boolean; @@ -1815,6 +1827,7 @@ export class SentryApiService { * @param params.organizationSlug Organization identifier * @param params.teamSlug Team identifier * @param params.name Project name + * @param params.slug Optional project slug * @param params.platform Platform identifier (e.g., "javascript", "python") * @param opts Request options * @returns Created project data @@ -1824,17 +1837,22 @@ export class SentryApiService { organizationSlug, teamSlug, name, + slug, platform, }: { organizationSlug: string; teamSlug: string; name: string; + slug?: string | null; platform?: string | null; }, opts?: RequestOptions, ): Promise { - const createData: Record = { name }; - // Only include platform if it has a meaningful value (not null, undefined, or empty) + const createData: CreateProjectRequest = { name }; + // Only include optional fields when they have meaningful values. + if (slug) { + createData.slug = slug; + } if (platform) { createData.platform = platform; } @@ -1878,7 +1896,7 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const updateData: Record = {}; + const updateData: UpdateProjectRequest = {}; // Only include fields that have meaningful values (truthy strings) if (name) updateData.name = name; if (slug) updateData.slug = slug; @@ -2194,6 +2212,33 @@ export class SentryApiService { }; } + /** + * Lists teams assigned to a project. + * + * @param params Query parameters + * @param params.organizationSlug Organization identifier + * @param params.projectSlug Project identifier + * @param opts Request options + * @returns Array of teams assigned to the project + */ + async listProjectTeams( + { + organizationSlug, + projectSlug, + }: { + organizationSlug: string; + projectSlug: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/projects/${organizationSlug}/${projectSlug}/teams/`, + undefined, + opts, + ); + return TeamListSchema.parse(body); + } + /** * Assigns a team to a project. * @@ -2225,6 +2270,36 @@ export class SentryApiService { ); } + /** + * Removes a team from a project. + * + * @param params Assignment parameters + * @param params.organizationSlug Organization identifier + * @param params.projectSlug Project identifier + * @param params.teamSlug Team identifier to remove + * @param opts Request options + */ + async removeTeamFromProject( + { + organizationSlug, + projectSlug, + teamSlug, + }: { + organizationSlug: string; + projectSlug: string; + teamSlug: string; + }, + opts?: RequestOptions, + ): Promise { + await this.request( + `/projects/${organizationSlug}/${projectSlug}/teams/${teamSlug}/`, + { + method: "DELETE", + }, + opts, + ); + } + /** * Creates a new client key (DSN) for a project. * From e8b95bf5904c593fa83a10c198d15c62b7fc595f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 19:14:56 -0700 Subject: [PATCH 03/10] feat(tools): Refine project metadata tools Keep create_project focused on project creation and DSN return behavior by removing repository linking, accepting explicit slugs, and reusing existing DSNs before creating a fallback key. Make update_project metadata-only so team access changes can move to dedicated project-management tools. Co-Authored-By: GPT-5 Codex --- .../refine-project-management-tools/tasks.md | 20 +-- packages/mcp-core/src/skillDefinitions.json | 4 +- packages/mcp-core/src/toolDefinitions.json | 34 ++-- .../src/tools/catalog/create-project.test.ts | 145 +++++++++++++--- .../src/tools/catalog/create-project.ts | 95 ++++------- .../src/tools/catalog/slug-case.test.ts | 2 - .../src/tools/catalog/update-project.test.ts | 160 +++++++++++------- .../src/tools/catalog/update-project.ts | 90 +++------- 8 files changed, 292 insertions(+), 258 deletions(-) diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md index 36aff56cd..0ecf17920 100644 --- a/openspec/changes/refine-project-management-tools/tasks.md +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -7,12 +7,12 @@ ## 2. Project Tool Changes -- [ ] 2.1 Update `create_project` schema and description to remove repository linking and add optional `slug`. -- [ ] 2.2 Change `create_project` DSN handling to list existing keys first, return an existing default/active DSN when available, and create a `Default` key only when no key exists. -- [ ] 2.3 Update `create_project` output and tests so the response always includes project identity and `SENTRY_DSN`. -- [ ] 2.4 Update `update_project` schema and description to remove `teamSlug`. -- [ ] 2.5 Add `update_project` validation requiring at least one metadata field. -- [ ] 2.6 Add `update_project` validation rejecting slug updates in project-scoped sessions. +- [x] 2.1 Update `create_project` schema and description to remove repository linking and add optional `slug`. +- [x] 2.2 Change `create_project` DSN handling to list existing keys first, return an existing default/active DSN when available, and create a `Default` key only when no key exists. +- [x] 2.3 Update `create_project` output and tests so the response always includes project identity and `SENTRY_DSN`. +- [x] 2.4 Update `update_project` schema and description to remove `teamSlug`. +- [x] 2.5 Add `update_project` validation requiring at least one metadata field. +- [x] 2.6 Add `update_project` validation rejecting slug updates in project-scoped sessions. ## 3. Team Access Tools @@ -27,13 +27,13 @@ - [ ] 4.1 Hide or reject `create_project` in project-scoped sessions. - [ ] 4.2 Verify organization constraints are injected for project-management tools and explicit conflicting org inputs are filtered. - [ ] 4.3 Verify project constraints are injected for `update_project`, `add_team_to_project`, and `remove_team_from_project`. -- [ ] 4.4 Add tests for project-scoped slug update rejection. +- [x] 4.4 Add tests for project-scoped slug update rejection. ## 5. Tests -- [ ] 5.1 Update `create-project.test.ts` snapshots for the narrowed schema and DSN-first behavior. -- [ ] 5.2 Add `create_project` tests for existing default DSN, fallback DSN creation, optional slug, and no repository linking. -- [ ] 5.3 Update `update-project.test.ts` snapshots and remove team assignment cases. +- [x] 5.1 Update `create-project.test.ts` snapshots for the narrowed schema and DSN-first behavior. +- [x] 5.2 Add `create_project` tests for existing default DSN, fallback DSN creation, optional slug, and no repository linking. +- [x] 5.3 Update `update-project.test.ts` snapshots and remove team assignment cases. - [ ] 5.4 Add `add-team-to-project.test.ts` coverage for add, already-assigned no-op, mixed-case slug preservation, and constraint injection. - [ ] 5.5 Add `remove-team-from-project.test.ts` coverage for remove, not assigned, last team guard, mixed-case slug preservation, and constraint injection. - [ ] 5.6 Update registry, tool count, skill gating, and generated-definition tests as needed. diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 4addc7972..f7e93b9f9 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -380,7 +380,7 @@ }, { "name": "create_project", - "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- 'Create a project for my-repo'\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- The repository parameter accepts a repo name (e.g. 'getsentry/sentry'). The repo must already be connected to the org via a VCS integration.\n", + "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", "requiredScopes": ["project:write", "team:read", "org:read"] }, { @@ -415,7 +415,7 @@ }, { "name": "update_project", - "description": "Update project settings in Sentry, such as name, slug, platform, and team assignment.\n\nBe careful when using this tool!\n\nUse this tool when you need to:\n- Update a project's name or slug to fix onboarding mistakes\n- Change the platform assigned to a project\n- Update team assignment for a project\n\n\n### Update a project's name and slug\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')\n```\n\n### Assign a project to a different team\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='backend-team')\n```\n\n### Update platform\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')\n```\n\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Team assignment is handled separately from other project settings\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- When updating the slug, the project will be accessible at the new slug after the update\n", + "description": "Update project metadata in Sentry, such as name, slug, and platform.\n\nBe careful when using this tool!\n\nUse this tool when you need to:\n- Update a project's name or slug to fix onboarding mistakes\n- Change the platform assigned to a project\n\n\n### Update a project's name and slug\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')\n```\n\n### Update platform\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')\n```\n\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Team access changes are handled by separate project-management tools.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- When updating the slug, the project will be accessible at the new slug after the update\n- Do not update the slug from a project-scoped session; reconnect with an organization-scoped or unconstrained session first.\n", "requiredScopes": ["project:write"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 94c2b045a..978d1346e 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -132,7 +132,7 @@ }, { "name": "create_project", - "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- 'Create a project for my-repo'\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- The repository parameter accepts a repo name (e.g. 'getsentry/sentry'). The repo must already be connected to the org via a VCS integration.\n", + "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", "inputSchema": { "type": "object", "properties": { @@ -159,32 +159,33 @@ }, "name": { "type": "string", - "description": "The name of the project to create. Typically this is commonly the name of the repository or service. It is only used as a visual label in Sentry." + "description": "The name of the project to create. Typically this is the name of the application or service. It is only used as a visual label in Sentry." }, - "platform": { + "slug": { "anyOf": [ { "type": "string", - "description": "The platform for the project. e.g., python, javascript, react, etc." + "description": "Optional project slug to create." }, { "type": "null" } ], - "description": "The platform for the project. e.g., python, javascript, react, etc.", + "description": "Optional project slug to create.", "default": null }, - "repository": { + "platform": { "anyOf": [ { - "type": "string" + "type": "string", + "description": "The platform for the project. e.g., python, javascript, react, etc." }, { "type": "null" } ], - "default": null, - "description": "Optional repository name to link to the project (e.g. 'getsentry/sentry'). The repo must already be connected to the organization via a VCS integration." + "description": "The platform for the project. e.g., python, javascript, react, etc.", + "default": null } }, "required": ["organizationSlug", "teamSlug", "name"], @@ -3130,7 +3131,7 @@ }, { "name": "update_project", - "description": "Update project settings in Sentry, such as name, slug, platform, and team assignment.\n\nBe careful when using this tool!\n\nUse this tool when you need to:\n- Update a project's name or slug to fix onboarding mistakes\n- Change the platform assigned to a project\n- Update team assignment for a project\n\n\n### Update a project's name and slug\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')\n```\n\n### Assign a project to a different team\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='backend-team')\n```\n\n### Update platform\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')\n```\n\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Team assignment is handled separately from other project settings\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- When updating the slug, the project will be accessible at the new slug after the update\n", + "description": "Update project metadata in Sentry, such as name, slug, and platform.\n\nBe careful when using this tool!\n\nUse this tool when you need to:\n- Update a project's name or slug to fix onboarding mistakes\n- Change the platform assigned to a project\n\n\n### Update a project's name and slug\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')\n```\n\n### Update platform\n\n```\nupdate_project(organizationSlug='my-organization', projectSlug='my-project', platform='python')\n```\n\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Team access changes are handled by separate project-management tools.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n- When updating the slug, the project will be accessible at the new slug after the update\n- Do not update the slug from a project-scoped session; reconnect with an organization-scoped or unconstrained session first.\n", "inputSchema": { "type": "object", "properties": { @@ -3193,19 +3194,6 @@ ], "description": "The platform for the project. e.g., python, javascript, react, etc.", "default": null - }, - "teamSlug": { - "anyOf": [ - { - "type": "string", - "description": "The team's slug. You can find a list of existing teams in an organization with the Sentry tool `find_teams`." - }, - { - "type": "null" - } - ], - "description": "The team to assign this project to. Note: this will replace the current team assignment.", - "default": null } }, "required": ["organizationSlug", "projectSlug"], diff --git a/packages/mcp-core/src/tools/catalog/create-project.test.ts b/packages/mcp-core/src/tools/catalog/create-project.test.ts index a5fbdc1d3..38a4cb14d 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.test.ts @@ -1,24 +1,43 @@ -import { describe, it, expect } from "vitest"; +import { + clientKeyFixture, + mswServer, + projectFixture, +} from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { afterEach, describe, it, expect } from "vitest"; import createProject from "./create-project.js"; +const context = { + constraints: { + organizationSlug: null, + projectSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + describe("create_project", () => { - it("serializes", async () => { + afterEach(() => { + mswServer.resetHandlers(); + }); + + it("serializes with the existing default DSN", async () => { + mswServer.use( + http.post("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => + HttpResponse.json({ detail: "unexpected fallback" }, { status: 500 }), + ), + ); + const result = await createProject.handler( { organizationSlug: "sentry-mcp-evals", teamSlug: "the-goats", name: "cloudflare-mcp", + slug: null, platform: "node", regionUrl: null, - repository: null, - }, - { - constraints: { - organizationSlug: null, - }, - accessToken: "access-token", - userId: "1", }, + context, ); expect(result).toMatchInlineSnapshot(` "# New Project in **sentry-mcp-evals** @@ -36,45 +55,115 @@ describe("create_project", () => { `); }); - it("links repository when provided", async () => { + it("uses an existing non-default DSN when no default key exists", async () => { + mswServer.use( + http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => + HttpResponse.json([ + { + ...clientKeyFixture, + name: "Production", + dsn: { + public: "https://production@example.com/1", + }, + }, + ]), + ), + http.post("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => + HttpResponse.json({ detail: "unexpected fallback" }, { status: 500 }), + ), + ); + const result = await createProject.handler( { organizationSlug: "sentry-mcp-evals", teamSlug: "the-goats", name: "cloudflare-mcp", + slug: null, platform: "node", regionUrl: null, - repository: "getsentry/sentry", - }, - { - constraints: { - organizationSlug: null, - }, - accessToken: "access-token", - userId: "1", }, + context, + ); + + expect(result).toContain( + "**SENTRY_DSN**: https://production@example.com/1", ); - expect(result).toContain("**Repository**: getsentry/sentry (linked)"); }); - it("reports when repository is not found", async () => { + it("creates a fallback default DSN when no key exists", async () => { + mswServer.use( + http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => + HttpResponse.json([]), + ), + http.post("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => + HttpResponse.json({ + ...clientKeyFixture, + name: "Default", + dsn: { + public: "https://fallback@example.com/1", + }, + }), + ), + ); + const result = await createProject.handler( { organizationSlug: "sentry-mcp-evals", teamSlug: "the-goats", name: "cloudflare-mcp", + slug: null, platform: "node", regionUrl: null, - repository: "nonexistent/repo", }, - { - constraints: { - organizationSlug: null, + context, + ); + + expect(result).toContain("**SENTRY_DSN**: https://fallback@example.com/1"); + }); + + it("passes an optional slug through project creation", async () => { + let createBody: unknown; + mswServer.use( + http.post( + "*/api/0/teams/sentry-mcp-evals/the-goats/projects/", + async ({ request }) => { + createBody = await request.json(); + return HttpResponse.json({ + ...projectFixture, + name: "My Project", + slug: "my-project", + platform: "node", + }); }, - accessToken: "access-token", - userId: "1", + ), + http.get("*/api/0/projects/sentry-mcp-evals/my-project/keys/", () => + HttpResponse.json([clientKeyFixture]), + ), + ); + + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "My Project", + slug: "my-project", + platform: "node", + regionUrl: null, }, + context, ); - expect(result).toContain('Could not find repository "nonexistent/repo"'); + + expect(createBody).toEqual({ + name: "My Project", + slug: "my-project", + platform: "node", + }); + expect(result).toContain("**Slug**: my-project"); + }); + + it("accepts slug but no repository linking parameter", () => { + expect(createProject.inputSchema).toHaveProperty("slug"); + expect(createProject.inputSchema).not.toHaveProperty("repository"); + expect(createProject.description).not.toContain("repository"); }); }); diff --git a/packages/mcp-core/src/tools/catalog/create-project.ts b/packages/mcp-core/src/tools/catalog/create-project.ts index 20493c3bc..3b8a2a3df 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.ts @@ -2,15 +2,26 @@ import { z } from "zod"; import { setTag } from "@sentry/core"; import { defineTool } from "../../internal/tool-helpers/define"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; -import { logIssue } from "../../telem/logging"; import type { ServerContext } from "../../types"; -import type { ClientKey } from "../../api-client/index"; import { ParamOrganizationSlug, + ParamProjectSlug, ParamRegionUrl, ParamTeamSlug, ParamPlatform, } from "../../schema"; +import type { ClientKey } from "../../api-client/index"; + +function getUsableClientKey(clientKeys: ClientKey[]): ClientKey | undefined { + return ( + clientKeys.find( + (key) => key.name === "Default" && key.isActive && key.dsn.public, + ) ?? + clientKeys.find((key) => key.isActive && key.dsn.public) ?? + clientKeys.find((key) => key.name === "Default" && key.dsn.public) ?? + clientKeys.find((key) => key.dsn.public) + ); +} export default defineTool({ name: "create_project", @@ -24,7 +35,6 @@ export default defineTool({ "- 'Set up a project for [app/service] with team [X]'", "- 'I need a new Sentry project'", "- Create project AND need DSN in one step", - "- 'Create a project for my-repo'", "", "DO NOT USE create_dsn after this - DSN is included in output.", "", @@ -35,16 +45,15 @@ export default defineTool({ "```", "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')", "```", - "### Create project and link to a repository", + "### Create project with an explicit slug", "```", - "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')", + "create_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')", "```", "", "", "", "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", "- If any parameter is ambiguous, you should clarify with the user what they meant.", - "- The repository parameter accepts a repo name (e.g. 'getsentry/sentry'). The repo must already be connected to the org via a VCS integration.", "", ].join("\n"), inputSchema: { @@ -55,17 +64,12 @@ export default defineTool({ .string() .trim() .describe( - "The name of the project to create. Typically this is commonly the name of the repository or service. It is only used as a visual label in Sentry.", + "The name of the project to create. Typically this is the name of the application or service. It is only used as a visual label in Sentry.", ), - platform: ParamPlatform.nullable().default(null), - repository: z - .string() - .trim() + slug: ParamProjectSlug.describe("Optional project slug to create.") .nullable() - .default(null) - .describe( - "Optional repository name to link to the project (e.g. 'getsentry/sentry'). The repo must already be connected to the organization via a VCS integration.", - ), + .default(null), + platform: ParamPlatform.nullable().default(null), }, annotations: { readOnlyHint: false, @@ -85,70 +89,29 @@ export default defineTool({ organizationSlug, teamSlug: params.teamSlug, name: params.name, + slug: params.slug, platform: params.platform, }); - let clientKey: ClientKey | null = null; - try { + + const clientKeys = await apiService.listClientKeys({ + organizationSlug, + projectSlug: project.slug, + }); + let clientKey = getUsableClientKey(clientKeys); + + if (!clientKey) { clientKey = await apiService.createClientKey({ organizationSlug, projectSlug: project.slug, name: "Default", }); - } catch (err) { - logIssue(err); - } - - let repoStatus: "linked" | "not_found" | "link_failed" | null = null; - let repoName: string | null = null; - if (params.repository) { - try { - const repos = await apiService.listRepos({ - organizationSlug, - query: params.repository, - }); - const match = repos.find( - (r) => - r.name === params.repository || - r.name.endsWith(`/${params.repository}`), - ); - if (match) { - repoName = match.name; - try { - await apiService.linkProjectRepo({ - organizationSlug, - projectSlug: project.slug, - repositoryId: match.id, - }); - repoStatus = "linked"; - } catch (err) { - logIssue(err); - repoStatus = "link_failed"; - } - } else { - repoStatus = "not_found"; - } - } catch (err) { - logIssue(err); - repoStatus = "not_found"; - } } let output = `# New Project in **${organizationSlug}**\n\n`; output += `**ID**: ${project.id}\n`; output += `**Slug**: ${project.slug}\n`; output += `**Name**: ${project.name}\n`; - if (clientKey) { - output += `**SENTRY_DSN**: ${clientKey?.dsn.public}\n\n`; - } else { - output += "**SENTRY_DSN**: There was an error fetching this value.\n\n"; - } - if (repoStatus === "linked" && repoName) { - output += `**Repository**: ${repoName} (linked)\n\n`; - } else if (repoStatus === "link_failed" && repoName) { - output += `**Repository**: Found ${repoName} but failed to link it to the project. Check permissions and try linking manually.\n\n`; - } else if (repoStatus === "not_found") { - output += `**Repository**: Could not find repository "${params.repository}" in the organization. Make sure it's connected via a VCS integration.\n\n`; - } + output += `**SENTRY_DSN**: ${clientKey.dsn.public}\n\n`; output += "## Response Notes\n\n"; output += `- Please tell the user the project slug and **SENTRY_DSN**.\n`; output += `- The **SENTRY_DSN** value is used to initialize Sentry SDKs.\n`; diff --git a/packages/mcp-core/src/tools/catalog/slug-case.test.ts b/packages/mcp-core/src/tools/catalog/slug-case.test.ts index 3e822e26b..555f7ff98 100644 --- a/packages/mcp-core/src/tools/catalog/slug-case.test.ts +++ b/packages/mcp-core/src/tools/catalog/slug-case.test.ts @@ -67,14 +67,12 @@ describe("slug parameter case preservation", () => { name: null, slug: " NewProject ", platform: null, - teamSlug: " MyTeam ", regionUrl: null, }), ).toMatchObject({ organizationSlug: "MyOrg", projectSlug: "OldProject", slug: "NewProject", - teamSlug: "MyTeam", }); }); }); diff --git a/packages/mcp-core/src/tools/catalog/update-project.test.ts b/packages/mcp-core/src/tools/catalog/update-project.test.ts index 9d0d60c77..e0dfe0fc0 100644 --- a/packages/mcp-core/src/tools/catalog/update-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-project.test.ts @@ -1,10 +1,24 @@ import { mswServer } from "@sentry/mcp-server-mocks"; import { http, HttpResponse } from "msw"; -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect } from "vitest"; +import { UserInputError } from "../../errors.js"; import updateProject from "./update-project.js"; import { prepareToolParams } from "../catalog-runtime/availability"; +const context = { + constraints: { + organizationSlug: null, + projectSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + describe("update_project", () => { + afterEach(() => { + mswServer.resetHandlers(); + }); + it("updates name and platform", async () => { const result = await updateProject.handler( { @@ -13,16 +27,9 @@ describe("update_project", () => { name: "New Project Name", slug: null, platform: "python", - teamSlug: null, regionUrl: null, }, - { - constraints: { - organizationSlug: null, - }, - accessToken: "access-token", - userId: "1", - }, + context, ); expect(result).toMatchInlineSnapshot(` "# Updated Project in **sentry-mcp-evals** @@ -43,52 +50,10 @@ describe("update_project", () => { `); }); - it("assigns project to new team", async () => { - const result = await updateProject.handler( - { - organizationSlug: "sentry-mcp-evals", - projectSlug: "cloudflare-mcp", - name: null, - slug: null, - platform: null, - teamSlug: "backend-team", - regionUrl: null, - }, - { - constraints: { - organizationSlug: null, - }, - accessToken: "access-token", - userId: "1", - }, - ); - expect(result).toMatchInlineSnapshot(` - "# Updated Project in **sentry-mcp-evals** - - **ID**: 4509106749636608 - **Slug**: cloudflare-mcp - **Name**: cloudflare-mcp - **Platform**: node - - ## Updates Applied - - Updated team assignment to "backend-team" - - ## Response Notes - - - Project slug for later requests: \`cloudflare-mcp\` - - Team assignment: \`backend-team\` - " - `); - }); - - it("preserves mixed-case slugs in team and project update requests", async () => { + it("preserves mixed-case slugs in project update requests", async () => { const paths: string[] = []; let updateBody: unknown; mswServer.use( - http.post("*/api/0/projects/*/*/teams/*/", ({ request }) => { - paths.push(new URL(request.url).pathname); - return new HttpResponse(null, { status: 204 }); - }), http.put("*/api/0/projects/*/*/", async ({ request }) => { paths.push(new URL(request.url).pathname); updateBody = await request.json(); @@ -101,13 +66,6 @@ describe("update_project", () => { }), ); - const context = { - constraints: { - organizationSlug: null, - }, - accessToken: "access-token", - userId: "1", - }; const params = prepareToolParams({ tool: updateProject, params: { @@ -117,19 +75,91 @@ describe("update_project", () => { name: null, slug: " NewProject ", platform: null, - teamSlug: " MyTeam ", }, context, }) as Parameters[0]; const result = await updateProject.handler(params, context); - expect(paths).toEqual([ - "/api/0/projects/MyOrg/OldProject/teams/MyTeam/", - "/api/0/projects/MyOrg/OldProject/", - ]); + expect(paths).toEqual(["/api/0/projects/MyOrg/OldProject/"]); expect(updateBody).toEqual({ slug: "NewProject" }); expect(result).toContain("**Slug**: NewProject"); - expect(result).toContain('- Updated team assignment to "MyTeam"'); + }); + + it("rejects calls without metadata fields", async () => { + await expect( + updateProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: null, + slug: null, + platform: null, + regionUrl: null, + }, + context, + ), + ).rejects.toThrow(UserInputError); + + await expect( + updateProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: null, + slug: null, + platform: null, + regionUrl: null, + }, + context, + ), + ).rejects.toThrow("At least one project metadata field is required"); + }); + + it("rejects slug updates from project-scoped sessions", async () => { + await expect( + updateProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: null, + slug: "new-project-slug", + platform: null, + regionUrl: null, + }, + { + ...context, + constraints: { + organizationSlug: null, + projectSlug: "cloudflare-mcp", + }, + }, + ), + ).rejects.toThrow(UserInputError); + + await expect( + updateProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + name: null, + slug: "new-project-slug", + platform: null, + regionUrl: null, + }, + { + ...context, + constraints: { + organizationSlug: null, + projectSlug: "cloudflare-mcp", + }, + }, + ), + ).rejects.toThrow("organization-scoped or unconstrained session"); + }); + + it("does not expose team assignment parameters", () => { + expect(updateProject.inputSchema).not.toHaveProperty("teamSlug"); + expect(updateProject.description).not.toContain("teamSlug"); }); }); diff --git a/packages/mcp-core/src/tools/catalog/update-project.ts b/packages/mcp-core/src/tools/catalog/update-project.ts index 76a0f739e..e99e9314c 100644 --- a/packages/mcp-core/src/tools/catalog/update-project.ts +++ b/packages/mcp-core/src/tools/catalog/update-project.ts @@ -11,7 +11,6 @@ import { ParamRegionUrl, ParamProjectSlug, ParamPlatform, - ParamTeamSlug, } from "../../schema"; export default defineTool({ @@ -19,14 +18,13 @@ export default defineTool({ skills: ["project-management"], // Only available in project-management skill requiredScopes: ["project:write"], description: [ - "Update project settings in Sentry, such as name, slug, platform, and team assignment.", + "Update project metadata in Sentry, such as name, slug, and platform.", "", "Be careful when using this tool!", "", "Use this tool when you need to:", "- Update a project's name or slug to fix onboarding mistakes", "- Change the platform assigned to a project", - "- Update team assignment for a project", "", "", "### Update a project's name and slug", @@ -35,12 +33,6 @@ export default defineTool({ "update_project(organizationSlug='my-organization', projectSlug='old-project', name='New Project Name', slug='new-project-slug')", "```", "", - "### Assign a project to a different team", - "", - "```", - "update_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='backend-team')", - "```", - "", "### Update platform", "", "```", @@ -51,9 +43,10 @@ export default defineTool({ "", "", "- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.", - "- Team assignment is handled separately from other project settings", + "- Team access changes are handled by separate project-management tools.", "- If any parameter is ambiguous, you should clarify with the user what they meant.", "- When updating the slug, the project will be accessible at the new slug after the update", + "- Do not update the slug from a project-scoped session; reconnect with an organization-scoped or unconstrained session first.", "", ].join("\n"), inputSchema: { @@ -72,11 +65,6 @@ export default defineTool({ .nullable() .default(null), platform: ParamPlatform.nullable().default(null), - teamSlug: ParamTeamSlug.nullable() - .default(null) - .describe( - "The team to assign this project to. Note: this will replace the current team assignment.", - ), }, annotations: { readOnlyHint: false, @@ -93,51 +81,34 @@ export default defineTool({ setTag("organization.slug", organizationSlug); setTag("project.slug", params.projectSlug); - // Handle team assignment separately if provided - if (params.teamSlug) { - setTag("team.slug", params.teamSlug); - try { - await apiService.addTeamToProject({ - organizationSlug, - projectSlug: params.projectSlug, - teamSlug: params.teamSlug, - }); - } catch (err) { - logIssue(err); - throw new Error( - `Failed to assign team ${params.teamSlug} to project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, - { cause: err }, - ); - } + const hasProjectUpdates = params.name || params.slug || params.platform; + if (!hasProjectUpdates) { + throw new UserInputError( + "At least one project metadata field is required: `name`, `slug`, or `platform`.", + ); } - // Update project settings if any are provided - const hasProjectUpdates = params.name || params.slug || params.platform; + if (params.slug && context.constraints.projectSlug) { + throw new UserInputError( + "Project slug changes require an organization-scoped or unconstrained session. Reconnect without a project constraint before renaming the project slug.", + ); + } - let project: Project | undefined; - if (hasProjectUpdates) { - try { - project = await apiService.updateProject({ - organizationSlug, - projectSlug: params.projectSlug, - name: params.name, - slug: params.slug, - platform: params.platform, - }); - } catch (err) { - logIssue(err); - throw new Error( - `Failed to update project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, - { cause: err }, - ); - } - } else { - // If only team assignment, fetch current project data for display - const projects = await apiService.listProjects(organizationSlug); - project = projects.find((p) => p.slug === params.projectSlug); - if (!project) { - throw new UserInputError(`Project ${params.projectSlug} not found`); - } + let project: Project; + try { + project = await apiService.updateProject({ + organizationSlug, + projectSlug: params.projectSlug, + name: params.name, + slug: params.slug, + platform: params.platform, + }); + } catch (err) { + logIssue(err); + throw new Error( + `Failed to update project ${params.projectSlug}: ${err instanceof Error ? err.message : "Unknown error"}`, + { cause: err }, + ); } let output = `# Updated Project in **${organizationSlug}**\n\n`; @@ -153,8 +124,6 @@ export default defineTool({ if (params.name) updates.push(`name to "${params.name}"`); if (params.slug) updates.push(`slug to "${params.slug}"`); if (params.platform) updates.push(`platform to "${params.platform}"`); - if (params.teamSlug) - updates.push(`team assignment to "${params.teamSlug}"`); if (updates.length > 0) { output += `\n## Updates Applied\n`; @@ -164,9 +133,6 @@ export default defineTool({ output += "\n## Response Notes\n\n"; output += `- Project slug for later requests: \`${project.slug}\`\n`; - if (params.teamSlug) { - output += `- Team assignment: \`${params.teamSlug}\`\n`; - } return output; }, }); From e7a71496c04bd5572393a1bb9f27b814af628ac8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 19:24:45 -0700 Subject: [PATCH 04/10] feat(tools): Add project team access tools Add explicit project-management tools for granting and revoking team access to projects. Keep the add path idempotent from the user perspective and guard removals when the team is not assigned or is the last assigned team. Co-Authored-By: GPT-5 Codex --- .../refine-project-management-tools/tasks.md | 16 +- packages/mcp-core/src/skillDefinitions.json | 12 +- packages/mcp-core/src/toolDefinitions.json | 80 ++++++ .../tools/catalog/add-team-to-project.test.ts | 198 +++++++++++++++ .../src/tools/catalog/add-team-to-project.ts | 142 +++++++++++ packages/mcp-core/src/tools/catalog/index.ts | 4 + .../catalog/remove-team-from-project.test.ts | 232 ++++++++++++++++++ .../tools/catalog/remove-team-from-project.ts | 137 +++++++++++ 8 files changed, 812 insertions(+), 9 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/add-team-to-project.ts create mode 100644 packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/remove-team-from-project.ts diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md index 0ecf17920..afc47ee9b 100644 --- a/openspec/changes/refine-project-management-tools/tasks.md +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -16,11 +16,11 @@ ## 3. Team Access Tools -- [ ] 3.1 Add `add_team_to_project` as a catalog-only `project-management` tool. -- [ ] 3.2 Implement `add_team_to_project` as idempotent from the user perspective by returning current teams when the team is already assigned. -- [ ] 3.3 Add `remove_team_from_project` as a catalog-only `project-management` tool with destructive safety annotation. -- [ ] 3.4 Implement `remove_team_from_project` preflight checks for team-not-assigned and last-team removal before calling DELETE. -- [ ] 3.5 Register the new tools in the catalog and confirm they are not added to the direct top-level surface. +- [x] 3.1 Add `add_team_to_project` as a catalog-only `project-management` tool. +- [x] 3.2 Implement `add_team_to_project` as idempotent from the user perspective by returning current teams when the team is already assigned. +- [x] 3.3 Add `remove_team_from_project` as a catalog-only `project-management` tool with destructive safety annotation. +- [x] 3.4 Implement `remove_team_from_project` preflight checks for team-not-assigned and last-team removal before calling DELETE. +- [x] 3.5 Register the new tools in the catalog and confirm they are not added to the direct top-level surface. ## 4. Constraint and Availability Behavior @@ -34,8 +34,8 @@ - [x] 5.1 Update `create-project.test.ts` snapshots for the narrowed schema and DSN-first behavior. - [x] 5.2 Add `create_project` tests for existing default DSN, fallback DSN creation, optional slug, and no repository linking. - [x] 5.3 Update `update-project.test.ts` snapshots and remove team assignment cases. -- [ ] 5.4 Add `add-team-to-project.test.ts` coverage for add, already-assigned no-op, mixed-case slug preservation, and constraint injection. -- [ ] 5.5 Add `remove-team-from-project.test.ts` coverage for remove, not assigned, last team guard, mixed-case slug preservation, and constraint injection. +- [x] 5.4 Add `add-team-to-project.test.ts` coverage for add, already-assigned no-op, mixed-case slug preservation, and constraint injection. +- [x] 5.5 Add `remove-team-from-project.test.ts` coverage for remove, not assigned, last team guard, mixed-case slug preservation, and constraint injection. - [ ] 5.6 Update registry, tool count, skill gating, and generated-definition tests as needed. ## 6. Documentation and Generated Definitions @@ -47,7 +47,7 @@ ## 7. Verification -- [ ] 7.1 Run targeted unit tests for project-management tools and catalog availability. +- [x] 7.1 Run targeted unit tests for project-management tools and catalog availability. - [ ] 7.2 Run `pnpm run tsc`. - [ ] 7.3 Run `pnpm run lint`. - [ ] 7.4 Run `pnpm run test`. diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index f7e93b9f9..54f81e79b 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -371,8 +371,13 @@ "description": "Create and modify projects, teams, and DSNs", "defaultEnabled": false, "order": 5, - "toolCount": 10, + "toolCount": 12, "tools": [ + { + "name": "add_team_to_project", + "description": "Grant a team access to an existing Sentry project.\n\nUse this tool when you need to:\n- Add another team to a project\n- Grant a team access without changing project metadata\n- Check whether a team already has project access before adding it\n\n\nadd_team_to_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')\n\n\n\n- Team access changes are separate from project metadata updates.\n- If the team is already assigned, this tool returns the current team list without making another change.\n", + "requiredScopes": ["project:write", "team:read", "org:read"] + }, { "name": "create_dsn", "description": "Create an additional DSN for an EXISTING project.\n\nUSE THIS TOOL WHEN:\n- Project already exists and needs additional DSN\n- 'Create another DSN for project X'\n- 'I need a production DSN for existing project'\n\nDO NOT USE for new projects (use create_project instead)\n\nBe careful when using this tool!\n\n\n### Create additional DSN for existing project\n```\ncreate_dsn(organizationSlug='my-organization', projectSlug='my-project', name='Production')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", @@ -408,6 +413,11 @@ "description": "Find teams in an organization in Sentry.\n\nUse this tool when you need to:\n- View teams in a Sentry organization\n- Find a team's slug and numeric ID to aid other tool requests\n- Search for specific teams by name or slug\n\nReturns up to 25 results. If you hit this limit, use the query parameter to narrow down results.", "requiredScopes": ["team:read"] }, + { + "name": "remove_team_from_project", + "description": "Revoke a team's access to an existing Sentry project.\n\nUse this tool when you need to:\n- Remove a team from a project\n- Revoke team access without changing project metadata\n- Check project team assignments before removing access\n\nBe careful when using this tool because it revokes project access.\n\n\nremove_team_from_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')\n\n\n\n- The team must already be assigned to the project.\n- This tool will not remove the last team assigned to a project.\n", + "requiredScopes": ["project:write", "team:read", "org:read"] + }, { "name": "update_dsn", "description": "Update settings for an existing DSN (client key) in a project, such as name, active status, rate limit, and loader script options.\n\nUSE THIS TOOL WHEN:\n- Deactivating or activating a DSN/client key\n- Setting or removing DSN rate limits ('set rate limit of 1000 per hour on DSN X')\n- Renaming a DSN ('rename DSN X to Production')\n- Configuring Javascript SDK loader script options (session replay, performance, debug, feedback, etc.)\n\nBe careful when using this tool!\n\n\n### Rename DSN and set rate limit\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', name='Production Key', rateLimitWindow=3600, rateLimitCount=500)\n```\n\n### Deactivate a DSN\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', isActive=false)\n```\n\n### Disable rate limit entirely\n```\nupdate_dsn(organizationSlug='my-organization', projectSlug='my-project', keyId='d20df0a1ab5031c7f3c7edca9c02814d', disableRateLimit=true)\n```\n\n\n\n- Use `find_dsns()` first to find the `keyId` for the DSN you want to update.\n- Both `rateLimitWindow` (seconds) and `rateLimitCount` (error cap) must be provided together to set a rate limit.\n", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 978d1346e..84f952f20 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -46,6 +46,46 @@ "skills": ["triage"], "surface": "catalog" }, + { + "name": "add_team_to_project", + "description": "Grant a team access to an existing Sentry project.\n\nUse this tool when you need to:\n- Add another team to a project\n- Grant a team access without changing project metadata\n- Check whether a team already has project access before adding it\n\n\nadd_team_to_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')\n\n\n\n- Team access changes are separate from project metadata updates.\n- If the team is already assigned, this tool returns the current team list without making another change.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ], + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", + "default": null + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "teamSlug": { + "type": "string", + "description": "The team's slug. You can find a list of existing teams in an organization with the Sentry tool `find_teams`." + } + }, + "required": ["organizationSlug", "projectSlug", "teamSlug"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["project:write", "team:read", "org:read"], + "skills": ["project-management"], + "surface": "catalog" + }, { "name": "analyze_issue_with_seer", "description": "Use Seer to analyze production errors and get detailed root cause analysis with specific code fixes.\n\nUse this tool when:\n- The user explicitly asks for root cause analysis, Seer analysis, or help fixing/debugging an issue\n- You are unable to accurately determine the root cause from the issue details alone\n\nDo NOT call this tool as an automatic follow-up to get_sentry_resource.\n\nWhat this tool provides:\n- Root cause analysis with code-level explanations\n- Specific file locations and line numbers where errors occur\n- Concrete code fixes you can apply\n- Step-by-step implementation guidance\n\nThis tool automatically:\n1. Checks if analysis already exists (instant results)\n2. Starts new AI analysis if needed (~2-5 minutes)\n3. Returns complete fix recommendations\n\n\n### User: \"Run Seer on this issue\"\n\n```\nanalyze_issue_with_seer(issueUrl='https://my-org.sentry.io/issues/PROJECT-1Z43')\n```\n\n### User: \"Analyze this issue and suggest a fix\"\n\n```\nanalyze_issue_with_seer(organizationSlug='my-organization', issueId='ERROR-456')\n```\n\n\n\n- Only use when the user explicitly requests analysis or you cannot determine the root cause from issue details alone\n- Seer Autofix does not support metric alert issues (issueCategory: metric); use get_issue_details and search_events instead\n- If the user provides an issueUrl, extract it and use that parameter alone\n- The analysis includes actual code snippets and fixes, not just error descriptions\n- Results are cached - subsequent calls return instantly\n", @@ -2189,6 +2229,46 @@ "skills": ["inspect"], "surface": "catalog" }, + { + "name": "remove_team_from_project", + "description": "Revoke a team's access to an existing Sentry project.\n\nUse this tool when you need to:\n- Remove a team from a project\n- Revoke team access without changing project metadata\n- Check project team assignments before removing access\n\nBe careful when using this tool because it revokes project access.\n\n\nremove_team_from_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')\n\n\n\n- The team must already be assigned to the project.\n- This tool will not remove the last team assigned to a project.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ], + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", + "default": null + }, + "projectSlug": { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "teamSlug": { + "type": "string", + "description": "The team's slug. You can find a list of existing teams in an organization with the Sentry tool `find_teams`." + } + }, + "required": ["organizationSlug", "projectSlug", "teamSlug"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["project:write", "team:read", "org:read"], + "skills": ["project-management"], + "surface": "catalog" + }, { "name": "search_ai_conversations", "description": "Search Sentry AI Conversations and return one summary row per conversation.\n\nUse this tool to find or list AI Conversations. Results are conversation summaries, not raw span rows.\nUse get_ai_conversation_details with a conversationId to fetch the transcript. Use get_sentry_resource for Sentry conversation URLs.\n\n\nsearch_ai_conversations(organizationSlug='my-org', query='failed conversations', period='7d')\nsearch_ai_conversations(organizationSlug='my-org', query='checkout', project='backend')\n", diff --git a/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts b/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts new file mode 100644 index 000000000..6c189b88e --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts @@ -0,0 +1,198 @@ +import { mswServer, teamFixture } from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareToolParams } from "../catalog-runtime/availability"; +import { TOP_LEVEL_TOOL_NAMES } from "../surfaces"; +import addTeamToProject from "./add-team-to-project"; +import catalogTools from "./index"; + +const context = { + constraints: { + organizationSlug: null, + projectSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +function team(overrides: { id: string; slug: string; name: string }) { + return { + ...teamFixture, + ...overrides, + }; +} + +describe("add_team_to_project", () => { + afterEach(() => { + mswServer.resetHandlers(); + }); + + it("adds a team and returns assigned teams", async () => { + const backendTeam = team({ + id: "4509109078196224", + slug: "backend", + name: "Backend", + }); + let listCalls = 0; + let postCalls = 0; + mswServer.use( + http.get( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => { + listCalls += 1; + return HttpResponse.json( + listCalls === 1 ? [teamFixture] : [teamFixture, backendTeam], + ); + }, + ), + http.post( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", + () => { + postCalls += 1; + return new HttpResponse(null, { status: 201 }); + }, + ), + ); + + const result = await addTeamToProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + teamSlug: "backend", + regionUrl: null, + }, + context, + ); + + expect(listCalls).toBe(2); + expect(postCalls).toBe(1); + expect(result).toMatchInlineSnapshot(` + "# Team Access Granted in **sentry-mcp-evals** + + **Project**: cloudflare-mcp + **Team**: backend + **Result**: Team access was granted. + + ## Current Project Teams + + - **the-goats** (ID: 4509106740854784) - the-goats + - **backend** (ID: 4509109078196224) - Backend + + ## Response Notes + + - Project slug for later requests: \`cloudflare-mcp\` + - Current team slugs: \`the-goats\`, \`backend\` + " + `); + }); + + it("returns current teams without posting when the team is already assigned", async () => { + const backendTeam = team({ + id: "4509109078196224", + slug: "backend", + name: "Backend", + }); + let postCalls = 0; + mswServer.use( + http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => + HttpResponse.json([teamFixture, backendTeam]), + ), + http.post( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", + () => { + postCalls += 1; + return HttpResponse.json( + { detail: "unexpected add" }, + { status: 500 }, + ); + }, + ), + ); + + const result = await addTeamToProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + teamSlug: "backend", + regionUrl: null, + }, + context, + ); + + expect(postCalls).toBe(0); + expect(result).toContain("# Team Already Assigned"); + expect(result).toContain( + "No change was made because the team already had project access.", + ); + expect(result).toContain("- **backend** (ID: 4509109078196224) - Backend"); + }); + + it("preserves mixed-case slugs and injects active constraints", async () => { + const paths: string[] = []; + let listCalls = 0; + mswServer.use( + http.get( + "*/api/0/projects/:organizationSlug/:projectSlug/teams/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + listCalls += 1; + return HttpResponse.json( + listCalls === 1 + ? [] + : [ + team({ + id: "99", + slug: "TeamABC", + name: "Team ABC", + }), + ], + ); + }, + ), + http.post( + "*/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return new HttpResponse(null, { status: 201 }); + }, + ), + ); + + const constrainedContext = { + ...context, + constraints: { + organizationSlug: "MyOrg", + projectSlug: "MyProject", + }, + }; + const params = prepareToolParams({ + tool: addTeamToProject, + params: { + organizationSlug: "OtherOrg", + projectSlug: "OtherProject", + teamSlug: " TeamABC ", + regionUrl: null, + }, + context: constrainedContext, + }) as Parameters[0]; + + const result = await addTeamToProject.handler(params, constrainedContext); + + expect(params).toMatchObject({ + organizationSlug: "MyOrg", + projectSlug: "MyProject", + teamSlug: "TeamABC", + }); + expect(paths).toEqual([ + "/api/0/projects/MyOrg/MyProject/teams/", + "/api/0/projects/MyOrg/MyProject/teams/TeamABC/", + "/api/0/projects/MyOrg/MyProject/teams/", + ]); + expect(result).toContain("**Team**: TeamABC"); + }); + + it("is registered as catalog-only", () => { + expect(catalogTools.add_team_to_project).toBe(addTeamToProject); + expect(TOP_LEVEL_TOOL_NAMES).not.toContain("add_team_to_project"); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/add-team-to-project.ts b/packages/mcp-core/src/tools/catalog/add-team-to-project.ts new file mode 100644 index 000000000..c1a746c7a --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/add-team-to-project.ts @@ -0,0 +1,142 @@ +import { setTag } from "@sentry/core"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import type { Team } from "../../api-client/index"; +import type { ServerContext } from "../../types"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, + ParamTeamSlug, +} from "../../schema"; + +function formatProjectTeams(teams: Team[]): string { + if (teams.length === 0) { + return "No teams are currently assigned to this project.\n"; + } + + return teams + .map((team) => `- **${team.slug}** (ID: ${team.id}) - ${team.name}`) + .join("\n"); +} + +function formatTeamSlugs(teams: Team[]): string { + if (teams.length === 0) { + return "none"; + } + + return teams.map((team) => `\`${team.slug}\``).join(", "); +} + +function formatResponse({ + organizationSlug, + projectSlug, + teamSlug, + teams, + alreadyAssigned, +}: { + organizationSlug: string; + projectSlug: string; + teamSlug: string; + teams: Team[]; + alreadyAssigned: boolean; +}): string { + let output = alreadyAssigned + ? `# Team Already Assigned in **${organizationSlug}**\n\n` + : `# Team Access Granted in **${organizationSlug}**\n\n`; + output += `**Project**: ${projectSlug}\n`; + output += `**Team**: ${teamSlug}\n`; + output += `**Result**: ${ + alreadyAssigned + ? "No change was made because the team already had project access." + : "Team access was granted." + }\n\n`; + output += "## Current Project Teams\n\n"; + output += formatProjectTeams(teams); + output += "\n\n## Response Notes\n\n"; + output += `- Project slug for later requests: \`${projectSlug}\`\n`; + output += `- Current team slugs: ${formatTeamSlugs(teams)}\n`; + return output; +} + +export default defineTool({ + name: "add_team_to_project", + skills: ["project-management"], + requiredScopes: ["project:write", "team:read", "org:read"], + description: [ + "Grant a team access to an existing Sentry project.", + "", + "Use this tool when you need to:", + "- Add another team to a project", + "- Grant a team access without changing project metadata", + "- Check whether a team already has project access before adding it", + "", + "", + "add_team_to_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')", + "", + "", + "", + "- Team access changes are separate from project metadata updates.", + "- If the team is already assigned, this tool returns the current team list without making another change.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + teamSlug: ParamTeamSlug, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + setTag("project.slug", params.projectSlug); + setTag("team.slug", params.teamSlug); + + const currentTeams = await apiService.listProjectTeams({ + organizationSlug, + projectSlug: params.projectSlug, + }); + const alreadyAssigned = currentTeams.some( + (team) => team.slug === params.teamSlug, + ); + + if (alreadyAssigned) { + return formatResponse({ + organizationSlug, + projectSlug: params.projectSlug, + teamSlug: params.teamSlug, + teams: currentTeams, + alreadyAssigned: true, + }); + } + + await apiService.addTeamToProject({ + organizationSlug, + projectSlug: params.projectSlug, + teamSlug: params.teamSlug, + }); + + const updatedTeams = await apiService.listProjectTeams({ + organizationSlug, + projectSlug: params.projectSlug, + }); + + return formatResponse({ + organizationSlug, + projectSlug: params.projectSlug, + teamSlug: params.teamSlug, + teams: updatedTeams, + alreadyAssigned: false, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index 80594e97a..df583ddb7 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -23,6 +23,8 @@ import searchEvents from "./search-events"; import createTeam from "./create-team"; import createProject from "./create-project"; import updateProject from "./update-project"; +import addTeamToProject from "./add-team-to-project"; +import removeTeamFromProject from "./remove-team-from-project"; import createDsn from "./create-dsn"; import findDsns from "./find-dsns"; import updateDsn from "./update-dsn"; @@ -77,6 +79,8 @@ const catalogTools = { create_team: createTeam, create_project: createProject, update_project: updateProject, + add_team_to_project: addTeamToProject, + remove_team_from_project: removeTeamFromProject, create_dsn: createDsn, find_dsns: findDsns, update_dsn: updateDsn, diff --git a/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts b/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts new file mode 100644 index 000000000..d173f4780 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts @@ -0,0 +1,232 @@ +import { mswServer, teamFixture } from "@sentry/mcp-server-mocks"; +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, it } from "vitest"; +import { UserInputError } from "../../errors"; +import { prepareToolParams } from "../catalog-runtime/availability"; +import { TOP_LEVEL_TOOL_NAMES } from "../surfaces"; +import catalogTools from "./index"; +import removeTeamFromProject from "./remove-team-from-project"; + +const context = { + constraints: { + organizationSlug: null, + projectSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +function team(overrides: { id: string; slug: string; name: string }) { + return { + ...teamFixture, + ...overrides, + }; +} + +describe("remove_team_from_project", () => { + afterEach(() => { + mswServer.resetHandlers(); + }); + + it("removes a team and returns remaining teams", async () => { + const backendTeam = team({ + id: "4509109078196224", + slug: "backend", + name: "Backend", + }); + let listCalls = 0; + let deleteCalls = 0; + mswServer.use( + http.get( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => { + listCalls += 1; + return HttpResponse.json( + listCalls === 1 ? [teamFixture, backendTeam] : [backendTeam], + ); + }, + ), + http.delete( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/the-goats/", + () => { + deleteCalls += 1; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const result = await removeTeamFromProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + teamSlug: "the-goats", + regionUrl: null, + }, + context, + ); + + expect(listCalls).toBe(2); + expect(deleteCalls).toBe(1); + expect(result).toMatchInlineSnapshot(` + "# Team Access Revoked in **sentry-mcp-evals** + + **Project**: cloudflare-mcp + **Removed Team**: the-goats + **Result**: Team access was revoked. + + ## Current Project Teams + + - **backend** (ID: 4509109078196224) - Backend + + ## Response Notes + + - Project slug for later requests: \`cloudflare-mcp\` + - Current team slugs: \`backend\` + " + `); + }); + + it("rejects removal when the team is not assigned", async () => { + let deleteCalls = 0; + mswServer.use( + http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => + HttpResponse.json([teamFixture]), + ), + http.delete( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", + () => { + deleteCalls += 1; + return HttpResponse.json( + { detail: "unexpected remove" }, + { status: 500 }, + ); + }, + ), + ); + + const promise = removeTeamFromProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + teamSlug: "backend", + regionUrl: null, + }, + context, + ); + + await expect(promise).rejects.toBeInstanceOf(UserInputError); + await expect(promise).rejects.toThrow("not assigned to this project"); + expect(deleteCalls).toBe(0); + }); + + it("rejects removal when the team is the last assigned team", async () => { + let deleteCalls = 0; + mswServer.use( + http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => + HttpResponse.json([teamFixture]), + ), + http.delete( + "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/the-goats/", + () => { + deleteCalls += 1; + return HttpResponse.json( + { detail: "unexpected remove" }, + { status: 500 }, + ); + }, + ), + ); + + const promise = removeTeamFromProject.handler( + { + organizationSlug: "sentry-mcp-evals", + projectSlug: "cloudflare-mcp", + teamSlug: "the-goats", + regionUrl: null, + }, + context, + ); + + await expect(promise).rejects.toBeInstanceOf(UserInputError); + await expect(promise).rejects.toThrow("last team assigned"); + expect(deleteCalls).toBe(0); + }); + + it("preserves mixed-case slugs and injects active constraints", async () => { + const otherTeam = team({ + id: "100", + slug: "OtherTeam", + name: "Other Team", + }); + const paths: string[] = []; + let listCalls = 0; + mswServer.use( + http.get( + "*/api/0/projects/:organizationSlug/:projectSlug/teams/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + listCalls += 1; + return HttpResponse.json( + listCalls === 1 + ? [ + team({ + id: "99", + slug: "TeamABC", + name: "Team ABC", + }), + otherTeam, + ] + : [otherTeam], + ); + }, + ), + http.delete( + "*/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", + ({ request }) => { + paths.push(new URL(request.url).pathname); + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const constrainedContext = { + ...context, + constraints: { + organizationSlug: "MyOrg", + projectSlug: "MyProject", + }, + }; + const params = prepareToolParams({ + tool: removeTeamFromProject, + params: { + organizationSlug: "OtherOrg", + projectSlug: "OtherProject", + teamSlug: " TeamABC ", + regionUrl: null, + }, + context: constrainedContext, + }) as Parameters[0]; + + const result = await removeTeamFromProject.handler( + params, + constrainedContext, + ); + + expect(params).toMatchObject({ + organizationSlug: "MyOrg", + projectSlug: "MyProject", + teamSlug: "TeamABC", + }); + expect(paths).toEqual([ + "/api/0/projects/MyOrg/MyProject/teams/", + "/api/0/projects/MyOrg/MyProject/teams/TeamABC/", + "/api/0/projects/MyOrg/MyProject/teams/", + ]); + expect(result).toContain("**Removed Team**: TeamABC"); + }); + + it("is registered as catalog-only", () => { + expect(catalogTools.remove_team_from_project).toBe(removeTeamFromProject); + expect(TOP_LEVEL_TOOL_NAMES).not.toContain("remove_team_from_project"); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/remove-team-from-project.ts b/packages/mcp-core/src/tools/catalog/remove-team-from-project.ts new file mode 100644 index 000000000..33f393028 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/remove-team-from-project.ts @@ -0,0 +1,137 @@ +import { setTag } from "@sentry/core"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { UserInputError } from "../../errors"; +import type { Team } from "../../api-client/index"; +import type { ServerContext } from "../../types"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, + ParamTeamSlug, +} from "../../schema"; + +function formatProjectTeams(teams: Team[]): string { + if (teams.length === 0) { + return "No teams are currently assigned to this project.\n"; + } + + return teams + .map((team) => `- **${team.slug}** (ID: ${team.id}) - ${team.name}`) + .join("\n"); +} + +function formatTeamSlugs(teams: Team[]): string { + if (teams.length === 0) { + return "none"; + } + + return teams.map((team) => `\`${team.slug}\``).join(", "); +} + +function formatResponse({ + organizationSlug, + projectSlug, + teamSlug, + teams, +}: { + organizationSlug: string; + projectSlug: string; + teamSlug: string; + teams: Team[]; +}): string { + let output = `# Team Access Revoked in **${organizationSlug}**\n\n`; + output += `**Project**: ${projectSlug}\n`; + output += `**Removed Team**: ${teamSlug}\n`; + output += "**Result**: Team access was revoked.\n\n"; + output += "## Current Project Teams\n\n"; + output += formatProjectTeams(teams); + output += "\n\n## Response Notes\n\n"; + output += `- Project slug for later requests: \`${projectSlug}\`\n`; + output += `- Current team slugs: ${formatTeamSlugs(teams)}\n`; + return output; +} + +export default defineTool({ + name: "remove_team_from_project", + skills: ["project-management"], + requiredScopes: ["project:write", "team:read", "org:read"], + description: [ + "Revoke a team's access to an existing Sentry project.", + "", + "Use this tool when you need to:", + "- Remove a team from a project", + "- Revoke team access without changing project metadata", + "- Check project team assignments before removing access", + "", + "Be careful when using this tool because it revokes project access.", + "", + "", + "remove_team_from_project(organizationSlug='my-organization', projectSlug='my-project', teamSlug='my-team')", + "", + "", + "", + "- The team must already be assigned to the project.", + "- This tool will not remove the last team assigned to a project.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + projectSlug: ParamProjectSlug, + teamSlug: ParamTeamSlug, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setTag("organization.slug", organizationSlug); + setTag("project.slug", params.projectSlug); + setTag("team.slug", params.teamSlug); + + const currentTeams = await apiService.listProjectTeams({ + organizationSlug, + projectSlug: params.projectSlug, + }); + const isAssigned = currentTeams.some( + (team) => team.slug === params.teamSlug, + ); + + if (!isAssigned) { + throw new UserInputError( + "The specified team is not assigned to this project. Choose one of the current project teams before removing access.", + ); + } + + if (currentTeams.length <= 1) { + throw new UserInputError( + "Cannot remove the last team assigned to a project. Add another team to the project before removing this team.", + ); + } + + await apiService.removeTeamFromProject({ + organizationSlug, + projectSlug: params.projectSlug, + teamSlug: params.teamSlug, + }); + + const updatedTeams = await apiService.listProjectTeams({ + organizationSlug, + projectSlug: params.projectSlug, + }); + + return formatResponse({ + organizationSlug, + projectSlug: params.projectSlug, + teamSlug: params.teamSlug, + teams: updatedTeams, + }); + }, +}); From f20533c37c84a41b260c6090af22f1b52f01244f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 19:27:50 -0700 Subject: [PATCH 05/10] feat(tools): Apply project management constraints Hide project creation from project-scoped catalog sessions and cover skill gating, catalog-only exposure, and constraint injection for project-management tools. Co-Authored-By: GPT-5 Codex --- .../refine-project-management-tools/tasks.md | 8 +- .../catalog-runtime/availability.test.ts | 177 ++++++++++++++++++ .../src/tools/catalog-runtime/availability.ts | 3 +- 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 packages/mcp-core/src/tools/catalog-runtime/availability.test.ts diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md index afc47ee9b..34864e0d7 100644 --- a/openspec/changes/refine-project-management-tools/tasks.md +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -24,9 +24,9 @@ ## 4. Constraint and Availability Behavior -- [ ] 4.1 Hide or reject `create_project` in project-scoped sessions. -- [ ] 4.2 Verify organization constraints are injected for project-management tools and explicit conflicting org inputs are filtered. -- [ ] 4.3 Verify project constraints are injected for `update_project`, `add_team_to_project`, and `remove_team_from_project`. +- [x] 4.1 Hide or reject `create_project` in project-scoped sessions. +- [x] 4.2 Verify organization constraints are injected for project-management tools and explicit conflicting org inputs are filtered. +- [x] 4.3 Verify project constraints are injected for `update_project`, `add_team_to_project`, and `remove_team_from_project`. - [x] 4.4 Add tests for project-scoped slug update rejection. ## 5. Tests @@ -36,7 +36,7 @@ - [x] 5.3 Update `update-project.test.ts` snapshots and remove team assignment cases. - [x] 5.4 Add `add-team-to-project.test.ts` coverage for add, already-assigned no-op, mixed-case slug preservation, and constraint injection. - [x] 5.5 Add `remove-team-from-project.test.ts` coverage for remove, not assigned, last team guard, mixed-case slug preservation, and constraint injection. -- [ ] 5.6 Update registry, tool count, skill gating, and generated-definition tests as needed. +- [x] 5.6 Update registry, tool count, skill gating, and generated-definition tests as needed. ## 6. Documentation and Generated Definitions diff --git a/packages/mcp-core/src/tools/catalog-runtime/availability.test.ts b/packages/mcp-core/src/tools/catalog-runtime/availability.test.ts new file mode 100644 index 000000000..b627354c8 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog-runtime/availability.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import type { Skill } from "../../skills"; +import { getServerContext } from "../../test-setup"; +import type { ServerContext } from "../../types"; +import addTeamToProject from "../catalog/add-team-to-project"; +import catalogTools from "../catalog"; +import createProject from "../catalog/create-project"; +import removeTeamFromProject from "../catalog/remove-team-from-project"; +import updateProject from "../catalog/update-project"; +import { + getFilteredInputSchema, + getToolsForMcpRegistration, + getSearchableTools, + prepareToolParams, +} from "./availability"; + +function getProjectManagementContext( + constraints: Partial = {}, +): ServerContext { + return getServerContext({ + grantedSkills: new Set(["project-management"]), + constraints, + }); +} + +function getSearchableToolNames(context: ServerContext): string[] { + return getSearchableTools({ + tools: catalogTools, + context, + experimentalMode: false, + useDefaultSurfacePolicy: true, + }).map(({ tool }) => tool.name); +} + +describe("catalog availability", () => { + it("keeps project-management tools skill-gated and catalog-only", () => { + const inspectContext = getServerContext({ + grantedSkills: new Set(["inspect"]), + }); + const inspectToolNames = getSearchableToolNames(inspectContext); + + expect(inspectToolNames).not.toEqual( + expect.arrayContaining([ + "create_project", + "update_project", + "add_team_to_project", + "remove_team_from_project", + ]), + ); + + const projectManagementContext = getProjectManagementContext(); + const directToolNames = getToolsForMcpRegistration({ + tools: catalogTools, + context: projectManagementContext, + experimentalMode: false, + useDefaultSurfacePolicy: true, + }).map(({ tool }) => tool.name); + + expect(directToolNames).not.toEqual( + expect.arrayContaining([ + "create_project", + "update_project", + "add_team_to_project", + "remove_team_from_project", + ]), + ); + expect(getSearchableToolNames(projectManagementContext)).toEqual( + expect.arrayContaining([ + "create_project", + "update_project", + "add_team_to_project", + "remove_team_from_project", + ]), + ); + }); + + it("hides create_project from project-scoped project-management sessions", () => { + const context = getProjectManagementContext({ + organizationSlug: "my-org", + projectSlug: "my-project", + }); + + expect(getSearchableToolNames(context)).toEqual( + expect.arrayContaining([ + "update_project", + "add_team_to_project", + "remove_team_from_project", + ]), + ); + expect(getSearchableToolNames(context)).not.toContain("create_project"); + }); + + it("injects organization constraints for project creation", () => { + const context = getProjectManagementContext({ + organizationSlug: "my-org", + }); + + expect(getFilteredInputSchema(createProject, context)).not.toHaveProperty( + "organizationSlug", + ); + expect( + prepareToolParams({ + tool: createProject, + params: { + organizationSlug: "other-org", + teamSlug: "my-team", + name: "My Project", + slug: null, + platform: null, + regionUrl: null, + }, + context, + }), + ).toMatchObject({ + organizationSlug: "my-org", + teamSlug: "my-team", + }); + }); + + it("injects project constraints for project update and team access tools", () => { + const context = getProjectManagementContext({ + organizationSlug: "my-org", + projectSlug: "my-project", + }); + + for (const tool of [ + updateProject, + addTeamToProject, + removeTeamFromProject, + ]) { + expect(getFilteredInputSchema(tool, context)).not.toHaveProperty( + "organizationSlug", + ); + expect(getFilteredInputSchema(tool, context)).not.toHaveProperty( + "projectSlug", + ); + } + + expect( + prepareToolParams({ + tool: updateProject, + params: { + organizationSlug: "other-org", + projectSlug: "other-project", + name: "Updated Project", + slug: null, + platform: null, + regionUrl: null, + }, + context, + }), + ).toMatchObject({ + organizationSlug: "my-org", + projectSlug: "my-project", + name: "Updated Project", + }); + + for (const tool of [addTeamToProject, removeTeamFromProject]) { + expect( + prepareToolParams({ + tool, + params: { + organizationSlug: "other-org", + projectSlug: "other-project", + teamSlug: "my-team", + regionUrl: null, + }, + context, + }), + ).toMatchObject({ + organizationSlug: "my-org", + projectSlug: "my-project", + teamSlug: "my-team", + }); + } + }); +}); diff --git a/packages/mcp-core/src/tools/catalog-runtime/availability.ts b/packages/mcp-core/src/tools/catalog-runtime/availability.ts index 049e1fc82..c0b38e869 100644 --- a/packages/mcp-core/src/tools/catalog-runtime/availability.ts +++ b/packages/mcp-core/src/tools/catalog-runtime/availability.ts @@ -118,7 +118,8 @@ function isHiddenByConstraints({ }): boolean { return ( (key === "find_organizations" && !!context.constraints.organizationSlug) || - (key === "find_projects" && !!context.constraints.projectSlug) + (key === "find_projects" && !!context.constraints.projectSlug) || + (key === "create_project" && !!context.constraints.projectSlug) ); } From 64495f787558b3faa6c593963e1bd37978c78895 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 19:36:55 -0700 Subject: [PATCH 06/10] docs: Document project management tools Add the durable project-management spec, update stale docs and eval expectations, and add an explicit --all-skills CLI flag for eval harnesses that need active non-deprecated skills. Co-Authored-By: GPT-5 Codex --- docs/specs/README.md | 1 + docs/specs/project-management.md | 143 ++++++++++++++++++ docs/testing/remote.md | 11 +- .../refine-project-management-tools/tasks.md | 16 +- packages/mcp-core/README.md | 3 +- packages/mcp-core/src/server.test.ts | 2 + .../src/evals/update-project.eval.ts | 4 +- .../src/evals/utils/toolPredictionScorer.ts | 2 +- packages/mcp-server/src/cli/parse.test.ts | 19 +++ packages/mcp-server/src/cli/parse.ts | 7 +- packages/mcp-server/src/cli/resolve.test.ts | 26 ++++ packages/mcp-server/src/cli/resolve.ts | 8 +- packages/mcp-server/src/cli/types.ts | 2 + packages/mcp-server/src/cli/usage.ts | 4 +- 14 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 docs/specs/project-management.md diff --git a/docs/specs/README.md b/docs/specs/README.md index dc04a8121..b7d33cce0 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -7,6 +7,7 @@ server. Each spec should live in a single Markdown file under `docs/specs/`. - [AI Conversations](ai-conversations.md) - [Embedded Agent OpenAI Routing](embedded-agent-openai-routing.md) +- [Project Management Tools](project-management.md) - [Remembered OAuth Skill Defaults](remembered-oauth-skills.md) - [Search Events](search-events.md) - [Sentry-Bearer Cloudflare Auth](sentry-bearer-cloudflare-auth.md) diff --git a/docs/specs/project-management.md b/docs/specs/project-management.md new file mode 100644 index 000000000..e59ea4957 --- /dev/null +++ b/docs/specs/project-management.md @@ -0,0 +1,143 @@ +# Project Management Tools + +Project-management tools create and update Sentry projects, teams, project DSNs, +and project team access through the searchable MCP catalog. + +## Overview + +Project management is intentionally powerful and infrequent. These tools are +available only when the `project-management` skill is granted, and they remain +catalog-only: discover them with `search_sentry_tools` and execute them with +`execute_sentry_tool`. + +Implementation references: +- Tool catalog: `packages/mcp-core/src/tools/catalog/` +- Availability rules: `packages/mcp-core/src/tools/catalog-runtime/availability.ts` +- API client: `packages/mcp-core/src/api-client/client.ts` + +## Tool Surface + +Project-management tools are not direct top-level MCP tools. They must not be +added to `TOP_LEVEL_TOOL_NAMES` in `packages/mcp-core/src/tools/surfaces.ts`. + +Core project tools: + +```typescript +create_project({ + organizationSlug: "my-org", + teamSlug: "my-team", + name: "My Project", + slug: "my-project", // optional + platform: "javascript", // optional +}) + +update_project({ + organizationSlug: "my-org", + projectSlug: "my-project", + name: "New Name", // at least one metadata field is required + slug: "new-slug", + platform: "python", +}) + +add_team_to_project({ + organizationSlug: "my-org", + projectSlug: "my-project", + teamSlug: "backend", +}) + +remove_team_from_project({ + organizationSlug: "my-org", + projectSlug: "my-project", + teamSlug: "backend", +}) +``` + +## Creation Contract + +`create_project` accepts only core setup fields: +- `organizationSlug` +- `teamSlug` +- `name` +- optional `slug` +- optional `platform` + +It does not expose repository linking, ownership rules, alert rule setup, +`defaultRules`, or broader project settings. + +Successful responses must include: +- Project ID +- Project slug +- Project name +- `SENTRY_DSN` + +The DSN contract is strict because SDK setup normally follows project creation. +After creating the project, the tool lists project client keys and returns an +existing usable DSN, preferring an active `Default` key. It creates a `Default` +client key only when no usable key exists. + +## Metadata Updates + +`update_project` is metadata-only. It updates only: +- `name` +- `slug` +- `platform` + +At least one metadata field is required. Team access changes must use +`add_team_to_project` or `remove_team_from_project`. + +Project-scoped sessions reject slug updates. A successful slug rename would +leave the active MCP session constrained to the old project slug until the +client reconnects. + +## Team Access + +`add_team_to_project` grants an existing team access to an existing project. It +lists current project teams first. If the team already has access, it returns a +successful no-op response with the current team list. + +`remove_team_from_project` revokes team access and is marked destructive. It +lists current project teams before deleting and rejects: +- Teams that are not currently assigned to the project +- Removing the last assigned team + +This MCP guard is stricter than the upstream delete endpoint so agents do not +accidentally leave a project without a team. + +## Constraints + +Organization and project constraints are enforced by catalog availability and +parameter injection: +- Organization-scoped sessions filter `organizationSlug` from schemas and inject + the constrained organization. +- Project-scoped sessions filter `projectSlug` from update/team-access schemas + and inject the constrained project. +- Project-scoped sessions hide `create_project` because sibling project creation + is outside the active project constraint. + +Conflicting explicit `organizationSlug` or `projectSlug` arguments from a caller +are filtered before handler execution. + +## Migration + +Previous behavior mixed unrelated operations into project tools: +- `create_project` could attempt repository linking. +- `update_project` could grant team access through `teamSlug`. + +Use these replacements: +- Repository linking: not supported by project creation; use Sentry directly + until a dedicated tool exists. +- Team grant: `add_team_to_project` +- Team revoke: `remove_team_from_project` + +## Testing + +Required coverage lives with the tools and catalog runtime: +- `packages/mcp-core/src/tools/catalog/create-project.test.ts` +- `packages/mcp-core/src/tools/catalog/update-project.test.ts` +- `packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts` +- `packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts` +- `packages/mcp-core/src/tools/catalog-runtime/availability.test.ts` +- `packages/mcp-core/src/tools/tools.test.ts` + +Run `pnpm run --filter @sentry/mcp-core generate-definitions` after changing +tool descriptions, schemas, or catalog registration. diff --git a/docs/testing/remote.md b/docs/testing/remote.md index f07c177c0..11539c29d 100644 --- a/docs/testing/remote.md +++ b/docs/testing/remote.md @@ -412,10 +412,10 @@ pnpm -w run cli "list organizations" **Verify tools reflect the current session grant:** ```bash # With read-only grants: no write tools -pnpm -w run cli "list tools" | grep "create_" +pnpm -w run cli "search Sentry tools for create project" -# With all grants: includes write tools -# Should see: create_project, create_team, update_issue, etc. +# With all grants: catalog search includes write-capable tools +# Should find: create_project, create_team, update_issue, etc. ``` ### 3. Test Multi-Organization Access @@ -547,8 +547,9 @@ pnpm -w run cli "who am I?" # Check tool list pnpm -w run cli "list tools" | jq '.tools[] | .name' -# Verify the session grant includes the capability for the tool you need -# Example: create_project is only available with project-management capabilities +# Verify the session grant includes the capability for the tool you need. +# Project-management tools are catalog-only; find them with search_sentry_tools. +# Example: create_project requires project-management and is hidden in project-scoped sessions. # Rebuild and restart pnpm -w run build && pnpm dev diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md index 34864e0d7..48af23c75 100644 --- a/openspec/changes/refine-project-management-tools/tasks.md +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -40,15 +40,15 @@ ## 6. Documentation and Generated Definitions -- [ ] 6.1 Add `docs/specs/project-management.md` covering the durable project-management contract. -- [ ] 6.2 Link the new spec from `docs/specs/README.md`. -- [ ] 6.3 Update any docs that mention `update_project` team assignment or `create_project` repository linking. -- [ ] 6.4 Run `pnpm run --filter @sentry/mcp-core generate-definitions` after tool changes. +- [x] 6.1 Add `docs/specs/project-management.md` covering the durable project-management contract. +- [x] 6.2 Link the new spec from `docs/specs/README.md`. +- [x] 6.3 Update any docs that mention `update_project` team assignment or `create_project` repository linking. +- [x] 6.4 Run `pnpm run --filter @sentry/mcp-core generate-definitions` after tool changes. ## 7. Verification - [x] 7.1 Run targeted unit tests for project-management tools and catalog availability. -- [ ] 7.2 Run `pnpm run tsc`. -- [ ] 7.3 Run `pnpm run lint`. -- [ ] 7.4 Run `pnpm run test`. -- [ ] 7.5 Run `pnpm run measure-tokens` if generated tool definitions materially change the catalog text size. +- [x] 7.2 Run `pnpm run tsc`. +- [x] 7.3 Run `pnpm run lint`. +- [x] 7.4 Run `pnpm run test`. +- [x] 7.5 Run `pnpm run measure-tokens` if generated tool definitions materially change the catalog text size. diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 53aa8d3fc..7ab103576 100644 --- a/packages/mcp-core/README.md +++ b/packages/mcp-core/README.md @@ -56,10 +56,11 @@ as an opaque model identifier. ### Constraint-Based Tool Exclusion -When a session is scoped to a specific organization or project (tenant-bound context), certain list tools are automatically excluded since they cannot query other resources: +When a session is scoped to a specific organization or project (tenant-bound context), certain tools are automatically excluded when they would escape or duplicate that scope: - **`find_organizations`** is hidden when the session is constrained to a specific organization (`organizationSlug` constraint) - **`find_projects`** is hidden when the session is constrained to a specific project (`projectSlug` constraint) +- **`create_project`** is hidden when the session is constrained to a specific project (`projectSlug` constraint) This ensures that only relevant tools are available in constrained contexts. When constraints are not set, all tools are available based on your granted skills. diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 1c27f7947..fc9a5e203 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -1148,6 +1148,8 @@ describe("buildServer", () => { ["create_project", ["project-management"]], ["create_team", ["project-management"]], ["update_project", ["project-management"]], + ["add_team_to_project", ["project-management"]], + ["remove_team_from_project", ["project-management"]], ["create_dsn", ["project-management"]], ["find_dsns", ["project-management"]], ] as const) { diff --git a/packages/mcp-server-evals/src/evals/update-project.eval.ts b/packages/mcp-server-evals/src/evals/update-project.eval.ts index 2f979007e..23cf58dca 100644 --- a/packages/mcp-server-evals/src/evals/update-project.eval.ts +++ b/packages/mcp-server-evals/src/evals/update-project.eval.ts @@ -19,10 +19,10 @@ describeEval("update-project", { ], }, { - input: `Assign the project '${FIXTURES.projectSlug}' in organization '${FIXTURES.organizationSlug}' to the team '${FIXTURES.teamSlug}'. Output only the team slug as plain text without any formatting:\nthe-goats`, + input: `Grant the team '${FIXTURES.teamSlug}' access to the project '${FIXTURES.projectSlug}' in organization '${FIXTURES.organizationSlug}'. Output only the team slug as plain text without any formatting:\nthe-goats`, expectedTools: [ { - name: "update_project", + name: "add_team_to_project", arguments: { organizationSlug: FIXTURES.organizationSlug, projectSlug: FIXTURES.projectSlug, diff --git a/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts b/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts index dcfaf1bbe..c1104787f 100644 --- a/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts +++ b/packages/mcp-server-evals/src/evals/utils/toolPredictionScorer.ts @@ -23,7 +23,7 @@ async function getAvailableTools(): Promise { "exec", "sentry-mcp", "--access-token=mocked-access-token", - "--all-scopes", + "--all-skills", ], env: { ...process.env, diff --git a/packages/mcp-server/src/cli/parse.test.ts b/packages/mcp-server/src/cli/parse.test.ts index 6ad7debd5..dfe8985a5 100644 --- a/packages/mcp-server/src/cli/parse.test.ts +++ b/packages/mcp-server/src/cli/parse.test.ts @@ -12,6 +12,7 @@ describe("cli/parseArgv", () => { "--sentry-dsn=dsn", "--openai-base-url=https://api.example.com/v1", "--skills=inspect,triage", + "--all-skills", "-h", "-v", ]); @@ -23,6 +24,7 @@ describe("cli/parseArgv", () => { expect(parsed.sentryDsn).toBe("dsn"); expect(parsed.openaiBaseUrl).toBe("https://api.example.com/v1"); expect(parsed.skills).toBe("inspect,triage"); + expect(parsed.allSkills).toBe(true); expect(parsed.help).toBe(true); expect(parsed.version).toBe(true); expect(parsed.unknownArgs).toEqual([]); @@ -39,6 +41,12 @@ describe("cli/parseArgv", () => { expect(parsed.disableSkills).toBe("seer"); }); + it("parses --all-skills", () => { + const parsed = parseArgv(["--access-token=tok", "--all-skills"]); + expect(parsed.accessToken).toBe("tok"); + expect(parsed.allSkills).toBe(true); + }); + it("parses --insecure-http", () => { const parsed = parseArgv([ "--access-token=tok", @@ -141,6 +149,17 @@ describe("cli/merge", () => { expect(merged.skills).toBe("inspect,triage"); }); + it("lets CLI allSkills override env skills", () => { + const env = parseEnv({ + SENTRY_ACCESS_TOKEN: "envtok", + MCP_SKILLS: "inspect", + } as any); + const cli = parseArgv(["--access-token=clitok", "--all-skills"]); + const merged = merge(cli, env); + expect(merged.allSkills).toBe(true); + expect(merged.skills).toBeUndefined(); + }); + it("applies precedence for disableSkills: CLI over env", () => { const env = parseEnv({ SENTRY_ACCESS_TOKEN: "envtok", diff --git a/packages/mcp-server/src/cli/parse.ts b/packages/mcp-server/src/cli/parse.ts index b9009b634..dbc4eb1d6 100644 --- a/packages/mcp-server/src/cli/parse.ts +++ b/packages/mcp-server/src/cli/parse.ts @@ -17,6 +17,7 @@ export function parseArgv(argv: string[]): CliArgs { "organization-slug": { type: "string" as const }, "project-slug": { type: "string" as const }, skills: { type: "string" as const }, + "all-skills": { type: "boolean" as const }, "disable-skills": { type: "string" as const }, agent: { type: "boolean" as const }, experimental: { type: "boolean" as const }, @@ -66,6 +67,7 @@ export function parseArgv(argv: string[]): CliArgs { organizationSlug: values["organization-slug"] as string | undefined, projectSlug: values["project-slug"] as string | undefined, skills: values.skills as string | undefined, + allSkills: values["all-skills"] === true, disableSkills: values["disable-skills"] as string | undefined, agent: values.agent === true, experimental: values.experimental === true, @@ -111,8 +113,9 @@ export function merge(cli: CliArgs, env: EnvArgs): MergedArgs { anthropicModel: cli.anthropicModel ?? env.anthropicModel, agentProvider: cli.agentProvider ?? env.agentProvider, clientId: env.clientId, - // Skills precedence: CLI skills override env - skills: cli.skills ?? env.skills, + // Skills precedence: CLI skills override env; --all-skills also overrides env skills. + skills: cli.skills ?? (cli.allSkills ? undefined : env.skills), + allSkills: cli.allSkills === true, disableSkills: cli.disableSkills ?? env.disableSkills, agent: cli.agent === true, experimental: cli.experimental === true, diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/mcp-server/src/cli/resolve.test.ts index 3c7542589..1cb91461d 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/mcp-server/src/cli/resolve.test.ts @@ -211,6 +211,32 @@ describe("cli/finalize", () => { expect(cfg.finalSkills.has("preprod")).toBe(false); }); + it("grants all active skills with --all-skills", () => { + const cfg = finalize({ + accessToken: "tok", + allSkills: true, + unknownArgs: [], + }); + expect(cfg.finalSkills.size).toBe(4); + expect(cfg.finalSkills.has("inspect")).toBe(true); + expect(cfg.finalSkills.has("triage")).toBe(true); + expect(cfg.finalSkills.has("project-management")).toBe(true); + expect(cfg.finalSkills.has("seer")).toBe(true); + expect(cfg.finalSkills.has("docs")).toBe(false); + expect(cfg.finalSkills.has("preprod")).toBe(false); + }); + + it("rejects combining --all-skills with --skills", () => { + expect(() => + finalize({ + accessToken: "tok", + allSkills: true, + skills: "inspect", + unknownArgs: [], + }), + ).toThrow(/--all-skills cannot be combined with --skills/); + }); + // --disable-skills tests it("removes disabled skills from default active-skills set", () => { const cfg = finalize({ diff --git a/packages/mcp-server/src/cli/resolve.ts b/packages/mcp-server/src/cli/resolve.ts index 03ba08755..3a4e37c73 100644 --- a/packages/mcp-server/src/cli/resolve.ts +++ b/packages/mcp-server/src/cli/resolve.ts @@ -72,7 +72,13 @@ export function finalize(input: MergedArgs): PartiallyResolvedConfig { // packages/mcp-cloudflare/src/server/oauth/routes/callback.ts (lines 234-248) // let finalSkills: Set; - if (input.skills) { + if (input.skills && input.allSkills) { + throw new Error("Error: --all-skills cannot be combined with --skills."); + } + + if (input.allSkills) { + finalSkills = new Set(ACTIVE_SKILLS); + } else if (input.skills) { // Override: use only the specified skills const { valid, invalid } = parseSkills(input.skills); if (invalid.length > 0) { diff --git a/packages/mcp-server/src/cli/types.ts b/packages/mcp-server/src/cli/types.ts index 864db974d..a5696aea4 100644 --- a/packages/mcp-server/src/cli/types.ts +++ b/packages/mcp-server/src/cli/types.ts @@ -14,6 +14,7 @@ export type CliArgs = { anthropicModel?: string; agentProvider?: string; skills?: string; + allSkills?: boolean; disableSkills?: string; agent?: boolean; experimental?: boolean; @@ -52,6 +53,7 @@ export type MergedArgs = { agentProvider?: string; clientId?: string; // env-only, carried from EnvArgs skills?: string; + allSkills?: boolean; disableSkills?: string; agent?: boolean; experimental?: boolean; diff --git a/packages/mcp-server/src/cli/usage.ts b/packages/mcp-server/src/cli/usage.ts index ddb760cc1..305f10e09 100644 --- a/packages/mcp-server/src/cli/usage.ts +++ b/packages/mcp-server/src/cli/usage.ts @@ -37,9 +37,10 @@ Session constraints: Skill controls: --skills Specify which skills to grant (default: active, non-deprecated skills) + --all-skills Grant all active, non-deprecated skills --disable-skills Remove specific skills (e.g. --disable-skills=seer) -All skills: ${allSkills.join(", ")} +Available skills: ${allSkills.join(", ")} Environment variables: SENTRY_ACCESS_TOKEN Sentry auth token (alternative to --access-token) @@ -54,6 +55,7 @@ Environment variables: Examples: ${packageName} # device code auth (sentry.io only) ${packageName} --access-token=TOKEN + ${packageName} --access-token=TOKEN --all-skills ${packageName} --access-token=TOKEN --skills=inspect,triage ${packageName} --access-token=TOKEN --host=sentry.example.com ${packageName} --access-token=TOKEN --host=sentry.internal:9000 --insecure-http From 3da9e1582d8d2e072bd3d0aceb7482dffc9519f9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 20:23:08 -0700 Subject: [PATCH 07/10] feat(tools): Link repositories during project creation Create root code mappings through the organization bulk endpoint when a repository is supplied. Keep project creation safe by preflighting repository matches and still returning the project slug and DSN when post-create linking fails. Tighten project creation around active DSNs, paginate project team lookups, and scope MSW Sentry API handlers to sentry.io instead of wildcard hosts. Co-Authored-By: GPT-5 Codex --- docs/README.md | 1 + docs/specs/project-management.md | 20 +- .../refine-project-management-tools/design.md | 17 +- .../proposal.md | 2 +- .../specs/project-management/spec.md | 13 +- .../refine-project-management-tools/tasks.md | 4 +- packages/mcp-core/README.md | 3 + .../mcp-core/src/api-client/client.test.ts | 122 +++++-- packages/mcp-core/src/api-client/client.ts | 53 ++- packages/mcp-core/src/api-client/schema.ts | 20 +- packages/mcp-core/src/api-client/types.ts | 4 + .../openai-provider.integration.test.ts | 6 +- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 14 +- .../tools/catalog/add-team-to-project.test.ts | 15 +- .../catalog/analyze-issue-with-seer.test.ts | 32 +- .../src/tools/catalog/create-project.test.ts | 251 ++++++++++++-- .../src/tools/catalog/create-project.ts | 111 +++++- .../src/tools/catalog/find-projects.test.ts | 27 +- .../tools/catalog/get-issue-details.test.ts | 11 +- .../catalog/get-issue-user-reports.test.ts | 2 +- .../tools/catalog/get-monitor-details.test.ts | 4 +- .../catalog/remove-team-from-project.test.ts | 22 +- .../tools/catalog/search-issue-events.test.ts | 220 +++++++----- .../src/tools/catalog/search-issues.test.ts | 315 ++++++++++-------- .../src/tools/catalog/update-project.test.ts | 2 +- packages/mcp-server-mocks/src/index.ts | 34 +- 27 files changed, 930 insertions(+), 397 deletions(-) diff --git a/docs/README.md b/docs/README.md index 577c45dfb..f632ebf13 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,7 @@ canonical workflow and required docs live in [../AGENTS.md](../AGENTS.md) - [specs/README.md](specs/README.md) - Specs index - [specs/embedded-agent-openai-routing.md](specs/embedded-agent-openai-routing.md) - Embedded agent OpenAI routing spec +- [specs/project-management.md](specs/project-management.md) - Project management tools spec - [specs/remembered-oauth-skills.md](specs/remembered-oauth-skills.md) - Remembered OAuth skill defaults spec - [specs/search-events.md](specs/search-events.md) - Search Events spec - [specs/sentry-bearer-cloudflare-auth.md](specs/sentry-bearer-cloudflare-auth.md) - Direct Sentry token auth for the Cloudflare transport diff --git a/docs/specs/project-management.md b/docs/specs/project-management.md index e59ea4957..f045a97e9 100644 --- a/docs/specs/project-management.md +++ b/docs/specs/project-management.md @@ -29,6 +29,7 @@ create_project({ name: "My Project", slug: "my-project", // optional platform: "javascript", // optional + repository: "acme/app", // optional, must already be connected }) update_project({ @@ -54,15 +55,16 @@ remove_team_from_project({ ## Creation Contract -`create_project` accepts only core setup fields: +`create_project` accepts core setup fields: - `organizationSlug` - `teamSlug` - `name` - optional `slug` - optional `platform` +- optional `repository` -It does not expose repository linking, ownership rules, alert rule setup, -`defaultRules`, or broader project settings. +It does not expose ownership rules, alert rule setup, `defaultRules`, or broader +project settings. Successful responses must include: - Project ID @@ -75,6 +77,13 @@ After creating the project, the tool lists project client keys and returns an existing usable DSN, preferring an active `Default` key. It creates a `Default` client key only when no usable key exists. +When `repository` is provided, `create_project` resolves the repository before +creating the project. Unknown or ambiguous repositories fail before the project +is created. After creation, the tool links the repository by creating a root +code mapping (`/` to `/`) through Sentry's organization code-mappings endpoint. +If linking fails after project creation, the response still includes the project +slug and DSN and notes that the repository must be linked manually. + ## Metadata Updates `update_project` is metadata-only. It updates only: @@ -120,12 +129,11 @@ are filtered before handler execution. ## Migration Previous behavior mixed unrelated operations into project tools: -- `create_project` could attempt repository linking. - `update_project` could grant team access through `teamSlug`. Use these replacements: -- Repository linking: not supported by project creation; use Sentry directly - until a dedicated tool exists. +- Repository linking: use `create_project(..., repository: "owner/repo")` during + creation. - Team grant: `add_team_to_project` - Team revoke: `remove_team_from_project` diff --git a/openspec/changes/refine-project-management-tools/design.md b/openspec/changes/refine-project-management-tools/design.md index f691610dd..298a15abc 100644 --- a/openspec/changes/refine-project-management-tools/design.md +++ b/openspec/changes/refine-project-management-tools/design.md @@ -8,6 +8,10 @@ The MCP catalog already includes `create_project`, `update_project`, `create_tea Sentry's upstream project creation endpoint creates the project under a team and emits normal project creation side effects, including default key creation through Sentry's project post-save hook. Agents still need the DSN in the tool response because creating a Sentry project is usually followed immediately by SDK setup. +Endpoint validation: +- `~/src/sentry/src/sentry/core/endpoints/project_teams.py` lists project teams through `ProjectTeamsEndpoint.get`, backed by `OffsetPaginator` and cursor `Link` headers, so MCP must page through cursors before applying team-access safety checks. +- `~/src/sentry/src/sentry/integrations/api/endpoints/organization_code_mappings_bulk.py` persists repository/project associations through `OrganizationCodeMappingsBulkEndpoint.post` at `/organizations/{org}/code-mappings/bulk/`; it resolves the project by slug, resolves the repository by name/provider, infers the default branch when possible, and creates or updates `RepositoryProjectPathConfig` rows. + ## Goals / Non-Goals **Goals:** @@ -21,7 +25,7 @@ Sentry's upstream project creation endpoint creates the project under a team and **Non-Goals:** -- Repository linking during project creation. +- Repository linking as a metadata update or team access concern. - Project deletion, transfer, ownership rule management, alert rule management, or broader project settings. - Direct top-level MCP exposure for project-management tools. - Changing Sentry's upstream API semantics. @@ -30,9 +34,11 @@ Sentry's upstream project creation endpoint creates the project under a team and ### Keep `create_project` focused on project setup -`create_project` will call Sentry's team project creation endpoint with only core fields: organization, team, name, optional slug, and optional platform. It will not expose the upstream `default_rules` flag in the first iteration; Sentry's default behavior remains active. +`create_project` will call Sentry's team project creation endpoint with core fields: organization, team, name, optional slug, and optional platform. It will not expose the upstream `default_rules` flag in the first iteration; Sentry's default behavior remains active. + +Repository linking stays first-class in `create_project` because project setup often includes connecting the new project to its source repository. When requested, the tool resolves the repository before creating the project so missing or ambiguous repositories fail without creating a project. Linking creates a root code mapping through Sentry's organization code-mappings endpoint after project creation because it requires the created project slug; link failures are reported in the response with the DSN so the user can retry manually. -Alternative considered: keep repository linking in `create_project`. This was rejected because repository linking has separate permissions, integration dependencies, and failure modes. Project creation should not partially succeed with unrelated VCS work hidden in the same tool. +Alternative considered: move repository linking to a separate tool only. This was rejected because repository linking is part of the primary project setup workflow and is useful to keep alongside creation while team access remains separate. ### Guarantee a DSN without creating duplicates in the normal path @@ -73,13 +79,14 @@ Alternative considered: expose `create_project` directly. This was rejected beca ## Risks / Trade-offs - Duplicate DSN fallback could still create a key if key listing races with Sentry's default key hook. Mitigation: list first, create only when the list is empty, and keep the fallback explicit in tests. +- Repository linking can fail after project creation because code mapping creation needs the created project. Mitigation: preflight repository resolution before project creation and report post-create link failures without hiding the project slug or DSN. - Last-team removal guard may reject a backend operation that Sentry technically allows. Mitigation: prefer safe MCP behavior; users can use Sentry directly for exceptional cases until a deliberate override is designed. -- Slug updates can make project-scoped sessions confusing after the mutation. Mitigation: document and test constraint behavior; consider rejecting slug changes in project-scoped sessions if implementation reveals ambiguous constraint state. +- Slug updates can make project-scoped sessions confusing after the mutation. Mitigation: reject and test slug changes in project-scoped sessions. - Adding team access tools increases catalog size. Mitigation: keep them catalog-only and behind the existing `project-management` skill. ## Migration Plan -1. Update API client support for project creation slug and project team access reads/deletes. +1. Update API client support for project creation slug, repository linking, and project team access reads/deletes. 2. Tighten `create_project` and `update_project` schemas and descriptions. 3. Add `add_team_to_project` and `remove_team_from_project`. 4. Add tests covering old behavior removal and new contracts. diff --git a/openspec/changes/refine-project-management-tools/proposal.md b/openspec/changes/refine-project-management-tools/proposal.md index a0d74e4f5..ef4b3f284 100644 --- a/openspec/changes/refine-project-management-tools/proposal.md +++ b/openspec/changes/refine-project-management-tools/proposal.md @@ -6,9 +6,9 @@ Project management is already partially exposed through catalog tools, but the c - Tighten `create_project` to focus on project creation and immediate SDK setup: - Accept organization, team, project name, optional project slug, and optional platform. + - Optionally link an existing organization repository as a first-class setup step. - Return the created project identity and a usable `SENTRY_DSN`. - Prefer the default DSN created by Sentry; create a `Default` DSN only as a fallback when no key exists. - - Remove repository linking from this tool. - Tighten `update_project` to metadata-only updates: - Allow project name, slug, and platform changes. - Reject slug changes from project-scoped sessions so the active project constraint does not become stale. diff --git a/openspec/changes/refine-project-management-tools/specs/project-management/spec.md b/openspec/changes/refine-project-management-tools/specs/project-management/spec.md index d2ff5b906..174c678f9 100644 --- a/openspec/changes/refine-project-management-tools/specs/project-management/spec.md +++ b/openspec/changes/refine-project-management-tools/specs/project-management/spec.md @@ -30,16 +30,21 @@ The `create_project` tool SHALL create a Sentry project and return a usable `SEN - **WHEN** `create_project` succeeds - **THEN** the response includes the project ID, project slug, project name, and `SENTRY_DSN` -### Requirement: Project creation accepts only core setup fields -The `create_project` tool SHALL accept only core project setup fields and SHALL NOT perform repository linking. +### Requirement: Project creation accepts core setup fields +The `create_project` tool SHALL accept core project setup fields and optional repository linking. #### Scenario: Caller provides core project fields - **WHEN** a caller provides `organizationSlug`, `teamSlug`, `name`, and optional `slug` or `platform` - **THEN** the tool sends the supported project creation fields to Sentry #### Scenario: Caller wants repository linking -- **WHEN** a caller asks `create_project` to link a repository -- **THEN** the tool schema does not accept repository linking parameters +- **WHEN** a caller provides a `repository` +- **THEN** the tool resolves the repository before creating the project +- **AND** creates a root code mapping for the created project and repository after project creation + +#### Scenario: Requested repository does not resolve +- **WHEN** a caller provides a `repository` that cannot be found or resolves ambiguously +- **THEN** the tool returns a user input error before creating the project ### Requirement: Project creation respects active constraints The system SHALL prevent project creation from escaping the active MCP constraints. diff --git a/openspec/changes/refine-project-management-tools/tasks.md b/openspec/changes/refine-project-management-tools/tasks.md index 48af23c75..4ece885da 100644 --- a/openspec/changes/refine-project-management-tools/tasks.md +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -7,7 +7,7 @@ ## 2. Project Tool Changes -- [x] 2.1 Update `create_project` schema and description to remove repository linking and add optional `slug`. +- [x] 2.1 Update `create_project` schema and description to keep repository linking first-class and add optional `slug`. - [x] 2.2 Change `create_project` DSN handling to list existing keys first, return an existing default/active DSN when available, and create a `Default` key only when no key exists. - [x] 2.3 Update `create_project` output and tests so the response always includes project identity and `SENTRY_DSN`. - [x] 2.4 Update `update_project` schema and description to remove `teamSlug`. @@ -32,7 +32,7 @@ ## 5. Tests - [x] 5.1 Update `create-project.test.ts` snapshots for the narrowed schema and DSN-first behavior. -- [x] 5.2 Add `create_project` tests for existing default DSN, fallback DSN creation, optional slug, and no repository linking. +- [x] 5.2 Add `create_project` tests for existing default DSN, fallback DSN creation, optional slug, and repository linking. - [x] 5.3 Update `update-project.test.ts` snapshots and remove team assignment cases. - [x] 5.4 Add `add-team-to-project.test.ts` coverage for add, already-assigned no-op, mixed-case slug preservation, and constraint injection. - [x] 5.5 Add `remove-team-from-project.test.ts` coverage for remove, not assigned, last team guard, mixed-case slug preservation, and constraint injection. diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 7ab103576..95a993eb6 100644 --- a/packages/mcp-core/README.md +++ b/packages/mcp-core/README.md @@ -35,6 +35,9 @@ npx @sentry/mcp-server@latest --access-token=sentry-user-token # Limit to specific skills only npx @sentry/mcp-server@latest --access-token=TOKEN --skills=inspect,docs +# Grant all active, non-deprecated skills, overriding MCP_SKILLS +npx @sentry/mcp-server@latest --access-token=TOKEN --all-skills + # Self-hosted Sentry npx @sentry/mcp-server@latest --access-token=TOKEN --host=sentry.example.com diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 2309bef94..b716534c5 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -1,6 +1,6 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; -import { mswServer } from "@sentry/mcp-server-mocks"; +import { mswServer, teamFixture } from "@sentry/mcp-server-mocks"; import { SentryApiService } from "./client"; import { ConfigurationError } from "../errors"; @@ -1913,7 +1913,7 @@ describe("API query builders", () => { }); }); - describe("linkProjectRepo", () => { + describe("linkProjectRepository", () => { let apiService: SentryApiService; beforeEach(() => { @@ -1927,7 +1927,7 @@ describe("API query builders", () => { vi.restoreAllMocks(); }); - it("should POST to the repo endpoint with repositoryId", async () => { + it("should POST to the code mappings bulk endpoint", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, headers: { @@ -1936,32 +1936,48 @@ describe("API query builders", () => { }, json: () => Promise.resolve({ - id: "1", - projectId: "456", - repositoryId: "101", - source: "scm_onboarding", - created: true, + created: 1, + updated: 0, + errors: 0, + mappings: [ + { + stackRoot: "", + sourceRoot: "", + status: "created", + }, + ], }), }); - const result = await apiService.linkProjectRepo({ + const result = await apiService.linkProjectRepository({ organizationSlug: "test-org", projectSlug: "my-project", - repositoryId: 101, + repository: "getsentry/sentry", + provider: "github", }); expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("/projects/test-org/my-project/repo/"), + expect.stringContaining("/organizations/test-org/code-mappings/bulk/"), expect.objectContaining({ method: "POST", - body: JSON.stringify({ repositoryId: 101 }), + body: JSON.stringify({ + project: "my-project", + repository: "getsentry/sentry", + provider: "github", + mappings: [ + { + stackRoot: "", + sourceRoot: "", + }, + ], + }), }), ); - expect(result.created).toBe(true); - expect(result.repositoryId).toBe("101"); + expect(result.created).toBe(1); + expect(result.mappings[0]!.status).toBe("created"); }); - it("should handle idempotent response", async () => { + it("should handle updated mappings response", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, headers: { @@ -1970,22 +1986,80 @@ describe("API query builders", () => { }, json: () => Promise.resolve({ - id: "1", - projectId: "456", - repositoryId: "101", - source: "manual", - created: false, + created: 0, + updated: 1, + errors: 0, + mappings: [ + { + stackRoot: "", + sourceRoot: "", + status: "updated", + }, + ], }), }); - const result = await apiService.linkProjectRepo({ + const result = await apiService.linkProjectRepository({ + organizationSlug: "test-org", + projectSlug: "my-project", + repository: "getsentry/sentry", + }); + + expect(result.updated).toBe(1); + expect(result.mappings[0]!.status).toBe("updated"); + }); + }); + + describe("listProjectTeams", () => { + let apiService: SentryApiService; + + beforeEach(() => { + apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("follows pagination cursors", async () => { + const backendTeam = { + ...teamFixture, + id: "4509109078196224", + slug: "backend", + name: "Backend", + }; + globalThis.fetch = vi.fn().mockImplementation((url: string) => { + const parsedUrl = new URL(url); + const isNextPage = + parsedUrl.searchParams.get("cursor") === "team-cursor"; + + return Promise.resolve({ + ok: true, + headers: { + get: (key: string) => { + if (key === "content-type") { + return "application/json"; + } + if (key === "link" && !isNextPage) { + return '; rel="next"; results="true"; cursor="team-cursor"'; + } + return null; + }, + }, + json: () => + Promise.resolve(isNextPage ? [backendTeam] : [teamFixture]), + }); + }); + + const teams = await apiService.listProjectTeams({ organizationSlug: "test-org", projectSlug: "my-project", - repositoryId: 101, }); - expect(result.created).toBe(false); - expect(result.source).toBe("manual"); + expect(teams.map((team) => team.slug)).toEqual(["the-goats", "backend"]); }); }); diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 2696dd4d0..320687865 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -34,7 +34,7 @@ import { TeamListSchema, TeamSchema, ProjectListSchema, - ProjectRepoLinkSchema, + ProjectRepositoryMappingSchema, ProjectSchema, CommitListSchema, DeployListSchema, @@ -1933,27 +1933,39 @@ export class SentryApiService { return RepositoryListSchema.parse(body); } - async linkProjectRepo( + async linkProjectRepository( { organizationSlug, projectSlug, - repositoryId, + repository, + provider, }: { organizationSlug: string; projectSlug: string; - repositoryId: number | string; + repository: string; + provider?: string | null; }, opts?: RequestOptions, ) { const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/repo/`, + `/organizations/${organizationSlug}/code-mappings/bulk/`, { method: "POST", - body: JSON.stringify({ repositoryId }), + body: JSON.stringify({ + project: projectSlug, + repository, + provider, + mappings: [ + { + stackRoot: "", + sourceRoot: "", + }, + ], + }), }, opts, ); - return ProjectRepoLinkSchema.parse(body); + return ProjectRepositoryMappingSchema.parse(body); } async listIssueAlertRules( @@ -2231,12 +2243,27 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/teams/`, - undefined, - opts, - ); - return TeamListSchema.parse(body); + const teams: TeamList = []; + let cursor: string | null = null; + + do { + const queryParams = new URLSearchParams(); + queryParams.set("per_page", "100"); + if (cursor) { + queryParams.set("cursor", cursor); + } + + const response = await this.request( + `/projects/${organizationSlug}/${projectSlug}/teams/?${queryParams.toString()}`, + undefined, + opts, + ); + const body = await this.parseJsonResponse(response); + teams.push(...TeamListSchema.parse(body)); + cursor = getNextCursor(response.headers.get("link")); + } while (cursor); + + return teams; } /** diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 6f0e0a4f4..a4a4ba30e 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -142,13 +142,21 @@ export const RepositorySchema = z export const RepositoryListSchema = z.array(RepositorySchema); -export const ProjectRepoLinkSchema = z +export const ProjectRepositoryMappingSchema = z .object({ - id: z.union([z.string(), z.number()]), - projectId: z.union([z.string(), z.number()]), - repositoryId: z.union([z.string(), z.number()]), - source: z.string(), - created: z.boolean(), + created: z.number(), + updated: z.number(), + errors: z.number(), + mappings: z.array( + z + .object({ + stackRoot: z.string(), + sourceRoot: z.string(), + status: z.string(), + detail: z.string().optional(), + }) + .passthrough(), + ), }) .passthrough(); diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index a700560cd..a2f720c5a 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -94,6 +94,7 @@ import type { ProfileChunkSchema, ProfileFrameSchema, ProjectListSchema, + ProjectRepositoryMappingSchema, ProjectSchema, ReleaseDetailsSchema, ReleaseListSchema, @@ -121,6 +122,9 @@ export type User = z.infer; export type Organization = z.infer; export type Team = z.infer; export type Project = z.infer; +export type ProjectRepositoryMapping = z.infer< + typeof ProjectRepositoryMappingSchema +>; export type IssueAlertRule = z.infer; export type MetricAlertRule = z.infer; export type ClientKey = z.infer; diff --git a/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts b/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts index 9730a0555..bcc4d80bc 100644 --- a/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts +++ b/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts @@ -22,11 +22,11 @@ import { setAgentProvider } from "./provider-factory"; // Mock Sentry API server - intercepts Sentry calls but bypasses OpenAI const mswServer = setupServer( // Mock the issue search fields endpoint (called by issueFields tool) - http.get("*/api/0/organizations/*/issues/", () => { + http.get("https://sentry.io/api/0/organizations/*/issues/", () => { return HttpResponse.json([]); }), // Mock the tags endpoint for field discovery - http.get("*/api/0/organizations/*/tags/", () => { + http.get("https://sentry.io/api/0/organizations/*/tags/", () => { return HttpResponse.json([ { key: "environment", name: "Environment" }, { key: "level", name: "Level" }, @@ -35,7 +35,7 @@ const mswServer = setupServer( ]); }), // Mock whoami endpoint (called by whoami tool) - http.get("*/api/0/users/me/", () => { + http.get("https://sentry.io/api/0/users/me/", () => { return HttpResponse.json({ id: "12345", email: "test@example.com", diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 54f81e79b..eb2f6aab9 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -385,7 +385,7 @@ }, { "name": "create_project", - "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", + "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", "requiredScopes": ["project:write", "team:read", "org:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 84f952f20..f771331a7 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -172,7 +172,7 @@ }, { "name": "create_project", - "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n\nDO NOT USE create_dsn after this - DSN is included in output.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If any parameter is ambiguous, you should clarify with the user what they meant.\n", + "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", "inputSchema": { "type": "object", "properties": { @@ -226,6 +226,18 @@ ], "description": "The platform for the project. e.g., python, javascript, react, etc.", "default": null + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional repository name to link to the project (e.g. 'getsentry/sentry'). The repo must already be connected to the organization via a VCS integration." } }, "required": ["organizationSlug", "teamSlug", "name"], diff --git a/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts b/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts index 6c189b88e..8537bf193 100644 --- a/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts @@ -37,7 +37,7 @@ describe("add_team_to_project", () => { let postCalls = 0; mswServer.use( http.get( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => { listCalls += 1; return HttpResponse.json( @@ -46,7 +46,7 @@ describe("add_team_to_project", () => { }, ), http.post( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", () => { postCalls += 1; return new HttpResponse(null, { status: 201 }); @@ -94,11 +94,12 @@ describe("add_team_to_project", () => { }); let postCalls = 0; mswServer.use( - http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => - HttpResponse.json([teamFixture, backendTeam]), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => HttpResponse.json([teamFixture, backendTeam]), ), http.post( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", () => { postCalls += 1; return HttpResponse.json( @@ -132,7 +133,7 @@ describe("add_team_to_project", () => { let listCalls = 0; mswServer.use( http.get( - "*/api/0/projects/:organizationSlug/:projectSlug/teams/", + "https://sentry.io/api/0/projects/:organizationSlug/:projectSlug/teams/", ({ request }) => { paths.push(new URL(request.url).pathname); listCalls += 1; @@ -150,7 +151,7 @@ describe("add_team_to_project", () => { }, ), http.post( - "*/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", + "https://sentry.io/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", ({ request }) => { paths.push(new URL(request.url).pathname); return new HttpResponse(null, { status: 201 }); diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts index 29511c3a4..dbae16652 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts @@ -12,12 +12,14 @@ describe("analyze_issue_with_seer", () => { beforeEach(() => { vi.useFakeTimers(); mswServer.use( - http.get("*/api/0/organizations/:org/issues/:issueId/", ({ params }) => - HttpResponse.json( - createUnsupportedIssue({ - shortId: String(params.issueId), - }), - ), + http.get( + "https://sentry.io/api/0/organizations/:org/issues/:issueId/", + ({ params }) => + HttpResponse.json( + createUnsupportedIssue({ + shortId: String(params.issueId), + }), + ), ), ); }); @@ -58,7 +60,7 @@ describe("analyze_issue_with_seer", () => { it("wraps completed Seer-authored sections with provenance tags", async () => { mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-TAGS/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-TAGS/autofix/", () => HttpResponse.json({ autofix: { @@ -166,7 +168,7 @@ describe("analyze_issue_with_seer", () => { let attempts = 0; mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-99/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-99/autofix/", () => { attempts++; if (attempts < 3) { @@ -209,7 +211,7 @@ describe("analyze_issue_with_seer", () => { let attempts = 0; mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-500/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-500/autofix/", () => { attempts++; if (attempts < 2) { @@ -273,7 +275,7 @@ describe("analyze_issue_with_seer", () => { mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-TIMEOUT/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-TIMEOUT/autofix/", () => { // Always return in progress return HttpResponse.json(inProgressState); @@ -321,7 +323,7 @@ describe("analyze_issue_with_seer", () => { mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-ERRORS/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-ERRORS/autofix/", () => { pollAttempts++; if (pollAttempts === 1) { @@ -371,7 +373,7 @@ describe("analyze_issue_with_seer", () => { mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-NEW/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-NEW/autofix/", () => { getCallCount++; if (getCallCount === 1) { @@ -383,7 +385,7 @@ describe("analyze_issue_with_seer", () => { }, ), http.post( - "*/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-NEW/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-NEW/autofix/", async ({ request }) => { const body = await request.json(); expect(body).toEqual({ @@ -428,11 +430,11 @@ describe("analyze_issue_with_seer", () => { mswServer.use( http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/MCP-SERVER-EQE/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/MCP-SERVER-EQE/", () => HttpResponse.json(createRegressedIssue()), ), http.get( - "*/api/0/organizations/sentry-mcp-evals/issues/MCP-SERVER-EQE/autofix/", + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/MCP-SERVER-EQE/autofix/", () => { autofixRequests++; return HttpResponse.json({ autofix: null }); diff --git a/packages/mcp-core/src/tools/catalog/create-project.test.ts b/packages/mcp-core/src/tools/catalog/create-project.test.ts index 38a4cb14d..e08abe78b 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.test.ts @@ -23,8 +23,10 @@ describe("create_project", () => { it("serializes with the existing default DSN", async () => { mswServer.use( - http.post("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => - HttpResponse.json({ detail: "unexpected fallback" }, { status: 500 }), + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json({ detail: "unexpected fallback" }, { status: 500 }), ), ); @@ -36,6 +38,7 @@ describe("create_project", () => { slug: null, platform: "node", regionUrl: null, + repository: null, }, context, ); @@ -50,6 +53,7 @@ describe("create_project", () => { ## Response Notes - Please tell the user the project slug and **SENTRY_DSN**. + - No additional DSN creation step is needed. - The **SENTRY_DSN** value is used to initialize Sentry SDKs. " `); @@ -57,19 +61,23 @@ describe("create_project", () => { it("uses an existing non-default DSN when no default key exists", async () => { mswServer.use( - http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => - HttpResponse.json([ - { - ...clientKeyFixture, - name: "Production", - dsn: { - public: "https://production@example.com/1", + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json([ + { + ...clientKeyFixture, + name: "Production", + dsn: { + public: "https://production@example.com/1", + }, }, - }, - ]), + ]), ), - http.post("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => - HttpResponse.json({ detail: "unexpected fallback" }, { status: 500 }), + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json({ detail: "unexpected fallback" }, { status: 500 }), ), ); @@ -81,6 +89,7 @@ describe("create_project", () => { slug: null, platform: "node", regionUrl: null, + repository: null, }, context, ); @@ -90,19 +99,67 @@ describe("create_project", () => { ); }); + it("creates a fallback default DSN when only inactive keys exist", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json([ + { + ...clientKeyFixture, + isActive: false, + dsn: { + public: "https://inactive@example.com/1", + }, + }, + ]), + ), + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json({ + ...clientKeyFixture, + name: "Default", + dsn: { + public: "https://fallback@example.com/1", + }, + }), + ), + ); + + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: null, + }, + context, + ); + + expect(result).toContain("**SENTRY_DSN**: https://fallback@example.com/1"); + expect(result).not.toContain("https://inactive@example.com/1"); + }); + it("creates a fallback default DSN when no key exists", async () => { mswServer.use( - http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => - HttpResponse.json([]), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => HttpResponse.json([]), ), - http.post("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", () => - HttpResponse.json({ - ...clientKeyFixture, - name: "Default", - dsn: { - public: "https://fallback@example.com/1", - }, - }), + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json({ + ...clientKeyFixture, + name: "Default", + dsn: { + public: "https://fallback@example.com/1", + }, + }), ), ); @@ -114,6 +171,7 @@ describe("create_project", () => { slug: null, platform: "node", regionUrl: null, + repository: null, }, context, ); @@ -125,7 +183,7 @@ describe("create_project", () => { let createBody: unknown; mswServer.use( http.post( - "*/api/0/teams/sentry-mcp-evals/the-goats/projects/", + "https://sentry.io/api/0/teams/sentry-mcp-evals/the-goats/projects/", async ({ request }) => { createBody = await request.json(); return HttpResponse.json({ @@ -136,8 +194,9 @@ describe("create_project", () => { }); }, ), - http.get("*/api/0/projects/sentry-mcp-evals/my-project/keys/", () => - HttpResponse.json([clientKeyFixture]), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/my-project/keys/", + () => HttpResponse.json([clientKeyFixture]), ), ); @@ -149,6 +208,7 @@ describe("create_project", () => { slug: "my-project", platform: "node", regionUrl: null, + repository: null, }, context, ); @@ -161,9 +221,142 @@ describe("create_project", () => { expect(result).toContain("**Slug**: my-project"); }); - it("accepts slug but no repository linking parameter", () => { + it("links a matching repository when provided", async () => { + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: "getsentry/sentry", + }, + context, + ); + + expect(result).toContain("**Repository**: getsentry/sentry (linked)"); + expect(result).toContain("**Code Mapping**: `/` -> `/`"); + expect(result).not.toContain("Repository Link ID"); + }); + + it("returns project setup details when repository linking fails after creation", async () => { + mswServer.use( + http.post( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/code-mappings/bulk/", + () => HttpResponse.json({ detail: "link failed" }, { status: 500 }), + ), + ); + + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: "getsentry/sentry", + }, + context, + ); + + expect(result).toContain("**Slug**: cloudflare-mcp"); + expect(result).toContain("**SENTRY_DSN**:"); + expect(result).toContain( + "Found getsentry/sentry but failed to link it to the project", + ); + }); + + it("rejects an unknown repository before creating the project", async () => { + let createCalls = 0; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/repos/", + () => HttpResponse.json([]), + ), + http.post( + "https://sentry.io/api/0/teams/sentry-mcp-evals/the-goats/projects/", + () => { + createCalls += 1; + return HttpResponse.json( + { detail: "unexpected create" }, + { status: 500 }, + ); + }, + ), + ); + + await expect( + createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: "missing/repo", + }, + context, + ), + ).rejects.toThrow('Could not find repository "missing/repo"'); + expect(createCalls).toBe(0); + }); + + it("rejects an ambiguous repository before creating the project", async () => { + let createCalls = 0; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/repos/", + () => + HttpResponse.json([ + { + id: "101", + name: "getsentry/sentry", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }, + { + id: "102", + name: "other/sentry", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }, + ]), + ), + http.post( + "https://sentry.io/api/0/teams/sentry-mcp-evals/the-goats/projects/", + () => { + createCalls += 1; + return HttpResponse.json( + { detail: "unexpected create" }, + { status: 500 }, + ); + }, + ), + ); + + await expect( + createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: "sentry", + }, + context, + ), + ).rejects.toThrow('Repository "sentry" matched multiple repositories'); + expect(createCalls).toBe(0); + }); + + it("accepts slug and repository linking parameters", () => { expect(createProject.inputSchema).toHaveProperty("slug"); - expect(createProject.inputSchema).not.toHaveProperty("repository"); - expect(createProject.description).not.toContain("repository"); + expect(createProject.inputSchema).toHaveProperty("repository"); + expect(createProject.description).toContain("repository"); }); }); diff --git a/packages/mcp-core/src/tools/catalog/create-project.ts b/packages/mcp-core/src/tools/catalog/create-project.ts index 3b8a2a3df..577067f39 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { setTag } from "@sentry/core"; import { defineTool } from "../../internal/tool-helpers/define"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { UserInputError } from "../../errors"; +import { logIssue } from "../../telem/logging"; import type { ServerContext } from "../../types"; import { ParamOrganizationSlug, @@ -12,17 +14,50 @@ import { } from "../../schema"; import type { ClientKey } from "../../api-client/index"; +type RepositoryMatch = { + name: string; + provider: { + id: string; + }; +}; + function getUsableClientKey(clientKeys: ClientKey[]): ClientKey | undefined { return ( clientKeys.find( (key) => key.name === "Default" && key.isActive && key.dsn.public, - ) ?? - clientKeys.find((key) => key.isActive && key.dsn.public) ?? - clientKeys.find((key) => key.name === "Default" && key.dsn.public) ?? - clientKeys.find((key) => key.dsn.public) + ) ?? clientKeys.find((key) => key.isActive && key.dsn.public) ); } +function findRepositoryMatch( + repositories: RepositoryMatch[], + repository: string, +): RepositoryMatch { + const matches = repositories.filter( + (candidate) => + candidate.name === repository || + candidate.name.endsWith(`/${repository}`), + ); + + if (matches.length === 0) { + throw new UserInputError( + `Could not find repository "${repository}" in the organization. Make sure it is connected through a VCS integration before creating the project.`, + ); + } + + if (matches.length > 1) { + throw new UserInputError( + `Repository "${repository}" matched multiple repositories. Provide the full repository name, such as owner/repo.`, + ); + } + + return matches[0]; +} + +function getRepositoryProvider(repository: RepositoryMatch): string | null { + return repository.provider.id.replace(/^integrations:/, "") || null; +} + export default defineTool({ name: "create_project", skills: ["project-management"], // Only available in project-management skill @@ -35,8 +70,9 @@ export default defineTool({ "- 'Set up a project for [app/service] with team [X]'", "- 'I need a new Sentry project'", "- Create project AND need DSN in one step", + "- Create project and link an existing repository", "", - "DO NOT USE create_dsn after this - DSN is included in output.", + "Returns the created project slug and a usable SENTRY_DSN.", "", "Be careful when using this tool!", "", @@ -49,12 +85,11 @@ export default defineTool({ "```", "create_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')", "```", + "### Create project and link to a repository", + "```", + "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')", + "```", "", - "", - "", - "- If the user passes a parameter in the form of name/otherName, its likely in the format of /.", - "- If any parameter is ambiguous, you should clarify with the user what they meant.", - "", ].join("\n"), inputSchema: { organizationSlug: ParamOrganizationSlug, @@ -70,6 +105,14 @@ export default defineTool({ .nullable() .default(null), platform: ParamPlatform.nullable().default(null), + repository: z + .string() + .trim() + .nullable() + .default(null) + .describe( + "Optional repository name to link to the project (e.g. 'getsentry/sentry'). The repo must already be connected to the organization via a VCS integration.", + ), }, annotations: { readOnlyHint: false, @@ -85,6 +128,18 @@ export default defineTool({ setTag("organization.slug", organizationSlug); setTag("team.slug", params.teamSlug); + // Resolve repository intent before creating the project so bad or ambiguous + // repo input cannot leave behind an otherwise unwanted project. + const repositoryMatch = params.repository + ? findRepositoryMatch( + await apiService.listRepos({ + organizationSlug, + query: params.repository, + }), + params.repository, + ) + : null; + const project = await apiService.createProject({ organizationSlug, teamSlug: params.teamSlug, @@ -93,6 +148,30 @@ export default defineTool({ platform: params.platform, }); + let repositoryLinked = false; + let repositoryLinkFailed = false; + if (repositoryMatch) { + try { + const repositoryMapping = await apiService.linkProjectRepository({ + organizationSlug, + projectSlug: project.slug, + repository: repositoryMatch.name, + provider: getRepositoryProvider(repositoryMatch), + }); + repositoryLinked = + repositoryMapping.errors === 0 && + repositoryMapping.mappings.some((mapping) => + ["created", "updated"].includes(mapping.status), + ); + repositoryLinkFailed = !repositoryLinked; + } catch (err) { + // Repository linking happens after project creation; preserve the + // project + DSN response and report the link failure in the output. + logIssue(err); + repositoryLinkFailed = true; + } + } + const clientKeys = await apiService.listClientKeys({ organizationSlug, projectSlug: project.slug, @@ -112,9 +191,21 @@ export default defineTool({ output += `**Slug**: ${project.slug}\n`; output += `**Name**: ${project.name}\n`; output += `**SENTRY_DSN**: ${clientKey.dsn.public}\n\n`; + if (repositoryMatch && repositoryLinked) { + output += `**Repository**: ${repositoryMatch.name} (linked)\n`; + output += "**Code Mapping**: `/` -> `/`\n\n"; + } else if (repositoryMatch && repositoryLinkFailed) { + output += `**Repository**: Found ${repositoryMatch.name} but failed to link it to the project. Check permissions and try linking manually.\n\n`; + } output += "## Response Notes\n\n"; output += `- Please tell the user the project slug and **SENTRY_DSN**.\n`; + output += `- No additional DSN creation step is needed.\n`; output += `- The **SENTRY_DSN** value is used to initialize Sentry SDKs.\n`; + if (repositoryMatch && repositoryLinked) { + output += `- Repository linked to project with a root code mapping: ${repositoryMatch.name}\n`; + } else if (repositoryMatch && repositoryLinkFailed) { + output += `- Project creation succeeded, but repository linking did not. Ask the user to confirm permissions or link the repository manually in Sentry.\n`; + } return output; }, }); diff --git a/packages/mcp-core/src/tools/catalog/find-projects.test.ts b/packages/mcp-core/src/tools/catalog/find-projects.test.ts index 9c744d7bf..eeb498c1f 100644 --- a/packages/mcp-core/src/tools/catalog/find-projects.test.ts +++ b/packages/mcp-core/src/tools/catalog/find-projects.test.ts @@ -27,18 +27,21 @@ describe("find_projects", () => { const context = getServerContext(); mswServer.use( - http.get("*/api/0/organizations/*/projects/", ({ request }) => { - expect(new URL(request.url).pathname).toBe( - "/api/0/organizations/MyOrg/projects/", - ); - return HttpResponse.json([ - { - id: "1", - slug: "MyProject", - name: "My Project", - }, - ]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/projects/", + ({ request }) => { + expect(new URL(request.url).pathname).toBe( + "/api/0/organizations/MyOrg/projects/", + ); + return HttpResponse.json([ + { + id: "1", + slug: "MyProject", + name: "My Project", + }, + ]); + }, + ), ); const params = prepareToolParams({ diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts index e1da60702..272880ae4 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts @@ -1659,14 +1659,17 @@ describe("get_issue_details", () => { mswServer.use( // More specific pattern for events (must come first to match before the issue pattern) http.get( - "*/api/0/organizations/*/issues/FUTURE-TYPE-001/events/latest/", + "https://sentry.io/api/0/organizations/*/issues/FUTURE-TYPE-001/events/latest/", () => { return HttpResponse.json(unsupportedEventFixture); }, ), - http.get("*/api/0/organizations/*/issues/FUTURE-TYPE-001", () => { - return HttpResponse.json(unsupportedIssueFixture); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/FUTURE-TYPE-001", + () => { + return HttpResponse.json(unsupportedIssueFixture); + }, + ), ); const result = await getIssueDetails.handler( diff --git a/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts index eb2a02766..a869623dc 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-user-reports.test.ts @@ -44,7 +44,7 @@ describe("get_issue_user_reports", () => { it("returns pagination guidance when more reports are available", async () => { mswServer.use( http.get( - "*/api/0/organizations/:org/issues/:issueId/user-reports/", + "https://sentry.io/api/0/organizations/:org/issues/:issueId/user-reports/", ({ request }) => { const url = new URL(request.url); expect(url.searchParams.get("per_page")).toBe("1"); diff --git a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts index 77b204a1e..3ae59031f 100644 --- a/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts @@ -205,14 +205,14 @@ describe("get_monitor_details", () => { }; mswServer.use( http.get( - "*/api/0/projects/*/*/monitors/nightly-import/", + "https://sentry.io/api/0/projects/*/*/monitors/nightly-import/", ({ request }) => { paths.push(new URL(request.url).pathname); return HttpResponse.json(monitorResponse); }, ), http.get( - "*/api/0/projects/*/*/monitors/nightly-import/checkins/", + "https://sentry.io/api/0/projects/*/*/monitors/nightly-import/checkins/", ({ request }) => { paths.push(new URL(request.url).pathname); return HttpResponse.json([]); diff --git a/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts b/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts index d173f4780..eaff91017 100644 --- a/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts @@ -38,7 +38,7 @@ describe("remove_team_from_project", () => { let deleteCalls = 0; mswServer.use( http.get( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => { listCalls += 1; return HttpResponse.json( @@ -47,7 +47,7 @@ describe("remove_team_from_project", () => { }, ), http.delete( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/the-goats/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/the-goats/", () => { deleteCalls += 1; return new HttpResponse(null, { status: 204 }); @@ -89,11 +89,12 @@ describe("remove_team_from_project", () => { it("rejects removal when the team is not assigned", async () => { let deleteCalls = 0; mswServer.use( - http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => - HttpResponse.json([teamFixture]), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => HttpResponse.json([teamFixture]), ), http.delete( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/backend/", () => { deleteCalls += 1; return HttpResponse.json( @@ -122,11 +123,12 @@ describe("remove_team_from_project", () => { it("rejects removal when the team is the last assigned team", async () => { let deleteCalls = 0; mswServer.use( - http.get("*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", () => - HttpResponse.json([teamFixture]), + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => HttpResponse.json([teamFixture]), ), http.delete( - "*/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/the-goats/", + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/the-goats/", () => { deleteCalls += 1; return HttpResponse.json( @@ -162,7 +164,7 @@ describe("remove_team_from_project", () => { let listCalls = 0; mswServer.use( http.get( - "*/api/0/projects/:organizationSlug/:projectSlug/teams/", + "https://sentry.io/api/0/projects/:organizationSlug/:projectSlug/teams/", ({ request }) => { paths.push(new URL(request.url).pathname); listCalls += 1; @@ -181,7 +183,7 @@ describe("remove_team_from_project", () => { }, ), http.delete( - "*/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", + "https://sentry.io/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", ({ request }) => { paths.push(new URL(request.url).pathname); return new HttpResponse(null, { status: 204 }); diff --git a/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts b/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts index 87e16b051..21d8b368d 100644 --- a/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts @@ -92,21 +92,24 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - // Endpoint is already scoped to the issue, no issue: in query needed - const query = url.searchParams.get("query"); - // Query param can be null or empty string when no filters - expect(query === null || query === "").toBe(true); - // Return array directly, not wrapped in {data: [...]} - return HttpResponse.json([ - { - id: "event1", - timestamp: "2025-01-15T10:00:00Z", - title: "Test Error", - }, - ]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + // Endpoint is already scoped to the issue, no issue: in query needed + const query = url.searchParams.get("query"); + // Query param can be null or empty string when no filters + expect(query === null || query === "").toBe(true); + // Return array directly, not wrapped in {data: [...]} + return HttpResponse.json([ + { + id: "event1", + timestamp: "2025-01-15T10:00:00Z", + title: "Test Error", + }, + ]); + }, + ), ); const result = await searchIssueEvents.handler( @@ -155,7 +158,7 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", () => + http.get("https://sentry.io/api/0/organizations/*/issues/*/events/", () => HttpResponse.json([ { id: "event1", @@ -196,7 +199,7 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", () => + http.get("https://sentry.io/api/0/organizations/*/issues/*/events/", () => HttpResponse.json([ { id: "event1", @@ -235,13 +238,16 @@ describe("search_issue_events", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - // Endpoint is scoped to issue, query param can be null or empty - const query = url.searchParams.get("query"); - expect(query === null || query === "").toBe(true); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + // Endpoint is scoped to issue, query param can be null or empty + const query = url.searchParams.get("query"); + expect(query === null || query === "").toBe(true); + return HttpResponse.json([]); + }, + ), ); const result = await searchIssueEvents.handler( @@ -296,13 +302,16 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - const query = url.searchParams.get("query"); - // Endpoint is scoped to issue, so only user filters in query - expect(query).toBe("environment:production release:v1.0"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("query"); + // Endpoint is scoped to issue, so only user filters in query + expect(query).toBe("environment:production release:v1.0"); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -329,11 +338,14 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("statsPeriod")).toBe("1h"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("statsPeriod")).toBe("1h"); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -361,12 +373,15 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("start")).toBe("2025-01-15T00:00:00Z"); - expect(url.searchParams.get("end")).toBe("2025-01-16T00:00:00Z"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("start")).toBe("2025-01-15T00:00:00Z"); + expect(url.searchParams.get("end")).toBe("2025-01-16T00:00:00Z"); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -391,11 +406,14 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("statsPeriod")).toBe("14d"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("statsPeriod")).toBe("14d"); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -442,9 +460,12 @@ describe("search_issue_events", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", () => { - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + () => { + return HttpResponse.json([]); + }, + ), ); const result = await searchIssueEvents.handler( @@ -532,13 +553,16 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - const query = url.searchParams.get("query"); - // No issue: prefix needed - endpoint handles it - expect(query).toBe("environment:production"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("query"); + // No issue: prefix needed - endpoint handles it + expect(query).toBe("environment:production"); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -561,11 +585,14 @@ describe("search_issue_events", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("per_page")).toBe("25"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("per_page")).toBe("25"); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -595,9 +622,12 @@ describe("search_issue_events", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", () => { - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + () => { + return HttpResponse.json([]); + }, + ), ); const result = await searchIssueEvents.handler( @@ -622,13 +652,16 @@ describe("search_issue_events", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - // Endpoint is scoped to issue, query param can be null or empty - const query = url.searchParams.get("query"); - expect(query === null || query === "").toBe(true); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + // Endpoint is scoped to issue, query param can be null or empty + const query = url.searchParams.get("query"); + expect(query === null || query === "").toBe(true); + return HttpResponse.json([]); + }, + ), ); await searchIssueEvents.handler( @@ -653,26 +686,29 @@ describe("search_issue_events", () => { process.env.OPENROUTER_API_KEY = ""; mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("query")).toBe("environment:production"); - expect(url.searchParams.get("sort")).toBe("-timestamp"); - expect(url.searchParams.get("statsPeriod")).toBe("7d"); - return HttpResponse.json([ - { - id: "event1", - timestamp: "2025-01-15T10:00:00Z", - title: "Test Error", - message: "Something went wrong", - level: "error", - environment: "production", - release: "v1.0", - "user.display": "alice", - trace: "abc123", - url: "/api/endpoint", - }, - ]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/*/events/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe("environment:production"); + expect(url.searchParams.get("sort")).toBe("-timestamp"); + expect(url.searchParams.get("statsPeriod")).toBe("7d"); + return HttpResponse.json([ + { + id: "event1", + timestamp: "2025-01-15T10:00:00Z", + title: "Test Error", + message: "Something went wrong", + level: "error", + environment: "production", + release: "v1.0", + "user.display": "alice", + trace: "abc123", + url: "/api/endpoint", + }, + ]); + }, + ), ); const result = await searchIssueEvents.handler( diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index 9a222dd99..eeb467b36 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -76,30 +76,33 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("is:unresolved", "date")); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - const query = url.searchParams.get("query"); - expect(query).toBe("is:unresolved"); - return HttpResponse.json([ - { - id: "123", - shortId: "PROJ-123", - title: "Test Error", - status: "unresolved", - count: "100", - userCount: 50, - firstSeen: "2025-01-15T10:00:00Z", - lastSeen: "2025-01-15T12:00:00Z", - permalink: "https://sentry.io/issues/123/", - project: { - id: "456", - slug: "test-project", - name: "Test Project", + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("query"); + expect(query).toBe("is:unresolved"); + return HttpResponse.json([ + { + id: "123", + shortId: "PROJ-123", + title: "Test Error", + status: "unresolved", + count: "100", + userCount: 50, + firstSeen: "2025-01-15T10:00:00Z", + lastSeen: "2025-01-15T12:00:00Z", + permalink: "https://sentry.io/issues/123/", + project: { + id: "456", + slug: "test-project", + name: "Test Project", + }, + culprit: "test.function", }, - culprit: "test.function", - }, - ]); - }), + ]); + }, + ), ); const result = await searchIssues.handler( @@ -153,32 +156,35 @@ describe("search_issues", () => { process.env.OPENROUTER_API_KEY = ""; mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("query")).toBe( - "is:unresolved is:unassigned", - ); - expect(url.searchParams.get("sort")).toBe("freq"); - return HttpResponse.json([ - { - id: "123", - shortId: "PROJ-123", - title: "Test Error", - status: "unresolved", - count: "100", - userCount: 50, - firstSeen: "2025-01-15T10:00:00Z", - lastSeen: "2025-01-15T12:00:00Z", - permalink: "https://sentry.io/issues/123/", - project: { - id: "456", - slug: "test-project", - name: "Test Project", + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe( + "is:unresolved is:unassigned", + ); + expect(url.searchParams.get("sort")).toBe("freq"); + return HttpResponse.json([ + { + id: "123", + shortId: "PROJ-123", + title: "Test Error", + status: "unresolved", + count: "100", + userCount: 50, + firstSeen: "2025-01-15T10:00:00Z", + lastSeen: "2025-01-15T12:00:00Z", + permalink: "https://sentry.io/issues/123/", + project: { + id: "456", + slug: "test-project", + name: "Test Project", + }, + culprit: "test.function", }, - culprit: "test.function", - }, - ]); - }), + ]); + }, + ), ); const result = await searchIssues.handler( @@ -207,29 +213,32 @@ describe("search_issues", () => { process.env.OPENROUTER_API_KEY = ""; mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("query")).toBe("is:unresolved"); - return HttpResponse.json([ - { - id: "123", - shortId: "PROJ-123", - title: "Test Error", - status: "unresolved", - count: "100", - userCount: 50, - firstSeen: "2025-01-15T10:00:00Z", - lastSeen: "2025-01-15T12:00:00Z", - permalink: "https://sentry.io/issues/123/", - project: { - id: "456", - slug: "test-project", - name: "Test Project", + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe("is:unresolved"); + return HttpResponse.json([ + { + id: "123", + shortId: "PROJ-123", + title: "Test Error", + status: "unresolved", + count: "100", + userCount: 50, + firstSeen: "2025-01-15T10:00:00Z", + lastSeen: "2025-01-15T12:00:00Z", + permalink: "https://sentry.io/issues/123/", + project: { + id: "456", + slug: "test-project", + name: "Test Project", + }, + culprit: "test.function", }, - culprit: "test.function", - }, - ]); - }), + ]); + }, + ), ); const result = await searchIssues.handler( @@ -277,30 +286,33 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse(explicitQuery, "date")); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("query")).toBe(explicitQuery); - expect(url.searchParams.get("sort")).toBe("date"); - return HttpResponse.json([ - { - id: "123", - shortId: "PROJ-123", - title: "Needs Review", - status: "unresolved", - count: "5", - userCount: 2, - firstSeen: "2025-01-15T10:00:00Z", - lastSeen: "2025-01-15T12:00:00Z", - permalink: "https://sentry.io/issues/123/", - culprit: "test.function", - project: { - id: "456", - slug: "test-project", - name: "Test Project", + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("query")).toBe(explicitQuery); + expect(url.searchParams.get("sort")).toBe("date"); + return HttpResponse.json([ + { + id: "123", + shortId: "PROJ-123", + title: "Needs Review", + status: "unresolved", + count: "5", + userCount: 2, + firstSeen: "2025-01-15T10:00:00Z", + lastSeen: "2025-01-15T12:00:00Z", + permalink: "https://sentry.io/issues/123/", + culprit: "test.function", + project: { + id: "456", + slug: "test-project", + name: "Test Project", + }, }, - }, - ]); - }), + ]); + }, + ), ); const result = await searchIssues.handler( @@ -328,7 +340,7 @@ describe("search_issues", () => { process.env.OPENROUTER_API_KEY = ""; mswServer.use( - http.get("*/api/0/projects/*/*/", ({ request }) => { + http.get("https://sentry.io/api/0/projects/*/*/", ({ request }) => { expect(new URL(request.url).pathname).toBe( "/api/0/projects/MyOrg/MyProject/", ); @@ -338,13 +350,16 @@ describe("search_issues", () => { name: "My Project", }); }), - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - expect(url.pathname).toBe("/api/0/organizations/MyOrg/issues/"); - expect(url.searchParams.get("project")).toBe("789"); - expect(url.searchParams.get("statsPeriod")).toBe("30d"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + expect(url.pathname).toBe("/api/0/organizations/MyOrg/issues/"); + expect(url.searchParams.get("project")).toBe("789"); + expect(url.searchParams.get("statsPeriod")).toBe("30d"); + return HttpResponse.json([]); + }, + ), ); const params = prepareToolParams({ @@ -372,12 +387,15 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("project")).toBe("123456"); - expect(url.searchParams.get("statsPeriod")).toBe("30d"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("project")).toBe("123456"); + expect(url.searchParams.get("statsPeriod")).toBe("30d"); + return HttpResponse.json([]); + }, + ), ); await searchIssues.handler( @@ -399,12 +417,15 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "freq")); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - const sort = url.searchParams.get("sort"); - expect(sort).toBe("freq"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + const sort = url.searchParams.get("sort"); + expect(sort).toBe("freq"); + return HttpResponse.json([]); + }, + ), ); await searchIssues.handler( @@ -426,12 +447,15 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("", null)); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - const sort = url.searchParams.get("sort"); - expect(sort).toBe("date"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + const sort = url.searchParams.get("sort"); + expect(sort).toBe("date"); + return HttpResponse.json([]); + }, + ), ); await searchIssues.handler( @@ -453,12 +477,15 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - const limit = url.searchParams.get("limit"); - expect(limit).toBe("25"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + const limit = url.searchParams.get("limit"); + expect(limit).toBe("25"); + return HttpResponse.json([]); + }, + ), ); await searchIssues.handler( @@ -480,7 +507,7 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("is:unresolved", "date")); mswServer.use( - http.get("*/api/0/organizations/*/issues/", () => { + http.get("https://sentry.io/api/0/organizations/*/issues/", () => { return HttpResponse.json([]); }), ); @@ -507,7 +534,7 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/", () => { + http.get("https://sentry.io/api/0/organizations/*/issues/", () => { return HttpResponse.json([]); }), ); @@ -535,12 +562,15 @@ describe("search_issues", () => { ); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - const query = url.searchParams.get("query"); - expect(query).toBe("is:unresolved level:error"); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("query"); + expect(query).toBe("is:unresolved level:error"); + return HttpResponse.json([]); + }, + ), ); await searchIssues.handler( @@ -570,12 +600,15 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("", sortOption)); mswServer.use( - http.get("*/api/0/organizations/*/issues/", ({ request }) => { - const url = new URL(request.url); - const sort = url.searchParams.get("sort"); - expect(sort).toBe(sortOption); - return HttpResponse.json([]); - }), + http.get( + "https://sentry.io/api/0/organizations/*/issues/", + ({ request }) => { + const url = new URL(request.url); + const sort = url.searchParams.get("sort"); + expect(sort).toBe(sortOption); + return HttpResponse.json([]); + }, + ), ); await searchIssues.handler( @@ -598,7 +631,7 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); mswServer.use( - http.get("*/api/0/organizations/*/issues/", () => { + http.get("https://sentry.io/api/0/organizations/*/issues/", () => { return HttpResponse.json([ { id: "123", @@ -664,7 +697,7 @@ describe("search_issues", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/", () => { + http.get("https://sentry.io/api/0/organizations/*/issues/", () => { return HttpResponse.json( { detail: "Organization not found" }, { status: 404 }, diff --git a/packages/mcp-core/src/tools/catalog/update-project.test.ts b/packages/mcp-core/src/tools/catalog/update-project.test.ts index e0dfe0fc0..7e9ff75a3 100644 --- a/packages/mcp-core/src/tools/catalog/update-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-project.test.ts @@ -54,7 +54,7 @@ describe("update_project", () => { const paths: string[] = []; let updateBody: unknown; mswServer.use( - http.put("*/api/0/projects/*/*/", async ({ request }) => { + http.put("https://sentry.io/api/0/projects/*/*/", async ({ request }) => { paths.push(new URL(request.url).pathname); updateBody = await request.json(); return HttpResponse.json({ diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index d26ff1dd0..4aca053d1 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -532,6 +532,20 @@ export const restHandlers = buildHandlers([ return HttpResponse.json([clientKeyFixture]); }, }, + { + method: "get", + path: "/api/0/projects/:organizationSlug/:projectSlug/teams/", + fetch: () => { + return HttpResponse.json([teamFixture]); + }, + }, + { + method: "delete", + path: "/api/0/projects/:organizationSlug/:projectSlug/teams/:teamSlug/", + fetch: () => { + return new HttpResponse(null, { status: 204 }); + }, + }, { method: "put", path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/:keyId/", @@ -1489,15 +1503,21 @@ export const restHandlers = buildHandlers([ }, { method: "post", - path: "/api/0/projects/:organizationSlug/:projectSlug/repo/", + path: "/api/0/organizations/:organizationSlug/code-mappings/bulk/", fetch: async ({ request }) => { - const body = (await request.json()) as Record; + const body = (await request.json()) as { + mappings?: Array<{ stackRoot?: string; sourceRoot?: string }>; + }; + const mappings = body.mappings ?? [{ stackRoot: "", sourceRoot: "" }]; return HttpResponse.json({ - id: "1", - projectId: "4509109104082945", - repositoryId: String((body?.repositoryId as string | number) || "101"), - source: "scm_onboarding", - created: true, + created: mappings.length, + updated: 0, + errors: 0, + mappings: mappings.map((mapping) => ({ + stackRoot: mapping.stackRoot ?? "", + sourceRoot: mapping.sourceRoot ?? "", + status: "created", + })), }); }, }, From b3809129f40ea2706f3a1677ffad3361c22804fe Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 20:32:52 -0700 Subject: [PATCH 08/10] fix(tools): Preserve project response on DSN failure Keep create_project from throwing after project creation when Sentry key lookup or default key creation fails. Return the created project details with an explicit DSN recovery note instead, while still attempting to create a Default key when key listing fails. Co-Authored-By: GPT-5 Codex --- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 2 +- .../src/tools/catalog/create-project.test.ts | 72 ++++++++++++++++ .../src/tools/catalog/create-project.ts | 84 ++++++++++++++----- 4 files changed, 139 insertions(+), 21 deletions(-) diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index eb2f6aab9..a9254eb25 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -385,7 +385,7 @@ }, { "name": "create_project", - "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", + "description": "Create a new project in Sentry (provisions DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN when key setup succeeds.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", "requiredScopes": ["project:write", "team:read", "org:read"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index f771331a7..042dbc581 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -172,7 +172,7 @@ }, { "name": "create_project", - "description": "Create a new project in Sentry (includes DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", + "description": "Create a new project in Sentry (provisions DSN automatically).\n\nUSE THIS TOOL WHEN USERS WANT TO:\n- 'Create a new project'\n- 'Set up a project for [app/service] with team [X]'\n- 'I need a new Sentry project'\n- Create project AND need DSN in one step\n- Create project and link an existing repository\n\nReturns the created project slug and a usable SENTRY_DSN when key setup succeeds.\n\nBe careful when using this tool!\n\n\n### Create new project with team\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')\n```\n### Create project with an explicit slug\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='My Project', slug='my-project', platform='javascript')\n```\n### Create project and link to a repository\n```\ncreate_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript', repository='getsentry/sentry')\n```\n", "inputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/create-project.test.ts b/packages/mcp-core/src/tools/catalog/create-project.test.ts index e08abe78b..eefb3096b 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.test.ts @@ -179,6 +179,78 @@ describe("create_project", () => { expect(result).toContain("**SENTRY_DSN**: https://fallback@example.com/1"); }); + it("creates a fallback default DSN when key listing fails after creation", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => HttpResponse.json({ detail: "lookup failed" }, { status: 500 }), + ), + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => + HttpResponse.json({ + ...clientKeyFixture, + name: "Default", + dsn: { + public: "https://fallback@example.com/1", + }, + }), + ), + ); + + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: null, + }, + context, + ); + + expect(result).toContain("**SENTRY_DSN**: https://fallback@example.com/1"); + expect(result).toContain("No additional DSN creation step is needed"); + }); + + it("returns project details when DSN setup fails after creation", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => HttpResponse.json({ detail: "lookup failed" }, { status: 500 }), + ), + http.post( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => HttpResponse.json({ detail: "create failed" }, { status: 500 }), + ), + ); + + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: null, + }, + context, + ); + + expect(result).toContain("**Slug**: cloudflare-mcp"); + expect(result).toContain("**SENTRY_DSN**: unavailable"); + expect(result).toContain( + "Project creation succeeded, but SENTRY_DSN could not be retrieved or created", + ); + expect(result).toContain( + "Use create_dsn for this project before initializing Sentry SDKs", + ); + expect(result).not.toContain("No additional DSN creation step is needed"); + }); + it("passes an optional slug through project creation", async () => { let createBody: unknown; mswServer.use( diff --git a/packages/mcp-core/src/tools/catalog/create-project.ts b/packages/mcp-core/src/tools/catalog/create-project.ts index 577067f39..c4beae2c8 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.ts @@ -3,7 +3,7 @@ import { setTag } from "@sentry/core"; import { defineTool } from "../../internal/tool-helpers/define"; import { apiServiceFromContext } from "../../internal/tool-helpers/api"; import { UserInputError } from "../../errors"; -import { logIssue } from "../../telem/logging"; +import { logWarn } from "../../telem/logging"; import type { ServerContext } from "../../types"; import { ParamOrganizationSlug, @@ -12,7 +12,7 @@ import { ParamTeamSlug, ParamPlatform, } from "../../schema"; -import type { ClientKey } from "../../api-client/index"; +import type { ClientKey, SentryApiService } from "../../api-client/index"; type RepositoryMatch = { name: string; @@ -58,12 +58,56 @@ function getRepositoryProvider(repository: RepositoryMatch): string | null { return repository.provider.id.replace(/^integrations:/, "") || null; } +async function getOrCreateClientKey({ + apiService, + organizationSlug, + projectSlug, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectSlug: string; +}): Promise { + let clientKey: ClientKey | undefined; + + try { + clientKey = getUsableClientKey( + await apiService.listClientKeys({ + organizationSlug, + projectSlug, + }), + ); + } catch (err) { + logWarn(err, { + loggerScope: ["runtime", "project-management"], + extra: { action: "list_project_client_keys" }, + }); + } + + if (clientKey) { + return clientKey; + } + + try { + return await apiService.createClientKey({ + organizationSlug, + projectSlug, + name: "Default", + }); + } catch (err) { + logWarn(err, { + loggerScope: ["runtime", "project-management"], + extra: { action: "create_project_client_key" }, + }); + return null; + } +} + export default defineTool({ name: "create_project", skills: ["project-management"], // Only available in project-management skill requiredScopes: ["project:write", "team:read", "org:read"], description: [ - "Create a new project in Sentry (includes DSN automatically).", + "Create a new project in Sentry (provisions DSN automatically).", "", "USE THIS TOOL WHEN USERS WANT TO:", "- 'Create a new project'", @@ -72,7 +116,7 @@ export default defineTool({ "- Create project AND need DSN in one step", "- Create project and link an existing repository", "", - "Returns the created project slug and a usable SENTRY_DSN.", + "Returns the created project slug and a usable SENTRY_DSN when key setup succeeds.", "", "Be careful when using this tool!", "", @@ -167,30 +211,26 @@ export default defineTool({ } catch (err) { // Repository linking happens after project creation; preserve the // project + DSN response and report the link failure in the output. - logIssue(err); + logWarn(err, { + loggerScope: ["runtime", "project-management"], + extra: { action: "link_project_repository" }, + }); repositoryLinkFailed = true; } } - const clientKeys = await apiService.listClientKeys({ + const clientKey = await getOrCreateClientKey({ + apiService, organizationSlug, projectSlug: project.slug, }); - let clientKey = getUsableClientKey(clientKeys); - - if (!clientKey) { - clientKey = await apiService.createClientKey({ - organizationSlug, - projectSlug: project.slug, - name: "Default", - }); - } + const sentryDsn = clientKey?.dsn.public ?? null; let output = `# New Project in **${organizationSlug}**\n\n`; output += `**ID**: ${project.id}\n`; output += `**Slug**: ${project.slug}\n`; output += `**Name**: ${project.name}\n`; - output += `**SENTRY_DSN**: ${clientKey.dsn.public}\n\n`; + output += `**SENTRY_DSN**: ${sentryDsn ?? "unavailable"}\n\n`; if (repositoryMatch && repositoryLinked) { output += `**Repository**: ${repositoryMatch.name} (linked)\n`; output += "**Code Mapping**: `/` -> `/`\n\n"; @@ -198,9 +238,15 @@ export default defineTool({ output += `**Repository**: Found ${repositoryMatch.name} but failed to link it to the project. Check permissions and try linking manually.\n\n`; } output += "## Response Notes\n\n"; - output += `- Please tell the user the project slug and **SENTRY_DSN**.\n`; - output += `- No additional DSN creation step is needed.\n`; - output += `- The **SENTRY_DSN** value is used to initialize Sentry SDKs.\n`; + if (sentryDsn) { + output += `- Please tell the user the project slug and **SENTRY_DSN**.\n`; + output += `- No additional DSN creation step is needed.\n`; + output += `- The **SENTRY_DSN** value is used to initialize Sentry SDKs.\n`; + } else { + output += `- Please tell the user the project slug.\n`; + output += `- Project creation succeeded, but SENTRY_DSN could not be retrieved or created.\n`; + output += `- Use create_dsn for this project before initializing Sentry SDKs.\n`; + } if (repositoryMatch && repositoryLinked) { output += `- Repository linked to project with a root code mapping: ${repositoryMatch.name}\n`; } else if (repositoryMatch && repositoryLinkFailed) { From 6777fe3f1e3c17cce4679c3f0881d9f7768fdcf9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 2 Jul 2026 20:42:24 -0700 Subject: [PATCH 09/10] fix(tools): Resolve repositories from the full list List organization repositories without narrowing to the caller query before project creation. Prefer exact repository names over suffix matches, and only use suffix matching for shorthand repo inputs so full owner/repo values do not become falsely ambiguous. Co-Authored-By: GPT-5 Codex --- .../mcp-core/src/api-client/client.test.ts | 49 ++++++++++++++ packages/mcp-core/src/api-client/client.ts | 32 ++++++--- .../src/tools/catalog/create-project.test.ts | 67 +++++++++++++++++-- .../src/tools/catalog/create-project.ts | 13 ++-- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index b716534c5..42763f37f 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -1866,6 +1866,55 @@ describe("API query builders", () => { expect(repos[0].name).toBe("getsentry/sentry"); }); + it("should paginate repos", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ + "content-type": "application/json", + link: '; rel="next"; results="true"; cursor="cursor-2"', + }), + json: () => + Promise.resolve([ + { + id: "101", + name: "getsentry/sentry", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }, + ]), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ + "content-type": "application/json", + }), + json: () => + Promise.resolve([ + { + id: "102", + name: "getsentry/sentry-javascript", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }, + ]), + }); + globalThis.fetch = fetchMock; + + const repos = await apiService.listRepos({ + organizationSlug: "test-org", + }); + + expect(repos.map((repo) => repo.name)).toEqual([ + "getsentry/sentry", + "getsentry/sentry-javascript", + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toContain("per_page=100"); + expect(fetchMock.mock.calls[1][0]).toContain("cursor=cursor-2"); + }); + it("should include query parameter when provided", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 320687865..d7cacabe1 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1923,14 +1923,30 @@ export class SentryApiService { }, opts?: RequestOptions, ) { - const params = new URLSearchParams(); - if (query) { - params.set("query", query); - } - const qs = params.toString(); - const url = `/organizations/${organizationSlug}/repos/${qs ? `?${qs}` : ""}`; - const body = await this.requestJSON(url, { method: "GET" }, opts); - return RepositoryListSchema.parse(body); + let cursor: string | null = null; + const repos: z.infer = []; + + do { + const params = new URLSearchParams(); + params.set("per_page", "100"); + if (query) { + params.set("query", query); + } + if (cursor) { + params.set("cursor", cursor); + } + + const response = await this.request( + `/organizations/${organizationSlug}/repos/?${params.toString()}`, + { method: "GET" }, + opts, + ); + const body = await this.parseJsonResponse(response); + repos.push(...RepositoryListSchema.parse(body)); + cursor = getNextCursor(response.headers.get("link")); + } while (cursor); + + return repos; } async linkProjectRepository( diff --git a/packages/mcp-core/src/tools/catalog/create-project.test.ts b/packages/mcp-core/src/tools/catalog/create-project.test.ts index eefb3096b..6b1fa56a6 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.test.ts @@ -312,6 +312,60 @@ describe("create_project", () => { expect(result).not.toContain("Repository Link ID"); }); + it("prefers an exact repository name over suffix matches", async () => { + let mappingBody: unknown; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/repos/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("query")).toBeNull(); + return HttpResponse.json([ + { + id: "101", + name: "mirror/getsentry/sentry", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }, + { + id: "102", + name: "getsentry/sentry", + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }, + ]); + }, + ), + http.post( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/code-mappings/bulk/", + async ({ request }) => { + mappingBody = await request.json(); + return HttpResponse.json({ + created: 1, + updated: 0, + errors: 0, + mappings: [{ stackRoot: "", sourceRoot: "", status: "created" }], + }); + }, + ), + ); + + const result = await createProject.handler( + { + organizationSlug: "sentry-mcp-evals", + teamSlug: "the-goats", + name: "cloudflare-mcp", + slug: null, + platform: "node", + regionUrl: null, + repository: "getsentry/sentry", + }, + context, + ); + + expect(mappingBody).toMatchObject({ repository: "getsentry/sentry" }); + expect(result).toContain("**Repository**: getsentry/sentry (linked)"); + }); + it("returns project setup details when repository linking fails after creation", async () => { mswServer.use( http.post( @@ -345,7 +399,10 @@ describe("create_project", () => { mswServer.use( http.get( "https://sentry.io/api/0/organizations/sentry-mcp-evals/repos/", - () => HttpResponse.json([]), + ({ request }) => { + expect(new URL(request.url).searchParams.get("query")).toBeNull(); + return HttpResponse.json([]); + }, ), http.post( "https://sentry.io/api/0/teams/sentry-mcp-evals/the-goats/projects/", @@ -381,8 +438,9 @@ describe("create_project", () => { mswServer.use( http.get( "https://sentry.io/api/0/organizations/sentry-mcp-evals/repos/", - () => - HttpResponse.json([ + ({ request }) => { + expect(new URL(request.url).searchParams.get("query")).toBeNull(); + return HttpResponse.json([ { id: "101", name: "getsentry/sentry", @@ -395,7 +453,8 @@ describe("create_project", () => { provider: { id: "integrations:github", name: "GitHub" }, status: "active", }, - ]), + ]); + }, ), http.post( "https://sentry.io/api/0/teams/sentry-mcp-evals/the-goats/projects/", diff --git a/packages/mcp-core/src/tools/catalog/create-project.ts b/packages/mcp-core/src/tools/catalog/create-project.ts index c4beae2c8..30997b061 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.ts @@ -33,11 +33,15 @@ function findRepositoryMatch( repositories: RepositoryMatch[], repository: string, ): RepositoryMatch { - const matches = repositories.filter( - (candidate) => - candidate.name === repository || - candidate.name.endsWith(`/${repository}`), + const exactMatches = repositories.filter( + (candidate) => candidate.name === repository, ); + const matches = + exactMatches.length > 0 || repository.includes("/") + ? exactMatches + : repositories.filter((candidate) => + candidate.name.endsWith(`/${repository}`), + ); if (matches.length === 0) { throw new UserInputError( @@ -178,7 +182,6 @@ export default defineTool({ ? findRepositoryMatch( await apiService.listRepos({ organizationSlug, - query: params.repository, }), params.repository, ) From 198cdf9d7291a670a77a3d6ed84c176b33843332 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 3 Jul 2026 08:03:37 -0700 Subject: [PATCH 10/10] fix(tools): Correct update_project idempotency hint Mark update_project as non-idempotent because slug renames invalidate the old project slug for identical retries. Add a focused test to keep the MCP annotation conservative. Co-Authored-By: GPT-5 Codex --- packages/mcp-core/src/tools/catalog/update-project.test.ts | 4 ++++ packages/mcp-core/src/tools/catalog/update-project.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/catalog/update-project.test.ts b/packages/mcp-core/src/tools/catalog/update-project.test.ts index 7e9ff75a3..f63fbba38 100644 --- a/packages/mcp-core/src/tools/catalog/update-project.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-project.test.ts @@ -162,4 +162,8 @@ describe("update_project", () => { expect(updateProject.inputSchema).not.toHaveProperty("teamSlug"); expect(updateProject.description).not.toContain("teamSlug"); }); + + it("does not claim idempotency because slug renames invalidate the old slug", () => { + expect(updateProject.annotations.idempotentHint).toBe(false); + }); }); diff --git a/packages/mcp-core/src/tools/catalog/update-project.ts b/packages/mcp-core/src/tools/catalog/update-project.ts index e99e9314c..0ceaac9ab 100644 --- a/packages/mcp-core/src/tools/catalog/update-project.ts +++ b/packages/mcp-core/src/tools/catalog/update-project.ts @@ -69,7 +69,7 @@ export default defineTool({ annotations: { readOnlyHint: false, destructiveHint: true, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, async handler(params, context: ServerContext) {