fix(connections): discover Sentry OAuth through its MCP resource (#15837)

Correct Sentry OAuth discovery and cover new and saved client registration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-10 17:10:23 -07:00
1 parent 4f680bc85c
commit 07f3536e37
7 files changed
+158 -18

No files matched your search

+19 -15
View File
@@ -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)
+1 -1
View File
@@ -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).
+44
View File
@@ -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.
@@ -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",
@@ -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",
+1 -1
View File
@@ -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.",
@@ -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<string, unknown>,
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);