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/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..f045a97e9 --- /dev/null +++ b/docs/specs/project-management.md @@ -0,0 +1,151 @@ +# 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 + repository: "acme/app", // optional, must already be connected +}) + +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 core setup fields: +- `organizationSlug` +- `teamSlug` +- `name` +- optional `slug` +- optional `platform` +- optional `repository` + +It does not expose 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. + +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: +- `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: +- `update_project` could grant team access through `teamSlug`. + +Use these replacements: +- Repository linking: use `create_project(..., repository: "owner/repo")` during + creation. +- 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/.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..298a15abc --- /dev/null +++ b/openspec/changes/refine-project-management-tools/design.md @@ -0,0 +1,100 @@ +## 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. + +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:** + +- 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 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. + +## Decisions + +### Keep `create_project` focused on project setup + +`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: 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 + +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. +- 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: 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, 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. +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..ef4b3f284 --- /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. + - 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. +- 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..174c678f9 --- /dev/null +++ b/openspec/changes/refine-project-management-tools/specs/project-management/spec.md @@ -0,0 +1,125 @@ +## 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 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 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. + +#### 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..4ece885da --- /dev/null +++ b/openspec/changes/refine-project-management-tools/tasks.md @@ -0,0 +1,54 @@ +## 1. API Client Support + +- [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 + +- [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`. +- [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 + +- [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 + +- [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 + +- [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 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. +- [x] 5.6 Update registry, tool count, skill gating, and generated-definition tests as needed. + +## 6. Documentation and Generated Definitions + +- [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. +- [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..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 @@ -56,10 +59,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/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 2309bef94..42763f37f 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"; @@ -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, @@ -1913,7 +1962,7 @@ describe("API query builders", () => { }); }); - describe("linkProjectRepo", () => { + describe("linkProjectRepository", () => { let apiService: SentryApiService; beforeEach(() => { @@ -1927,7 +1976,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 +1985,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 +2035,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 5883c79f1..d7cacabe1 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, @@ -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; @@ -1905,37 +1923,65 @@ 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 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( @@ -2194,6 +2240,48 @@ 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 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; + } + /** * Assigns a team to a project. * @@ -2225,6 +2313,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. * 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/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-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 4addc7972..a9254eb25 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", @@ -380,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 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 (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"] }, { @@ -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", @@ -415,7 +425,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..042dbc581 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", @@ -132,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 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 (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": { @@ -159,7 +199,20 @@ }, "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." + }, + "slug": { + "anyOf": [ + { + "type": "string", + "description": "Optional project slug to create." + }, + { + "type": "null" + } + ], + "description": "Optional project slug to create.", + "default": null }, "platform": { "anyOf": [ @@ -2188,6 +2241,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", @@ -3130,7 +3223,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 +3286,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-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) ); } 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..8537bf193 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/add-team-to-project.test.ts @@ -0,0 +1,199 @@ +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( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => { + listCalls += 1; + return HttpResponse.json( + listCalls === 1 ? [teamFixture] : [teamFixture, backendTeam], + ); + }, + ), + http.post( + "https://sentry.io/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( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => HttpResponse.json([teamFixture, backendTeam]), + ), + http.post( + "https://sentry.io/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( + "https://sentry.io/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( + "https://sentry.io/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/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 a5fbdc1d3..6b1fa56a6 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,46 @@ -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( + "https://sentry.io/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** @@ -31,50 +53,441 @@ 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. " `); }); - it("links repository when provided", async () => { + it("uses an existing non-default DSN when no default key exists", async () => { + mswServer.use( + 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( + "https://sentry.io/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", + repository: null, + }, + context, + ); + + expect(result).toContain( + "**SENTRY_DSN**: https://production@example.com/1", + ); + }); + + 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( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/keys/", + () => HttpResponse.json([]), + ), + 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"); + }); + + 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( { - constraints: { - organizationSlug: null, + 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( + http.post( + "https://sentry.io/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( + "https://sentry.io/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, + repository: null, }, + context, ); + + expect(createBody).toEqual({ + name: "My Project", + slug: "my-project", + platform: "node", + }); + expect(result).toContain("**Slug**: my-project"); + }); + + 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("reports when repository is not found", async () => { + 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: "nonexistent/repo", + 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( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/code-mappings/bulk/", + () => HttpResponse.json({ detail: "link failed" }, { status: 500 }), + ), + ); + + const result = await createProject.handler( { - constraints: { - organizationSlug: null, - }, - accessToken: "access-token", - userId: "1", + 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", ); - expect(result).toContain('Could not find repository "nonexistent/repo"'); + }); + + 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/", + ({ 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/", + () => { + 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/", + ({ request }) => { + expect(new URL(request.url).searchParams.get("query")).toBeNull(); + return 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).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 20493c3bc..30997b061 100644 --- a/packages/mcp-core/src/tools/catalog/create-project.ts +++ b/packages/mcp-core/src/tools/catalog/create-project.ts @@ -2,31 +2,125 @@ 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 { UserInputError } from "../../errors"; +import { logWarn } 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, SentryApiService } 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) + ); +} + +function findRepositoryMatch( + repositories: RepositoryMatch[], + repository: string, +): RepositoryMatch { + 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( + `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; +} + +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'", "- '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'", + "- 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 when key setup succeeds.", "", "Be careful when using this tool!", "", @@ -35,17 +129,15 @@ export default defineTool({ "```", "create_project(organizationSlug='my-organization', teamSlug='my-team', name='my-project', platform='javascript')", "```", + "### Create project with an explicit slug", + "```", + "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.", - "- 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: { organizationSlug: ParamOrganizationSlug, @@ -55,8 +147,11 @@ 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.", ), + slug: ParamProjectSlug.describe("Optional project slug to create.") + .nullable() + .default(null), platform: ParamPlatform.nullable().default(null), repository: z .string() @@ -81,77 +176,85 @@ 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, + }), + params.repository, + ) + : null; + const project = await apiService.createProject({ organizationSlug, teamSlug: params.teamSlug, name: params.name, + slug: params.slug, platform: params.platform, }); - let clientKey: ClientKey | null = null; - try { - 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) { + let repositoryLinked = false; + let repositoryLinkFailed = false; + if (repositoryMatch) { try { - const repos = await apiService.listRepos({ + const repositoryMapping = await apiService.linkProjectRepository({ organizationSlug, - query: params.repository, + projectSlug: project.slug, + repository: repositoryMatch.name, + provider: getRepositoryProvider(repositoryMatch), }); - 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"; - } + repositoryLinked = + repositoryMapping.errors === 0 && + repositoryMapping.mappings.some((mapping) => + ["created", "updated"].includes(mapping.status), + ); + repositoryLinkFailed = !repositoryLinked; } catch (err) { - logIssue(err); - repoStatus = "not_found"; + // Repository linking happens after project creation; preserve the + // project + DSN response and report the link failure in the output. + logWarn(err, { + loggerScope: ["runtime", "project-management"], + extra: { action: "link_project_repository" }, + }); + repositoryLinkFailed = true; } } + const clientKey = await getOrCreateClientKey({ + apiService, + organizationSlug, + projectSlug: project.slug, + }); + 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`; - if (clientKey) { - 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"; + } 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"; + 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 += "**SENTRY_DSN**: There was an error fetching this value.\n\n"; + 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 (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`; + 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`; } - 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`; 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/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..eaff91017 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/remove-team-from-project.test.ts @@ -0,0 +1,234 @@ +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( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => { + listCalls += 1; + return HttpResponse.json( + listCalls === 1 ? [teamFixture, backendTeam] : [backendTeam], + ); + }, + ), + http.delete( + "https://sentry.io/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( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => HttpResponse.json([teamFixture]), + ), + http.delete( + "https://sentry.io/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( + "https://sentry.io/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/", + () => HttpResponse.json([teamFixture]), + ), + http.delete( + "https://sentry.io/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( + "https://sentry.io/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( + "https://sentry.io/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, + }); + }, +}); 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/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..f63fbba38 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,53 +50,11 @@ 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 }) => { + http.put("https://sentry.io/api/0/projects/*/*/", async ({ request }) => { paths.push(new URL(request.url).pathname); updateBody = await request.json(); return HttpResponse.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,95 @@ 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"); + }); + + 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 76a0f739e..0ceaac9ab 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,16 +65,11 @@ 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, destructiveHint: true, - idempotentHint: true, + idempotentHint: false, openWorldHint: true, }, async handler(params, context: ServerContext) { @@ -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; }, }); 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-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", + })), }); }, }, 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