Skip to content
Closed
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
51 changes: 27 additions & 24 deletions docs/operations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,27 @@ not mean the same thing.

### Upstream Sentry Scopes

When we redirect to Sentry OAuth, we always request the shared Sentry scope set
defined in `packages/mcp-core/src/scopes.ts`:
When we redirect to Sentry OAuth, we request only the scopes needed by the
tools the user's selected skills enable. `getScopesForSkills()` in
`packages/mcp-core/src/skills.ts` computes them from each tool's
`requiredScopes`, plus the default read scopes
(`org:read project:read team:read event:read`).

For example, approving only **Inspect Issues & Events** requests:

```text
org:read project:write team:write event:write alerts:write
event:read org:read project:read team:read
```

We ask Sentry for this broader shared token because:

1. Sentry OAuth scopes are coarse compared to our MCP capability model.
2. A single upstream token must support every tool that could be enabled for the granted MCP skills.
3. The worker reuses the same upstream token across later MCP token refreshes and MCP requests for that grant.
Triage, Seer, and Manage Projects & Teams add the write scopes their tools need
(for example `event:write` for triage and Seer, and `project:write`,
`team:write`, and `alerts:write` for project management).

This token is therefore a server-side capability token for talking to Sentry,
not the final permission boundary presented to the MCP client.
The upstream token is still a server-side capability token for talking to
Sentry. It is reused across MCP token refreshes for the same grant, so the
skills chosen at consent time fix its scopes. Granting more skills later goes
through the OAuth flow again, and Sentry asks the user to approve any new
scopes.

### Downstream MCP Restrictions

Expand Down Expand Up @@ -122,7 +128,7 @@ The remote deployment intentionally separates trust boundaries:
- All Sentry API access happens server-side

3. **Session narrowing**
- Broad upstream Sentry scopes are narrowed by MCP-side skills and URL constraints
- Upstream Sentry scopes match the granted skills, and MCP-side skills and URL constraints narrow access further
- A client may hold a valid MCP token but still be unable to access tools or resources outside the granted session shape

4. **Revocation on upstream failure**
Expand Down Expand Up @@ -182,7 +188,7 @@ interface ServerContext {
userIpAddress?: string;
clientId: string;
accessToken: string;
grantedSkills: Set<Skill>; // Primary authorization method
grantedSkills: Set<Skill>; // Primary authorization method
// grantedScopes is deprecated and will be removed Jan 1, 2026
constraints: Constraints;
sentryHost: string;
Expand Down Expand Up @@ -240,7 +246,7 @@ Example protection in tools:

```typescript
// URLs must be valid and from expected domains
if (!issueUrl.includes('sentry.io')) {
if (!issueUrl.includes("sentry.io")) {
throw new UserInputError("Invalid Sentry URL");
}

Expand All @@ -257,10 +263,10 @@ The OAuth `state` is a compact HMAC-signed payload with a 10‑minute expiry:
type OAuthState = {
clientId: string;
redirectUri: string; // must be a valid URL
scope: string[]; // from OAuth provider parseAuthRequest
scope: string[]; // from OAuth provider parseAuthRequest
permissions?: string[]; // user selections from approval
iat: number; // issued at (ms)
exp: number; // expires at (ms)
iat: number; // issued at (ms)
exp: number; // expires at (ms)
};

// Sign: `${hex(hmacSHA256(json))}.${btoa(json)}` using COOKIE_SECRET
Expand All @@ -285,11 +291,11 @@ All user inputs sanitized:
// Signed cookie for approved clients
const cookie = await signCookie(
`approved_clients=${JSON.stringify(approvedClients)}`,
COOKIE_SECRET
COOKIE_SECRET,
);

// Cookie attributes
"HttpOnly; Secure; SameSite=Lax; Max-Age=2592000" // 30 days
("HttpOnly; Secure; SameSite=Lax; Max-Age=2592000"); // 30 days
```

## Error Handling
Expand Down Expand Up @@ -324,7 +330,7 @@ catch (error) {
```typescript
// Verify organization access
const { organizations } = await apiService.listOrganizations();
if (!organizations.find(org => org.slug === requestedOrg)) {
if (!organizations.find((org) => org.slug === requestedOrg)) {
throw new UserInputError("No access to organization");
}
```
Expand All @@ -335,18 +341,15 @@ Required for OAuth:

```bash
SENTRY_CLIENT_ID=your_oauth_app_id
SENTRY_CLIENT_SECRET=your_oauth_app_secret
SENTRY_CLIENT_SECRET=your_oauth_app_secret
COOKIE_SECRET=random_32_char_string
```

## CORS Configuration

```typescript
// Allowed origins for OAuth flow
const ALLOWED_ORIGINS = [
"https://sentry.io",
"https://*.sentry.io"
];
const ALLOWED_ORIGINS = ["https://sentry.io", "https://*.sentry.io"];
```

## References
Expand Down
75 changes: 75 additions & 0 deletions packages/mcp-cloudflare/src/server/oauth/authorize.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AuthorizationError } from "@cloudflare/workers-oauth-provider";
import { Hono } from "hono";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type Skill, getScopesForSkills } from "@sentry/mcp-core/skills";
import { SKILL_PREFERENCES_COOKIE_NAME } from "../lib/approval-dialog";
import type { Env } from "../types";
import oauthRoute from "./index";
Expand Down Expand Up @@ -466,6 +467,80 @@ describe("oauth authorize routes", () => {
expect((decodedState.req as any).scope).toEqual(["read", "write"]);
});

