Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/cloudflare/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,44 @@ SENTRY_CLIENT_SECRET = "..." # OAuth app secret
- `/api/metadata` - MCP metadata endpoint
- `/sse` - Server-sent events for MCP

### Sentry API Response Cache

The MCP handler can cache Sentry API GET responses through the shared
response cache in `@sentry/toolkit-core/api-client/response-cache`. It is
behind `MCP_RESPONSE_CACHE`:

| Value | Behavior |
| ------------------- | -------------------------------------------------------------------------- |
| unset, `off`, other | No caching. The default in every deployed environment. |
| `memory` | Caches every tier in the isolate's memory. |
| `tiered` | Memory only for now; the R2 tier for allowlisted detail endpoints follows. |

How it works (`src/server/lib/response-cache.ts`):

- **Identity.** Each entry is keyed by the SHA-256 of the MCP grant's user ID
and grant ID, both from the wrapper bearer token (`userId:grantId:secret`).
A grant never reads another grant's entries, even for the same user and
URL, and the key stays the same when the wrapper token refreshes. Requests
without a grant ID, including Sentry-Bearer direct tokens, are not cached.
- **Storage.** One memory store per isolate, shared by every request it
serves: a 24 MiB byte budget counted over compressed bodies, a 1 MiB
per-entry cap, and least recently used eviction.
- **Freshness.** Server cache headers win; otherwise the URL's tier applies
(60 s volatile, 5 minutes stable, never for autofix polling). A successful
write invalidates the same grant's entries under the written resource.
- **Writes** run in `ctx.waitUntil()`, so they never delay a response.
- **Bodies** are compressed with zstd level 3 when `node:zlib` has it, and
brotli quality 4 otherwise. Local workerd (miniflare `5.20261019.1-beta`)
has zstd; a deployed Worker is not confirmed yet. The
`app.response_cache.write` metric records the encoding each write used.

