mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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:
1 parent
4f680bc85c
commit
07f3536e37
7 files changed
+158
-18
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user