describe("upstream Sentry scopes match selected skills", () => {
async function upstreamScopesFor(skills: string[]): Promise<string[]> {
const formData = new FormData();
const signedState = await signState(
{
req: {
oauthReqInfo: {
clientId: "test-client",
redirectUri: "https://example.com/callback",
scope: ["read"],
state: "original-state",
},
},
iat: Date.now(),
exp: Date.now() + 10 * 60 * 1000,
},
testEnv.COOKIE_SECRET!,
);
formData.append("state", signedState);
for (const skill of skills) {
formData.append("skill", skill);
}
const response = await app.fetch(
new Request("http://localhost/oauth/authorize", {
method: "POST",
body: formData,
}),
testEnv as Env,
);
expect(response.status).toBe(302);
const redirectUrl = new URL(response.headers.get("location")!);
expect(redirectUrl.pathname).toBe("/oauth/authorize/");
return (redirectUrl.searchParams.get("scope") ?? "")
.split(" ")
.filter(Boolean)
.sort();
}

it("requests exactly the read scopes from Sentry when only 'inspect' is selected", async () => {
expect(await upstreamScopesFor(["inspect"])).toEqual([
"event:read",
"org:read",
"project:read",
"team:read",
]);
});

it("requests event:write when Seer is selected, for starting autofix", async () => {
expect(await upstreamScopesFor(["inspect", "seer"])).toEqual([
"event:read",
"event:write",
"org:read",
"project:read",
"team:read",
]);
});

it.each([
[["inspect"]],
[["inspect", "seer"]],
[["inspect", "triage"]],
[["inspect", "project-management"]],
])(
"requests exactly getScopesForSkills(%j) from Sentry",
async (skills) => {
const expected = Array.from(
await getScopesForSkills(new Set(skills as Skill[])),
).sort();

expect(await upstreamScopesFor(skills)).toEqual(expected);
},
);
});

it("uses submitted skills rather than remembered skills for redirect state", async () => {
mockOAuthProvider.lookupClient.mockResolvedValue({
clientId: "test-client",
Expand Down
19 changes: 17 additions & 2 deletions packages/mcp-cloudflare/src/server/oauth/routes/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {
type AuthRequest,
} from "@cloudflare/workers-oauth-provider";
import * as Sentry from "@sentry/cloudflare";
import { getScopesForSkills, parseSkills } from "@sentry/mcp-core/skills";
import { logWarn } from "@sentry/mcp-core/telem/logging";
import { Hono } from "hono";
import { SCOPES } from "../../../constants";
import {
getRememberedSkillsForClient,
parseRedirectApproval,
Expand Down Expand Up @@ -38,10 +38,24 @@ interface AuthRequestWithSkills extends AuthRequest {
resource?: string;
}

/**
* Sentry API scopes to request upstream for the skills the user approved.
*
* Only ask Sentry for what the enabled tools need, so the consent screen and
* the resulting upstream token match the selected skills.
*/
async function getUpstreamScope(skills: unknown): Promise<string> {
const { valid } = parseSkills(skills);
return Array.from(await getScopesForSkills(valid))
.sort()
.join(" ");
}

async function redirectToUpstream(
env: Env,
request: Request,
oauthReqInfo: AuthRequest | AuthRequestWithSkills,
scope: string,
headers: HeadersInit = {},
stateOverride?: string,
) {
Expand All @@ -53,7 +67,7 @@ async function redirectToUpstream(
SENTRY_AUTH_URL,
`https://${env.SENTRY_HOST || "sentry.io"}`,
).href,
scope: Object.keys(SCOPES).join(" "),
scope,
client_id: env.SENTRY_CLIENT_ID,
redirect_uri: new URL("/oauth/callback", request.url).href,
state: stateOverride ?? btoa(JSON.stringify(oauthReqInfo)),
Expand Down Expand Up @@ -379,6 +393,7 @@ export default new Hono<{ Bindings: Env }>()
c.env,
c.req.raw,
oauthReqWithSkills,
await getUpstreamScope(skills),
headers,
signedState,
);
Expand Down
Loading
Loading