Turning it on in production is a separate decision: revoked access or deleted
data can still be served from the cache until the entry expires (up to 5
minutes for stable entries). Leave `MCP_RESPONSE_CACHE` unset in production
until that window has privacy and security sign-off. Setting it back to `off`
restores uncached behavior. Metrics are listed in
[Monitoring](../operations/monitoring.md#response-cache-metrics).

## Security Considerations

1. **Authentication**: OAuth tokens stored encrypted in KV
Expand Down
30 changes: 30 additions & 0 deletions docs/operations/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,36 @@ Interpretation:
- Upstream/provider 429s increment `app.server.response` with status `429`, but
do not include `app.response.reason`

### Response Cache Metrics

The hosted MCP's Sentry API response cache (`MCP_RESPONSE_CACHE`, see
[Cloudflare Architecture](../cloudflare/architecture.md#sentry-api-response-cache))
sends these through the same metrics API. Nothing is sent while the cache is
off.

- `app.response_cache.lookup` - Count of cache lookups, with
`app.response_cache.result`: `hit` (served from cache), `miss`, or `stale`
(an expired entry with an `ETag`, revalidated with `If-None-Match`)
- `app.response_cache.write` - Count of responses written to the memory
store, with `app.response_cache.encoding`: `zstd`, `br`, or `identity`
(bodies under 256 bytes). `br` on every write means the Worker has no zstd.
- `app.response_cache.eviction` - Count of entries evicted to stay within the
memory budget
- `app.response_cache.memory.bytes` - Gauge of bytes the isolate's store
charges against its budget, after each write
- `app.response_cache.memory.entries` - Gauge of entries in the isolate's
store, after each write

The first eviction in an isolate also logs `Response cache reached its memory
budget` at info level, with the budget, bytes, and entry count.

Interpretation:

- Hit rate: `sum(app.response_cache.lookup)` filtered to `result=hit`, over
the unfiltered sum
- A steady eviction rate with `memory.bytes` at the budget means the budget is
too small for the traffic an isolate serves

### Traces Configuration

```typescript
Expand Down
126 changes: 126 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock("@sentry/cloudflare", async (importOriginal) => ({
getActiveSpan: vi.fn(() => undefined),
metrics: {
count: sentryMetricsCount,
gauge: vi.fn(),
},
setUser: sentrySetUser,
}));
Expand Down Expand Up @@ -925,6 +926,131 @@ describe("MCP Handler", () => {
});
});

describe("response cache", () => {
const whoamiCall = {
name: "execute_sentry_tool",
arguments: { name: "whoami", arguments: {} },
};
let grantSequence = 0;

/** A wrapper token for a grant no other test has used. */
function newGrantToken(userId = DEFAULT_OAUTH_PROPS.id): string {
grantSequence += 1;
return `${userId}:cache-grant-${grantSequence}:secret`;
}

function spyOnAuthRequests() {
const spy = vi.spyOn(globalThis, "fetch");
return {
count: () =>
spy.mock.calls.filter(([input]) =>
String(input instanceof Request ? input.url : input).endsWith(
"/api/0/auth/",
),
).length,
restore: () => spy.mockRestore(),
};
}

async function callWhoami(
env: Env,
bearerToken: string,
props: Partial<OAuthProps> = {},
): Promise<void> {
const ctx = Object.assign(createExecutionContext(), {
props: { ...DEFAULT_OAUTH_PROPS, ...props },
});
const response = await mcpHandler.fetch!(
createMcpRequest("tools/call", whoamiCall, { bearerToken }),
env,
ctx,
);
expect(response.status).toBe(200);
const body = await parseSSEResponse<{ result: { isError?: boolean } }>(
response,
);
expect(body.result.isError).toBeFalsy();
await waitOnExecutionContext(ctx);
}

it("is off by default", async () => {
const auth = spyOnAuthRequests();
try {
const token = newGrantToken();
await callWhoami(createTestEnv(), token);
await callWhoami(createTestEnv(), token);
expect(auth.count()).toBe(2);
} finally {
auth.restore();
}
});

it("serves a grant's repeated reads from memory, but never another grant's", async () => {
const env = { ...createTestEnv(), MCP_RESPONSE_CACHE: "memory" };
const auth = spyOnAuthRequests();
try {
const grantA = newGrantToken();
await callWhoami(env, grantA);
await callWhoami(env, grantA);
expect(auth.count()).toBe(1);

// Same user and URL, another grant: a separate identity.
const grantB = newGrantToken();
await callWhoami(env, grantB);
expect(auth.count()).toBe(2);

// Another user on the same isolate.
await callWhoami(env, newGrantToken("other-user"), {
id: "other-user",
accessToken: "other-access-token",
});
expect(auth.count()).toBe(3);
} finally {
auth.restore();
}
});

it("does not cache requests without a wrapper grant", async () => {
const env = { ...createTestEnv(), MCP_RESPONSE_CACHE: "memory" };
const auth = spyOnAuthRequests();
try {
await callWhoami(env, "not-a-wrapper-token");
await callWhoami(env, "not-a-wrapper-token");
expect(auth.count()).toBe(2);
} finally {
auth.restore();
}
});

it("does not cache Sentry-Bearer direct tokens", async () => {
const env = {
...createTestEnv(),
MCP_RESPONSE_CACHE: "memory",
SENTRY_HOST: "direct-token.test",
};
const auth = spyOnAuthRequests();
try {
for (let i = 0; i < 2; i++) {
const ctx = createExecutionContext();
const response = await handleSentryBearerMcpRequest(
createMcpRequest("tools/call", whoamiCall, {
headers: { "X-Sentry-Utm-Source": "plugin" },
}),
env,
ctx,
DIRECT_AUTH_ASSERTION_TOKEN,
);
expect(response.status).toBe(200);
await response.text();
await waitOnExecutionContext(ctx);
}
expect(auth.count()).toBe(2);
} finally {
auth.restore();
}
});
});

describe("request limits", () => {
function createDirectContext(): ExecutionContext {
return {
Expand Down
17 changes: 17 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "./attribution";
import { resolveClientFamily } from "./client-family";
import { verifyConstraintsAccess } from "./constraint-utils";
import { getResponseCache, resolveResponseCacheMode } from "./response-cache";

/**
* ExecutionContext with OAuth props injected by the OAuth provider.
Expand All @@ -60,6 +61,8 @@ type OAuthMcpContext = {
accessToken: string;
grantedSkills: Set<Skill>;
userId: string;
/** From the wrapper token; scopes the response cache. */
grantId: string | null;
clientId: string;
clientName?: string;
tokenConstraintOrganizationSlug?: string | null;
Expand Down Expand Up @@ -443,6 +446,18 @@ async function handleAuthenticatedMcpRequest(
return constraints;
}

// Only an MCP grant gives a cache identity; direct tokens are not cached.
const responseCache =
auth.kind === "oauth" && auth.grantId
? await getResponseCache({
mode: resolveResponseCacheMode(env.MCP_RESPONSE_CACHE),
userId: auth.userId,
grantId: auth.grantId,
sentryHost,
waitUntil: (promise) => ctx.waitUntil(promise),
})
: undefined;

const serverContext: ServerContext = {
userId: auth.kind === "oauth" ? auth.userId : undefined,
userIpAddress,
Expand All @@ -459,6 +474,7 @@ async function handleAuthenticatedMcpRequest(
transport: "http",
onUpstreamUnauthorized:
auth.kind === "oauth" ? auth.onUpstreamUnauthorized : undefined,
responseCache,
};

// `createMcpHandler` from agents only forwards `maxRequestBodySize` to the
Expand Down Expand Up @@ -610,6 +626,7 @@ const mcpHandler: ExportedHandler<Env> = {
return await handleAuthenticatedMcpRequest(request, env, ctx, {
kind: "oauth",
userId,
grantId: requestGrantId,
clientId,
clientName,
accessToken,
Expand Down
Loading
Loading