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
9 changes: 5 additions & 4 deletions docs/cloudflare/oauth-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,13 @@ MCP tokens contain encrypted properties including Sentry tokens:
```typescript
interface WorkerProps {
id: string; // Sentry user ID
name: string; // User name
accessToken: string; // Sentry access token
refreshToken?: string; // Sentry refresh token
accessTokenExpiresAt?: number; // Sentry token expiry timestamp
clientId: string; // MCP client ID
scope: string; // MCP permissions granted
grantedScopes?: string[]; // Sentry API scopes
grantedSkills?: string[]; // Skills granted (primary authorization)
// grantedScopes is deprecated and will be removed Jan 1, 2026
}
```

Expand Down Expand Up @@ -234,12 +235,12 @@ const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
// ... other params
props: {
id: payload.user.id, // From Sentry
name: payload.user.name, // From Sentry
accessToken: payload.access_token, // Sentry's access token
refreshToken: payload.refresh_token, // Sentry's refresh token
accessTokenExpiresAt: Date.now() + payload.expires_in * 1000,
clientId: oauthReqInfo.clientId, // MCP client ID
scope: oauthReqInfo.scope.join(" "), // MCP scopes
grantedScopes: Array.from(grantedScopes), // Sentry API scopes
grantedSkills: Array.from(validSkills), // Skills granted (primary authorization)
// ... other fields
}
});
Expand Down
2 changes: 1 addition & 1 deletion docs/releases/cloudflare.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const mcpHandler: ExportedHandler<Env> = {
userId: oauthCtx.props.userId,
clientId: oauthCtx.props.clientId,
accessToken: oauthCtx.props.accessToken,
grantedScopes: expandedScopes,
grantedSkills, // Primary authorization method
constraints: verification.constraints,
sentryHost,
mcpUrl: oauthCtx.props.mcpUrl,
Expand Down
3 changes: 2 additions & 1 deletion docs/security.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ interface ServerContext {
userId?: string;
clientId: string;
accessToken: string;
grantedScopes?: Set<Scope>;
grantedSkills: Set<Skill>; // Primary authorization method
// grantedScopes is deprecated and will be removed Jan 1, 2026
constraints: Constraints;
sentryHost: string;
mcpUrl?: string;
Expand Down
52 changes: 51 additions & 1 deletion packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("mcp-handler", () => {
id: "test-user-123",
clientId: "test-client",
accessToken: "test-token",
grantedScopes: ["org:read", "project:read"],
grantedSkills: ["inspect", "docs"],
},
};
});
Expand Down Expand Up @@ -109,4 +109,54 @@ describe("mcp-handler", () => {
// Verify successful response
expect(response.status).toBe(200);
});

it("returns 401 and revokes grant for legacy tokens without grantedSkills", async () => {
const legacyCtx = {
waitUntil: vi.fn(),
passThroughOnException: vi.fn(),
props: {
id: "test-user-123",
clientId: "test-client",
accessToken: "test-token",
// Legacy token: has grantedScopes but no grantedSkills
grantedScopes: ["org:read", "project:read"],
},
};

// Mock the OAuth provider for grant revocation
const mockRevokeGrant = vi.fn();
const mockListUserGrants = vi.fn().mockResolvedValue({
items: [{ id: "grant-123", clientId: "test-client" }],
});
const envWithOAuth = {
...env,
OAUTH_PROVIDER: {
listUserGrants: mockListUserGrants,
revokeGrant: mockRevokeGrant,
},
} as unknown as Env;

const request = new Request("https://test.mcp.sentry.io/mcp");

const response = await handler.fetch!(
request as any,
envWithOAuth,
legacyCtx as any,
);

// Verify 401 response with re-auth message
expect(response.status).toBe(401);
expect(await response.text()).toContain("re-authorize");

// Verify waitUntil was called for background grant revocation
expect(legacyCtx.waitUntil).toHaveBeenCalled();

// Wait for the background task to complete
const waitUntilPromise = legacyCtx.waitUntil.mock.calls[0][0];
await waitUntilPromise;

// Verify grant was looked up and revoked
expect(mockListUserGrants).toHaveBeenCalledWith("test-user-123");
expect(mockRevokeGrant).toHaveBeenCalledWith("grant-123", "test-user-123");
});
});
116 changes: 51 additions & 65 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,8 @@

import { createMcpHandler } from "agents/mcp";
import { buildServer } from "@sentry/mcp-core/server";
import {
expandScopes,
parseScopes,
type Scope,
} from "@sentry/mcp-core/permissions";
import { parseSkills, type Skill } from "@sentry/mcp-core/skills";
import { logIssue, logWarn } from "@sentry/mcp-core/telem/logging";
import { parseSkills } from "@sentry/mcp-core/skills";
import { logWarn } from "@sentry/mcp-core/telem/logging";
import type { ServerContext } from "@sentry/mcp-core/types";
import type { Env } from "../types";
import { verifyConstraintsAccess } from "./constraint-utils";
Expand Down Expand Up @@ -85,67 +80,61 @@ const mcpHandler: ExportedHandler<Env> = {
});
}

