diff --git a/docs/cloudflare/architecture.md b/docs/cloudflare/architecture.md index 51b28889e..65b46e0cd 100644 --- a/docs/cloudflare/architecture.md +++ b/docs/cloudflare/architecture.md @@ -96,6 +96,31 @@ const mcpClient = await experimental_createMCPClient({ - Prompt metadata endpoint - Error handling with fallbacks +### Hosted MCP Code Mode + +The authenticated hosted MCP also registers `search_sentry_api` and +`execute_sentry_code` on the fresh server built for each request. The external +client's model writes the code; a Cloudflare Dynamic Worker executes it. This +path does not use the chat interface's model or introduce another OAuth flow. + +The Worker Loader binding (`LOADER`) creates an isolated execution with all +network egress disabled. The trusted sandbox wrapper receives an `ApiBroker` +capability through its `run()` RPC method and uses it for `sentry.request()`; +raw `fetch()` is blocked. The broker retains the access token and an +`ApiAuthorization` object in the parent request context. It checks published +operations and the explicit API read/write MCP grant, enforces +organization constraints, resolves validated regional routing and forwards +client attribution. Generated code cannot access the private token or +authorization context. Upstream 401s use the existing unauthorized callback. + +This integration has no beta or opt-in flag. Code Mode is unavailable for +project-constrained sessions because arbitrary API operations can affect shared +resources even through project-bearing routes. Existing grants do not acquire +API permissions automatically. The shared upstream OAuth token remains separate +from connection permissions; Sentry enforces its scopes and resource access. See [HTTP Code Mode integration](../testing/code-mode.md#authentication-and-scope) +for the permission policy, fixture limitations and validation status. Source and +configuration changes alone do not establish that a deployment is active. + ### 4. AI Assistant GPT-4 integration with Sentry-specific system prompt: diff --git a/docs/cloudflare/code-mode-architecture.png b/docs/cloudflare/code-mode-architecture.png new file mode 100644 index 000000000..b526d28f0 Binary files /dev/null and b/docs/cloudflare/code-mode-architecture.png differ diff --git a/docs/cloudflare/oauth-architecture.md b/docs/cloudflare/oauth-architecture.md index a52da228f..2e6a63182 100644 --- a/docs/cloudflare/oauth-architecture.md +++ b/docs/cloudflare/oauth-architecture.md @@ -308,6 +308,10 @@ const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ The system only refreshes MCP tokens. It does not rotate upstream Sentry OAuth tokens anymore. +Refresh preserves `upstreamScopes` from the original Sentry token response. +Older grants and responses that omit `scope` leave this metadata unknown; +refresh does not infer it from MCP skills or the requested scope set. + 1. **MCP Token Refresh**: MCP clients exchange an MCP refresh token for a new MCP access token 2. **Sentry Token Reuse**: The worker keeps reusing the cached Sentry access token while it is still valid 3. **Stale grant rejection**: Grants missing required stored props like `refreshToken` are treated as stale and revoked @@ -420,7 +424,17 @@ remains valid RFC 8414 metadata. When a constrained OAuth request includes `resource=/mcp/{org}` or `resource=/mcp/{org}/{project}`, the approval page surfaces that scope to the -user as a "Session scope" banner before permissions are granted. +user before permissions are granted. Organization-only and unconstrained +connections show an "Organization scope" banner; project-constrained +connections retain the "Session scope" banner. The unconstrained banner +explicitly covers all organizations authorized by the upstream token. + +New consent forms leave API Write unchecked, while signed remembered selections +remain preselected for the same client. API Write's warning explains that it is +an independent, additive permission: it allows API writes even when Triage Issues +or Manage Projects & Teams is unchecked, subject to Sentry permissions and the +connection's organization scope. Project-constrained sessions do not offer +Code Mode skills. Note: These describe the MCP OAuth server, not Sentry's OAuth endpoints. diff --git a/docs/integrations/claude-code-plugin.md b/docs/integrations/claude-code-plugin.md index d198e416c..9227b65ba 100644 --- a/docs/integrations/claude-code-plugin.md +++ b/docs/integrations/claude-code-plugin.md @@ -63,18 +63,20 @@ The body below the frontmatter is the agent's system prompt. ## Keeping `allowedTools` in Sync -The `allowedTools` list in the agent frontmatter must match the generated default-grant direct MCP surface. The `generate-definitions` script handles this automatically: +The `allowedTools` list in the agent frontmatter must match the generated default-grant direct MCP surface, including the hosted `search_sentry_api` and `execute_sentry_code` tools. Their availability and write permissions still depend on the connection's existing grant and constraints. The `generate-definitions` script handles this automatically: ```bash pnpm run --filter @sentry/mcp-core generate-definitions ``` +The public `/.mcp/tools.json` metadata includes the same hosted definitions. Code Mode has no fixed tool-level API scopes (`requiredScopes: []`): each HTTP operation is authorized against the connection's permissions and organization constraint before the request is sent. + This script: 1. Imports all tools from `packages/mcp-core/src/tools/index.ts` 2. Imports all skills from `packages/mcp-core/src/skills.ts` 3. Writes `toolDefinitions.json` and `skillDefinitions.json` to `packages/mcp-core/src/` -4. Updates `allowedTools` in both `plugins/sentry-mcp/agents/sentry-mcp.md` and `plugins/sentry-mcp-experimental/agents/sentry-mcp.md`, using the default-grant direct surface for each plugin mode. Optional-skill tools remain reachable through `search_sentry_tools` and `execute_sentry_tool` when available. +4. Updates `allowedTools` in both `plugins/sentry-mcp/agents/sentry-mcp.md` and `plugins/sentry-mcp-experimental/agents/sentry-mcp.md`, using the default-grant direct surface for each plugin mode plus hosted Code Mode names from `packages/mcp-cloudflare/src/code-mode/definitions.ts`. These hosted tools are not added to core/stdio JSON definitions. Optional-skill tools remain reachable through `search_sentry_tools` and `execute_sentry_tool` when available. The script runs automatically as a `prebuild` and `pretest` hook in `packages/mcp-core/package.json`. Run it explicitly after: @@ -82,7 +84,7 @@ The script runs automatically as a `prebuild` and `pretest` hook in `packages/mc - Changing tool skills assignments - Modifying agent prompts (to verify frontmatter stays valid) -The script skips regeneration if all outputs are newer than all inputs (tool source files, `skills.ts`, and the script itself). +The script skips regeneration if all outputs are newer than all inputs (tool source files, hosted Code Mode definitions, `skills.ts`, and the script itself). ## Modifying the Agent Prompt diff --git a/docs/mcp/README.md b/docs/mcp/README.md index a57b1b313..87d07ce04 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -17,6 +17,8 @@ them. The [Toolkit index](../README.md) also links the CLI and shared guides. [error handling](../contributing/error-handling.md) — MCP client conventions. - [Testing](../testing/overview.md), [stdio](../testing/stdio.md), and [remote](../testing/remote.md) — tests and transport QA. +- [HTTP Code Mode](../testing/code-mode.md) — runtime integration, permissions, + and safety validation. ## Run and Release diff --git a/docs/operations/security.md b/docs/operations/security.md index 670ce6120..881feecd7 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -104,10 +104,12 @@ Today, runtime authorization for remote HTTP sessions is driven primarily by: `grantedScopes` still exists on tokens for backward compatibility with older clients, but it is transitional. Skills are the primary authorization model. -Note that `grantedScopes` is derived from the selected skills, not from what -Sentry granted. The scopes Sentry actually granted on the upstream token (from -the `scope` field of Sentry's token response) are stored separately as -`upstreamScopes`. Grants created before `upstreamScopes` existed do not have it. +`grantedScopes` is derived from selected skills, not from what Sentry granted. +The token response's actual `scope` is stored separately as `upstreamScopes`. +Older grants and responses without `scope` leave `upstreamScopes` undefined, +which means unknown rather than no permissions. MCP refresh preserves that +metadata without requesting or inferring additional scopes. It does not grant +the MCP session permission to use every capability of the upstream token. Direct `Sentry-Bearer` sessions default to all active skills and can be narrowed with `skills` or `disable-skills` query parameters. They do not get OAuth diff --git a/docs/testing/code-mode.md b/docs/testing/code-mode.md new file mode 100644 index 000000000..6c8fdf6b2 --- /dev/null +++ b/docs/testing/code-mode.md @@ -0,0 +1,258 @@ +# HTTP Code Mode + +HTTP Code Mode lets an external MCP agent discover published API operations, +write JavaScript, and execute authenticated HTTP requests without a dedicated +tool for each operation. This version is HTTP-only; an SDK interface is deferred. + +## Execution and ownership + +The hosted MCP exposes two ordinary MCP tools: + +- `search_sentry_api({code})` runs an async arrow function against the configured + OpenAPI document, available as `spec`. Network access is disabled. +- `execute_sentry_code({code})` runs an async arrow function with + `sentry.request({method?, path, query?, body?})`, defaulting to GET. Requests + return `{status, headers, body}`; non-2xx responses throw with HTTP details. + +The client's model discovers the API, generates code, corrects failures and +writes the answer. The server executes that code in a fresh Cloudflare Dynamic +Worker and does not call a model. Programs can paginate, combine responses and +return selected fields without sending every intermediate response to the model. +Query arrays become repeated parameters; `headers.link` supports pagination. + +| Responsibility | Owner and cost | +| ---------------------------------------------- | ----------------------------------------------- | +| Planning, code generation and final answer | Client's agent/provider account | +| Schema search, execution and credential broker | MCP operator's Cloudflare infrastructure | +| API requests | Sentry infrastructure and applicable API limits | + +This follows Cloudflare's [server-side search/execute pattern](https://blog.cloudflare.com/code-mode-mcp/). +Worker Loader creates a fresh isolate with `globalOutbound: null`. Raw `fetch()` +and other network access are blocked. A trusted wrapper calls an `ApiBroker` +RPC capability passed to `run()`; the broker owns credentials in the parent +request context. Generated code is a separate module and cannot access them. +The broker validates the origin and API prefix, supplies authentication and +rejects redirects. The request-scoped capability also retains the existing +unauthorized callback, which cannot be serialized into loopback binding props. + +![The sandbox calls the parent Worker's broker over RPC; only the broker holds the Sentry token and contacts the API.](../cloudflare/code-mode-architecture.png) + +Code Mode is an architecture, not a separate MCP protocol capability. Tools use +normal `tools/list` and `tools/call`, typed schemas, JSON text and +`structuredContent`; failures use `isError`. The MCP SDK's package version is +separate from the dated [wire protocol](https://modelcontextprotocol.io/specification/2026-07-28/server/tools). + +## Authentication and scope + +`packages/mcp-cloudflare/src/server/lib/mcp-handler.ts` creates a fresh server +for each authenticated request and registers Code Mode with that request's +`ServerContext`. The existing OAuth flow and requested upstream scopes remain +unchanged. Production and canary configurations provide a `LOADER` binding. +Code Mode is hosted-only, with no separate OAuth flow or rollout flag. + +Two permissions control access through this MCP connection: + +- **API Read** permits reads and is selected for new approvals. +- **API Write** permits reads and create/update/delete operations. It starts + unchecked. Explicitly remembered selections can preselect it, but approval + is still required. + +Existing grants and remembered skill selections do not acquire new permissions +implicitly. Permissions are additive: API Write can modify issues with **Triage +Issues** unchecked, or projects/teams with **Manage Projects & Teams** unchecked. +Those checkboxes control their catalog tools, not deny rules for API Write. The +consent screen explains this before selection and always displays the scope: +`/mcp` permits organizations authorized by the token; `/mcp/{org}` limits the +connection to that organization. Sentry's own permissions still apply. + +The token authenticates API calls and sets the maximum access through its OAuth +scopes and the user's permissions. The consent screen limits what this MCP +connection may do without changing the token or its scopes. The implementation +keeps three values distinct: + +- `grantedSkills` records MCP authorization; `api-write` includes reads. +- Legacy `grantedScopes` comes from tool declarations. It is not evidence of + token permissions, and Code Mode does not use it. +- `upstreamScopes` records Sentry's actual token response. Missing metadata on + older grants means unknown. It neither grants MCP access nor replaces backend + token, role and resource checks. + +Before every `sentry.request()`, the broker's `ApiAuthorization` checks: + +1. The exact path and method exist in the pinned OpenAPI. HEAD may use a GET + contract when no HEAD operation exists. Unknown or ambiguous paths fail. +2. Without `api-write`, only GET, HEAD and OPTIONS can reach Sentry, regardless + of the token's broader access. +3. An organization constraint matches a recognized organization path parameter + exactly. Other organizations, unresolved numeric aliases and global object-ID + routes fail. Query parameters cannot substitute for a scoped path. + +The policy uses the existing organization helper for regional routing, validates +the regional host before attaching credentials, and forwards MCP client +attribution. A 401 invokes the existing unauthorized callback once per policy +instance. Sentry performs its own token, role and resource authorization. + +For example, dashboard creation accepts `org:read`, `org:write` or `org:admin` +upstream. API Write lets that POST reach Sentry without inventing a `:write` +suffix requirement. API Read blocks it before contacting Sentry. Neither MCP +permission guarantees that Sentry will accept the request. + +Project-constrained connections retain their existing tools without Code Mode. +A project URL alone cannot prove isolation: release files can be shared across +projects, and release deploys can affect every project when `projects` is omitted. +The published schema has no general isolation contract for these resources. +The policy therefore does not infer isolation from path prefixes or add filters +to arbitrary operations. The backend references are +`src/sentry/releases/endpoints/project_release_file_details.py` and +`src/sentry/releases/endpoints/release_deploys.py`. + +## Discovery and HTTP contracts + +The default `OPENAPI_URL` uses Sentry's published `openapi-derefed.json`, pinned at +`803d6de98c9156c15e9ddc1bf9accbac6e24cebc`. This is the same artifact family the +CLI consumes. Custom schema URLs must supply trusted, pre-dereferenced JSON; +the runtime does not resolve or fetch references. The pinned snapshot contains +159 paths / 254 operations, not every Sentry feature or internal endpoint. + +Tool descriptions guide searches across paths, operation IDs, summaries, +descriptions and tags. Empty or unrelated results should lead to broader metadata +or category searches before guessing routes. Discovery does not add a second +operation catalog, synonym list or semantic search service. + +After finding an operation, inspect its request/response schemas and both +path-level and operation-level parameters. The latter override path parameters +with the same name and location. Return selected JSON values rather than sliced +JSON strings that may hide required fields. + +Write guidance distinguishes raw API bodies from enriched catalog-tool results. +Even a GET response is not a write template: resending an aggregate field can +change child resources. Agents should consult the contract and defaults, read +the current resource, send required fields and requested changes, and preserve +IDs and relationships in nested collections. Omission is not assumed to preserve +every field, and PUT is not assumed to replace every resource. Agents should +verify the result and report ambiguity or side effects rather than inventing +semantics or attempting speculative repair writes. + +These instructions cannot enforce endpoint-specific preservation. JSON/HTTP +support does not establish multipart, streaming, internal or undocumented API +coverage. Result projection is available but not automatic semantic truncation. + +## Limits and failures + +Each execution has a 20-call fetch budget, 30-second deadline, 50,000-byte +result limit and 2,000,000-byte upstream response limit. The HTTP MCP SDK rejects +tool arguments with more than 10,000 array elements or object keys before +validation. A bounded reader rejects request bodies over 1 MiB before dispatch, +including legacy clients whose Agents transport omits the SDK body-limit option. + +Dynamic Worker CPU and subrequest limits are configured through the +[Cloudflare API](https://developers.cloudflare.com/dynamic-workers/usage/limits/). +Local workerd did not enforce those platform limits in the recorded probes. +**Hosted CPU termination and deployment enforcement remain unvalidated.** + +The runtime has no automatic retries or multi-call transaction. A failed program +may already have created resources; the agent must inspect state before retrying +writes. HTTP errors preserve status, message and allowed headers such as +`WWW-Authenticate` and `Retry-After`. A generic 403 is not diagnosed as a missing +scope or a need to reconnect. Uncaught program errors retain execution statistics +when available; startup failures do not have sandbox statistics. + +Upstream 5xx responses use the existing `logIssue` helper even when generated +code catches them. The broker exposes the event ID as `x-sentry-event-id` and +reports only method/status, not request or response payloads. Expected 4xx +responses do not create issues. + +`LOADER.load()` creates a fresh Worker for each execution without ID caching, +following the [Worker Loader API](https://developers.cloudflare.com/dynamic-workers/api-reference/#load). +This preserves per-execution isolation but affects +[Dynamic Workers costs](https://developers.cloudflare.com/dynamic-workers/pricing/). +Any later reuse must preserve request credentials and authorization; changing +the ID alone is insufficient. + +### Lifecycle investigation + +The parent owns one `ExecutionGate` per tool call, closed in `finally` on success, +error or timeout. Its 30-second deadline covers schema loading, Worker startup, +execution and reading the result. The sandbox receives the same deadline. + +Like Cloudflare's [ExecutionGate](https://github.com/cloudflare/agents/blob/a2d371ec333198855f4ac2aa4dadbe2696e73c75/packages/codemode/src/executor.ts), +closing releases pending sandbox-to-host RPC. The Sentry gate also aborts +upstream HTTP and rejects new work before authorization, then checks again after +authorization before sending an API request. A slow authorization lookup cannot +start a write after execution has closed. The shared OpenAPI cache retains its +independent 15-second timeout; closing one execution does not cancel a schema +load used by another caller. + +Programs must await every `sentry.request()`, including parallel calls. Returning +with calls still pending is an execution error, not success. Throwing preserves +the original error while cleanup aborts pending requests. The sandbox disables +its API access when the program ends; the parent broker enforces the boundary +independently of generated code. Closed RPC calls settle with an error response +instead of an unhandled rejection. + +Cancellation is best effort for HTTP already sent: a write accepted upstream +cannot be rolled back. The tool retains the warning to inspect state before +retrying writes. Gate closure revokes API access; it does not forcibly terminate +JavaScript still running inside an isolate. CPU and platform concurrency +enforcement still require hosted verification. Local handles do not expose +`Symbol.dispose`, so cleanup does not depend on optional disposal. + +## Runtime and integration validation + +Use Node 24 for this checkout; its `better-sqlite3` dependency does not compile +with Node 26. After `pnpm install --frozen-lockfile`: + +```bash +pnpm --filter '@sentry/mcp-core...' --if-present run build +pnpm --filter @sentry/mcp-cloudflare test +pnpm --filter @sentry/mcp-cloudflare test:code-mode +pnpm --filter @sentry/mcp-cloudflare tsc:code-mode +``` + +Cloudflare's `test:ci` and `tsc:ci` include the Code Mode checks. The ordinary +suite exercises the authenticated MCP handler, Dynamic Workers and RPC broker +with external OpenAPI/Sentry fixtures. It covers grant and scope visibility, +regional routing, attribution, concurrent token isolation, organization escapes, +401 revocation, caught 5xx reporting, and modern/legacy input limits. Dashboard +copying verifies persisted state with API Write and an upstream `org:read` token. + +The isolated runtime suite uses `wrangler.code-mode.jsonc` to test execution, +network denial and API path restrictions. Its direct-token entrypoint is for +local tests, not hosted deployment or OAuth validation. Only the hosted handler +applies the connection's skills, constraints and regional policy. + +`@cloudflare/vitest-plugin` 1.4.6 aligns the hosted test runtime with Wrangler and +the standalone Miniflare tests. Updating only direct Miniflare previously left +the Vitest pool using older workerd. Miniflare 5 uses +`convertV4MiniflareOptions()` for standalone configuration; hosted outbound +fixtures use the existing Undici `MockAgent` with `disableNetConnect()`. No +binary override is required. + +Lifecycle validation on 2026-10-27 passed 474 hosted tests, 27 isolated +runtime/access tests, 1,836 core tests (six skipped), 89 stdio tests, root build +and typechecks, lint and documentation links. Three hosted regressions reproduce +the original closure bugs: return/throw with pending HTTP and delayed +authorization. The contexts stay alive after the result to prove cleanup does +not depend on request teardown. Two fake-timer tests check timeout behavior +before headers and during the response body; these do not establish a baseline +cancellation bug because fake timers do not advance native `AbortSignal.timeout`. +A real 30-second test observes the upstream socket close while an accepted write +remains. No unhandled RPC exceptions appear in these runs. + +These checks use external HTTP fixtures, not a real Sentry account or deployment. +No new model evals were run; the agent harness remains in its separate PR. +Unrelated CLI fixture/platform failures prevented a green full workspace suite. +Before production, validate hosted OAuth-to-API authorization and Cloudflare +CPU/termination limits. No deployment +is established by these checks. + +## References + +- Implementation: `packages/mcp-cloudflare/src/code-mode/production.ts`, + `broker.ts`, `worker.ts`, `sandbox.ts` and `definitions.ts`. +- Integration tests: + `packages/mcp-cloudflare/src/server/lib/code-mode.integration.test.ts`. +- [Cloudflare architecture](../cloudflare/architecture.md) and + [OAuth architecture](../cloudflare/oauth-architecture.md). +- [Stainless SDK Code Mode](https://www.stainless.com/blog/sdk-code-mode/) + describes the deferred SDK interface; it does not change who owns model usage. diff --git a/packages/mcp-cloudflare/package.json b/packages/mcp-cloudflare/package.json index f46a10934..1ef5f634e 100644 --- a/packages/mcp-cloudflare/package.json +++ b/packages/mcp-cloudflare/package.json @@ -3,7 +3,8 @@ "sentryCi": { "roles": [ "cloudflare" - ] + ], + "typecheck": "tsc:ci" }, "version": "0.42.0", "private": true, @@ -25,9 +26,12 @@ "preview": "vite preview", "cf-typegen": "wrangler types", "test": "vitest run", - "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "test:code-mode": "vitest run --config=src/code-mode/vitest.config.ts", + "tsc:code-mode": "tsc --project src/code-mode/tsconfig.json", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml && pnpm run test:code-mode", "test:watch": "vitest", - "tsc": "tsc --noEmit" + "tsc": "tsc --noEmit", + "tsc:ci": "tsc -b && pnpm run tsc:code-mode" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.13.15", diff --git a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx index 6a4f0e20d..3b4354b62 100644 --- a/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx +++ b/packages/mcp-cloudflare/src/client/components/fragments/stdio-setup.tsx @@ -7,7 +7,7 @@ import InstallTabs, { Tab } from "./install-tabs"; const mcpServerName = import.meta.env.DEV ? "sentry-dev" : "sentry"; const orderedSkills = [...skillDefinitions] - .filter((skill) => !skill.deprecated) + .filter((skill) => !skill.deprecated && !skill.hostedOnly) .sort((a, b) => a.order - b.order); export default function StdioSetup() { diff --git a/packages/mcp-cloudflare/src/code-mode/access.test.ts b/packages/mcp-cloudflare/src/code-mode/access.test.ts new file mode 100644 index 000000000..41e61fb01 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/access.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { authorizeApiRequest } from "./access"; + +const dashboardPath = + "/api/0/organizations/{organization_id_or_slug}/dashboards/"; +const monitorPath = + "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/monitors/{monitor_id_or_slug}/"; +const releasePath = "/api/0/organizations/{organization_id_or_slug}/releases/"; +const issuePath = + "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/"; + +// Scope excerpts from the pinned Sentry OpenAPI. In particular, dashboard +// writes accept org:read upstream, while monitor and release writes do not. +const spec = { + paths: { + [dashboardPath]: { + get: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + post: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + }, + [monitorPath]: { + put: { + security: [ + { auth_token: ["alerts:write", "project:admin", "project:write"] }, + ], + }, + }, + [releasePath]: { + post: { + security: [ + { + auth_token: [ + "org:ci", + "project:admin", + "project:releases", + "project:write", + ], + }, + ], + }, + }, + [issuePath]: { + put: { security: [{ auth_token: ["event:admin", "event:write"] }] }, + }, + "/api/0/issues/{issue_id}/": { + get: { + security: [ + { auth_token: ["event:admin", "event:read", "event:write"] }, + ], + }, + }, + }, +}; + +function request(path: string, method: string, organizationSlug?: string) { + return authorizeApiRequest(new URL(path, "https://sentry.io"), method, spec, { + organizationSlug, + }); +} + +describe("authorizeApiRequest", () => { + it("accepts a published read and HEAD fallback", () => { + expect( + request("/api/0/organizations/example/dashboards/?cursor=next", "GET"), + ).toEqual({ + pathTemplate: dashboardPath, + organizationSlug: "example", + }); + expect( + request("/api/0/organizations/example/dashboards/", "HEAD").pathTemplate, + ).toBe(dashboardPath); + }); + + it("leaves upstream scopes to Sentry for published writes", () => { + expect( + request("/api/0/organizations/example/dashboards/", "POST").pathTemplate, + ).toBe(dashboardPath); + expect( + request("/api/0/projects/example/frontend/monitors/import/", "PUT") + .pathTemplate, + ).toBe(monitorPath); + expect( + request("/api/0/organizations/example/releases/", "POST").pathTemplate, + ).toBe(releasePath); + expect( + request("/api/0/organizations/example/issues/123/", "PUT").pathTemplate, + ).toBe(issuePath); + }); + + it("enforces the organization slug and rejects unscoped object IDs", () => { + expect( + request("/api/0/organizations/ex%61mple/dashboards/", "GET", "example") + .organizationSlug, + ).toBe("example"); + for (const path of [ + "/api/0/organizations/another/dashboards/", + "/api/0/organizations/123/dashboards/", + "/api/0/issues/123/?organization=example", + ]) { + expect(() => request(path, "GET", "example")).toThrow( + "organization constraint", + ); + } + expect(request("/api/0/issues/123/", "GET")).toEqual({ + pathTemplate: "/api/0/issues/{issue_id}/", + }); + }); + + it("requires the exact published path and HTTP method", () => { + expect(() => + request("/api/0/organizations/example/private/", "GET"), + ).toThrow("published API schema"); + expect(() => + request("/api/0/organizations/example/dashboards/", "DELETE"), + ).toThrow("HTTP method"); + expect(() => + request("/api/0/organizations/example/dashboards", "GET"), + ).toThrow("published API schema"); + }); + + it("rejects encoded separators before treating a path segment as an identifier", () => { + for (const organization of [ + "example%2fanother", + "example%5canother", + "example%252fanother", + "example%zz", + ]) { + expect(() => + request(`/api/0/organizations/${organization}/dashboards/`, "GET"), + ).toThrow(/API path/); + } + // URL normalization happens before authorization: traversing to another + // organization must be checked against the final path, not the input prefix. + expect(() => + request( + "/api/0/organizations/example/%2e%2e/another/dashboards/", + "GET", + "example", + ), + ).toThrow("organization constraint"); + }); + + it("does not fall back to a generic route when a literal route denies access", () => { + const overlappingSpec = { + paths: { + "/api/0/organizations/{organization_id_or_slug}/": { + get: { security: [{ auth_token: ["org:read"] }] }, + }, + "/api/0/organizations/settings/": { + post: { security: [{ auth_token: ["org:write"] }] }, + }, + }, + }; + expect(() => + authorizeApiRequest( + new URL("https://sentry.io/api/0/organizations/settings/"), + "GET", + overlappingSpec, + {}, + ), + ).toThrow("HTTP method"); + }); + + it("requires a catalog but does not use security metadata as an MCP grant", () => { + const url = new URL( + "https://sentry.io/api/0/organizations/example/dashboards/", + ); + expect(() => authorizeApiRequest(url, "GET", {}, {})).toThrow( + "operation catalog", + ); + expect( + authorizeApiRequest( + url, + "GET", + { paths: { [dashboardPath]: { get: {} } } }, + {}, + ).pathTemplate, + ).toBe(dashboardPath); + }); +}); diff --git a/packages/mcp-cloudflare/src/code-mode/access.ts b/packages/mcp-cloudflare/src/code-mode/access.ts new file mode 100644 index 000000000..045e21c98 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/access.ts @@ -0,0 +1,111 @@ +export interface CodeModeAccess { + organizationSlug?: string | null; +} + +interface MatchedOperation { + pathTemplate: string; + pathItem: Record; + parameters: Record; + literalSegments: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function decodePathSegment(segment: string): string { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + throw new Error("The API path contains invalid percent encoding."); + } + if (decoded === "." || decoded === ".." || /[/\\]|%2f|%5c/i.test(decoded)) { + throw new Error( + "The API path cannot contain encoded path separators or traversal segments.", + ); + } + return decoded; +} + +function findOperation( + url: URL, + spec: Record, +): MatchedOperation { + if (!isRecord(spec.paths)) { + throw new Error("The API schema does not contain an operation catalog."); + } + const segments = url.pathname.split("/").map(decodePathSegment); + const matches: MatchedOperation[] = []; + for (const [pathTemplate, pathItem] of Object.entries(spec.paths)) { + if (!pathTemplate.startsWith("/") || !isRecord(pathItem)) continue; + const templateSegments = pathTemplate.split("/"); + if (templateSegments.length !== segments.length) continue; + const parameters: Record = {}; + let literalSegments = 0; + const matchesPath = templateSegments.every((segment, index) => { + const parameter = /^\{([^{}]+)\}$/.exec(segment); + if (parameter) { + if (!segments[index] || Object.hasOwn(parameters, parameter[1])) { + return false; + } + parameters[parameter[1]] = segments[index]; + return true; + } + literalSegments++; + return segment === segments[index]; + }); + if (matchesPath) { + matches.push({ pathTemplate, pathItem, parameters, literalSegments }); + } + } + matches.sort((a, b) => b.literalSegments - a.literalSegments); + const match = matches[0]; + if (!match) { + throw new Error( + "This API path is not available in the published API schema.", + ); + } + if (matches[1]?.literalSegments === match.literalSegments) { + throw new Error( + "This API path matches multiple operations in the API schema.", + ); + } + return match; +} + +/** Enforce the published API surface and connection constraint before calling Sentry. */ +export function authorizeApiRequest( + url: URL, + method: string, + spec: object, + access: CodeModeAccess, +): { pathTemplate: string; organizationSlug?: string } { + if (!isRecord(spec)) { + throw new Error("The API schema does not contain an operation catalog."); + } + const { pathTemplate, pathItem, parameters } = findOperation(url, spec); + const normalizedMethod = method.toLowerCase(); + const operationMethod = + normalizedMethod === "head" && !Object.hasOwn(pathItem, "head") + ? "get" + : normalizedMethod; + const operation = Object.hasOwn(pathItem, operationMethod) + ? pathItem[operationMethod] + : undefined; + if (!isRecord(operation)) { + throw new Error( + "This HTTP method is not available for the published API path.", + ); + } + + const organizationSlug = + parameters.organization_id_or_slug ?? parameters.organization_slug; + if (access.organizationSlug && organizationSlug !== access.organizationSlug) { + throw new Error( + "This API operation is outside the active organization constraint. Use an organization-scoped API path with the authorized organization slug.", + ); + } + + return { pathTemplate, ...(organizationSlug ? { organizationSlug } : {}) }; +} diff --git a/packages/mcp-cloudflare/src/code-mode/broker.ts b/packages/mcp-cloudflare/src/code-mode/broker.ts new file mode 100644 index 000000000..f3b9d4c30 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/broker.ts @@ -0,0 +1,168 @@ +import { RpcTarget } from "cloudflare:workers"; +import { ApiServerError } from "@sentry/mcp-core/api-client"; +import { logIssue } from "@sentry/mcp-core/telem/logging"; +import type { ExecutionGate } from "./execution"; +import { configuredUrl, readBoundedBody } from "./http"; +import type { ApiAuthorization } from "./production"; + +export interface CodeModeEnv { + LOADER: WorkerLoader; + /** Fixed origin and API prefix, e.g. https://sentry.io/api/0. */ + API_BASE_URL: string; + /** Trusted, pre-dereferenced OpenAPI JSON source; never supplied by the model. */ + OPENAPI_URL: string; + CODE_MODE_ALLOW_WRITES?: string; +} + +export interface BrokerOptions { + accessToken: string; + execution: ExecutionGate; + apiBaseUrl: string; + allowWrites: boolean; + authorization?: ApiAuthorization; +} + +/** The only HTTP capability passed to the sandbox; credentials remain private. */ +export class ApiBroker extends RpcTarget { + #options: BrokerOptions; + + constructor(options: BrokerOptions) { + super(); + this.#options = options; + } + async fetch(request: Request): Promise { + try { + return await this.#options.execution.run(() => this.#fetch(request)); + } catch (error) { + if (!this.#options.execution.signal.aborted) throw error; + // Settle RPC with a value, even when the sandbox no longer awaits it. + return Response.json( + { + detail: + error instanceof Error + ? error.message + : "Code execution has finished.", + }, + { status: 408 }, + ); + } + } + + async #fetch(request: Request): Promise { + const base = configuredUrl(this.#options.apiBaseUrl); + const requested = new URL(request.url); + const prefix = base.pathname.replace(/\/$/, ""); + if ( + requested.origin !== base.origin || + requested.username || + requested.password || + (requested.pathname !== prefix && + !requested.pathname.startsWith(`${prefix}/`)) || + /%2f|%5c/i.test(requested.pathname) + ) { + return Response.json( + { + detail: + "Only the configured Sentry API origin and path prefix are allowed.", + }, + { status: 403 }, + ); + } + if ( + !this.#options.allowWrites && + !["GET", "HEAD", "OPTIONS"].includes(request.method) + ) { + return Response.json( + { detail: "Writes are disabled for this MCP connection." }, + { status: 403 }, + ); + } + let destination = requested.toString(); + let attribution: Record = {}; + if (this.#options.authorization) { + const authorized = await this.#options.authorization.authorize( + request.url, + request.method, + ); + this.#options.execution.assertOpen(); + if ("error" in authorized) { + return Response.json({ detail: authorized.error }, { status: 403 }); + } + destination = authorized.url; + attribution = authorized.headers; + } + this.#options.execution.assertOpen(); + // Only API-relevant input headers cross the boundary. Auth is always replaced. + const headers = new Headers(); + for (const name of [ + "accept", + "content-type", + "if-match", + "if-none-match", + ]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + for (const [name, value] of Object.entries(attribution)) + headers.set(name, value); + headers.set("Authorization", `Bearer ${this.#options.accessToken}`); + const response = await fetch( + new Request(new Request(destination, request), { + headers, + redirect: "manual", + signal: this.#options.execution.signal, + }), + ); + if (response.status === 401) { + await this.#options.authorization?.onUnauthorized(); + } + // Report before returning to user code, which may handle the HTTP error. + const eventId = + response.status >= 500 + ? logIssue( + new ApiServerError( + "Sentry API request failed during Code Mode execution.", + response.status, + ), + { + loggerScope: ["code-mode", "api"], + extra: { method: request.method, status: response.status }, + }, + ) + : undefined; + // Do not allow a redirect to carry our bearer token to another location. + if (response.status >= 300 && response.status < 400) { + await response.body?.cancel(); + return Response.json( + { + detail: + "API redirects are not followed. Use the canonical endpoint path.", + }, + { status: 502 }, + ); + } + const responseHeaders = new Headers(); + if (eventId) responseHeaders.set("x-sentry-event-id", eventId); + for (const name of [ + "content-type", + "link", + "retry-after", + "www-authenticate", + "x-sentry-rate-limits", + "etag", + ]) { + const value = response.headers.get(name); + if (value) responseHeaders.set(name, value); + } + const body = await readBoundedBody(response.body, 2_000_000); + return new Response( + [204, 205, 304].includes(response.status) || request.method === "HEAD" + ? null + : body, + { + status: response.status, + headers: responseHeaders, + }, + ); + } +} diff --git a/packages/mcp-cloudflare/src/code-mode/definitions.ts b/packages/mcp-cloudflare/src/code-mode/definitions.ts new file mode 100644 index 000000000..189ef643f --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/definitions.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +export const CODE_MODE_SKILLS = ["api-read", "api-write"] as const; + +export const codeModeResultSchema = z + .object({ + result: z.unknown().optional(), + error: z.string().optional(), + httpError: z + .object({ + status: z.number(), + method: z.string(), + path: z.string(), + headers: z.record(z.string(), z.string()), + }) + .optional(), + stats: z.object({ apiCalls: z.number(), writesAttempted: z.number() }), + }) + .refine( + (value) => "result" in value !== "error" in value, + "Return either a result or an error.", + ); + +/** Shared by hosted registration, public metadata and plugin generation. */ +export function getCodeModeToolDefinitions({ + allowWrites, + authorizationRequired, +}: { + allowWrites: boolean; + authorizationRequired: boolean; +}) { + return { + search_sentry_api: { + title: "Search Sentry API", + description: `Discover Sentry HTTP API operations by executing a JavaScript async arrow function against the global spec. The configured OpenAPI document has schemas inline; no $ref resolution is needed. No network or API calls are available. +Search using the user's product terms across operation metadata: paths may use different internal names. Each spec.paths[path] has optional path-level parameters and HTTP-method operations. Operations have optional operationId, summary, description, tags (string[]), parameters, requestBody and responses. spec.tags, when present, describes categories with name, description and optional x-sidebar-name. +First return a short list of matching methods, paths and summaries. If results are empty or unrelated, inspect categories and broaden the terms before trying API routes. Then inspect the selected operation's descriptions, parameters, requestBody and relevant response fields. Path-level parameters also apply; operation parameters override them by name and in. Do not return whole paths, the full spec or sliced JSON strings. Return only needed fields; the result limit is 50,000 bytes. +Discovery example: async () => { + const term = "release"; + const results = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, op] of Object.entries(item)) { + if (!["get", "post", "put", "patch", "delete", "head", "options", "trace"].includes(method)) continue; + const text = [path, op.operationId, op.summary, op.description, ...(op.tags ?? [])].join(" ").toLowerCase(); + if (text.includes(term.toLowerCase())) results.push({method, path, operationId: op.operationId, summary: op.summary, tags: op.tags}); + } + } + return results; +} +Categories example: async () => spec.tags?.length ? spec.tags : [...new Set(Object.values(spec.paths).flatMap(item => Object.entries(item).filter(([method]) => ["get", "post", "put", "patch", "delete", "head", "options", "trace"].includes(method)).flatMap(([, op]) => op.tags ?? [])))].map(name => ({name})) +Schema example: async () => { const path = "/api/0/organizations/{organization_id_or_slug}/releases/"; const item = spec.paths[path]; const op = item.post; return {description: op.description, pathParameters: item.parameters, operationParameters: op.parameters, requestBody: op.requestBody}; }`, + inputSchema: z.object({ + code: z + .string() + .min(1) + .max(30_000) + .describe( + "JavaScript async arrow function, e.g. async () => Object.keys(spec.paths)", + ), + }), + outputSchema: codeModeResultSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, + execute_sentry_code: { + title: "Execute Sentry API code", + description: `Execute a JavaScript async arrow function with sentry.request({method?, path, query?, body?}). Discover endpoints and schemas with search_sentry_api. method defaults to GET. path is relative to the configured API prefix; full /api/0 paths are also accepted. query accepts string, number or boolean values and arrays of those values; arrays produce repeated parameters, and null/undefined values are omitted. body is JSON. Each request returns {status, headers, body}; headers.link preserves pagination. Non-2xx responses throw an Error with status, method, path, headers, data. On 403, headers["www-authenticate"] may provide insufficient_scope evidence; a denial without that evidence does not identify its cause. Credentials are injected outside the sandbox. Direct network access is disabled; use sentry.request for HTTP.${authorizationRequired ? " Only documented operations within this connection’s API read/write permission and organization constraint are allowed. Sentry separately enforces the upstream token scopes and user/resource permissions. A permission denial cannot be bypassed by changing routes or using fetch directly." : ""} +Before writing, discover the operation and inspect its request contract, defaults and update semantics. For edits, read the current resource through HTTP: other MCP tools may return renamed, enriched or filtered views. Even a raw GET response is not a write template: sending the same field and value back can trigger effects, including propagating an aggregate value to child resources. Build the body explicitly from required fields and requested changes. Include other fields only when the request contract or documented update behavior requires them; do not resend unchanged optional fields merely because GET returned them. Do not assume PUT replaces everything or that omitted fields always stay unchanged, and do not turn a missing field into an empty list or null. When the contract requires resending nested collections, retain existing IDs, values and relationships unless the user requested changing them. If preservation is ambiguous, obtain more documentation or report the limitation rather than inventing write semantics. Re-read after a write to verify requested changes and preserved settings. If an unexpected side effect occurs, do not probe speculative write bodies or routes to repair it; report the actual state unless a documented repair is available. +Combine dependent requests, loops, pagination and verification in code. Await every sentry.request(), including parallel calls, before returning. Pending calls are cancelled when execution ends or times out; already accepted writes are not rolled back. Keep intermediate responses inside the program and return only the fields needed to answer the user, such as IDs, URLs, counts or a concise verification result. Return full objects only when the user needs their full contents. +Limits: 20 API calls, 30 seconds, 50,000 output bytes, 2 MB per API response. Writes are ${allowWrites ? "enabled" : "disabled"}. No automatic retries. Execution is not atomic; inspect state before retrying a failed write. +Example: async () => { const response = await sentry.request({path: "/organizations/"}); return {organizations: response.body.map(({slug, name}) => ({slug, name})), pagination: response.headers.link}; }`, + inputSchema: z.object({ + code: z + .string() + .min(1) + .max(30_000) + .describe("JavaScript async arrow function using sentry.request"), + }), + outputSchema: codeModeResultSchema, + annotations: { + readOnlyHint: !allowWrites, + destructiveHint: allowWrites, + openWorldHint: true, + }, + }, + }; +} + +export const CODE_MODE_TOOL_NAMES: readonly string[] = Object.freeze( + Object.keys( + getCodeModeToolDefinitions({ + allowWrites: false, + authorizationRequired: true, + }), + ), +); diff --git a/packages/mcp-cloudflare/src/code-mode/execution.ts b/packages/mcp-cloudflare/src/code-mode/execution.ts new file mode 100644 index 000000000..48228b019 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/execution.ts @@ -0,0 +1,41 @@ +/** Close pending RPC like Cloudflare's ExecutionGate, and abort upstream HTTP. */ +export class ExecutionGate { + readonly deadline = Date.now() + 30_000; + readonly #controller = new AbortController(); + readonly signal = this.#controller.signal; + #resolveClosed!: (reason: Error) => void; + readonly #whenClosed = new Promise((resolve) => { + this.#resolveClosed = resolve; + }); + readonly #timer = setTimeout( + () => this.close(new Error("Execution deadline exceeded.")), + 30_000, + ); + + assertOpen(): void { + if (Date.now() >= this.deadline) { + this.close(new Error("Execution deadline exceeded.")); + } + this.signal.throwIfAborted(); + } + + /** Callbacks may outlive this wait; recheck the gate after awaits before HTTP. */ + async run(work: () => Promise): Promise { + this.assertOpen(); + const result = await Promise.race([ + work(), + this.#whenClosed.then((reason) => { + throw reason; + }), + ]); + this.assertOpen(); + return result; + } + + close(reason = new Error("Code execution has finished.")): void { + if (this.signal.aborted) return; + clearTimeout(this.#timer); + this.#controller.abort(reason); + this.#resolveClosed(reason); + } +} diff --git a/packages/mcp-cloudflare/src/code-mode/http.ts b/packages/mcp-cloudflare/src/code-mode/http.ts new file mode 100644 index 000000000..c8646877e --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/http.ts @@ -0,0 +1,78 @@ +import { MAX_MCP_REQUEST_BODY_SIZE } from "@sentry/mcp-core/constants"; + +export function configuredUrl(value: string): URL { + const url = new URL(value); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + url.username || + url.password || + url.search || + url.hash || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) + ) { + throw new Error( + "Configured URLs must use HTTPS (or loopback HTTP for local tests), without credentials, query, or fragment.", + ); + } + return url; +} + +export class BodySizeLimitError extends Error {} + +/** Read before forwarding so streaming bodies cannot exceed the byte budget. */ +export async function readBoundedBody( + body: ReadableStream | null, + maximumBytes: number, +): Promise { + if (!body) return new Uint8Array(); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + size += next.value.byteLength; + if (size > maximumBytes) { + await reader.cancel(); + throw new BodySizeLimitError( + `Response exceeds ${maximumBytes} bytes. Narrow the query or use pagination.`, + ); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +/** Agents' legacy transport does not forward the SDK's maxRequestBodySize. */ +export async function limitMcpRequestBody( + request: Request, +): Promise { + if (request.method !== "POST") return request; + try { + const body = await readBoundedBody(request.body, MAX_MCP_REQUEST_BODY_SIZE); + return new Request(request, { method: "POST", body }); + } catch (error) { + if (!(error instanceof BodySizeLimitError)) throw error; + return Response.json( + { + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: `Payload Too Large: Request body must not exceed ${MAX_MCP_REQUEST_BODY_SIZE} bytes`, + }, + }, + { status: 413 }, + ); + } +} diff --git a/packages/mcp-cloudflare/src/code-mode/openapi.ts b/packages/mcp-cloudflare/src/code-mode/openapi.ts new file mode 100644 index 000000000..8c464d9ee --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/openapi.ts @@ -0,0 +1,33 @@ +import { configuredUrl, readBoundedBody } from "./http"; + +export const SENTRY_OPENAPI_URL = + "https://raw.githubusercontent.com/getsentry/sentry-api-schema/803d6de98c9156c15e9ddc1bf9accbac6e24cebc/openapi-derefed.json"; + +let cachedSpec: { url: string; promise: Promise } | undefined; + +/** Share the trusted schema between discovery and authorization; retry failed loads. */ +export async function getSpec(url: string): Promise { + configuredUrl(url); + if (cachedSpec?.url === url) return await cachedSpec.promise; + const promise = (async () => { + const response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) + throw new Error(`OpenAPI could not be loaded (HTTP ${response.status}).`); + const body = await readBoundedBody(response.body, 30_000_000); + const parsed: unknown = JSON.parse(new TextDecoder().decode(body)); + if (!parsed || typeof parsed !== "object" || !("paths" in parsed)) { + throw new Error("The configured OpenAPI document has no paths object."); + } + return parsed; + })(); + cachedSpec = { url, promise }; + try { + return await promise; + } catch (error) { + cachedSpec = undefined; + throw error; + } +} diff --git a/packages/mcp-cloudflare/src/code-mode/production.ts b/packages/mcp-cloudflare/src/code-mode/production.ts new file mode 100644 index 000000000..a09d0ed98 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/production.ts @@ -0,0 +1,102 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { resolveRegionUrlForOrganization } from "@sentry/mcp-core/internal/tool-helpers/resolve-region-url"; +import { validateRegionUrl } from "@sentry/mcp-core/internal/tool-helpers/validate-region-url"; +import type { Skill } from "@sentry/mcp-core/skills"; +import type { ServerContext } from "@sentry/mcp-core/types"; +import { USER_AGENT } from "@sentry/mcp-core/version"; +import { authorizeApiRequest } from "./access"; +import { CODE_MODE_SKILLS } from "./definitions"; +import { getSpec, SENTRY_OPENAPI_URL } from "./openapi"; +import { registerCodeModeTools } from "./worker"; + +/** Trusted per-request policy, available to the outbound broker, never to user code. */ +export class ApiAuthorization { + #context: ServerContext; + #unauthorized = false; + + constructor(context: ServerContext) { + this.#context = context; + } + + async authorize( + url: string, + method: string, + ): Promise< + { url: string; headers: Record } | { error: string } + > { + const spec = await getSpec(SENTRY_OPENAPI_URL); + const target = new URL(url); + let organizationSlug: string | undefined; + try { + ({ organizationSlug } = authorizeApiRequest(target, method, spec, { + organizationSlug: this.#context.constraints.organizationSlug, + })); + } catch (error) { + return { + error: error instanceof Error ? error.message : "API access denied.", + }; + } + if (organizationSlug) { + const regionUrl = await resolveRegionUrlForOrganization({ + context: this.#context, + organizationSlug, + }); + if (regionUrl) { + target.host = validateRegionUrl( + regionUrl, + this.#context.sentryHost ?? "sentry.io", + ); + target.protocol = "https:"; + } + } + const headers: Record = { "User-Agent": USER_AGENT }; + for (const [field, header] of [ + ["clientId", "X-Sentry-MCP-Client-Id"], + ["clientName", "X-Sentry-MCP-Client-Name"], + ["clientFamily", "X-Sentry-MCP-Client-Family"], + ["utmSource", "X-Sentry-MCP-Utm-Source"], + ] as const) { + const value = this.#context[field]; + if (value) headers[header] = value; + } + return { url: target.toString(), headers }; + } + + async onUnauthorized(): Promise { + if (this.#unauthorized) return; + this.#unauthorized = true; + try { + await this.#context.onUpstreamUnauthorized?.(); + } catch { + // Match the normal tool boundary: revocation failures do not hide the 401. + } + } +} + +/** Register on the existing authenticated, instrumented MCP server. */ +export function registerProductionCodeModeTools( + server: McpServer, + loader: WorkerLoader, + context: ServerContext, +): void { + const skills = new Set( + [...(context.grantedSkills ?? [])].filter((skill) => + CODE_MODE_SKILLS.some((allowedSkill) => skill === allowedSkill), + ), + ); + // A project route can mutate shared resources (e.g. release files). OpenAPI + // does not prove project isolation, so retain the existing scoped tools only. + if (!skills.size || context.constraints.projectSlug) return; + const allowWrites = skills.has("api-write"); + registerCodeModeTools( + server, + { + LOADER: loader, + API_BASE_URL: `${context.sentryProtocol ?? "https"}://${context.sentryHost ?? "sentry.io"}/api/0`, + OPENAPI_URL: SENTRY_OPENAPI_URL, + CODE_MODE_ALLOW_WRITES: String(allowWrites), + }, + context.accessToken, + new ApiAuthorization(context), + ); +} diff --git a/packages/mcp-cloudflare/src/code-mode/sandbox.ts b/packages/mcp-cloudflare/src/code-mode/sandbox.ts new file mode 100644 index 000000000..7f2ed01f5 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/sandbox.ts @@ -0,0 +1,95 @@ +/** Trusted harness; model code is loaded after the private API broker is set. */ +export function sandboxSource(apiBase?: string): string { + return ` +import { WorkerEntrypoint } from "cloudflare:workers"; +${apiBase ? "" : 'import spec from "spec.json";'} +${apiBase ? "" : 'Object.defineProperty(globalThis, "spec", { value: spec });'} +const apiBase = ${JSON.stringify(apiBase ?? null)}; +let deadline = 0; +let apiCalls = 0; +let writesAttempted = 0; +let pendingApiCalls = 0; +let apiBroker; +async function request({ method = "GET", path, query, body }) { + if (!apiBase) throw new Error("API access is unavailable during search."); + if (!apiBroker) throw new Error("Code execution has finished."); + if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//")) { + throw new Error("path must start with / and contain an API path, not a URL."); + } + const base = new URL(apiBase); + const prefix = base.pathname.replace(/\\/$/, ""); + const url = new URL(path === prefix || path.startsWith(prefix + "/") + ? path : prefix + path, base.origin); + if (query) for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(item)); + } + const request = new Request(url, { + method, + headers: body === undefined ? {} : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: "manual", + }); + if (++apiCalls > 20) throw new Error("API call budget exhausted (20 calls per execution)."); + if (Date.now() >= deadline) throw new Error("Execution deadline exceeded."); + if (!["GET", "HEAD", "OPTIONS"].includes(request.method)) writesAttempted++; + const response = await apiBroker.fetch(request); + const text = await response.text(); + let data = text; + if (text && (response.headers.get("content-type") || "").toLowerCase().includes("json")) { + try { data = JSON.parse(text); } catch { throw new Error("API response was not valid JSON."); } + } else if (!text) data = null; + if (!response.ok) { + const error = new Error("Sentry API error: " + method.toUpperCase() + " " + url.pathname + + " returned HTTP " + response.status + ": " + JSON.stringify(data).slice(0, 2_000)); + error.status = response.status; + error.method = method.toUpperCase(); + error.path = url.pathname; + error.headers = Object.fromEntries(response.headers); + error.data = data; + throw error; + } + return { status: response.status, headers: Object.fromEntries(response.headers), body: data }; +} +const sentry = { + request(options) { + pendingApiCalls++; + const pending = request(options).finally(() => pendingApiCalls--); + // A forgotten await must become the execution error below, not an unhandled rejection. + pending.catch(() => {}); + return pending; + }, +}; +Object.defineProperty(globalThis, "sentry", { value: sentry }); +export default class extends WorkerEntrypoint { + async run(broker, executionDeadline) { + apiBroker = broker; + deadline = executionDeadline; + try { + const { default: run } = await import("./user.js"); + const result = await run(); + const serialized = JSON.stringify({ result: result ?? null, stats: { apiCalls, writesAttempted } }); + if (pendingApiCalls > 0) { + throw new Error("Await every sentry.request() before returning. API calls are still pending."); + } + if (new TextEncoder().encode(serialized).byteLength > 50_000) { + throw new Error("Result exceeds 50,000 bytes. Return selected fields or a smaller page."); + } + return new Response(serialized, { headers: { "content-type": "application/json" } }); + } catch (error) { + const hasHttpDetails = Number.isFinite(error?.status) && + typeof error.method === "string" && typeof error.path === "string" && + error.headers !== null && typeof error.headers === "object" && + !Array.isArray(error.headers) && Object.values(error.headers).every(value => typeof value === "string"); + return Response.json({ error: String(error?.message || error).slice(0, 4_000), + ...(hasHttpDetails ? { httpError: { + status: error.status, method: error.method, path: error.path, headers: error.headers, + } } : {}), + stats: { apiCalls, writesAttempted } }, { status: 400 }); + } finally { + apiBroker = undefined; + } + } +} +`; +} diff --git a/packages/mcp-cloudflare/src/code-mode/tsconfig.json b/packages/mcp-cloudflare/src/code-mode/tsconfig.json new file mode 100644 index 000000000..f1c824202 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.server.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../../../..", + "tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.code-mode.tsbuildinfo" + }, + "include": ["./worker.ts", "./broker.ts", "./sandbox.ts"] +} diff --git a/packages/mcp-cloudflare/src/code-mode/vitest.config.ts b/packages/mcp-cloudflare/src/code-mode/vitest.config.ts new file mode 100644 index 000000000..7c30049d1 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +// This experiment has its own Worker entrypoint and uses real Dynamic Workers. +export default defineConfig({ + test: { + environment: "node", + include: ["src/code-mode/*.test.ts"], + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 90_000, + }, +}); diff --git a/packages/mcp-cloudflare/src/code-mode/worker.test.ts b/packages/mcp-cloudflare/src/code-mode/worker.test.ts new file mode 100644 index 000000000..dd4b4f864 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/worker.test.ts @@ -0,0 +1,746 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer, type Server, type ServerResponse } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { convertV4MiniflareOptions, Miniflare } from "miniflare"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { MAX_MCP_TOOL_INPUT_ELEMENTS } from "../../../mcp-core/src/constants"; + +interface ToolResult { + content: Array<{ type: string; text?: string }>; + structuredContent?: { + result?: unknown; + error?: string; + httpError?: { + status: number; + method: string; + path: string; + headers: Record; + }; + stats: { apiCalls: number; writesAttempted: number }; + }; + isError?: boolean; +} + +const requests: Array<{ method: string; path: string; auth?: string }> = []; +const specRequests: string[] = []; +const acceptedWrites: string[] = []; +let slowWriteResponse: ServerResponse | undefined; +let slowWriteClosed = false; +const workflowPath = + "/api/0/organizations/{organization_id_or_slug}/workflows/{workflow_id}/"; +const releasePath = "/api/0/organizations/{organization_id_or_slug}/releases/"; +const metadataSpec = { + openapi: "3.0.0", + paths: { + [workflowPath]: { + put: { summary: "Update an Alert by ID", tags: ["workflows"] }, + }, + "/api/0/detectors/": { + get: { operationId: "listAlertDetectors", tags: ["workflows"] }, + }, + "/api/0/notifications/": { + get: { description: "Retrieve alert notification history." }, + }, + "/api/0/rules/": { get: { tags: ["Alerts"] } }, + "/api/0/alerts/": { + summary: "Alert operations", + parameters: [{ name: "cursor", in: "query", schema: { type: "string" } }], + get: {}, + }, + [releasePath]: { + parameters: [ + { + name: "organization_id_or_slug", + in: "path", + required: true, + schema: { type: "string" }, + }, + { name: "cursor", in: "query", schema: { type: "string" } }, + ], + post: { + description: "Create a release for the organization.", + parameters: [ + { name: "cursor", in: "query", schema: { type: "integer" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["version", "projects"], + properties: { + version: { type: "string" }, + projects: { type: "array", items: { type: "string" } }, + }, + }, + }, + }, + }, + }, + }, + }, +}; +let fixture: Server; +let origin: string; +let outputDirectory: string; +let worker: Miniflare; + +async function createWorker(allowWrites = true, specPath = "/spec.json") { + return new Miniflare( + convertV4MiniflareOptions({ + modules: true, + modulesRoot: outputDirectory, + scriptPath: join(outputDirectory, "worker.js"), + compatibilityDate: "2026-01-12", + compatibilityFlags: ["nodejs_compat"], + workerLoaders: { LOADER: {} }, + bindings: { + API_BASE_URL: `${origin}/api/0`, + OPENAPI_URL: `${origin}${specPath}`, + CODE_MODE_ALLOW_WRITES: String(allowWrites), + }, + }), + ); +} + +async function rpc( + method: string, + params: Record, + target = worker, +): Promise { + const response = await target.dispatchFetch("http://localhost/mcp", { + method: "POST", + headers: { + authorization: "Bearer fixture-only-token", + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + "mcp-method": method, + ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + name: "code-mode-tests", + version: "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`MCP HTTP ${response.status}: ${text}`); + const data = text.startsWith("event:") + ? text + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6) + : text; + const envelope = JSON.parse(data ?? "{}"); + if (envelope.error) throw new Error(JSON.stringify(envelope.error)); + return envelope.result; +} + +async function callTool( + name: "search_sentry_api" | "execute_sentry_code", + code: string, + target = worker, +): Promise { + return await rpc("tools/call", { name, arguments: { code } }, target); +} + +async function publishedExample( + name: "Discovery" | "Categories" | "Schema", + target: Miniflare, +): Promise { + const { tools } = await rpc<{ + tools: Array<{ name: string; description: string }>; + }>("tools/list", {}, target); + const description = tools.find( + (tool) => tool.name === "search_sentry_api", + )?.description; + const example = description + ?.split(`${name} example: `)[1] + ?.split(/\n(?:Discovery|Categories|Schema) example: /)[0] + ?.trim(); + if (!example) throw new Error(`${name} example missing from tools/list`); + return example; +} + +beforeAll(async () => { + outputDirectory = await mkdtemp(join(tmpdir(), "sentry-code-mode-test-")); + await promisify(execFile)( + process.execPath, + [ + resolve("node_modules/wrangler/bin/wrangler.js"), + "deploy", + "--dry-run", + "--config", + "wrangler.code-mode.jsonc", + "--outdir", + outputDirectory, + ], + { + timeout: 60_000, + env: { ...process.env, WRANGLER_SEND_METRICS: "false" }, + }, + ); + fixture = createServer((request, response) => { + if ( + request.url === "/metadata-spec.json" || + request.url === "/metadata-spec-categories.json" + ) { + specRequests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + ...metadataSpec, + ...(request.url === "/metadata-spec-categories.json" + ? { + tags: [ + { + name: "workflows", + description: "Manage alert workflows.", + "x-sidebar-name": "Alerts", + }, + ], + } + : {}), + }), + ); + return; + } + if (request.url === "/spec.json") { + specRequests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + openapi: "3.0.0", + servers: [{ url: origin }], + paths: { "/api/0/items/": { get: { summary: "List items" } } }, + }), + ); + return; + } + requests.push({ + method: request.method ?? "GET", + path: request.url ?? "", + auth: request.headers.authorization, + }); + if (request.url === "/api/0/slow-write/") { + acceptedWrites.push("accepted-write"); + slowWriteResponse = response; + response.once("close", () => { + slowWriteClosed = true; + }); + response.writeHead(201, { "Content-Type": "application/json" }); + response.write('{"id":"accepted-write",'); + return; + } + if (request.url === "/api/0/redirect/") { + response.writeHead(302, { Location: `${origin}/outside-api` }); + response.end(); + return; + } + if (request.url === "/api/0/failure/") { + response.writeHead(400, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ title: ["This field is required."] })); + return; + } + if ( + request.url === "/api/0/insufficient-scope/" || + request.url === "/api/0/forbidden/" + ) { + response.writeHead(403, { + "Content-Type": "application/json", + "Set-Cookie": "private-session=not-for-sandbox", + ...(request.url === "/api/0/insufficient-scope/" + ? { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="org:admin org:write"', + } + : {}), + }); + response.end( + JSON.stringify({ + detail: "You do not have permission to perform this action.", + }), + ); + return; + } + response.writeHead(request.method === "POST" ? 201 : 200, { + "Content-Type": "application/json", + Link: '; rel="next"; results="true"', + "Set-Cookie": "private-session=not-for-sandbox", + }); + response.end( + JSON.stringify({ id: String(requests.length), name: "Fixture item" }), + ); + }); + await new Promise((resolveReady) => + fixture.listen(0, "127.0.0.1", resolveReady), + ); + const address = fixture.address(); + if (!address || typeof address === "string") + throw new Error("Fixture did not bind a port."); + origin = `http://127.0.0.1:${address.port}`; + worker = await createWorker(); +}); + +beforeEach(() => { + requests.length = 0; + specRequests.length = 0; + acceptedWrites.length = 0; + slowWriteResponse = undefined; + slowWriteClosed = false; +}); + +afterAll(async () => { + await worker?.dispose(); + if (fixture) + await new Promise((resolveClosed) => + fixture.close(() => resolveClosed()), + ); + if (outputDirectory) + await rm(outputDirectory, { recursive: true, force: true }); +}); + +describe("HTTP Code Mode through real Dynamic Workers", () => { + it("aborts a stalled response body at the real deadline without rolling back an accepted write", async () => { + const started = performance.now(); + try { + const result = await callTool( + "execute_sentry_code", + 'async () => sentry.request({method: "POST", path: "/slow-write/", body: {name: "Fixture item"}})', + ); + expect(performance.now() - started).toBeGreaterThanOrEqual(29_000); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain("Execution deadline exceeded"); + expect(result.content[0]?.text).toContain( + "writes may already have completed", + ); + await expect.poll(() => slowWriteClosed, { timeout: 2_000 }).toBe(true); + expect(acceptedWrites).toEqual(["accepted-write"]); + expect(requests).toEqual([ + { + method: "POST", + path: "/api/0/slow-write/", + auth: "Bearer fixture-only-token", + }, + ]); + } finally { + slowWriteResponse?.destroy(); + } + }, 45_000); + + it("bounds nested tool arguments before starting a Dynamic Worker", async () => { + const result = await rpc("tools/call", { + name: "execute_sentry_code", + arguments: { + code: 'async () => sentry.request({path: "/items/"})', + unused: { values: Array(MAX_MCP_TOOL_INPUT_ELEMENTS + 1).fill(null) }, + }, + }); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain( + `maximum of ${MAX_MCP_TOOL_INPUT_ELEMENTS} elements`, + ); + expect(requests).toEqual([]); + }); + + it("requires an explicit direct API bearer token", async () => { + const response = await worker.dispatchFetch("http://localhost/mcp", { + method: "POST", + }); + expect(response.status).toBe(401); + expect(requests).toEqual([]); + }); + it("discovers the configured OpenAPI and returns a structured result", async () => { + expect( + await callTool( + "search_sentry_api", + "async () => Object.keys(spec.paths)", + ), + ).toMatchInlineSnapshot(` + { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "sentry-code-mode-experiment", + "version": "0.1.0", + }, + }, + "content": [ + { + "text": "{"result":["/api/0/items/"],"stats":{"apiCalls":0,"writesAttempted":0}}", + "type": "text", + }, + ], + "resultType": "complete", + "structuredContent": { + "result": [ + "/api/0/items/", + ], + "stats": { + "apiCalls": 0, + "writesAttempted": 0, + }, + }, + } + `); + expect(requests).toEqual([]); + }); + + it("discovers alerts from metadata using the example published to MCP clients", async () => { + const metadataWorker = await createWorker(true, "/metadata-spec.json"); + try { + const example = await publishedExample("Discovery", metadataWorker); + expect(specRequests).toEqual([]); + const result = await callTool( + "search_sentry_api", + example.replace('const term = "release";', 'const term = "alert";'), + metadataWorker, + ); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual({ + result: [ + { + method: "put", + path: workflowPath, + summary: "Update an Alert by ID", + tags: ["workflows"], + }, + { + method: "get", + path: "/api/0/detectors/", + operationId: "listAlertDetectors", + tags: ["workflows"], + }, + { method: "get", path: "/api/0/notifications/" }, + { method: "get", path: "/api/0/rules/", tags: ["Alerts"] }, + { method: "get", path: "/api/0/alerts/" }, + ], + stats: { apiCalls: 0, writesAttempted: 0 }, + }); + expect(specRequests).toEqual(["/metadata-spec.json"]); + expect(requests).toEqual([]); + } finally { + await metadataWorker.dispose(); + } + }); + + it.each([ + { + source: "operation tags", + specPath: "/metadata-spec.json", + categories: [{ name: "workflows" }, { name: "Alerts" }], + }, + { + source: "declared category metadata", + specPath: "/metadata-spec-categories.json", + categories: [ + { + name: "workflows", + description: "Manage alert workflows.", + "x-sidebar-name": "Alerts", + }, + ], + }, + ])( + "discovers categories from $source with the published example", + async ({ specPath, categories }) => { + const metadataWorker = await createWorker(true, specPath); + try { + const result = await callTool( + "search_sentry_api", + await publishedExample("Categories", metadataWorker), + metadataWorker, + ); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual({ + result: categories, + stats: { apiCalls: 0, writesAttempted: 0 }, + }); + expect(requests).toEqual([]); + } finally { + await metadataWorker.dispose(); + } + }, + ); + + it("returns path and operation parameters separately with the published schema example", async () => { + const metadataWorker = await createWorker(true, "/metadata-spec.json"); + try { + const result = await callTool( + "search_sentry_api", + await publishedExample("Schema", metadataWorker), + metadataWorker, + ); + const path = metadataSpec.paths[releasePath]; + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual({ + result: { + description: path.post.description, + pathParameters: path.parameters, + operationParameters: path.post.parameters, + requestBody: path.post.requestBody, + }, + stats: { apiCalls: 0, writesAttempted: 0 }, + }); + expect(requests).toEqual([]); + } finally { + await metadataWorker.dispose(); + } + }); + + it("combines authenticated API calls and preserves pagination", async () => { + const result = await callTool( + "execute_sentry_code", + `async () => { + const source = await sentry.request({path: "/items/", query: {project: [1, 2]}}); + const created = await sentry.request({method: "POST", path: "/api/0/items/", body: {name: source.body.name}}); + return {id: created.body.id, link: source.headers.link, cookie: source.headers["set-cookie"] ?? null}; + }`, + ); + expect(result).toMatchInlineSnapshot(` + { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "sentry-code-mode-experiment", + "version": "0.1.0", + }, + }, + "content": [ + { + "text": "{"result":{"id":"2","link":"; rel=\\"next\\"; results=\\"true\\"","cookie":null},"stats":{"apiCalls":2,"writesAttempted":1}}", + "type": "text", + }, + ], + "resultType": "complete", + "structuredContent": { + "result": { + "cookie": null, + "id": "2", + "link": "; rel="next"; results="true"", + }, + "stats": { + "apiCalls": 2, + "writesAttempted": 1, + }, + }, + } + `); + expect(result.structuredContent).toEqual({ + result: { + id: "2", + link: '; rel="next"; results="true"', + cookie: null, + }, + stats: { apiCalls: 2, writesAttempted: 1 }, + }); + expect(requests).toEqual([ + { + method: "GET", + path: "/api/0/items/?project=1&project=2", + auth: "Bearer fixture-only-token", + }, + { + method: "POST", + path: "/api/0/items/", + auth: "Bearer fixture-only-token", + }, + ]); + }); + + it("prevents search from accessing the network", async () => { + const result = await callTool( + "search_sentry_api", + 'async () => (await fetch(new URL("/api/0/items/", spec.servers[0].url))).text()', + ); + expect(result.isError).toBe(true); + expect(requests).toEqual([]); + }); + + it("blocks raw network access, API path escapes and API redirects", async () => { + const result = await callTool( + "execute_sentry_code", + `async () => { + const outcomes = []; + for (const url of ["https://unconfigured.invalid/api/0/items/", ${JSON.stringify(`${origin}/api/0/items/`)}]) { + try { await fetch(url, {headers: {Authorization: "Bearer forged"}}); outcomes.push("unexpected access"); } + catch { outcomes.push("blocked"); } + } + for (const path of ["/../../outside-api", "/redirect/"]) { + try { await sentry.request({path}); } + catch (error) { outcomes.push(error.status); } + } + return outcomes; + }`, + ); + expect(result.structuredContent?.result).toEqual([ + "blocked", + "blocked", + 403, + 502, + ]); + expect(requests).toHaveLength(1); + expect(requests[0]?.path).toBe("/api/0/redirect/"); + }); + + it("rejects writes for a read-only connection", async () => { + const readOnlyWorker = await createWorker(false); + try { + const result = await callTool( + "execute_sentry_code", + `async () => { + try { await sentry.request({path: "/items/", method: "POST"}); } + catch (error) { return error.status; } + }`, + readOnlyWorker, + ); + expect(result.structuredContent?.result).toBe(403); + expect(requests).toEqual([]); + } finally { + await readOnlyWorker.dispose(); + } + }); + + it("returns API failures as tool errors without retrying writes", async () => { + const result = await callTool( + "execute_sentry_code", + "async () => sentry.request({method: 'POST', path: '/failure/', body: {}})", + ); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain( + "POST /api/0/failure/ returned HTTP 400", + ); + expect(result.content[0]?.text).toContain( + "writes may already have completed", + ); + expect(requests).toHaveLength(1); + }); + + it("preserves upstream scope evidence in caught HTTP errors", async () => { + const result = await callTool( + "execute_sentry_code", + `async () => { + try { await sentry.request({path: "/insufficient-scope/", method: "POST"}); } + catch (error) { + return {status: error.status, method: error.method, path: error.path, + headers: error.headers, data: error.data}; + } + }`, + ); + expect(result.isError).not.toBe(true); + expect(result.structuredContent?.result).toEqual({ + status: 403, + method: "POST", + path: "/api/0/insufficient-scope/", + headers: { + "content-type": "application/json", + "www-authenticate": + 'Bearer error="insufficient_scope", scope="org:admin org:write"', + }, + data: { detail: "You do not have permission to perform this action." }, + }); + expect(requests).toHaveLength(1); + }); + + it.each(["insufficient-scope", "forbidden"])( + "returns an uncaught %s denial with HTTP evidence and no retries", + async (endpoint) => { + const result = await callTool( + "execute_sentry_code", + `async () => sentry.request({method: "POST", path: "/${endpoint}/"})`, + ); + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + error: expect.stringContaining( + `POST /api/0/${endpoint}/ returned HTTP 403`, + ), + httpError: { + status: 403, + method: "POST", + path: `/api/0/${endpoint}/`, + headers: { + "content-type": "application/json", + ...(endpoint === "insufficient-scope" + ? { + "www-authenticate": + 'Bearer error="insufficient_scope", scope="org:admin org:write"', + } + : {}), + }, + }, + stats: { apiCalls: 1, writesAttempted: 1 }, + }); + expect(result.content[0]?.text).toContain( + "You do not have permission to perform this action.", + ); + expect(result.content[0]?.text).toContain( + "writes may already have completed", + ); + expect(result.content[0]?.text).toContain('"status":403'); + if (endpoint === "forbidden") { + expect(result.content[0]?.text).not.toContain("insufficient_scope"); + } + expect(requests).toHaveLength(1); + }, + ); + + it("bounds API requests per execution", async () => { + const result = await callTool( + "execute_sentry_code", + "async () => { for (let i = 0; i < 21; i++) await sentry.request({path: '/items/'}); }", + ); + expect(result.isError).toBe(true); + expect(requests).toHaveLength(20); + }); + + it("keeps the API broker outside the model module", async () => { + const result = await callTool( + "execute_sentry_code", + "async () => ({moduleBroker: typeof apiBroker, parameterBroker: typeof broker})", + ); + expect(result.structuredContent?.result).toEqual({ + moduleBroker: "undefined", + parameterBroker: "undefined", + }); + }); + + it("preserves a user error with partial HTTP-like properties", async () => { + const result = await callTool( + "execute_sentry_code", + 'async () => { throw {status: 403, message: "Example denied"}; }', + ); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: expect.stringContaining("Example denied"), + stats: { apiCalls: 0, writesAttempted: 0 }, + }); + expect(result.structuredContent).not.toHaveProperty("httpError"); + expect(result.content[0]?.text).not.toContain("Invalid input"); + }); + + it("returns useful errors for invalid code and oversized results", async () => { + const invalid = await callTool("execute_sentry_code", "async () => {"); + expect(invalid.isError).toBe(true); + const oversized = await callTool( + "execute_sentry_code", + "async () => 'x'.repeat(50_001)", + ); + expect(oversized.isError).toBe(true); + expect(oversized.content[0]?.text).toContain("Result exceeds 50,000 bytes"); + expect(requests).toEqual([]); + }); +}); diff --git a/packages/mcp-cloudflare/src/code-mode/worker.ts b/packages/mcp-cloudflare/src/code-mode/worker.ts new file mode 100644 index 000000000..6e8b28842 --- /dev/null +++ b/packages/mcp-cloudflare/src/code-mode/worker.ts @@ -0,0 +1,148 @@ +import type { WorkerEntrypoint } from "cloudflare:workers"; +import { McpServer } from "@modelcontextprotocol/server"; +import { + MAX_MCP_REQUEST_BODY_SIZE, + MAX_MCP_TOOL_INPUT_ELEMENTS, +} from "@sentry/mcp-core/constants"; +import { createMcpHandler } from "agents/mcp/server"; +import { ApiBroker, type CodeModeEnv } from "./broker"; +import { + codeModeResultSchema, + getCodeModeToolDefinitions, +} from "./definitions"; +import { ExecutionGate } from "./execution"; +import { configuredUrl, limitMcpRequestBody, readBoundedBody } from "./http"; +import { getSpec } from "./openapi"; +import type { ApiAuthorization } from "./production"; +import { sandboxSource } from "./sandbox"; + +export type { CodeModeEnv } from "./broker"; + +declare class Sandbox extends WorkerEntrypoint { + run(broker: ApiBroker | undefined, deadline: number): Promise; +} + +/** Callbacks capture this request's bearer token; never share this server across users. */ +export function registerCodeModeTools( + server: McpServer, + env: CodeModeEnv, + token: string, + authorization?: ApiAuthorization, +): void { + async function evaluate(code: string, execute: boolean) { + const execution = new ExecutionGate(); + const hint = + execute && env.CODE_MODE_ALLOW_WRITES === "true" + ? " Execution is not atomic: writes may already have completed. Inspect API state before retrying writes." + : ""; + try { + const value = await execution.run(async () => { + const apiBase = configuredUrl(env.API_BASE_URL) + .toString() + .replace(/\/$/, ""); + const spec = execute ? undefined : await getSpec(env.OPENAPI_URL); + execution.assertOpen(); + const workerCode = { + compatibilityDate: "2026-01-12", + mainModule: "worker.js", + modules: { + "worker.js": sandboxSource(execute ? apiBase : undefined), + "user.js": `export default (${code});`, + ...(spec ? { "spec.json": { json: spec } } : {}), + }, + // Cloudflare enforces these in production. Local workerd does not; + // the trusted harness separately bounds HTTP calls and output size. + limits: { cpuMs: 1_000, subRequests: execute ? 20 : 0 }, + globalOutbound: null, + }; + const worker = env.LOADER.load(workerCode); + const broker = execute + ? new ApiBroker({ + accessToken: token, + execution, + apiBaseUrl: env.API_BASE_URL, + allowWrites: env.CODE_MODE_ALLOW_WRITES === "true", + authorization, + }) + : undefined; + const response = await worker + .getEntrypoint() + .run(broker, execution.deadline); + const body = await readBoundedBody(response.body, 55_000); + return codeModeResultSchema.parse( + JSON.parse(new TextDecoder().decode(body)), + ); + }); + if (value.error !== undefined) { + const failure = { ...value, error: value.error + hint }; + return { + isError: true, + content: [{ type: "text" as const, text: JSON.stringify(failure) }], + structuredContent: failure, + }; + } + return { + content: [{ type: "text" as const, text: JSON.stringify(value) }], + structuredContent: value, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Code execution failed."; + return { + isError: true, + content: [ + { type: "text" as const, text: message.slice(0, 4_000) + hint }, + ], + }; + } finally { + execution.close(); + } + } + for (const [name, definition] of Object.entries( + getCodeModeToolDefinitions({ + allowWrites: env.CODE_MODE_ALLOW_WRITES === "true", + authorizationRequired: !!authorization, + }), + )) { + server.registerTool(name, definition, ({ code }) => + evaluate(code, name === "execute_sentry_code"), + ); + } +} + +function buildServer(env: CodeModeEnv, token: string): McpServer { + const server = new McpServer( + { + name: "sentry-code-mode-experiment", + version: "0.1.0", + }, + { maxToolInputElements: MAX_MCP_TOOL_INPUT_ELEMENTS }, + ); + registerCodeModeTools(server, env, token); + return server; +} + +/** Separate experimental entrypoint; this does not change the production OAuth MCP. */ +export default { + async fetch( + request: Request, + env: CodeModeEnv, + ctx: ExecutionContext, + ): Promise { + if (new URL(request.url).pathname !== "/mcp") + return new Response("Not found", { status: 404 }); + const token = request.headers + .get("authorization") + ?.match(/^Bearer\s+(\S+)$/i)?.[1]; + if (!token) + return new Response("A direct Sentry API bearer token is required.", { + status: 401, + }); + const boundedRequest = await limitMcpRequestBody(request); + if (boundedRequest instanceof Response) return boundedRequest; + return await createMcpHandler(() => buildServer(env, token), { + route: "/mcp", + maxRequestBodySize: MAX_MCP_REQUEST_BODY_SIZE, + })(boundedRequest, env, ctx); + }, +} satisfies ExportedHandler; diff --git a/packages/mcp-cloudflare/src/server/lib/approval-dialog.test.ts b/packages/mcp-cloudflare/src/server/lib/approval-dialog.test.ts index 7030427d1..54acd83d1 100644 --- a/packages/mcp-cloudflare/src/server/lib/approval-dialog.test.ts +++ b/packages/mcp-cloudflare/src/server/lib/approval-dialog.test.ts @@ -13,7 +13,8 @@ function skillInputAttributes(html: string, skillId: string): string { `]*)>`, ), ); - return match?.[1] ?? ""; + expect(match, `Missing skill checkbox: ${skillId}`).not.toBeNull(); + return match![1]; } function expectSkillChecked(html: string, skillId: string): void { @@ -71,7 +72,7 @@ describe("approval-dialog", () => { expect(html).not.toContain("history.back"); }); - it("selects all approvable skills when no remembered defaults are provided", async () => { + it("leaves API Write unchecked for new approvals and explains its independent authority", async () => { const response = await renderApprovalDialog( new Request("https://example.com/oauth/authorize"), { @@ -87,6 +88,37 @@ describe("approval-dialog", () => { expectSkillChecked(html, "seer"); expectSkillChecked(html, "triage"); expectSkillChecked(html, "project-management"); + expectSkillChecked(html, "api-read"); + expectSkillUnchecked(html, "api-write"); + expect(html).toContain('id="skill-name-api-write">API Write'); + expect(skillInputAttributes(html, "api-write")).toContain( + 'aria-describedby="skill-description-api-write api-write-help"', + ); + expect(html).toContain( + "API Write allows issue changes even when Triage Issues is unchecked.", + ); + expect(html).toContain( + "It also allows project and team changes when Manage Projects & Teams is unchecked.", + ); + }); + + it("preserves an explicit remembered API Write selection without selecting Triage", async () => { + const response = await renderApprovalDialog( + new Request("https://example.com/oauth/authorize"), + { + client: mockClient, + server: { name: "Test Server" }, + state: { oauthReqInfo: { clientId: "test-client" } }, + cookieSecret: TEST_SECRET, + defaultSkills: ["api-write"], + }, + ); + const html = await response.text(); + + expectSkillChecked(html, "api-write"); + expectSkillUnchecked(html, "triage"); + expectSkillUnchecked(html, "project-management"); + expect(html).toContain("Includes API Read."); }); it("uses remembered skill defaults when provided", async () => { @@ -106,6 +138,10 @@ describe("approval-dialog", () => { expectSkillUnchecked(html, "seer"); expectSkillChecked(html, "triage"); expectSkillChecked(html, "project-management"); + expect(html).toContain('value="api-read"'); + expect(html).toContain('value="api-write"'); + expectSkillUnchecked(html, "api-read"); + expectSkillUnchecked(html, "api-write"); }); it("ignores unknown and deprecated remembered skill defaults", async () => { @@ -183,11 +219,36 @@ describe("approval-dialog", () => { const html = await response.text(); - expect(html).toContain("Session scope"); - expect(html).toContain("This session will be limited to the"); - expect(html).toContain(">sentry organization"); + expect(html).toContain('aria-label="Organization scope"'); + expect(html).toContain( + 'Only sentry', + ); + expect(html).toContain( + "Every permission below, including API Write, is limited to this organization.", + ); + expect(html).toContain("Your Sentry permissions still apply."); + expect(html).toContain('value="api-read"'); + expect(html).toContain('value="api-write"'); + }); + + it("explains that an unconstrained connection can access every organization authorized by its token", async () => { + const response = await renderApprovalDialog( + new Request("https://example.com/oauth/authorize"), + { + client: mockClient, + server: { name: "Sentry MCP" }, + state: { oauthReqInfo: { clientId: "test-client" } }, + cookieSecret: TEST_SECRET, + }, + ); + const html = await response.text(); + + expect(html).toContain("All authorized organizations"); + expect(html).toContain( + "This connection can access any organization allowed by its Sentry token.", + ); expect(html).toContain( - "The permissions below apply only within this scope.", + "The permissions below, including API Write, apply across those organizations.", ); }); @@ -210,6 +271,8 @@ describe("approval-dialog", () => { expect(html).toContain(">javascript project"); expect(html).toContain(">sentry organization"); + expect(html).not.toContain('name="skill" value="api-read"'); + expect(html).not.toContain('name="skill" value="api-write"'); }); }); diff --git a/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts b/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts index ab46f290e..33e9d0ebf 100644 --- a/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts +++ b/packages/mcp-cloudflare/src/server/lib/approval-dialog.ts @@ -6,6 +6,7 @@ import skillDefinitions, { type SkillDefinition, } from "@sentry/mcp-core/skillDefinitions"; import { logError, logIssue, logWarn } from "@sentry/mcp-core/telem/logging"; +import { CODE_MODE_SKILLS } from "../../code-mode/definitions"; import { type OAuthState, signState, @@ -384,7 +385,8 @@ export interface ApprovalDialogOptions { */ cookieSecret: string; /** - * Skill IDs to preselect. When omitted, all approvable skills are selected. + * Remembered skill IDs to preselect. New approvals select all approvable + * skills except API Write, which requires an explicit choice. */ defaultSkills?: string[]; } @@ -463,28 +465,48 @@ export async function renderApprovalDialog( cookieSecret, defaultSkills, } = options; + const approvableSkills = APPROVABLE_SKILLS.filter( + (skill) => + !scope?.projectSlug || + !CODE_MODE_SKILLS.some((codeModeSkill) => codeModeSkill === skill.id), + ); const defaultSkillIds = defaultSkills === undefined - ? new Set(APPROVABLE_SKILLS.map((skill) => skill.id)) + ? new Set( + approvableSkills + .filter((skill) => skill.id !== "api-write") + .map((skill) => skill.id), + ) : new Set(filterApprovableSkillIds(defaultSkills)); // Use static skill definitions bundled at build time // Generate HTML for all approvable skills. - const skillsHtml = APPROVABLE_SKILLS.map( - (skill) => ` + const skillsHtml = approvableSkills + .map( + (skill) => ` `, - ).join(""); + ) + .join(""); // Encode state for form submission (HMAC-signed to prevent tampering) const encodedState = await encodeState(state, cookieSecret); @@ -530,19 +552,21 @@ export async function renderApprovalDialog( ` : ""; - const scopeHtml = scope + const scopeHtml = scope?.projectSlug ? `
Session scope
-

${ - scope.projectSlug - ? `This session will be limited to the ${sanitizeHtml(scope.projectSlug)} project in the ${sanitizeHtml(scope.organizationSlug)} organization.` - : `This session will be limited to the ${sanitizeHtml(scope.organizationSlug)} organization.` - }

-

The permissions below apply only within this scope.

+

This session will be limited to the ${sanitizeHtml(scope.projectSlug)} project in the ${sanitizeHtml(scope.organizationSlug)} organization.

+

The permissions below apply only within this scope. Your Sentry permissions still apply.

` - : ""; + : ` +
+
Organization scope
+

${scope ? `Only ${sanitizeHtml(scope.organizationSlug)}` : "All authorized organizations"}

+

${scope ? "Every permission below, including API Write, is limited to this organization. Your Sentry permissions still apply." : "This connection can access any organization allowed by its Sentry token. The permissions below, including API Write, apply across those organizations."}

+
+ `; // Generate HTML for the approval dialog const htmlContent = ` @@ -886,6 +910,48 @@ export async function renderApprovalDialog( line-height: 1.5; } + .permission-note { + margin-top: 12px; + padding: 12px 14px; + border: 1px solid rgba(242, 185, 94, 0.35); + border-radius: var(--radius-sm); + background: rgba(242, 185, 94, 0.06); + font-size: 0.8125rem; + line-height: 1.55; + color: oklch(0.86 0.07 75); + } + + .permission-note > strong { + display: block; + font-weight: 600; + margin-bottom: 5px; + } + + .permission-note p { + margin: 0; + } + + .permission-note p + p { + margin-top: 5px; + } + + .permission-item:has(input[value="api-write"]) { + border: 1px solid var(--border-default); + border-left-width: 3px; + border-radius: var(--radius-sm); + margin-top: var(--space-md); + padding: var(--space-md); + } + + .permission-item:has(input[value="api-write"]:checked) { + border-color: var(--purple-primary); + } + + .permission-item:has(input:focus-visible) { + outline: 2px solid var(--purple-light); + outline-offset: 3px; + } + /* Two-column layout for wider screens */ .approval-grid { display: flex; @@ -973,6 +1039,10 @@ export async function renderApprovalDialog( padding: var(--space-sm); } + .permission-item:has(input[value="api-write"]) { + padding: 12px; + } + .permission-header { flex-direction: column; align-items: flex-start; diff --git a/packages/mcp-cloudflare/src/server/lib/code-mode.integration.test.ts b/packages/mcp-cloudflare/src/server/lib/code-mode.integration.test.ts new file mode 100644 index 000000000..acb096e56 --- /dev/null +++ b/packages/mcp-cloudflare/src/server/lib/code-mode.integration.test.ts @@ -0,0 +1,769 @@ +import { + createExecutionContext, + env, + waitOnExecutionContext, +} from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + MAX_MCP_REQUEST_BODY_SIZE, + MAX_MCP_TOOL_INPUT_ELEMENTS, +} from "@sentry/mcp-core/constants"; +import * as logging from "@sentry/mcp-core/telem/logging"; +import { SENTRY_OPENAPI_URL } from "../../code-mode/openapi"; +import type { Env, WorkerProps } from "../types"; +import mcpHandler from "./mcp-handler"; + +const monitorPath = + "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/monitors/{monitor_id_or_slug}/"; +const projectListPath = + "/api/0/organizations/{organization_id_or_slug}/projects/"; +// A small subset of the pinned schema's real paths and auth_token alternatives. +const spec = { + openapi: "3.0.0", + paths: { + "/api/0/organizations/": { + get: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + }, + [projectListPath]: { + get: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + }, + "/api/0/organizations/{organization_id_or_slug}/dashboards/": { + get: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + post: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + }, + "/api/0/organizations/{organization_id_or_slug}/dashboards/{dashboard_id}/": + { + get: { + security: [{ auth_token: ["org:admin", "org:read", "org:write"] }], + }, + }, + [monitorPath]: { + get: { + security: [ + { auth_token: ["project:admin", "project:read", "project:write"] }, + ], + }, + put: { + security: [ + { auth_token: ["alerts:write", "project:admin", "project:write"] }, + ], + }, + }, + "/api/0/sentry-apps/{sentry_app_id_or_slug}/": { + get: { security: [{ auth_token: ["org:read", "project:read"] }] }, + }, + }, +}; + +interface ApiRequest { + url: string; + method: string; + headers: Headers; + body: string; + signal: AbortSignal; +} + +interface ToolResult { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + structuredContent?: { + result: unknown; + stats: { apiCalls: number; writesAttempted: number }; + }; +} + +interface ToolList { + tools: Array<{ name: string; annotations?: { readOnlyHint?: boolean } }>; +} + +const originalFetch = globalThis.fetch; +let apiRequests: ApiRequest[]; +let apiResponse: (request: ApiRequest) => Response | Promise; +let beforeRegionResponse: (() => Promise) | undefined; +const revokeGrant = vi.fn(async () => {}); + +function workerEnv(): Env { + return { + ...(env as Record), + MCP_CACHE: env.OAUTH_KV, + OAUTH_PROVIDER: { revokeGrant }, + } as Env; +} + +async function dispatchRpc( + method: string, + params: Record, + options: { + props?: Partial; + path?: string; + keepAlive?: Promise; + protocolVersion?: "2026-07-28" | "2025-03-26"; + } = {}, +) { + const protocolVersion = options.protocolVersion ?? "2026-07-28"; + const props: WorkerProps = { + id: "code-mode-user", + clientId: "code-mode-client", + clientName: "Code Mode Test Client", + accessToken: "test-api-token", + refreshToken: "test-refresh-token", + scope: "org:read project:read", + grantedSkills: ["api-read"], + ...options.props, + }; + const ctx = Object.assign(createExecutionContext(), { props }); + if (options.keepAlive) ctx.waitUntil(options.keepAlive); + const request = new Request(`http://localhost${options.path ?? "/mcp"}`, { + method: "POST", + headers: { + Authorization: `Bearer ${props.id}:code-mode-grant:secret`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Host: "localhost", + "CF-Connecting-IP": "192.0.2.1", + "User-Agent": "claude-code/2.0.0", + "X-Sentry-Utm-Source": "plugin", + "mcp-protocol-version": protocolVersion, + "mcp-method": method, + ...(typeof params.name === "string" ? { "mcp-name": params.name } : {}), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params: { + ...params, + ...(protocolVersion === "2026-07-28" + ? { + _meta: { + "io.modelcontextprotocol/protocolVersion": protocolVersion, + "io.modelcontextprotocol/clientInfo": { + name: "code-mode-integration-tests", + version: "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + } + : {}), + }, + }), + }); + const response = await mcpHandler.fetch!(request, workerEnv(), ctx); + return { response, ctx }; +} + +async function rpc( + method: string, + params: Record, + options: Parameters[2] = {}, +): Promise { + const { response, ctx } = await dispatchRpc(method, params, options); + const result = await readRpcResult(response); + await waitOnExecutionContext(ctx); + return result; +} + +async function readRpcResult(response: Response): Promise { + const text = await response.text(); + expect(response.status, text).toBe(200); + const data = text.startsWith("{") + ? text + : text + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6); + expect(data, text).toBeDefined(); + const message = JSON.parse(data!) as { result?: T; error?: unknown }; + expect(message.error).toBeUndefined(); + return message.result as T; +} + +async function execute( + code: string, + options?: Parameters[2], +): Promise { + return await rpc( + "tools/call", + { name: "execute_sentry_code", arguments: { code } }, + options, + ); +} + +beforeEach(() => { + apiRequests = []; + revokeGrant.mockClear(); + apiResponse = () => Response.json({ ok: true }); + beforeRegionResponse = undefined; + // The pool runs the real main Worker in this isolate. Only external HTTP is + // simulated; MCP handlers, Dynamic Workers and the RPC broker are real. + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + if (request.url === SENTRY_OPENAPI_URL) return Response.json(spec); + const url = new URL(request.url); + if (!["sentry.io", "de.sentry.io"].includes(url.hostname)) { + return await originalFetch(input, init); + } + const recorded = { + url: request.url, + method: request.method, + headers: request.headers, + body: await request.text(), + signal: request.signal, + }; + apiRequests.push(recorded); + const organization = /^\/api\/0\/organizations\/([^/]+)\/$/.exec( + url.pathname, + ); + if (organization) { + await beforeRegionResponse?.(); + return Response.json({ + id: "100", + slug: organization[1], + name: organization[1], + links: { + regionUrl: "https://de.sentry.io", + organizationUrl: `https://sentry.io/organizations/${organization[1]}/`, + }, + }); + } + if (url.pathname === "/api/0/projects/code-mode-org/web/") { + return Response.json({ id: "200", slug: "web", name: "Web" }); + } + return await apiResponse(recorded); + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((finish) => { + resolve = finish; + }); + return { promise, resolve }; +} + +describe("Code Mode on the authenticated MCP", () => { + it.each(["return", "throw"])( + "closes unawaited API work when the program exits with %s", + async (exit) => { + const started = deferred(); + const finish = deferred(); + let write: ApiRequest | undefined; + apiResponse = async (request) => { + if (request.method === "POST") { + write = request; + started.resolve(); + await finish.promise; + } else { + await started.promise; + } + return Response.json({ ok: true }); + }; + const { response, ctx } = await dispatchRpc( + "tools/call", + { + name: "execute_sentry_code", + arguments: { + code: `async () => { + sentry.request({method: "POST", path: "/organizations/code-mode-org/dashboards/", body: {title: "Copy"}}).catch(() => {}); + await sentry.request({path: "/organizations/"}); + ${exit === "return" ? 'return "done";' : 'throw new Error("Program failed");'} + }`, + }, + }, + { props: { grantedSkills: ["api-write"] }, keepAlive: finish.promise }, + ); + try { + const result = await readRpcResult(response); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + exit === "return" ? "Await every sentry.request()" : "Program failed", + ); + expect(result.content[0].text).toContain( + "writes may already have completed", + ); + expect(write?.signal.aborted).toBe(true); + } finally { + finish.resolve(); + await waitOnExecutionContext(ctx); + } + }, + ); + + it("does not send a write when authorization finishes after the program", async () => { + const authorizing = deferred(); + const finish = deferred(); + beforeRegionResponse = async () => { + authorizing.resolve(); + await finish.promise; + }; + apiResponse = async () => { + await authorizing.promise; + return Response.json({ ok: true }); + }; + const { response, ctx } = await dispatchRpc( + "tools/call", + { + name: "execute_sentry_code", + arguments: { + code: `async () => { + sentry.request({method: "POST", path: "/organizations/code-mode-org/dashboards/", body: {title: "Copy"}}).catch(() => {}); + await sentry.request({path: "/organizations/"}); + return "done"; + }`, + }, + }, + { props: { grantedSkills: ["api-write"] }, keepAlive: finish.promise }, + ); + try { + const result = await readRpcResult(response); + finish.resolve(); + await waitOnExecutionContext(ctx); + // Drain the released lookup and any continuation into the API broker. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(apiRequests.filter(({ method }) => method === "POST")).toEqual([]); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Await every sentry.request()"); + } finally { + finish.resolve(); + await waitOnExecutionContext(ctx); + } + }); + + it.each(["headers", "body"])( + "aborts an awaited API request stalled before its %s at the execution deadline", + async (phase) => { + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + const started = deferred(); + const finish = deferred(); + let pending: ApiRequest | undefined; + apiResponse = async (request) => { + pending = request; + started.resolve(); + if (phase === "headers") { + await finish.promise; + return Response.json({ ok: true }); + } + return new Response( + new ReadableStream({ + start(controller) { + request.signal.addEventListener( + "abort", + () => controller.error(request.signal.reason), + { once: true }, + ); + void finish.promise.then(() => { + if (!request.signal.aborted) controller.close(); + }); + }, + }), + { headers: { "content-type": "application/json" } }, + ); + }; + const dispatched = dispatchRpc( + "tools/call", + { + name: "execute_sentry_code", + arguments: { + code: 'async () => sentry.request({path: "/organizations/"})', + }, + }, + { keepAlive: finish.promise }, + ); + await started.promise; + await vi.advanceTimersByTimeAsync(30_000); + const { response, ctx } = await dispatched; + try { + const result = await readRpcResult(response); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Execution deadline exceeded"); + expect(pending?.signal.aborted).toBe(true); + expect(apiRequests).toHaveLength(1); + } finally { + finish.resolve(); + vi.useRealTimers(); + await waitOnExecutionContext(ctx); + } + }, + ); + + it("rejects excessive nested argument members before executing code", async () => { + const result = await rpc("tools/call", { + name: "execute_sentry_code", + arguments: { + code: 'async () => sentry.request({path: "/organizations/"})', + unused: Object.fromEntries( + Array.from( + { length: MAX_MCP_TOOL_INPUT_ELEMENTS + 1 }, + (_, index) => [`key${index}`, null], + ), + ), + }, + }); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain( + `maximum of ${MAX_MCP_TOOL_INPUT_ELEMENTS} elements`, + ); + expect(apiRequests).toEqual([]); + }); + + it.each(["2026-07-28", "2025-03-26"] as const)( + "rejects HTTP bodies above the limit with protocol %s", + async (protocolVersion) => { + const { response, ctx } = await dispatchRpc( + "tools/call", + { + name: "execute_sentry_code", + arguments: { code: "x".repeat(MAX_MCP_REQUEST_BODY_SIZE) }, + }, + { protocolVersion }, + ); + const body = await response.text(); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(413); + expect(response.headers.get("Content-Type")).toContain( + "application/json", + ); + expect(JSON.parse(body)).toEqual({ + jsonrpc: "2.0", + id: null, + error: { + code: -32000, + message: `Payload Too Large: Request body must not exceed ${MAX_MCP_REQUEST_BODY_SIZE} bytes`, + }, + }); + expect(apiRequests).toEqual([]); + }, + ); + + it("advertises Code Mode alongside the normal catalog and executes discovery", async () => { + const list = await rpc("tools/list", {}); + expect(list.tools.map(({ name }) => name)).toEqual( + expect.arrayContaining([ + "search_sentry_api", + "execute_sentry_code", + "find_organizations", + ]), + ); + expect( + list.tools.find(({ name }) => name === "execute_sentry_code")?.annotations + ?.readOnlyHint, + ).toBe(true); + const result = await rpc("tools/call", { + name: "search_sentry_api", + arguments: { code: "async () => Object.keys(spec.paths)" }, + }); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toContain(monitorPath); + expect(apiRequests).toHaveLength(0); + }); + + it("blocks api-read writes through both sentry.request and raw fetch", async () => { + const result = await execute( + `async () => { + let wrappedStatus; + try { await sentry.request({method: "PUT", path: "/projects/code-mode-org/web/monitors/billing/", body: {name: "changed"}}); } + catch (error) { wrappedStatus = error.status; } + let rawBlocked = false; + try { await fetch("https://sentry.io/api/0/projects/code-mode-org/web/monitors/billing/", {method: "PUT", body: "{}"}); } + catch { rawBlocked = true; } + return {wrappedStatus, rawBlocked}; + }`, + { + props: { + upstreamScopes: [ + "org:read", + "project:write", + "team:write", + "event:write", + ], + }, + }, + ); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toEqual({ + wrappedStatus: 403, + rawBlocked: true, + }); + expect(apiRequests).toHaveLength(0); + }); + + it("uses the API write grant, regional routing and attribution headers", async () => { + apiResponse = (request) => + Response.json( + { saved: JSON.parse(request.body) }, + { headers: { ETag: '"saved-monitor"', Link: "; rel=next" } }, + ); + const result = await execute( + `async () => sentry.request({ + method: "PUT", path: "/projects/code-mode-org/web/monitors/billing/", body: {name: "Billing"} + })`, + { props: { grantedSkills: ["api-write"] } }, + ); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toMatchObject({ + status: 200, + headers: { etag: '"saved-monitor"', link: "; rel=next" }, + body: { saved: { name: "Billing" } }, + }); + const writes = apiRequests.filter(({ method }) => method === "PUT"); + expect(writes).toHaveLength(1); + expect(writes[0].url).toBe( + "https://de.sentry.io/api/0/projects/code-mode-org/web/monitors/billing/", + ); + expect(writes[0].headers.get("authorization")).toBe( + "Bearer test-api-token", + ); + expect(writes[0].headers.get("x-sentry-mcp-client-id")).toBe( + "code-mode-client", + ); + expect(writes[0].headers.get("x-sentry-mcp-client-name")).toBe( + "Code Mode Test Client", + ); + expect(writes[0].headers.get("x-sentry-mcp-utm-source")).toBe("plugin"); + }); + + it("keeps concurrent users' upstream credentials separate", async () => { + apiResponse = ({ headers }) => + Response.json({ owner: headers.get("authorization") }); + const code = + 'async () => (await sentry.request({path: "/organizations/"})).body'; + const results = await Promise.all([ + execute(code, { + props: { id: "first-user", accessToken: "first-api-token" }, + }), + execute(code, { + props: { id: "second-user", accessToken: "second-api-token" }, + }), + ]); + expect(results.map((result) => result.isError)).toEqual([ + undefined, + undefined, + ]); + expect(results.map((result) => result.structuredContent?.result)).toEqual([ + { owner: "Bearer first-api-token" }, + { owner: "Bearer second-api-token" }, + ]); + expect(apiRequests).toHaveLength(2); + }); + + it("enforces an organization grant against other organizations and global ID routes", async () => { + const result = await execute( + `async () => { + const outcomes = []; + for (const path of ["/organizations/other-org/projects/", "/sentry-apps/an-app/"]) { + try { await sentry.request({path}); } + catch (error) { outcomes.push({status: error.status, body: error.data}); } + } + return outcomes; + }`, + { + path: "/mcp/code-mode-org", + props: { constraintOrganizationSlug: "code-mode-org" }, + }, + ); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toEqual([ + { + status: 403, + body: { detail: expect.stringContaining("organization constraint") }, + }, + { + status: 403, + body: { detail: expect.stringContaining("organization constraint") }, + }, + ]); + expect( + apiRequests.every( + ({ url }) => + url === "https://sentry.io/api/0/organizations/code-mode-org/", + ), + ).toBe(true); + }); + + it.each([ + { + name: "docs-only grant", + props: { grantedSkills: ["docs"] }, + path: "/mcp", + }, + { + name: "project constraint", + props: { + grantedSkills: ["api-read"], + constraintOrganizationSlug: "code-mode-org", + constraintProjectSlug: "web", + }, + path: "/mcp/code-mode-org/web", + }, + ])("preserves the existing surface for a $name", async ({ props, path }) => { + const result = await rpc("tools/list", {}, { props, path }); + expect(result.tools.map(({ name }) => name)).not.toContain( + "execute_sentry_code", + ); + expect(result.tools.map(({ name }) => name)).not.toContain( + "search_sentry_api", + ); + }); + + it.each(["inspect", "triage", "project-management", "seer"])( + "does not turn a legacy %s grant into arbitrary API access", + async (skill) => { + const result = await rpc( + "tools/list", + {}, + { + props: { + grantedSkills: [skill], + upstreamScopes: ["org:read", "project:write", "event:write"], + }, + }, + ); + expect(result.tools.map(({ name }) => name)).not.toContain( + "execute_sentry_code", + ); + expect(result.tools.map(({ name }) => name)).not.toContain( + "search_sentry_api", + ); + expect(apiRequests).toHaveLength(0); + }, + ); + + it("copies a dashboard for two projects with api-write and an upstream org:read token", async () => { + // Dashboard POST accepts org:read in both the published schema and backend. + const original = { + id: "101", + title: "Errors", + projects: [101, 102], + widgets: [], + }; + const dashboards = new Map([[original.id, original]]); + apiResponse = ({ url, method, body }) => { + if (method === "POST") { + const saved = { + ...JSON.parse(body), + id: String(dashboards.size + 101), + }; + dashboards.set(saved.id, saved); + return Response.json(saved, { status: 201 }); + } + const id = new URL(url).pathname.split("/").at(-2)!; + return Response.json(dashboards.get(id)); + }; + const result = await execute( + `async () => { + const path = "/organizations/code-mode-org/dashboards/"; + const original = (await sentry.request({path: path + "101/"})).body; + const copies = []; + for (const project of [201, 202]) { + const created = (await sentry.request({method: "POST", path, + body: {title: original.title + " " + project, widgets: original.widgets, projects: [project]} + })).body; + const verified = (await sentry.request({path: path + created.id + "/"})).body; + copies.push({id: verified.id, projects: verified.projects}); + } + return copies; + }`, + { + props: { + grantedSkills: ["api-write"], + upstreamScopes: ["org:read"], + grantedScopes: ["org:read"], + }, + }, + ); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toEqual([ + { id: "102", projects: [201] }, + { id: "103", projects: [202] }, + ]); + expect(apiRequests.filter(({ method }) => method === "POST")).toHaveLength( + 2, + ); + expect(dashboards.get("101")).toEqual(original); + }); + + it.each([undefined, ["org:read", "project:write"]])( + "preserves the real upstream permission denial with recorded scopes %j", + async (upstreamScopes) => { + apiResponse = () => + Response.json( + { detail: "You do not have permission to perform this action." }, + { status: 403 }, + ); + const result = await execute( + 'async () => sentry.request({method: "POST", path: "/organizations/code-mode-org/dashboards/", body: {title: "Copy", widgets: []}})', + { props: { grantedSkills: ["api-write"], upstreamScopes } }, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("403"); + expect(result.content[0].text).toContain("You do not have permission"); + expect( + apiRequests.filter(({ method }) => method === "POST"), + ).toHaveLength(1); + expect(revokeGrant).not.toHaveBeenCalled(); + }, + ); + + it("revokes an upstream-rejected grant even when user code catches the 401", async () => { + apiResponse = () => + Response.json({ detail: "Expired upstream token" }, { status: 401 }); + const result = await execute(`async () => { + const statuses = []; + for (let i = 0; i < 2; i++) { + try { await sentry.request({path: "/organizations/"}); } + catch (error) { statuses.push(error.status); } + } + return statuses; + }`); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toEqual([401, 401]); + expect(revokeGrant).toHaveBeenCalledExactlyOnceWith( + "code-mode-grant", + "code-mode-user", + ); + }); + + it.each([403, 503])( + "reports only upstream server failures when user code catches HTTP %s", + async (status) => { + const logIssue = vi + .spyOn(logging, "logIssue") + .mockReturnValue("code-mode-event"); + apiResponse = () => + Response.json({ detail: "Upstream failure" }, { status }); + const result = await execute(`async () => { + try { await sentry.request({path: "/organizations/"}); } + catch (error) { return {status: error.status, eventId: error.headers["x-sentry-event-id"] ?? null}; } + }`); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent?.result).toEqual({ + status, + eventId: status === 503 ? "code-mode-event" : null, + }); + if (status === 503) { + expect(logIssue).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: "ApiServerError", status: 503 }), + { + loggerScope: ["code-mode", "api"], + extra: { method: "GET", status: 503 }, + }, + ); + } else { + expect(logIssue).not.toHaveBeenCalled(); + } + }, + ); +}); diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index d31978e62..65fa6856f 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -14,15 +14,18 @@ import type { ExportedHandler } from "@cloudflare/workers-types"; import * as Sentry from "@sentry/cloudflare"; +import { MAX_MCP_REQUEST_BODY_SIZE } from "@sentry/mcp-core/constants"; import { buildServer } from "@sentry/mcp-core/server"; import { ACTIVE_SKILLS, - type Skill, parseSkills, + type Skill, } from "@sentry/mcp-core/skills"; import { logWarn } from "@sentry/mcp-core/telem/logging"; import type { ServerContext } from "@sentry/mcp-core/types"; import { createMcpHandler } from "agents/mcp/server"; +import { limitMcpRequestBody } from "../../code-mode/http"; +import { registerProductionCodeModeTools } from "../../code-mode/production"; import { annotateResponseMetric } from "../metrics"; import { ACCESS_METHOD_ATTRIBUTE, @@ -30,16 +33,15 @@ import { getOAuthGrantTelemetry, OAUTH_GRANT_REVOKED_REASON_ATTRIBUTE, } from "../oauth/telemetry"; -import type { WorkerProps } from "../types"; -import type { Env } from "../types"; +import type { Env, WorkerProps } from "../types"; import { - MCP_RATE_LIMIT_EXCEEDED_MESSAGE, checkRateLimit, + MCP_RATE_LIMIT_EXCEEDED_MESSAGE, } from "../utils/rate-limiter"; import { setSentryUserFromRequest } from "../utils/sentry-user"; import { - UTM_SOURCE_ATTRIBUTE, resolveUtmSourceFromRequest, + UTM_SOURCE_ATTRIBUTE, } from "./attribution"; import { resolveClientFamily } from "./client-family"; import { verifyConstraintsAccess } from "./constraint-utils"; @@ -457,22 +459,29 @@ async function handleAuthenticatedMcpRequest( auth.kind === "oauth" ? auth.onUpstreamUnauthorized : undefined, }; + const boundedRequest = await limitMcpRequestBody(request); + if (boundedRequest instanceof Response) return boundedRequest; + // The modern handler requires a factory and creates a fresh SDK v2 server // for every request. Context remains request-scoped through tool closures. return await createMcpHandler( - () => - buildServer({ + () => { + const server = buildServer({ context: serverContext, experimentalMode: isExperimentalMode, sdkVersion: "v2", - }), + }); + registerProductionCodeModeTools(server, env.LOADER, serverContext); + return server; + }, { route: url.pathname, + maxRequestBodySize: MAX_MCP_REQUEST_BODY_SIZE, // The Agents handler rejects browser Origins by default except localhost. // Permit only the hostname serving this authenticated endpoint. allowedOriginHostnames: [url.hostname], }, - )(request, env, ctx); + )(boundedRequest, env, ctx); } /** diff --git a/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts b/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts index 5dffe42e7..6e8c75ed6 100644 --- a/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts +++ b/packages/mcp-cloudflare/src/server/oauth/authorize.test.ts @@ -1,6 +1,7 @@ import { AuthorizationError } from "@cloudflare/workers-oauth-provider"; import { Hono } from "hono"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SCOPES } from "../../constants"; import { SKILL_PREFERENCES_COOKIE_NAME } from "../lib/approval-dialog"; import type { Env } from "../types"; import oauthRoute from "./index"; @@ -26,7 +27,8 @@ function skillInputAttributes(html: string, skillId: string): string { `]*)>`, ), ); - return match?.[1] ?? ""; + expect(match).not.toBeNull(); + return match![1]; } function expectSkillChecked(html: string, skillId: string): void { @@ -262,6 +264,7 @@ describe("oauth authorize routes", () => { expectSkillUnchecked(html, "seer"); expectSkillChecked(html, "triage"); expectSkillChecked(html, "project-management"); + expectSkillUnchecked(html, "api-write"); }); it("does not reuse remembered skills for a different client ID", async () => { @@ -304,9 +307,10 @@ describe("oauth authorize routes", () => { expectSkillChecked(html, "seer"); expectSkillChecked(html, "triage"); expectSkillChecked(html, "project-management"); + expectSkillUnchecked(html, "api-write"); }); - it("ignores tampered remembered skill cookies and falls back to all approvable skills", async () => { + it("ignores tampered remembered skill cookies and falls back to initial defaults", async () => { mockOAuthProvider.parseAuthRequest.mockResolvedValueOnce({ clientId: "test-client", redirectUri: "https://example.com/callback", @@ -340,9 +344,97 @@ describe("oauth authorize routes", () => { expectSkillChecked(html, "seer"); expectSkillChecked(html, "triage"); expectSkillChecked(html, "project-management"); + expectSkillUnchecked(html, "api-write"); }); }); + describe("Code Mode consent", () => { + it.each([ + { name: "initial read-only Code Mode defaults", explicitWrite: false }, + { name: "explicit API Write without Triage", explicitWrite: true }, + ])( + "preserves $name through approval and the next prompt", + async ({ explicitWrite }) => { + const resource = "http://localhost/mcp/test-org"; + mockOAuthProvider.parseAuthRequest.mockResolvedValue({ + clientId: "test-client", + redirectUri: "https://example.com/callback", + scope: ["read"], + state: "original-state", + }); + mockOAuthProvider.lookupClient.mockResolvedValue({ + clientId: "test-client", + clientName: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + const url = new URL("http://localhost/oauth/authorize"); + url.searchParams.set("resource", resource); + + const prompt = await app.fetch(new Request(url), testEnv as Env); + expect(prompt.status).toBe(200); + const html = await prompt.text(); + expect(html).toContain('value="api-read"'); + expect(html).toContain('value="api-write"'); + expectSkillChecked(html, "api-read"); + expectSkillUnchecked(html, "api-write"); + const state = html.match(/name="state" value="([^"]+)"/)?.[1]; + expect(state).toBeDefined(); + const initialSkills = [ + ...html.matchAll( + /]*)>/g, + ), + ] + .filter((match) => /\bchecked\b/.test(match[2])) + .map((match) => match[1]); + const selectedSkills = explicitWrite ? ["api-write"] : initialSkills; + const form = new FormData(); + form.set("state", state!); + form.set("decision", "approve"); + for (const skill of selectedSkills) form.append("skill", skill); + + const approval = await app.fetch( + new Request(url, { + method: "POST", + body: form, + }), + testEnv as Env, + ); + expect(approval.status).toBe(302); + const upstream = new URL(approval.headers.get("Location")!); + const approvedState = await verifyAndParseState( + upstream.searchParams.get("state")!, + testEnv.COOKIE_SECRET!, + ); + expect(approvedState.req.skills).toEqual(selectedSkills); + expect(approvedState.req.resource).toBe(resource); + expect(upstream.searchParams.get("scope")?.split(" ")).toEqual( + Object.keys(SCOPES), + ); + + const cookie = extractCookiePair( + approval.headers.get("Set-Cookie") ?? "", + SKILL_PREFERENCES_COOKIE_NAME, + ); + const remembered = await app.fetch( + new Request(url, { + headers: { Cookie: cookie }, + }), + testEnv as Env, + ); + expect(remembered.status).toBe(200); + const rememberedHtml = await remembered.text(); + const renderedSkills = [ + ...rememberedHtml.matchAll( + /]*)>/g, + ), + ] + .filter((match) => /\bchecked\b/.test(match[2])) + .map((match) => match[1]); + expect(renderedSkills).toEqual(selectedSkills); + }, + ); + }); + describe("POST /oauth/authorize", () => { beforeEach(() => { mockOAuthProvider.lookupClient.mockResolvedValue({ @@ -841,7 +933,10 @@ describe("oauth authorize routes", () => { expect(response.status).toBe(200); const html = await response.text(); - expect(html).toContain(">test-org organization"); + expect(html).toContain('aria-label="Organization scope"'); + expect(html).toContain( + 'Only test-org', + ); }); it("should allow request with project-scoped resource parameter", async () => { @@ -851,25 +946,36 @@ describe("oauth authorize routes", () => { scope: ["read"], resource: "http://localhost/mcp/test-org/test-project", }); - mockOAuthProvider.lookupClient.mockResolvedValueOnce({ + mockOAuthProvider.lookupClient.mockResolvedValue({ clientId: "test-client", clientName: "Test Client", redirectUris: ["https://example.com/callback"], }); + const preferenceCookie = await createSkillPreferenceCookie( + app, + testEnv, + "test-client", + ["api-write"], + ); const url = new URL("http://localhost/oauth/authorize"); url.searchParams.set( "resource", "http://localhost/mcp/test-org/test-project", ); - const request = new Request(url, { method: "GET" }); + const request = new Request(url, { + method: "GET", + headers: { Cookie: preferenceCookie }, + }); const response = await app.fetch(request, testEnv as Env); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain(">test-project project"); expect(html).toContain(">test-org organization"); + expect(html).not.toContain('name="skill" value="api-read"'); + expect(html).not.toContain('name="skill" value="api-write"'); }); it("should reject request with invalid resource hostname", async () => { diff --git a/packages/mcp-cloudflare/src/server/oauth/callback.test.ts b/packages/mcp-cloudflare/src/server/oauth/callback.test.ts index 5035d97bf..80e3e5e24 100644 --- a/packages/mcp-cloudflare/src/server/oauth/callback.test.ts +++ b/packages/mcp-cloudflare/src/server/oauth/callback.test.ts @@ -676,7 +676,8 @@ describe("oauth callback routes", () => { expires_at: new Date(Date.now() + 3600_000).toISOString(), token_type: "bearer", user: { id: "user-123", name: "Test User" }, - scope: "team:write org:read event:write project:write alerts:write", + scope: + " team:write org:read\tevent:write project:write alerts:write org:read ", }, null, ]); diff --git a/packages/mcp-cloudflare/src/server/oauth/constants.ts b/packages/mcp-cloudflare/src/server/oauth/constants.ts index b9c8c6680..757dcb870 100644 --- a/packages/mcp-cloudflare/src/server/oauth/constants.ts +++ b/packages/mcp-cloudflare/src/server/oauth/constants.ts @@ -15,5 +15,5 @@ export const TokenResponseSchema = z.object({ id: z.string(), name: z.string().nullable(), }), - scope: z.string(), + scope: z.string().optional(), }); diff --git a/packages/mcp-cloudflare/src/server/oauth/helpers.test.ts b/packages/mcp-cloudflare/src/server/oauth/helpers.test.ts index ee30d6872..ed8a7d858 100644 --- a/packages/mcp-cloudflare/src/server/oauth/helpers.test.ts +++ b/packages/mcp-cloudflare/src/server/oauth/helpers.test.ts @@ -1,6 +1,7 @@ import type { TokenExchangeCallbackOptions } from "@cloudflare/workers-oauth-provider"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkerProps } from "../types"; + const { logIssue, logWarn, sentryMetricsCount, sentrySetUser } = vi.hoisted( () => ({ logIssue: vi.fn(), @@ -22,12 +23,6 @@ vi.mock("@sentry/cloudflare", () => ({ setUser: sentrySetUser, })); -import { - OAUTH_GRANT_AGE_BUCKET_ATTRIBUTE, - OAUTH_GRANT_SHAPE_ATTRIBUTE, - OAUTH_REFRESH_OUTCOME_ATTRIBUTE, - OAUTH_UPSTREAM_EXPIRES_IN_BUCKET_ATTRIBUTE, -} from "./telemetry"; import { appendAuthorizationResponseIss, createResourceValidationError, @@ -38,6 +33,12 @@ import { tokenExchangeCallback, validateResourceParameter, } from "./helpers"; +import { + OAUTH_GRANT_AGE_BUCKET_ATTRIBUTE, + OAUTH_GRANT_SHAPE_ATTRIBUTE, + OAUTH_REFRESH_OUTCOME_ATTRIBUTE, + OAUTH_UPSTREAM_EXPIRES_IN_BUCKET_ATTRIBUTE, +} from "./telemetry"; const GRANT_TYPES = { AUTHORIZATION_CODE: @@ -230,45 +231,54 @@ describe("tokenExchangeCallback", () => { expect(result).toBeUndefined(); }); - it("should return cached token TTL when token is locally valid", async () => { - const now = Date.now(); - const futureExpiry = Date.now() + 10 * 60 * 1000; - const options = createRefreshOptions({ - accessTokenExpiresAt: futureExpiry, - sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000, - upstreamExpiresAt: now + 3 * 24 * 60 * 60 * 1000, - }); - - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - const result = await callTokenExchangeCallback(options); - expect(result).toEqual({ - newProps: expect.objectContaining({ - accessToken: "old-access-token", - }), - accessTokenTTL: expect.any(Number), - }); - expect(fetchSpy).not.toHaveBeenCalled(); - expect(sentryMetricsCount).toHaveBeenCalledWith( - "app.oauth.token_exchange", - 1, - { - attributes: expect.objectContaining({ - [OAUTH_REFRESH_OUTCOME_ATTRIBUTE]: "cached_valid_local", - [OAUTH_GRANT_AGE_BUCKET_ATTRIBUTE]: "1d_7d", - [OAUTH_UPSTREAM_EXPIRES_IN_BUCKET_ATTRIBUTE]: "1d_7d", - "app.client.registration.method": - expect.stringMatching(/^(cimd|dcr|unknown)$/), + it.each([ + ["known", ["event:write", "org:read"]], + ["unknown", undefined], + ] as const)( + "preserves %s upstream scopes when reusing a locally valid token", + async (_, upstreamScopes) => { + const now = Date.now(); + const futureExpiry = Date.now() + 10 * 60 * 1000; + const options = createRefreshOptions({ + accessTokenExpiresAt: futureExpiry, + sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000, + upstreamExpiresAt: now + 3 * 24 * 60 * 60 * 1000, + upstreamScopes: upstreamScopes ? [...upstreamScopes] : undefined, + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await callTokenExchangeCallback(options); + expect(result).toEqual({ + newProps: expect.objectContaining({ + accessToken: "old-access-token", + upstreamScopes, }), - }, - ); - }); + accessTokenTTL: expect.any(Number), + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sentryMetricsCount).toHaveBeenCalledWith( + "app.oauth.token_exchange", + 1, + { + attributes: expect.objectContaining({ + [OAUTH_REFRESH_OUTCOME_ATTRIBUTE]: "cached_valid_local", + [OAUTH_GRANT_AGE_BUCKET_ATTRIBUTE]: "1d_7d", + [OAUTH_UPSTREAM_EXPIRES_IN_BUCKET_ATTRIBUTE]: "1d_7d", + "app.client.registration.method": + expect.stringMatching(/^(cimd|dcr|unknown)$/), + }), + }, + ); + }, + ); it("should clear upstreamTokenInvalid after a successful probe", async () => { const pastExpiry = Date.now() - 60 * 1000; const options = createRefreshOptions({ accessTokenExpiresAt: pastExpiry, upstreamTokenInvalid: true, + upstreamScopes: ["event:write", "org:read"], }); vi.spyOn(globalThis, "fetch").mockResolvedValue( @@ -286,6 +296,10 @@ describe("tokenExchangeCallback", () => { ); const result = await callTokenExchangeCallback(options); + expect(result?.newProps?.upstreamScopes).toEqual([ + "event:write", + "org:read", + ]); expect(result).toEqual({ newProps: expect.not.objectContaining({ upstreamTokenInvalid: true, @@ -399,6 +413,32 @@ describe("exchangeCodeForAccessToken", () => { expect(logWarn).not.toHaveBeenCalled(); }); + it("accepts token responses with omitted scope without inferring permissions", async () => { + const token = { + access_token: "test-access-token", + refresh_token: "test-refresh-token", + token_type: "bearer", + expires_in: 3600, + expires_at: "2026-04-13T16:36:23.087Z", + user: { id: "user-123", name: "Test User" }, + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(token)); + + const [payload, response] = await exchangeCodeForAccessToken({ + client_id: "test-client-id", + client_secret: "test-client-secret", + code: "test-code", + upstream_url: "https://sentry.io/oauth/token", + redirect_uri: "https://mcp.sentry.dev/oauth/callback", + }); + + expect(response).toBeNull(); + expect(payload).toEqual(token); + expect(payload?.scope).toBeUndefined(); + expect(logIssue).not.toHaveBeenCalled(); + expect(logWarn).not.toHaveBeenCalled(); + }); + it("returns a specific invalid_grant message without an event ID", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( diff --git a/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts b/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts index f321d4356..e052b3013 100644 --- a/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts +++ b/packages/mcp-cloudflare/src/server/routes/__tests__/mcp-discovery.test.ts @@ -1,5 +1,5 @@ import { env } from "cloudflare:test"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import app from "../../app"; describe("/.mcp discovery routes", () => { @@ -72,5 +72,28 @@ describe("/.mcp discovery routes", () => { surface: "catalog", }), ); + for (const name of ["search_sentry_api", "execute_sentry_code"]) { + expect(toolsByName.get(name)).toEqual( + expect.objectContaining({ + surface: "direct", + requiredScopes: [], + skills: ["api-read", "api-write"], + inputSchema: expect.objectContaining({ + type: "object", + required: ["code"], + properties: expect.objectContaining({ + code: expect.objectContaining({ type: "string" }), + }), + }), + outputSchema: expect.objectContaining({ + type: "object", + properties: expect.objectContaining({ + result: expect.any(Object), + stats: expect.objectContaining({ type: "object" }), + }), + }), + }), + ); + } }); }); diff --git a/packages/mcp-cloudflare/src/server/routes/mcp.ts b/packages/mcp-cloudflare/src/server/routes/mcp.ts index 359361198..08ce56dbb 100644 --- a/packages/mcp-cloudflare/src/server/routes/mcp.ts +++ b/packages/mcp-cloudflare/src/server/routes/mcp.ts @@ -1,12 +1,46 @@ /** * Public MCP metadata endpoints under `/.mcp/*` for external documentation sites. * - * Responds with pre-generated JSON payloads from @sentry/mcp-core. + * Combines generated core definitions with the hosted Code Mode definitions. * CORS is handled by the wrapper in index.ts (these paths are in PUBLIC_METADATA_PATHS), * so routes here only need to set content-specific headers like Cache-Control. */ -import { Hono } from "hono"; + import TOOL_DEFINITIONS from "@sentry/mcp-core/toolDefinitions"; +import { Hono } from "hono"; +import { z } from "zod"; +import { + CODE_MODE_SKILLS, + getCodeModeToolDefinitions, +} from "../../code-mode/definitions"; + +const hostedTools = Object.entries( + getCodeModeToolDefinitions({ + allowWrites: true, + authorizationRequired: true, + }), +).map(([name, { inputSchema, outputSchema, ...definition }]) => ({ + name, + ...definition, + inputSchema: toJsonSchema(inputSchema, "input"), + outputSchema: toJsonSchema(outputSchema, "output"), + // API permissions are checked per operation, not as one fixed tool scope. + requiredScopes: [], + skills: [...CODE_MODE_SKILLS], + surface: "direct", +})); +const toolDefinitions = [...TOOL_DEFINITIONS, ...hostedTools].sort((a, b) => + a.name.localeCompare(b.name), +); + +function toJsonSchema(schema: z.ZodType, io: "input" | "output") { + const { $schema: _, ...jsonSchema } = z.toJSONSchema(schema, { + io, + target: "draft-7", + unrepresentable: "any", + }); + return jsonSchema; +} function jsonResponse(json: unknown, status = 200) { const body = JSON.stringify(json); @@ -27,4 +61,4 @@ export default new Hono() }), ) // Tools - .get("/tools.json", (c) => jsonResponse(TOOL_DEFINITIONS)); + .get("/tools.json", (c) => jsonResponse(toolDefinitions)); diff --git a/packages/mcp-cloudflare/src/server/types.ts b/packages/mcp-cloudflare/src/server/types.ts index 5d493089e..2ee3606e1 100644 --- a/packages/mcp-cloudflare/src/server/types.ts +++ b/packages/mcp-cloudflare/src/server/types.ts @@ -33,9 +33,9 @@ export type WorkerProps = { /** * Scopes Sentry actually granted on the upstream access token, as returned * in the `scope` field of Sentry's OAuth token response. Unlike - * `grantedScopes` (derived from selected skills), this reflects what the - * stored Sentry token can really do. Absent on grants created before this - * field existed. + * `grantedScopes` (derived from selected skills), these describe the token, + * not the MCP session's permissions. Absent on older grants or when Sentry + * omitted `scope`. */ upstreamScopes?: string[]; /** Primary authorization method - array of skill strings */ @@ -60,6 +60,7 @@ export type WorkerProps = { export interface Env { NODE_ENV: string; ASSETS: Fetcher; + LOADER: WorkerLoader; OAUTH_KV: KVNamespace; MCP_CACHE: KVNamespace; COOKIE_SECRET: string; diff --git a/packages/mcp-cloudflare/vitest.config.ts b/packages/mcp-cloudflare/vitest.config.ts index ba16c5ba4..6bb51b496 100644 --- a/packages/mcp-cloudflare/vitest.config.ts +++ b/packages/mcp-cloudflare/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ ], test: { include: ["src/**/*.test.ts"], + exclude: ["src/code-mode/**"], setupFiles: ["src/test-setup.ts"], fileParallelism: false, minWorkers: 1, diff --git a/packages/mcp-cloudflare/wrangler.canary.jsonc b/packages/mcp-cloudflare/wrangler.canary.jsonc index e1b6405db..9448a98b9 100644 --- a/packages/mcp-cloudflare/wrangler.canary.jsonc +++ b/packages/mcp-cloudflare/wrangler.canary.jsonc @@ -22,6 +22,7 @@ "binding": "CF_VERSION_METADATA", }, "vars": {}, + "worker_loaders": [{ "binding": "LOADER" }], "unsafe": { "bindings": [ { diff --git a/packages/mcp-cloudflare/wrangler.code-mode.jsonc b/packages/mcp-cloudflare/wrangler.code-mode.jsonc new file mode 100644 index 000000000..205078088 --- /dev/null +++ b/packages/mcp-cloudflare/wrangler.code-mode.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "sentry-code-mode-experiment", + "main": "src/code-mode/worker.ts", + "compatibility_date": "2026-01-12", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "worker_loaders": [{ "binding": "LOADER" }], + "vars": { + "API_BASE_URL": "https://sentry.io/api/0", + // Sentry's existing schema pipeline resolves references before publishing. + // Pin the artifact so schema changes are reviewed independently of execution. + "OPENAPI_URL": "https://raw.githubusercontent.com/getsentry/sentry-api-schema/803d6de98c9156c15e9ddc1bf9accbac6e24cebc/openapi-derefed.json", + "CODE_MODE_ALLOW_WRITES": "false", + }, +} diff --git a/packages/mcp-cloudflare/wrangler.jsonc b/packages/mcp-cloudflare/wrangler.jsonc index 6ea648773..c140d4239 100644 --- a/packages/mcp-cloudflare/wrangler.jsonc +++ b/packages/mcp-cloudflare/wrangler.jsonc @@ -27,6 +27,7 @@ "binding": "CF_VERSION_METADATA", }, "vars": {}, + "worker_loaders": [{ "binding": "LOADER" }], "unsafe": { "bindings": [ { diff --git a/packages/mcp-cloudflare/wrangler.test.jsonc b/packages/mcp-cloudflare/wrangler.test.jsonc index b972909b7..5a87f0f06 100644 --- a/packages/mcp-cloudflare/wrangler.test.jsonc +++ b/packages/mcp-cloudflare/wrangler.test.jsonc @@ -9,6 +9,7 @@ "nodejs_compat_populate_process_env", "global_fetch_strictly_public", ], + "worker_loaders": [{ "binding": "LOADER" }], "vars": { "COOKIE_SECRET": "test-cookie-secret-32-characters", "SENTRY_CLIENT_ID": "test-client-id", diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 8227c2949..d751e9dc9 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -105,6 +105,14 @@ "types": "./dist/tools/agent-tools.d.ts", "default": "./dist/tools/agent-tools.js" }, + "./internal/tool-helpers/resolve-region-url": { + "types": "./dist/internal/tool-helpers/resolve-region-url.d.ts", + "default": "./dist/internal/tool-helpers/resolve-region-url.js" + }, + "./internal/tool-helpers/validate-region-url": { + "types": "./dist/internal/tool-helpers/validate-region-url.d.ts", + "default": "./dist/internal/tool-helpers/validate-region-url.js" + }, "./internal/agents/callEmbeddedAgent": { "types": "./dist/internal/agents/callEmbeddedAgent.d.ts", "default": "./dist/internal/agents/callEmbeddedAgent.js" diff --git a/packages/mcp-core/scripts/generate-definitions.ts b/packages/mcp-core/scripts/generate-definitions.ts index 94805e898..145681ecb 100644 --- a/packages/mcp-core/scripts/generate-definitions.ts +++ b/packages/mcp-core/scripts/generate-definitions.ts @@ -18,6 +18,9 @@ const toolsModule = await import("../src/tools/index.ts"); const surfacesModule = await import("../src/tools/surfaces.ts"); const skillsModule = await import("../src/skills.ts"); const toolTypesModule = await import("../src/tools/types.ts"); +// Hosted-only tools belong in remote plugin allowlists, not core/stdio exports. +const { CODE_MODE_TOOL_NAMES, CODE_MODE_SKILLS, getCodeModeToolDefinitions } = + await import("../../mcp-cloudflare/src/code-mode/definitions.ts"); function writeJson(file: string, data: unknown) { fs.writeFileSync(file, JSON.stringify(data, null, 2)); @@ -194,6 +197,7 @@ async function generateSkillDefinitions() { description: string; defaultEnabled: boolean; deprecated?: boolean; + hostedOnly?: boolean; order: number; toolCount?: number; }> @@ -283,11 +287,26 @@ async function generateSkillDefinitions() { }); } + if (CODE_MODE_SKILLS.some((skillId) => skillId === skill.id)) { + const definitions = getCodeModeToolDefinitions({ + allowWrites: skill.id === "api-write", + authorizationRequired: true, + }); + for (const [name, definition] of Object.entries(definitions)) { + skillTools.push({ + name, + description: definition.description, + requiredScopes: [], + }); + } + } + // Sort tools alphabetically by name skillTools.sort((a, b) => a.name.localeCompare(b.name)); return { ...skill, + ...(skill.hostedOnly ? { toolCount: skillTools.length } : {}), tools: skillTools, }; }); @@ -349,6 +368,7 @@ function isUpToDate(outDir: string): boolean { path.join(__dirname, "../src/skills.ts"), path.join(__dirname, "../src/tools/surfaces.ts"), path.join(__dirname, "../src/tools/types.ts"), + path.join(__dirname, "../../mcp-cloudflare/src/code-mode/definitions.ts"), path.join( __dirname, "../src/internal/tool-helpers/tool-call-formatting.ts", @@ -415,12 +435,16 @@ async function main() { const experimentalDirectTools = experimentalTools.filter( (tool) => tool.surface === "direct", ); - const toolNames = directTools - .filter(isEnabledByDefaultSkills) - .map((t) => t.name); - const experimentalToolNames = experimentalDirectTools - .filter(isEnabledByDefaultSkills) - .map((t) => t.name); + const toolNames = [ + ...directTools.filter(isEnabledByDefaultSkills).map((t) => t.name), + ...CODE_MODE_TOOL_NAMES, + ].sort(); + const experimentalToolNames = [ + ...experimentalDirectTools + .filter(isEnabledByDefaultSkills) + .map((t) => t.name), + ...CODE_MODE_TOOL_NAMES, + ].sort(); let agentsSynced = 0; for (const agentConfig of agentConfigs()) { diff --git a/packages/mcp-core/src/constants.ts b/packages/mcp-core/src/constants.ts index 487bd04c5..a0509f13c 100644 --- a/packages/mcp-core/src/constants.ts +++ b/packages/mcp-core/src/constants.ts @@ -9,6 +9,10 @@ */ export const MCP_SERVER_NAME = "Sentry MCP" as const; +/** Bound HTTP tool input before schema validation expands nested arguments. */ +export const MAX_MCP_TOOL_INPUT_ELEMENTS = 10_000; +export const MAX_MCP_REQUEST_BODY_SIZE = 1024 * 1024; + /** * Supported issue-search windows exposed by search_issues. * Intentionally bounded for query performance; lower-level API methods still accept arbitrary Sentry statsPeriod strings. diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index fd2d3e831..a8561df75 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -30,7 +30,7 @@ import { } from "@sentry/core"; import { wrapMcpServerWithSentry } from "@sentry/core/server"; import { isApiAuthenticationErrorDeep } from "./api-client"; -import { MCP_SERVER_NAME } from "./constants"; +import { MAX_MCP_TOOL_INPUT_ELEMENTS, MCP_SERVER_NAME } from "./constants"; import { formatErrorForUser, isExpectedToolError, @@ -158,7 +158,9 @@ export function buildServer({ }; if (sdkVersion === "v2") { - const server = new ModernMcpServer(serverInfo); + const server = new ModernMcpServer(serverInfo, { + maxToolInputElements: MAX_MCP_TOOL_INPUT_ELEMENTS, + }); const registrations = configureServer({ server, context: contextWithModes, diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 1f3a1d245..9dec89dd2 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -639,5 +639,87 @@ "requiredScopes": [] } ] + }, + { + "id": "api-read", + "name": "API Read", + "description": "Read data through the Sentry API using Code Mode. Your Sentry permissions still apply.", + "defaultEnabled": false, + "hostedOnly": true, + "order": 6, + "toolCount": 6, + "tools": [ + { + "name": "execute_sentry_code", + "description": "Execute a JavaScript async arrow function with sentry.request({method?, path, query?, body?}). Discover endpoints and schemas with search_sentry_api. method defaults to GET. path is relative to the configured API prefix; full /api/0 paths are also accepted. query accepts string, number or boolean values and arrays of those values; arrays produce repeated parameters, and null/undefined values are omitted. body is JSON. Each request returns {status, headers, body}; headers.link preserves pagination. Non-2xx responses throw an Error with status, method, path, headers, data. On 403, headers[\"www-authenticate\"] may provide insufficient_scope evidence; a denial without that evidence does not identify its cause. Credentials are injected outside the sandbox. Direct network access is disabled; use sentry.request for HTTP. Only documented operations within this connection’s API read/write permission and organization constraint are allowed. Sentry separately enforces the upstream token scopes and user/resource permissions. A permission denial cannot be bypassed by changing routes or using fetch directly.\nBefore writing, discover the operation and inspect its request contract, defaults and update semantics. For edits, read the current resource through HTTP: other MCP tools may return renamed, enriched or filtered views. Even a raw GET response is not a write template: sending the same field and value back can trigger effects, including propagating an aggregate value to child resources. Build the body explicitly from required fields and requested changes. Include other fields only when the request contract or documented update behavior requires them; do not resend unchanged optional fields merely because GET returned them. Do not assume PUT replaces everything or that omitted fields always stay unchanged, and do not turn a missing field into an empty list or null. When the contract requires resending nested collections, retain existing IDs, values and relationships unless the user requested changing them. If preservation is ambiguous, obtain more documentation or report the limitation rather than inventing write semantics. Re-read after a write to verify requested changes and preserved settings. If an unexpected side effect occurs, do not probe speculative write bodies or routes to repair it; report the actual state unless a documented repair is available.\nCombine dependent requests, loops, pagination and verification in code. Await every sentry.request(), including parallel calls, before returning. Pending calls are cancelled when execution ends or times out; already accepted writes are not rolled back. Keep intermediate responses inside the program and return only the fields needed to answer the user, such as IDs, URLs, counts or a concise verification result. Return full objects only when the user needs their full contents.\nLimits: 20 API calls, 30 seconds, 50,000 output bytes, 2 MB per API response. Writes are disabled. No automatic retries. Execution is not atomic; inspect state before retrying a failed write.\nExample: async () => { const response = await sentry.request({path: \"/organizations/\"}); return {organizations: response.body.map(({slug, name}) => ({slug, name})), pagination: response.headers.link}; }", + "requiredScopes": [] + }, + { + "name": "find_organizations", + "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results per page. When hasMore is true, pass the returned nextCursor with the same filters and scope to fetch the next page.", + "requiredScopes": ["org:read"] + }, + { + "name": "find_projects", + "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results per page. When hasMore is true, pass the returned nextCursor with the same filters and scope to fetch the next page.", + "requiredScopes": ["project:read"] + }, + { + "name": "get_sentry_mcp_info", + "description": "Get information about the running Sentry MCP server.\n\nUse this tool when you need to:\n- Check the Sentry MCP server version\n- Verify compatibility with a Skill or integration", + "requiredScopes": [] + }, + { + "name": "search_sentry_api", + "description": "Discover Sentry HTTP API operations by executing a JavaScript async arrow function against the global spec. The configured OpenAPI document has schemas inline; no $ref resolution is needed. No network or API calls are available.\nSearch using the user's product terms across operation metadata: paths may use different internal names. Each spec.paths[path] has optional path-level parameters and HTTP-method operations. Operations have optional operationId, summary, description, tags (string[]), parameters, requestBody and responses. spec.tags, when present, describes categories with name, description and optional x-sidebar-name.\nFirst return a short list of matching methods, paths and summaries. If results are empty or unrelated, inspect categories and broaden the terms before trying API routes. Then inspect the selected operation's descriptions, parameters, requestBody and relevant response fields. Path-level parameters also apply; operation parameters override them by name and in. Do not return whole paths, the full spec or sliced JSON strings. Return only needed fields; the result limit is 50,000 bytes.\nDiscovery example: async () => {\n const term = \"release\";\n const results = [];\n for (const [path, item] of Object.entries(spec.paths)) {\n for (const [method, op] of Object.entries(item)) {\n if (![\"get\", \"post\", \"put\", \"patch\", \"delete\", \"head\", \"options\", \"trace\"].includes(method)) continue;\n const text = [path, op.operationId, op.summary, op.description, ...(op.tags ?? [])].join(\" \").toLowerCase();\n if (text.includes(term.toLowerCase())) results.push({method, path, operationId: op.operationId, summary: op.summary, tags: op.tags});\n }\n }\n return results;\n}\nCategories example: async () => spec.tags?.length ? spec.tags : [...new Set(Object.values(spec.paths).flatMap(item => Object.entries(item).filter(([method]) => [\"get\", \"post\", \"put\", \"patch\", \"delete\", \"head\", \"options\", \"trace\"].includes(method)).flatMap(([, op]) => op.tags ?? [])))].map(name => ({name}))\nSchema example: async () => { const path = \"/api/0/organizations/{organization_id_or_slug}/releases/\"; const item = spec.paths[path]; const op = item.post; return {description: op.description, pathParameters: item.parameters, operationParameters: op.parameters, requestBody: op.requestBody}; }", + "requiredScopes": [] + }, + { + "name": "whoami", + "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.", + "requiredScopes": [] + } + ] + }, + { + "id": "api-write", + "name": "API Write", + "description": "Read, create, modify, and delete data through the Sentry API. Includes API Read.", + "defaultEnabled": false, + "hostedOnly": true, + "order": 7, + "toolCount": 6, + "tools": [ + { + "name": "execute_sentry_code", + "description": "Execute a JavaScript async arrow function with sentry.request({method?, path, query?, body?}). Discover endpoints and schemas with search_sentry_api. method defaults to GET. path is relative to the configured API prefix; full /api/0 paths are also accepted. query accepts string, number or boolean values and arrays of those values; arrays produce repeated parameters, and null/undefined values are omitted. body is JSON. Each request returns {status, headers, body}; headers.link preserves pagination. Non-2xx responses throw an Error with status, method, path, headers, data. On 403, headers[\"www-authenticate\"] may provide insufficient_scope evidence; a denial without that evidence does not identify its cause. Credentials are injected outside the sandbox. Direct network access is disabled; use sentry.request for HTTP. Only documented operations within this connection’s API read/write permission and organization constraint are allowed. Sentry separately enforces the upstream token scopes and user/resource permissions. A permission denial cannot be bypassed by changing routes or using fetch directly.\nBefore writing, discover the operation and inspect its request contract, defaults and update semantics. For edits, read the current resource through HTTP: other MCP tools may return renamed, enriched or filtered views. Even a raw GET response is not a write template: sending the same field and value back can trigger effects, including propagating an aggregate value to child resources. Build the body explicitly from required fields and requested changes. Include other fields only when the request contract or documented update behavior requires them; do not resend unchanged optional fields merely because GET returned them. Do not assume PUT replaces everything or that omitted fields always stay unchanged, and do not turn a missing field into an empty list or null. When the contract requires resending nested collections, retain existing IDs, values and relationships unless the user requested changing them. If preservation is ambiguous, obtain more documentation or report the limitation rather than inventing write semantics. Re-read after a write to verify requested changes and preserved settings. If an unexpected side effect occurs, do not probe speculative write bodies or routes to repair it; report the actual state unless a documented repair is available.\nCombine dependent requests, loops, pagination and verification in code. Await every sentry.request(), including parallel calls, before returning. Pending calls are cancelled when execution ends or times out; already accepted writes are not rolled back. Keep intermediate responses inside the program and return only the fields needed to answer the user, such as IDs, URLs, counts or a concise verification result. Return full objects only when the user needs their full contents.\nLimits: 20 API calls, 30 seconds, 50,000 output bytes, 2 MB per API response. Writes are enabled. No automatic retries. Execution is not atomic; inspect state before retrying a failed write.\nExample: async () => { const response = await sentry.request({path: \"/organizations/\"}); return {organizations: response.body.map(({slug, name}) => ({slug, name})), pagination: response.headers.link}; }", + "requiredScopes": [] + }, + { + "name": "find_organizations", + "description": "Find organizations that the user has access to in Sentry.\n\nUse this tool when you need to:\n- View organizations in Sentry\n- Find an organization's slug to aid other tool requests\n- Search for specific organizations by name or slug\n\nReturns up to 25 results per page. When hasMore is true, pass the returned nextCursor with the same filters and scope to fetch the next page.", + "requiredScopes": ["org:read"] + }, + { + "name": "find_projects", + "description": "Find projects in Sentry.\n\nUse this tool when you need to:\n- View projects in a Sentry organization\n- Find a project's slug to aid other tool requests\n- Search for specific projects by name or slug\n\nReturns up to 25 results per page. When hasMore is true, pass the returned nextCursor with the same filters and scope to fetch the next page.", + "requiredScopes": ["project:read"] + }, + { + "name": "get_sentry_mcp_info", + "description": "Get information about the running Sentry MCP server.\n\nUse this tool when you need to:\n- Check the Sentry MCP server version\n- Verify compatibility with a Skill or integration", + "requiredScopes": [] + }, + { + "name": "search_sentry_api", + "description": "Discover Sentry HTTP API operations by executing a JavaScript async arrow function against the global spec. The configured OpenAPI document has schemas inline; no $ref resolution is needed. No network or API calls are available.\nSearch using the user's product terms across operation metadata: paths may use different internal names. Each spec.paths[path] has optional path-level parameters and HTTP-method operations. Operations have optional operationId, summary, description, tags (string[]), parameters, requestBody and responses. spec.tags, when present, describes categories with name, description and optional x-sidebar-name.\nFirst return a short list of matching methods, paths and summaries. If results are empty or unrelated, inspect categories and broaden the terms before trying API routes. Then inspect the selected operation's descriptions, parameters, requestBody and relevant response fields. Path-level parameters also apply; operation parameters override them by name and in. Do not return whole paths, the full spec or sliced JSON strings. Return only needed fields; the result limit is 50,000 bytes.\nDiscovery example: async () => {\n const term = \"release\";\n const results = [];\n for (const [path, item] of Object.entries(spec.paths)) {\n for (const [method, op] of Object.entries(item)) {\n if (![\"get\", \"post\", \"put\", \"patch\", \"delete\", \"head\", \"options\", \"trace\"].includes(method)) continue;\n const text = [path, op.operationId, op.summary, op.description, ...(op.tags ?? [])].join(\" \").toLowerCase();\n if (text.includes(term.toLowerCase())) results.push({method, path, operationId: op.operationId, summary: op.summary, tags: op.tags});\n }\n }\n return results;\n}\nCategories example: async () => spec.tags?.length ? spec.tags : [...new Set(Object.values(spec.paths).flatMap(item => Object.entries(item).filter(([method]) => [\"get\", \"post\", \"put\", \"patch\", \"delete\", \"head\", \"options\", \"trace\"].includes(method)).flatMap(([, op]) => op.tags ?? [])))].map(name => ({name}))\nSchema example: async () => { const path = \"/api/0/organizations/{organization_id_or_slug}/releases/\"; const item = spec.paths[path]; const op = item.post; return {description: op.description, pathParameters: item.parameters, operationParameters: op.parameters, requestBody: op.requestBody}; }", + "requiredScopes": [] + }, + { + "name": "whoami", + "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.", + "requiredScopes": [] + } + ] } ] diff --git a/packages/mcp-core/src/skillDefinitions.ts b/packages/mcp-core/src/skillDefinitions.ts index 3be3c07ec..c7f035d23 100644 --- a/packages/mcp-core/src/skillDefinitions.ts +++ b/packages/mcp-core/src/skillDefinitions.ts @@ -7,6 +7,7 @@ export interface SkillDefinition { description: string; defaultEnabled: boolean; deprecated?: boolean; + hostedOnly?: boolean; order: number; toolCount?: number; tools?: Array<{ diff --git a/packages/mcp-core/src/skills.test.ts b/packages/mcp-core/src/skills.test.ts index 1ab5142db..e9c6dbc9a 100644 --- a/packages/mcp-core/src/skills.test.ts +++ b/packages/mcp-core/src/skills.test.ts @@ -1,14 +1,15 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; +import skillDefinitions from "./skillDefinitions"; import { - SKILLS, ACTIVE_SKILLS, DEFAULT_SKILLS, + isEnabledBySkills, isValidSkill, parseSkills, - isEnabledBySkills, + SKILLS, type Skill, } from "./skills"; -import skillDefinitions from "./skillDefinitions"; +import toolDefinitions from "./toolDefinitions"; function getGeneratedSkillToolNames(skillId: string) { const skill = skillDefinitions.find( @@ -32,6 +33,24 @@ function getGeneratedSkillToolDescription(skillId: string, toolName: string) { } describe("skills module", () => { + it.each([ + ["api-read", "disabled"], + ["api-write", "enabled"], + ])("describes hosted Code Mode access for %s", (skillId, writeState) => { + const definition = skillDefinitions.find((skill) => skill.id === skillId); + expect(definition?.hostedOnly).toBe(true); + expect([...getGeneratedSkillToolNames(skillId)]).toEqual( + expect.arrayContaining(["search_sentry_api", "execute_sentry_code"]), + ); + expect(definition?.toolCount).toBe(definition?.tools?.length); + expect( + getGeneratedSkillToolDescription(skillId, "execute_sentry_code"), + ).toContain(`Writes are ${writeState}.`); + expect(toolDefinitions.map((tool) => tool.name)).not.toContain( + "execute_sentry_code", + ); + }); + it("does not advertise workflow-only tools in generated skill prompts", () => { for (const skill of skillDefinitions) { expect(skill.tools?.map((tool) => tool.name) ?? []).not.toContain( @@ -47,6 +66,8 @@ describe("skills module", () => { expect(SKILLS["project-management"]).toBeDefined(); expect(SKILLS.seer).toBeDefined(); expect(SKILLS.docs).toBeDefined(); + expect(SKILLS["api-read"].hostedOnly).toBe(true); + expect(SKILLS["api-write"].hostedOnly).toBe(true); expect("preprod" in SKILLS).toBe(false); }); @@ -72,6 +93,8 @@ describe("skills module", () => { expect(DEFAULT_SKILLS).not.toContain("docs"); expect(DEFAULT_SKILLS).not.toContain("triage"); expect(DEFAULT_SKILLS).not.toContain("project-management"); + expect(DEFAULT_SKILLS).not.toContain("api-read"); + expect(DEFAULT_SKILLS).not.toContain("api-write"); }); it("has exactly 2 default skills", () => { @@ -86,6 +109,8 @@ describe("skills module", () => { "seer", "triage", "project-management", + "api-read", + "api-write", ]); expect(ACTIVE_SKILLS).not.toContain("docs"); }); @@ -98,6 +123,8 @@ describe("skills module", () => { expect(isValidSkill("project-management")).toBe(true); expect(isValidSkill("seer")).toBe(true); expect(isValidSkill("docs")).toBe(true); + expect(isValidSkill("api-read")).toBe(true); + expect(isValidSkill("api-write")).toBe(true); }); it("returns false for invalid skills", () => { @@ -110,6 +137,12 @@ describe("skills module", () => { }); describe("parseSkills", () => { + it("keeps existing grants unchanged when API capabilities are introduced", () => { + expect(parseSkills(["inspect", "triage"]).valid).toEqual( + new Set(["inspect", "triage"]), + ); + expect(parseSkills(["api-write"]).valid).toEqual(new Set(["api-write"])); + }); it("parses comma-separated string", () => { const { valid, invalid } = parseSkills("inspect,triage,docs"); expect(valid.size).toBe(3); diff --git a/packages/mcp-core/src/skills.ts b/packages/mcp-core/src/skills.ts index dd89d52b3..ba247e6dd 100644 --- a/packages/mcp-core/src/skills.ts +++ b/packages/mcp-core/src/skills.ts @@ -11,7 +11,9 @@ export type Skill = | "triage" | "project-management" | "seer" - | "docs"; + | "docs" + | "api-read" + | "api-write"; // Central registry with metadata (used by OAuth UI) export interface SkillDefinition { @@ -20,6 +22,7 @@ export interface SkillDefinition { description: string; defaultEnabled: boolean; deprecated?: boolean; + hostedOnly?: boolean; order: number; toolCount?: number; // Number of tools enabled by this skill (calculated dynamically) } @@ -65,6 +68,24 @@ export const SKILLS: Record = { defaultEnabled: false, order: 5, }, + "api-read": { + id: "api-read", + name: "API Read", + description: + "Read data through the Sentry API using Code Mode. Your Sentry permissions still apply.", + defaultEnabled: false, + hostedOnly: true, + order: 6, + }, + "api-write": { + id: "api-write", + name: "API Write", + description: + "Read, create, modify, and delete data through the Sentry API. Includes API Read.", + defaultEnabled: false, + hostedOnly: true, + order: 7, + }, }; // Sorted array for UI ordering diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 50d8ec298..830f5d00a 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -1556,7 +1556,15 @@ "required": ["name"] }, "requiredScopes": [], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "direct" }, { @@ -2654,7 +2662,15 @@ "additionalProperties": false }, "requiredScopes": ["org:read"], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "direct" }, { @@ -2733,7 +2749,15 @@ "additionalProperties": false }, "requiredScopes": ["project:read"], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "direct" }, { @@ -5524,7 +5548,15 @@ "additionalProperties": false }, "requiredScopes": [], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "catalog" }, { @@ -6077,7 +6109,15 @@ "required": ["organizationSlug", "runToken", "update"] }, "requiredScopes": ["org:read"], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "catalog" }, { @@ -7484,7 +7524,15 @@ "additionalProperties": false }, "requiredScopes": [], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "direct" }, { @@ -9006,7 +9054,15 @@ "additionalProperties": false }, "requiredScopes": [], - "skills": ["inspect", "seer", "docs", "triage", "project-management"], + "skills": [ + "inspect", + "seer", + "docs", + "triage", + "project-management", + "api-read", + "api-write" + ], "surface": "catalog" } ] diff --git a/packages/mcp-server/src/cli/resolve.test.ts b/packages/mcp-server/src/cli/resolve.test.ts index d3fe56251..5a6a23f56 100644 --- a/packages/mcp-server/src/cli/resolve.test.ts +++ b/packages/mcp-server/src/cli/resolve.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { finalize } from "./resolve"; describe("cli/finalize", () => { @@ -138,6 +138,18 @@ describe("cli/finalize", () => { ); // Skills tests + it.each(["api-read", "api-write"])( + "rejects hosted-only %s in explicit stdio configuration", + (skill) => { + expect(() => + finalize({ skills: `inspect,${skill}`, unknownArgs: [] }), + ).toThrow(`available only on the hosted MCP: ${skill}`); + expect(() => finalize({ disableSkills: skill, unknownArgs: [] })).toThrow( + `available only on the hosted MCP: ${skill}`, + ); + }, + ); + it("throws on invalid skills", () => { expect(() => finalize({ @@ -212,6 +224,8 @@ describe("cli/finalize", () => { expect(cfg.finalSkills.has("seer")).toBe(true); expect(cfg.finalSkills.has("docs")).toBe(false); expect(cfg.finalSkills.has("preprod")).toBe(false); + expect(cfg.finalSkills.has("api-read")).toBe(false); + expect(cfg.finalSkills.has("api-write")).toBe(false); }); it("grants all active skills with --all-skills", () => { @@ -227,6 +241,8 @@ describe("cli/finalize", () => { expect(cfg.finalSkills.has("seer")).toBe(true); expect(cfg.finalSkills.has("docs")).toBe(false); expect(cfg.finalSkills.has("preprod")).toBe(false); + expect(cfg.finalSkills.has("api-read")).toBe(false); + expect(cfg.finalSkills.has("api-write")).toBe(false); }); // Self-hosted defaults diff --git a/packages/mcp-server/src/cli/resolve.ts b/packages/mcp-server/src/cli/resolve.ts index d51b0baaa..de26fd632 100644 --- a/packages/mcp-server/src/cli/resolve.ts +++ b/packages/mcp-server/src/cli/resolve.ts @@ -2,6 +2,7 @@ import { ACTIVE_SKILLS, ALL_SKILLS, parseSkills, + SKILLS, type Skill, } from "@sentry/mcp-core/skills"; import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; @@ -13,12 +14,31 @@ import { import { DEFAULT_SENTRY_CLIENT_ID } from "../auth/constants"; import type { MergedArgs, PartiallyResolvedConfig } from "./types"; +const STDIO_SKILLS = ALL_SKILLS.filter((skill) => !SKILLS[skill].hostedOnly); +const ACTIVE_STDIO_SKILLS = ACTIVE_SKILLS.filter( + (skill) => !SKILLS[skill].hostedOnly, +); + export function formatInvalidSkills( invalid: string[], source?: string, ): string { const prefix = source ? `${source} provided` : "Invalid skills provided"; - return `Error: ${prefix}: ${invalid.join(", ")}\nAvailable skills: ${ALL_SKILLS.join(", ")}`; + return `Error: ${prefix}: ${invalid.join(", ")}\nAvailable skills: ${STDIO_SKILLS.join(", ")}`; +} + +function parseStdioSkills(input: unknown, source?: string): Set { + const { valid, invalid } = parseSkills(input); + if (invalid.length > 0) { + throw new Error(formatInvalidSkills(invalid, source)); + } + const hostedSkills = [...valid].filter((skill) => SKILLS[skill].hostedOnly); + if (hostedSkills.length > 0) { + throw new Error( + `Error: These skills are available only on the hosted MCP: ${hostedSkills.join(", ")}. They cannot be used with stdio.`, + ); + } + return valid; } /** @@ -77,20 +97,17 @@ export function finalize(input: MergedArgs): PartiallyResolvedConfig { } if (input.allSkills) { - finalSkills = new Set(ACTIVE_SKILLS); + finalSkills = new Set(ACTIVE_STDIO_SKILLS); } else if (input.skills) { // Override: use only the specified skills - const { valid, invalid } = parseSkills(input.skills); - if (invalid.length > 0) { - throw new Error(formatInvalidSkills(invalid)); - } + const valid = parseStdioSkills(input.skills); if (valid.size === 0) { throw new Error("Error: Invalid skills provided. No valid skills found."); } finalSkills = valid; } else { // Default: grant all active skills when no flag is provided (see comment block above for rationale) - finalSkills = new Set(ACTIVE_SKILLS); + finalSkills = new Set(ACTIVE_STDIO_SKILLS); // Seer is a sentry.io service. Self-hosted Sentry ships without it, and its // autofix endpoints answer 500 when the Seer backend is not configured, so @@ -105,12 +122,10 @@ export function finalize(input: MergedArgs): PartiallyResolvedConfig { // Disable-skills: remove specific skills from the active set if (input.disableSkills) { - const { valid: skillsToDisable, invalid } = parseSkills( + const skillsToDisable = parseStdioSkills( input.disableSkills, + "--disable-skills", ); - if (invalid.length > 0) { - throw new Error(formatInvalidSkills(invalid, "--disable-skills")); - } for (const skill of skillsToDisable) { finalSkills.delete(skill); } diff --git a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md index eebfb87b7..adcd9c2bc 100644 --- a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md +++ b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md @@ -11,6 +11,7 @@ mcpServers: - sentry allowedTools: - analyze_issue_with_seer + - execute_sentry_code - execute_sentry_tool - find_dropped_events - find_organizations @@ -22,6 +23,7 @@ allowedTools: - search_metrics - search_profiles - search_replays + - search_sentry_api - search_sentry_tools - search_traces --- diff --git a/plugins/sentry-mcp/agents/sentry-mcp.md b/plugins/sentry-mcp/agents/sentry-mcp.md index eebfb87b7..adcd9c2bc 100644 --- a/plugins/sentry-mcp/agents/sentry-mcp.md +++ b/plugins/sentry-mcp/agents/sentry-mcp.md @@ -11,6 +11,7 @@ mcpServers: - sentry allowedTools: - analyze_issue_with_seer + - execute_sentry_code - execute_sentry_tool - find_dropped_events - find_organizations @@ -22,6 +23,7 @@ allowedTools: - search_metrics - search_profiles - search_replays + - search_sentry_api - search_sentry_tools - search_traces --- diff --git a/scripts/pre-commit-generated.mjs b/scripts/pre-commit-generated.mjs index 98aed210b..675d3c80e 100644 --- a/scripts/pre-commit-generated.mjs +++ b/scripts/pre-commit-generated.mjs @@ -16,6 +16,7 @@ const GENERATED_TASKS = [ /^packages\/mcp-core\/src\/skills\.ts$/, /^packages\/mcp-core\/src\/internal\/tool-helpers\/tool-call-formatting\.ts$/, /^packages\/mcp-core\/scripts\/generate-definitions\.ts$/, + /^packages\/mcp-cloudflare\/src\/code-mode\/definitions\.ts$/, /^plugins\/sentry-mcp\/agents\/sentry-mcp\.md$/, /^plugins\/sentry-mcp-experimental\/agents\/sentry-mcp\.md$/, ],