diff --git a/src/lib/discover.test.ts b/src/lib/discover.test.ts index 3218437c..e5fc3a1d 100644 --- a/src/lib/discover.test.ts +++ b/src/lib/discover.test.ts @@ -141,6 +141,104 @@ describe("discover llms.txt seed facts", () => { }); describe("discover MCP onboarding overrides", () => { + test("binds detected MCP OAuth to the credential declared for that surface", async () => { + const source = "https://samva.dev/.well-known/integrations.json"; + const detect: DetectionResult = { + ...mcpDetection({ + url: "https://mcp.samva.dev/mcp", + authorizationServer: "https://mcp.samva.dev", + authorizationServerMetadataFetched: true, + cimd: true, + }), + domain: "samva.dev", + integrationsJson: { + url: source, + result: { + version: 3, + credentials: { + samva_mcp_oauth: { + type: "oauth2", + label: "Samva MCP OAuth", + setup: "Connect an OAuth-capable MCP client to the hosted MCP URL.", + }, + samva_cli_oauth: { + type: "oauth2", + label: "Samva CLI login", + setup: "Run `samva login` to complete the device flow.", + }, + }, + surfaces: [ + { + slug: "samva-mcp", + name: "Samva hosted MCP", + type: "mcp", + url: "https://mcp.samva.dev/mcp", + basis: { via: "declared", source }, + auth: { + status: "required", + entries: [ + { + use: [{ id: "samva_mcp_oauth", mechanics: { source: "well-known" } }], + basis: { via: "declared", source }, + }, + ], + }, + }, + { + slug: "samva-cli", + name: "Samva CLI", + type: "cli", + command: "samva", + basis: { via: "declared", source }, + auth: { + status: "required", + entries: [ + { + use: [{ id: "samva_cli_oauth", mechanics: { source: "cli", command: "samva login" } }], + basis: { via: "declared", source }, + }, + ], + }, + }, + ], + }, + }, + }; + const chat: ChatFn = async () => ({ + message: { role: "assistant", content: null }, + toolCalls: [ + { + id: "cred-1", + name: "record_credential", + arguments: { + id: "samva_cli_oauth", + type: "oauth2", + label: "Samva CLI login", + setup: "Run `samva login` to complete the device flow.", + }, + }, + { + id: "finish-1", + name: "finish", + arguments: { + summary: "Samva exposes MCP and CLI surfaces.", + description: "Samva is an email API.", + }, + }, + ], + }); + + const result = await discover(detect.domain, detect, chat, web); + if (!result) throw new Error("discover returned null"); + const mcp = result.surfaces.find((surface) => surface.type === "mcp"); + + expect(mcp?.auth.status).toBe("required"); + if (mcp?.auth.status !== "required") throw new Error("MCP auth was not required"); + expect(mcp.auth.entries[0]?.use[0]?.id).toBe("samva_mcp_oauth"); + expect(result.credentials.samva_mcp_oauth?.setup).toContain("Client ID Metadata Document"); + expect(result.credentials.samva_cli_oauth?.setup).toBe("Run `samva login` to complete the device flow."); + }); + test("rewrites Slack no-DCR MCP setup to manual registration with the manifest deep link", async () => { const result = await discoverMcp(mcpDetection({ authorizationServerMetadataFetched: true, dcr: false, cimd: false })); const setup = setupFrom(result); diff --git a/src/lib/discover.ts b/src/lib/discover.ts index 021f9857..fd2118bf 100644 --- a/src/lib/discover.ts +++ b/src/lib/discover.ts @@ -838,8 +838,33 @@ function merge(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): Discov const oauth = detect.auth?.oauth; const hasDetOauth = oauth && (oauth.authorizationEndpoint || oauth.tokenEndpoint || oauth.registrationEndpoint || oauth.scopes?.length); - // Ensure a detected OAuth credential exists (referenced by detected MCP auth). - const ensureOauthCred = (): string => { + const declaredMcpOauthCredential = (mcpUrl: string): readonly [string, Credential] | undefined => { + const declared = detect.integrationsJson?.result; + const surface = declared?.surfaces?.find((candidate) => candidate.type === "mcp" && candidate.url === mcpUrl); + if (surface?.auth.status !== "required") return undefined; + for (const entry of surface.auth.entries) { + for (const use of entry.use) { + const credential = declared?.credentials?.[use.id]; + if (credential && (/oauth/i.test(credential.type) || /oauth/i.test(credential.label))) { + return [use.id, credential]; + } + } + } + return undefined; + }; + + // Preserve the OAuth identity that the owner bound to this MCP surface before + // falling back to a model-discovered, domain-wide OAuth credential. + const ensureOauthCred = (mcpUrl: string): string => { + const declared = declaredMcpOauthCredential(mcpUrl); + if (declared) { + const [id, credential] = declared; + if (!r.credentials[id]) { + r.credentials[id] = cloneJson(credential); + emit?.({ kind: "credential", id, credential: r.credentials[id] }); + } + return id; + } const existing = Object.entries(r.credentials).find(([, c]) => /oauth/i.test(c.type) || /oauth/i.test(c.label)); if (existing) return existing[0]; const id = "oauth"; @@ -864,7 +889,7 @@ function merge(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): Discov // developer portal, copy client_id/client_secret" story the model wrote from // docs — and bind the surface to it as a detected signal. const bindMcpSelfOnboard = (surface: Surface, mcp: McpDetection): void => { - const id = ensureOauthCred(); + const id = ensureOauthCred(mcp.url); const c = r.credentials[id]; c.type = "oauth2"; c.label = "OAuth 2.0"; @@ -895,7 +920,7 @@ function merge(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): Discov const isMcpManualOnboard = (mcp: McpDetection): boolean => mcp.authorizationServerMetadataFetched === true && !mcp.dcr && !mcp.cimd; const bindMcpManualOnboard = (surface: Surface, mcp: McpDetection): void => { - const id = ensureOauthCred(); + const id = ensureOauthCred(mcp.url); const c = r.credentials[id]; const hostKnowledge = mcpHostKnowledge(mcp.url); c.type = "oauth2"; @@ -922,7 +947,7 @@ function merge(r: DiscoveryResult, detect: DetectionResult, emit?: Emit): Discov } const auth: AuthStatus = hasDetOauth || mcp.auth - ? { status: "required", entries: [{ use: [{ id: ensureOauthCred(), mechanics: { source: "well-known" } }], basis: { via: "detected", signal: "oauth-protected-resource", verifiedAt } }] } + ? { status: "required", entries: [{ use: [{ id: ensureOauthCred(mcp.url), mechanics: { source: "well-known" } }], basis: { via: "detected", signal: "oauth-protected-resource", verifiedAt } }] } : { status: "unknown" }; const s: Surface = { slug: assignSlug("MCP server", r.surfaces), name: "MCP server", type: "mcp", url: mcp.url, basis: { via: "detected", signal: "mcp:initialize", verifiedAt }, auth, notes: mcp.dcr || mcp.cimd ? "Self-onboarding (DCR/CIMD)" : undefined }; r.surfaces.unshift(s);