Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/cloudflare/oauth-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ interface WorkerProps {
scope: string; // MCP permissions granted
grantedSkills?: string[]; // Skills granted (primary authorization)
// grantedScopes is deprecated and will be removed Jan 1, 2026
upstreamScopes?: string[]; // Scopes Sentry granted on the upstream token
}
```

Expand Down
4 changes: 4 additions & 0 deletions docs/operations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ 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.

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
Expand Down
76 changes: 76 additions & 0 deletions packages/mcp-cloudflare/src/server/oauth/callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,82 @@ describe("oauth callback routes", () => {
expect(body.result?.protocolVersion).toBeDefined();
});

it("stores the scopes Sentry actually granted alongside skill-derived grantedScopes", async () => {
// Sentry returns the token's real scopes in the token response. With
// only `inspect` selected, skill-derived grantedScopes are read-only,
// but the upstream token can still carry write scopes.
exchangeCodeForAccessToken.mockResolvedValueOnce([
{
access_token: "sentry-access-token",
refresh_token: "sentry-refresh-token",
expires_in: 3600,
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",
},
null,
]);

const testEnv = createTestEnv();
const completeAuthorization = vi.spyOn(
testEnv.OAUTH_PROVIDER,
"completeAuthorization",
);
const oauthApp = createTestApp();
const client = await createClient(testEnv);
const cookie = await approveClient(oauthApp, testEnv, client.clientId);
const state = await createSignedCallbackState(client.clientId);

const callbackResponse = await callCallback(oauthApp, testEnv, {
state,
code: "test-code",
cookie,
});

expect(callbackResponse.status).toBe(302);
expect(completeAuthorization).toHaveBeenCalledOnce();
const props = completeAuthorization.mock.calls[0]![0].props as Record<
string,
unknown
>;

expect(props.upstreamScopes).toEqual([
"alerts:write",
"event:write",
"org:read",
"project:write",
"team:write",
]);
expect(props.grantedSkills).toEqual(["inspect"]);
expect(props.grantedScopes).not.toEqual(props.upstreamScopes);
});

it("omits upstreamScopes when Sentry's token response has no scope", async () => {
const testEnv = createTestEnv();
const completeAuthorization = vi.spyOn(
testEnv.OAUTH_PROVIDER,
"completeAuthorization",
);
const oauthApp = createTestApp();
const client = await createClient(testEnv);
const cookie = await approveClient(oauthApp, testEnv, client.clientId);
const state = await createSignedCallbackState(client.clientId);

const callbackResponse = await callCallback(oauthApp, testEnv, {
state,
code: "test-code",
cookie,
});

expect(callbackResponse.status).toBe(302);
const props = completeAuthorization.mock.calls[0]![0].props as Record<
string,
unknown
>;
expect(props.upstreamScopes).toBeUndefined();
});

it("completes callback for a scoped resource, exchanges the code, and uses the token at the same scoped /mcp path", async () => {
const scopedResource =
"http://localhost/mcp/sentry-mcp-evals/cloudflare-mcp";
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-cloudflare/src/server/oauth/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ export async function tokenExchangeCallback(
clientId: rawProps.clientId as string,
scope: rawProps.scope as string,
grantedScopes: rawProps.grantedScopes,
upstreamScopes: rawProps.upstreamScopes,
grantedSkills: rawProps.grantedSkills,
} satisfies WorkerProps & Record<string, unknown>;

Expand Down
15 changes: 15 additions & 0 deletions packages/mcp-cloudflare/src/server/oauth/routes/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ function getUpstreamTokenExpiryTimestamp(payload: {
: Date.now() + payload.expires_in * 1000;
}

/**
* Parse the space-delimited `scope` from Sentry's token response (RFC 6749
* §5.1) into a sorted list. Returns undefined when Sentry omitted it so we
* don't record an empty list that looks like "no scopes granted".
*/
function parseUpstreamScopes(scope: string | undefined): string[] | undefined {
if (typeof scope !== "string") {
return undefined;
}
return Array.from(new Set(scope.split(/\s+/).filter(Boolean))).sort();
}

function getUpstreamUserLabel(payload: {
user: { name: string | null; email?: string | null; id: string };
}): string {
Expand Down Expand Up @@ -323,6 +335,9 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => {
// Scopes derived from skills - for backward compatibility with old MCP clients
// that don't support grantedSkills and only understand grantedScopes
grantedScopes: Array.from(grantedScopes),
// Scopes Sentry actually granted on the upstream token (from the token
// response), which can differ from the skill-derived grantedScopes
upstreamScopes: parseUpstreamScopes(payload.scope),
grantedSkills, // Primary authorization method

constraintOrganizationSlug,
Expand Down
8 changes: 8 additions & 0 deletions packages/mcp-cloudflare/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export type WorkerProps = {
* This field exists only for backward compatibility during the transition period.
*/
grantedScopes?: string[];
/**
* 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.
*/
upstreamScopes?: string[];
/** Primary authorization method - array of skill strings */
grantedSkills?: string[];

Expand Down
Loading