// Parse and expand granted scopes (LEGACY - for backward compatibility)
let expandedScopes: Set<Scope> | undefined;
if (oauthCtx.props.grantedScopes) {
const { valid, invalid } = parseScopes(
oauthCtx.props.grantedScopes as string[],
// Parse and validate granted skills (primary authorization method)
// Legacy tokens without grantedSkills are no longer supported
if (!oauthCtx.props.grantedSkills) {
const userId = oauthCtx.props.id as string;
const clientId = oauthCtx.props.clientId as string;

logWarn("Legacy token without grantedSkills detected - revoking grant", {
loggerScope: ["cloudflare", "mcp-handler"],
extra: { clientId, userId },
});

// Revoke the grant in the background (don't block the response)
ctx.waitUntil(
(async () => {
try {
// Find the grant for this user/client combination
const grants = await env.OAUTH_PROVIDER.listUserGrants(userId);
const grant = grants.items.find((g) => g.clientId === clientId);

if (grant) {
await env.OAUTH_PROVIDER.revokeGrant(grant.id, userId);
}
} catch (err) {
logWarn("Failed to revoke legacy grant", {
loggerScope: ["cloudflare", "mcp-handler"],
extra: { error: String(err), clientId, userId },
});
}
})(),
);
if (invalid.length > 0) {
logWarn("Ignoring invalid scopes from OAuth provider", {
loggerScope: ["cloudflare", "mcp-handler"],
extra: {
invalidScopes: invalid,
},
});
}
expandedScopes = expandScopes(new Set(valid));
}

// Parse and validate granted skills (NEW - primary authorization method)
let grantedSkills: Set<Skill> | undefined;
if (oauthCtx.props.grantedSkills) {
const { valid, invalid } = parseSkills(
oauthCtx.props.grantedSkills as string[],
return new Response(
"Your authorization has expired. Please re-authorize to continue using Sentry MCP.",
{ status: 401 },
);
if (invalid.length > 0) {
logWarn("Ignoring invalid skills from OAuth provider", {
loggerScope: ["cloudflare", "mcp-handler"],
extra: {
invalidSkills: invalid,
},
});
}
grantedSkills = new Set(valid);

// Validate that at least one valid skill was granted
if (grantedSkills.size === 0) {
return new Response(
"Authorization failed: No valid skills were granted. Please re-authorize and select at least one permission.",
{ status: 400 },
);
}
}

// Validate that at least one authorization system is active
// This should never happen in practice - indicates a bug in OAuth flow
if (!grantedSkills && !expandedScopes) {
logIssue(
new Error(
"No authorization grants found - server would expose no tools",
),
{
loggerScope: ["cloudflare", "mcp-handler"],
extra: {
clientId: oauthCtx.props.clientId,
hasGrantedSkills: !!oauthCtx.props.grantedSkills,
hasGrantedScopes: !!oauthCtx.props.grantedScopes,
},
const { valid: validSkills, invalid: invalidSkills } = parseSkills(
oauthCtx.props.grantedSkills as string[],
);

if (invalidSkills.length > 0) {
logWarn("Ignoring invalid skills from OAuth provider", {
loggerScope: ["cloudflare", "mcp-handler"],
extra: {
invalidSkills,
},
);
});
}

// Validate that at least one valid skill was granted
if (validSkills.size === 0) {
return new Response(
"Authorization failed: No valid permissions were granted. Please re-authorize and select at least one permission.",
{ status: 401 },
"Authorization failed: No valid skills were granted. Please re-authorize and select at least one permission.",
{ status: 400 },
);
}

Expand All @@ -154,10 +143,7 @@ const mcpHandler: ExportedHandler<Env> = {
userId: oauthCtx.props.id as string | undefined,
clientId: oauthCtx.props.clientId as string,
accessToken: oauthCtx.props.accessToken as string,
// Scopes derived from skills - for backward compatibility with old MCP clients
// that don't support grantedSkills and only understand grantedScopes
grantedScopes: expandedScopes,
grantedSkills, // Primary authorization method
grantedSkills: validSkills,
constraints: verification.constraints,
sentryHost,
mcpUrl: env.MCP_URL,
Expand Down
8 changes: 1 addition & 7 deletions packages/mcp-cloudflare/src/server/oauth/routes/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { SENTRY_TOKEN_URL } from "../constants";
import { exchangeCodeForAccessToken } from "../helpers";
import { verifyAndParseState, type OAuthState } from "../state";
import { logWarn } from "@sentry/mcp-core/telem/logging";
import { parseSkills, getScopesForSkills } from "@sentry/mcp-core/skills";
import { parseSkills } from "@sentry/mcp-core/skills";

/**
* Extended AuthRequest that includes skills
Expand Down Expand Up @@ -151,9 +151,6 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => {
);
}

// Calculate Sentry API scopes from validated skills
const grantedScopes = await getScopesForSkills(validSkills);

// Convert valid skills Set to array for OAuth props
const grantedSkills = Array.from(validSkills);

Expand All @@ -178,9 +175,6 @@ export default new Hono<{ Bindings: Env }>().get("/", async (c) => {
accessTokenExpiresAt: Date.now() + payload.expires_in * 1000,
clientId: oauthReqInfo.clientId,
scope: oauthReqInfo.scope.join(" "),
// Scopes derived from skills - for backward compatibility with old MCP clients
// that don't support grantedSkills and only understand grantedScopes
grantedScopes: Array.from(grantedScopes),
grantedSkills, // Primary authorization method

// Note: constraints are NOT included here - they're extracted per-request from URL
Expand Down
10 changes: 7 additions & 3 deletions packages/mcp-cloudflare/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ export type WorkerProps = {
accessTokenExpiresAt?: number; // Timestamp when the upstream access token expires
clientId: string;
scope: string;
// Scopes derived from skills - for backward compatibility with old MCP clients
// that don't support grantedSkills and only understand grantedScopes
/**
* @deprecated grantedScopes is deprecated and will be removed on Jan 1, 2026.
* Use grantedSkills instead. Skills are the primary authorization method.
* This field exists only for backward compatibility during the transition period.
*/
grantedScopes?: string[];
grantedSkills?: string[]; // Array of skill strings (primary authorization method)
/** Primary authorization method - array of skill strings */
grantedSkills?: string[];

// Note: constraints are NOT included - they're extracted per-request from URL
// Note: sentryHost and mcpUrl come from env, not OAuth props
Expand Down
Loading
Loading