diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index c04bed4648c..04c650f7f11 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -1712,27 +1712,31 @@ The broker implements this in `discoverOAuthEndpoints` unconditional. `oauthEndpointsForConnection` resolves endpoints in this order: -1. If the manifest's method `defaults` ship a **complete** pair - (`authorizationEndpoint` **and** `tokenEndpoint`), those are used - unconditionally. `discoverOAuthEndpoints` never runs in this case, so - endpoints stored on the connection's own OAuth config and 401 challenge - hints are **not consulted at all**. -2. Otherwise, for `mcp_remote` connections, the broker calls +1. A method's `defaults.discoveryUrl` is authoritative. The broker resolves + that document before cached endpoints, including when a saved connection + resumes or reconnects. It must describe the MCP resource or its authorization + server, not a separate web/API OAuth service. Failure stops setup. +2. A complete manifest pair (`authorizationEndpoint` and `tokenEndpoint`) + replaces discovery when the method does not support dynamic registration, + or when the connection uses an operator-entered client. A method that can + register automatically still discovers its MCP authorization server. +3. Otherwise, for `mcp_remote` connections, the broker calls `discoverOAuthEndpoints`, which first checks endpoints already stored on the connection's own OAuth config (falling back field-by-field to the 401 challenge hints); a complete stored/hinted pair is used as-is — no `.well-known` fetch. -3. Only when neither of the above yields a complete pair does the broker run +4. Only when none of the above yields a complete pair does the broker run the RFC 9728 → RFC 8414 discovery chain above. -Consequence: complete manifest endpoint hints are **authoritative, not -hints** — they override even endpoints that an earlier discovery persisted -on the connection, and if they go stale the broker keeps using them. For -discovery-capable vendors, ship only `serverUrl` in `defaults` (as -`notion.json` does) so the broker discovers fresh endpoints at connect -time; add explicit `authorizationEndpoint`/`tokenEndpoint` only for vendors -that do not publish RFC 9728/8414 metadata, and then own keeping them -current. +For discovery-capable vendors, prefer `serverUrl` alone (as `notion.json` +does), or pin the reviewed protected-resource document when the provider +requires an exact path. Keep explicit metadata and endpoint URLs current. +When changing discovery precedence, test each affected catalog method against +its actual metadata shape. Cover both new setup and a saved registration. +A metadata preflight that finds registration somewhere in its search is not +proof that sign-in selects that issuer. Sentry's web/API and MCP hosts both +serve valid metadata, but only its MCP issuer supports automatic registration; +see [Sentry](./SENTRY.md). ### Dynamic client registration (RFC 7591) diff --git a/doc/connections/README.md b/doc/connections/README.md index 829ff427b52..df86a57e61a 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), -[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), +[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md), [AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and [Enterpret](./ENTERPRET.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/SENTRY.md b/doc/connections/SENTRY.md new file mode 100644 index 00000000000..8a05839ee3d --- /dev/null +++ b/doc/connections/SENTRY.md @@ -0,0 +1,44 @@ +# Sentry + +Paperclip connects to Sentry's [official MCP server](https://mcp.sentry.dev/) +at `https://mcp.sentry.dev/mcp`. Browser sign-in uses the MCP authorization +server. A normal connection does not require a customer-created OAuth app. + +## Discovery + +The method pins the [MCP protected-resource metadata](https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp). +That document names `https://mcp.sentry.dev` as its authorization server. +Its [authorization-server metadata](https://mcp.sentry.dev/.well-known/oauth-authorization-server) +advertises automatic client registration, PKCE S256, refresh tokens, and +client ID metadata documents. Paperclip retains its normal client selection +and binding checks. + +Do not use `https://sentry.io/.well-known/oauth-authorization-server` for this +method. That server describes the web/API OAuth service. It does not advertise +MCP client registration. Both metadata URLs return valid JSON, so checking +only HTTP status or the presence of authorization/token endpoints misses this +failure. + +## Repair an existing connection + +After deploying the corrected catalog, resume or reconnect the existing Sentry +connection. Keep its connection identity and access policies. Complete browser +consent if required. Confirm the intended organization and projects through a +read-only tool call from the assigned agent. + +The catalog repair fixes setup and reconnect discovery. It cannot restore an +expired or revoked grant without the owner's consent. If the saved connection +changes to **Finish setup**, inspect its health error, refresh outcome, and +transition timestamp separately. A setup error alone does not prove why the +previous grant stopped working. + +## Verification + +The service regression uses separate, realistic metadata for both Sentry +hosts. It checks new registration and reuse of an existing MCP registration, +the authorization URL, resource, requested scopes, and PKCE. These fixtures +prove Paperclip's endpoint selection; they do not prove live account consent. + +Before claiming restoration, verify browser consent, catalog discovery, and a +real agent read. Record token refresh separately. Do not publish credentials +or unredacted authorization URLs in evidence. diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index f92d1f1e6d8..ef162f11466 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -689,6 +689,13 @@ describe("AppDefinition catalog", () => { consoleLinks: { register: "https://app.asana.com/0/my-apps" }, }); expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]); + expect(method("sentry")).toMatchObject({ + key: "mcp-oauth", + defaults: { + serverUrl: "https://mcp.sentry.dev/mcp", + discoveryUrl: "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp", + }, + }); expect(method("zapier")).toMatchObject({ key: "generated-url", auth: "none", diff --git a/packages/shared/src/app-definitions/sentry.json b/packages/shared/src/app-definitions/sentry.json index b626e6e870d..5a85dd024f1 100644 --- a/packages/shared/src/app-definitions/sentry.json +++ b/packages/shared/src/app-definitions/sentry.json @@ -26,7 +26,7 @@ "whenToUse": "Use the provider-hosted connection for the quickest setup.", "defaults": { "serverUrl": "https://mcp.sentry.dev/mcp", - "discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server", + "discoveryUrl": "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp", "scopesHint": [ "org:read", "project:write", diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 73151fa9125..e8be36185f1 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -783,7 +783,7 @@ const apps = [ { serverUrl: "https://mcp.sentry.dev/mcp", discoveryUrl: - "https://sentry.io/.well-known/oauth-authorization-server", + "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp", }, "S2", "Connect the Sentry organization and projects agents need for incident work.", diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 97a9be58ec2..84b3152e3bf 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -12952,6 +12952,91 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it.each(["new", "saved"])("starts Sentry MCP sign-in for a %s connection without a manual OAuth app", async (state) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "sentry", connectionMethodKey: "mcp-oauth", name: "Sentry", + }); + const redirectUri = "http://localhost:3200/api/tools/oauth/callback"; + const issuer = "https://mcp.sentry.dev"; + const resource = `${issuer}/mcp`; + const scopes = ["org:read", "project:write", "team:write", "event:write", "alerts:write"]; + if (state === "saved") { + const [connection] = await db.select().from(toolConnections) + .where(eq(toolConnections.id, connected.connectionId)); + // An existing MCP registration must survive catalog changes. This is the + // state before a reconnect, with endpoints cached by successful discovery. + await db.update(toolConnections).set({ config: { + ...connection.config, + oauth: { + ...connection.config.oauth as Record, + provider: "sentry", issuer, resource, + authorizationUrl: `${issuer}/oauth/authorize`, tokenUrl: `${issuer}/oauth/token`, + metadataUrl: `${issuer}/.well-known/oauth-authorization-server`, + clientId: "saved-sentry-client", clientRegistrationSource: "dcr", + clientIssuer: issuer, clientResource: resource, + clientCompanyId: company.id, clientRedirectUri: redirectUri, + clientTokenEndpointAuthMethod: "none", + }, + } }).where(eq(toolConnections.id, connected.connectionId)); + } + const calls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const href = String(url); + calls.push(href); + // Both hosts publish valid OAuth metadata. The web/API issuer cannot + // register an MCP client; discovery must select the resource's issuer. + if (href === "https://sentry.io/.well-known/oauth-authorization-server") { + return mcpHttpResponse({ + issuer: "https://sentry.io", authorization_endpoint: "https://sentry.io/oauth/authorize/", + token_endpoint: "https://sentry.io/oauth/token/", code_challenge_methods_supported: ["S256"], + }); + } + if (href === `${issuer}/.well-known/oauth-protected-resource/mcp`) { + return mcpHttpResponse({ resource, authorization_servers: [issuer], scopes_supported: scopes }); + } + if (href === `${issuer}/.well-known/oauth-authorization-server`) { + return mcpHttpResponse({ + issuer, authorization_endpoint: `${issuer}/oauth/authorize`, token_endpoint: `${issuer}/oauth/token`, + registration_endpoint: `${issuer}/oauth/register`, scopes_supported: scopes, + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"], + client_id_metadata_document_supported: true, + }); + } + if (href === `${issuer}/oauth/register`) { + expect(JSON.parse(String(init?.body))).toMatchObject({ + redirect_uris: [redirectUri], token_endpoint_auth_method: "none", + }); + return mcpHttpResponse({ + client_id: "new-sentry-client", redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], + token_endpoint_auth_method: "none", + }); + } + throw new Error(`unexpected fetch ${href}`); + }); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri, actor: { actorType: "user", actorId: "board" }, + }); + const authorization = new URL(started.authorizationUrl); + expect(authorization.origin + authorization.pathname).toBe(`${issuer}/oauth/authorize`); + expect(authorization.searchParams.get("resource")).toBe(resource); + expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(scopes); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorization.searchParams.get("client_id")).toBe(`${state}-sentry-client`); + expect(calls).not.toContain("https://sentry.io/.well-known/oauth-authorization-server"); + expect(calls.filter((url) => url === `${issuer}/oauth/register`)).toHaveLength(state === "new" ? 1 : 0); + const [saved] = await db.select().from(toolConnections) + .where(eq(toolConnections.id, connected.connectionId)); + expect(saved.config.oauth).toMatchObject({ + clientId: `${state}-sentry-client`, clientIssuer: issuer, clientResource: resource, + clientCompanyId: company.id, clientRedirectUri: redirectUri, + }); + }); + it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db);