From 873f4f6d88f8d32835cdb424d49d06fe73a04f35 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 26 Sep 2026 07:45:30 -0700 Subject: [PATCH] feat(hub)!: pin discovery to the credential's origin via @corbits/credential-http MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace @corbits/credential-mcp with @corbits/credential-http. Discovery that sends a secret now pins to the credential's provider apiBaseUrl origin, and a host allows extra origins per pinned origin with `extraOrigins`. No origin is allowed by default, which drops the built-in vendor allowance credential-mcp carried. A keyless credential pins to the URL's origin. OAuth discovery's fallback now requires the resource-served metadata to name the resource's own issuer (RFC 8414 §3.3). BREAKING CHANGE: readCredentialSecret is now readCredential and returns { secret, origin? }; discoverMcpServer takes `credential` instead of `secret`; a credential with a secret whose provider has no apiBaseUrl, or a URL off its origin without `extraOrigins`, is refused. --- AGENTS.md | 2 +- README.md | 67 +++++++++++----- bun.lock | 20 ++--- package.json | 8 +- src/hub/discover.test.ts | 155 +++++++++++++++++++++++++++++++----- src/hub/discover.ts | 88 ++++++++++++++------ src/hub/index.ts | 3 +- src/oauth-discovery.test.ts | 41 +++++++++- src/oauth-discovery.ts | 20 ++++- src/sidecar-bundle.test.ts | 2 +- 10 files changed, 316 insertions(+), 90 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 088b04e..a755145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ never hold a server's bearer. network work at construction: the names and the `ask` marks come from the stored catalog. 4. **At run time the agent only ever sees a mediated handle.** Each server's - credential resolves to an `http` handle (a fetch pinned to that server's + credential resolves to an `http` handle (a fetch pinned to the credential's origin that injects the bearer per request), so the token never reaches agent code, and a relative path is all the bundle ever asks for. diff --git a/README.md b/README.md index 019a8e9..cc27f55 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Runs on Node.js 24+ and Bun 1.2+. For the `@corbits/mcp/hub` routes, also install: ```bash -bun add @corbits/credential-mcp @intx/authz @intx/crypto @intx/db @intx/hub-api drizzle-orm hono +bun add @corbits/credential-http @intx/authz @intx/crypto @intx/db @intx/hub-api drizzle-orm hono ``` ## Quickstart @@ -46,25 +46,25 @@ Every client call takes `{ fetch }` as its last argument, for example a fetch th - **Hub:** `@corbits/mcp/hub` mounts a discovery route on an [`@intx/hub-api`](https://github.com/faremeter/interchange/tree/main/packages/hub-api) app and reads tenant credentials from [`@intx/db`](https://github.com/faremeter/interchange/tree/main/packages/db). - **Sidecar:** `@corbits/mcp/sidecar-bundle` builds [`@intx/agent`](https://github.com/faremeter/interchange/tree/main/packages/agent) tools from a stored catalog. -- **Pairs with:** [`@corbits/oauth-core`](https://github.com/corbitsdev/corbits-oauth-core) for the login flow and [`@corbits/credential-mcp`](https://github.com/corbitsdev/corbits-credential-mcp) for the origin-pinned credential provider. +- **Pairs with:** [`@corbits/oauth-core`](https://github.com/corbitsdev/corbits-oauth-core) for the login flow and [`@corbits/credential-http`](https://github.com/corbitsdev/credential-http) for the origin-pinned credential provider. ## Reference -| Export | Entry | Purpose | -| ------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `mcpInitialize(url, opts?)` | `@corbits/mcp` | Send `initialize`; returns `serverInfo` and `protocolVersion`. | -| `mcpListTools(url, opts?)` | `@corbits/mcp` | Send `tools/list`; returns `McpTool[]`. | -| `mcpCallTool(url, name, args, opts?)` | `@corbits/mcp` | Send `tools/call`; returns `McpToolResult`. | -| `mcpTools(options, env?)` | `@corbits/mcp` | Discover each server live, then build tools. For hosts that can await; servers use `name` and `credentialHandle`. | -| `qualifiedName`, `toolDescription`, `isAskExempt` | `@corbits/mcp` | Naming and `ask`-floor rules the tool builders use. | -| `discoverMcpLoginEntry({ resourceUrl })` | `@corbits/mcp` | Resolve the authorization server (RFC 9728, then RFC 8414). | -| `registerMcpClient(opts)` | `@corbits/mcp` | Register a loopback public client (RFC 7591). | -| `mcpClientConfig(entry, opts)` | `@corbits/mcp` | Build the `OAuthClientConfig` oauth-core's login helpers take. | -| `selectMcpScopes(opts)` | `@corbits/mcp` | Pick scopes in the MCP spec's selection order. | -| `mcpServers(config)` | `@corbits/mcp/sidecar-bundle` | Build tools from stored catalogs over mediated credentials. | -| `shapeMcpContent(content)`, `SIDECAR_BUNDLE_ID` | `@corbits/mcp/sidecar-bundle` | Result shaping and the bundle's tool id. | -| `mountMcpDiscovery(app, opts)` | `@corbits/mcp/hub` | Mount `POST /mcp/discover`. | -| `discoverMcpServer(args)`, `readCredentialSecret(opts)` | `@corbits/mcp/hub` | The discovery and credential reads the route uses. | +| Export | Entry | Purpose | +| ------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `mcpInitialize(url, opts?)` | `@corbits/mcp` | Send `initialize`; returns `serverInfo` and `protocolVersion`. | +| `mcpListTools(url, opts?)` | `@corbits/mcp` | Send `tools/list`; returns `McpTool[]`. | +| `mcpCallTool(url, name, args, opts?)` | `@corbits/mcp` | Send `tools/call`; returns `McpToolResult`. | +| `mcpTools(options, env?)` | `@corbits/mcp` | Discover each server live, then build tools. For hosts that can await; servers use `name` and `credentialHandle`. | +| `qualifiedName`, `toolDescription`, `isAskExempt` | `@corbits/mcp` | Naming and `ask`-floor rules the tool builders use. | +| `discoverMcpLoginEntry({ resourceUrl })` | `@corbits/mcp` | Resolve the authorization server (RFC 9728, then RFC 8414). | +| `registerMcpClient(opts)` | `@corbits/mcp` | Register a loopback public client (RFC 7591). | +| `mcpClientConfig(entry, opts)` | `@corbits/mcp` | Build the `OAuthClientConfig` oauth-core's login helpers take. | +| `selectMcpScopes(opts)` | `@corbits/mcp` | Pick scopes in the MCP spec's selection order. | +| `mcpServers(config)` | `@corbits/mcp/sidecar-bundle` | Build tools from stored catalogs over mediated credentials. | +| `shapeMcpContent(content)`, `SIDECAR_BUNDLE_ID` | `@corbits/mcp/sidecar-bundle` | Result shaping and the bundle's tool id. | +| `mountMcpDiscovery(app, opts)` | `@corbits/mcp/hub` | Mount `POST /mcp/discover`. | +| `discoverMcpServer(args)`, `readCredential(opts)` | `@corbits/mcp/hub` | The discovery and credential reads the route uses. | `McpToolSchema` validates a catalog entry (`McpTool`). Transport and protocol failures throw `McpError`. OAuth discovery failures throw `OAuthDiscoveryError` from `@corbits/oauth-core`. @@ -112,7 +112,32 @@ mountMcpDiscovery(mcpRoutes, { Mount `mcpRoutes` on the hub app under `/api/tenants/:tenantId`, behind the hub's auth and tenant middleware. -`POST /api/tenants/:tenantId/mcp/discover` with `{ url, credentialId? }` returns `{ data: { serverInfo, tools } }`. `url` must be https (http only on loopback). `credentialId` names a tenant credential whose secret is sent as a bearer; a credential holding `MCP_NO_TOKEN_SENTINEL` from `@corbits/credential-mcp` sends no `authorization` header. Errors: 400 for a bad body or URL, 404 for an unknown credential, 422 when the server fails discovery. `requireGrant` is the host's own grant middleware for this route. The fetch is pinned to the server's origin and refuses redirects, so the secret never leaves that origin. +`POST /api/tenants/:tenantId/mcp/discover` with `{ url, credentialId? }` returns `{ data: { serverInfo, tools } }`. `url` must be https (http only on loopback). `credentialId` names a tenant credential whose secret is sent as a bearer; a credential holding `MCP_NO_TOKEN_SENTINEL` from `@corbits/credential-http` sends no `authorization` header. Errors: 400 for a bad body or URL, 404 for an unknown credential, 422 when the server fails discovery or the request would leave the credential's origin. `requireGrant` is the host's own grant middleware for this route. + +When a secret is sent, the fetch is pinned to the origin of the credential's provider `apiBaseUrl`; with no credential or a keyless one, to the URL's origin. Redirects are always refused, so the secret never leaves that origin. + +#### Extra origins + +Some servers serve MCP on a different origin from the one their credential is issued for. Nothing is allowed off the pinned origin by default. The host lists extra origins per pinned origin in `extraOrigins`, which is passed through to `@corbits/credential-http`: + +```ts +mountMcpDiscovery(mcpRoutes, { + db, + cipher, + requireGrant: requireGrant("credential:*", "read"), + extraOrigins: { + "https://api.example.com": ["https://mcp.example.net"], + }, +}); +``` + +| Option | Type | Default | +| -------------- | --------------------------------------------- | ------- | +| `extraOrigins` | `Readonly>` | `{}` | + +A credential pinned to `https://api.example.com` may then be sent to `https://mcp.example.net`; no other credential can. + +`extraOrigins` is host-wide, not per tenant: an entry applies to every tenant's credential pinned to that origin, so list only origins you trust with all of them. `discoverMcpServer` takes the same option. For the sidecar bundle, configure the same allowance on the credential provider the host registers. ### Load the sidecar bundle @@ -136,7 +161,7 @@ export const tools = mcpServers({ Each catalog entry becomes a tool named `.`, with the remote `inputSchema` passed through and the call proxied to `tools/call`. Text content comes back as text and anything else as JSON; `isError` passes through. A handle that does not resolve, or a server that fails `initialize`, fails only that server's calls. -`handle` is the credential handle the host binds this server's token to. The bundle resolves it from the runtime `credentials` capability and expects an `http` credential, such as the one `@corbits/credential-mcp`'s provider returns. The package manifest declares the `mcp-server` credential for this. +`handle` is the credential handle the host binds this server's token to. The bundle resolves it from the runtime `credentials` capability and expects an `http` credential, such as the one `@corbits/credential-http`'s MCP provider returns. The package manifest declares the `mcp-server` credential for this. ### Grant access @@ -146,7 +171,9 @@ Grant the agent's principal `tool:deepwiki.*` for the whole server, or `tool:dee - Existing server configs, stored catalogs and the `mcp-server` credential keep working unchanged. - `@intx/harness` is a required peer. Import `FetchLike` from it; `@corbits/mcp` no longer exports that type. -- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `@corbits/credential-mcp` are optional peers. Install them if you use `@corbits/mcp/hub`. +- `@intx/db`, `@intx/hub-api`, `drizzle-orm`, `hono` and `@corbits/credential-http` are optional peers. Install them if you use `@corbits/mcp/hub`. `@corbits/credential-http` replaces `@corbits/credential-mcp`. +- Discovery with a credential pins to the credential's provider `apiBaseUrl` origin, not to the requested URL's origin. A credential with a secret whose provider has no `apiBaseUrl` is refused, and a URL on another origin needs `extraOrigins`. No origin is allowed by default. +- `readCredentialSecret` is now `readCredential`, which returns `{ secret, origin? }`. `discoverMcpServer` takes `credential: { secret, origin? }` instead of `secret`. - `@intx/agent` and `@intx/harness` peers are `^0.4.0`. - Discovery rejects authorization-server metadata whose `issuer` differs from the one the protected resource names. diff --git a/bun.lock b/bun.lock index 5c605f0..42e7853 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,12 @@ "": { "name": "@corbits/mcp", "dependencies": { - "@corbits/oauth-core": "^0.1.0", + "@corbits/oauth-core": "^0.2.0", "@intx/types": "^0.4.0", "arktype": "^2.2.3", }, "devDependencies": { - "@corbits/credential-mcp": "^0.1.0", + "@corbits/credential-http": "0.1.0", "@intx/agent": "0.4.0", "@intx/authz": "0.4.0", "@intx/db": "0.4.0", @@ -24,7 +24,7 @@ "typescript": "5.9.3", }, "peerDependencies": { - "@corbits/credential-mcp": "^0.1.0", + "@corbits/credential-http": "^0.1.0", "@intx/agent": "^0.4.0", "@intx/db": "^0.4.0", "@intx/harness": "^0.4.0", @@ -33,7 +33,7 @@ "hono": "^4.11.9", }, "optionalPeers": [ - "@corbits/credential-mcp", + "@corbits/credential-http", "@intx/db", "@intx/hub-api", "drizzle-orm", @@ -64,9 +64,9 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="], - "@corbits/credential-mcp": ["@corbits/credential-mcp@0.1.0", "", { "peerDependencies": { "@intx/harness": "^0.4.0", "@intx/types": ">=0.4.0" } }, "sha512-QVetVsrFHXzAoJGMASpdScHxtz7jshpn4Xg0ZtThifORYSG2DIg0SxmpJD/S8KUYEHx1jRrMiK5ncinJHg+i2Q=="], + "@corbits/credential-http": ["@corbits/credential-http@0.1.0", "", { "peerDependencies": { "@intx/harness": "^0.4.0", "@intx/types": "^0.4.0" } }, "sha512-/I4lKk9u3DhPhrBvMNRHFdpzHTwbfGwt/P6yGELby8YQ1qPxmhCQoZYyOqMWl4ONbiShMUHJrkAFi0EtzzgpyQ=="], - "@corbits/oauth-core": ["@corbits/oauth-core@0.1.0", "", { "dependencies": { "@intx/db": "^0.4.0", "@intx/hub-api": "^0.4.0", "@intx/hub-common": "^0.4.0", "@intx/types": "^0.4.0", "arktype": "2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "sha512-gS+5eRIAli4iPCaEBxolUuPhmH2G61ceVM09V2p7sytNAgWm6k93jXuoUw96Xx/+faFIyDrw5EMPmnH8Jh11Yg=="], + "@corbits/oauth-core": ["@corbits/oauth-core@0.2.0", "", { "dependencies": { "@intx/db": "^0.4.0", "@intx/hub-api": "^0.4.0", "@intx/hub-common": "^0.4.0", "@intx/types": "^0.4.0", "arktype": "^2.2.3", "drizzle-orm": "^0.45.2", "hono": "^4.11.9" } }, "sha512-1pNMfxPUALwwXzpBzate3hDs61DOmv+fWo8/+aWfMhgXTxq8xtqeYk7Ue4uf/mW+FT0fUV/9vC2hZHyrm8+8KA=="], "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], @@ -470,8 +470,6 @@ "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - "@corbits/oauth-core/arktype": ["arktype@2.2.3", "", { "dependencies": { "@ark/schema": "0.56.2", "@ark/util": "0.56.2", "arkregex": "0.0.8" } }, "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg=="], - "@npmcli/agent/lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -496,12 +494,6 @@ "path-scurry/lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], - "@corbits/oauth-core/arktype/@ark/schema": ["@ark/schema@0.56.2", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg=="], - - "@corbits/oauth-core/arktype/@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], - - "@corbits/oauth-core/arktype/arkregex": ["arkregex@0.0.8", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/package.json b/package.json index 1999cbe..d953432 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,12 @@ "check": "bun run typecheck && bun run lint && bun run format:check && bun run test" }, "dependencies": { - "@corbits/oauth-core": "^0.1.0", + "@corbits/oauth-core": "^0.2.0", "@intx/types": "^0.4.0", "arktype": "^2.2.3" }, "devDependencies": { - "@corbits/credential-mcp": "^0.1.0", + "@corbits/credential-http": "0.1.0", "@intx/agent": "0.4.0", "@intx/authz": "0.4.0", "@intx/db": "0.4.0", @@ -73,7 +73,7 @@ "typescript": "5.9.3" }, "peerDependencies": { - "@corbits/credential-mcp": "^0.1.0", + "@corbits/credential-http": "^0.1.0", "@intx/agent": "^0.4.0", "@intx/db": "^0.4.0", "@intx/harness": "^0.4.0", @@ -82,7 +82,7 @@ "hono": "^4.11.9" }, "peerDependenciesMeta": { - "@corbits/credential-mcp": { + "@corbits/credential-http": { "optional": true }, "@intx/db": { diff --git a/src/hub/discover.test.ts b/src/hub/discover.test.ts index 89e287e..3010605 100644 --- a/src/hub/discover.test.ts +++ b/src/hub/discover.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, afterEach } from "bun:test"; -import { MCP_NO_TOKEN_SENTINEL } from "@corbits/credential-mcp"; +import { MCP_NO_TOKEN_SENTINEL } from "@corbits/credential-http"; import type { TenantEnv } from "@intx/hub-api"; import { Hono } from "hono"; @@ -17,7 +17,13 @@ afterEach(() => { * one credential read it performs. The gate is the host's own middleware, so * the test supplies a pass-through and asserts the route's own behavior. */ -function appWith(secrets: Record): Hono { +function appWith( + secrets: Record, + opts: { + readonly apiBaseUrl?: string | null; + readonly extraOrigins?: Record; + } = {}, +): Hono { const app = new Hono(); app.use("*", async (c, next) => { (c as unknown as { set(k: string, v: unknown): void }).set("tenant", { @@ -26,28 +32,34 @@ function appWith(secrets: Record): Hono { await next(); }); const row = (id: string) => - secrets[id] === undefined ? [] : [{ id, secret: secrets[id] }]; + secrets[id] === undefined + ? [] + : [{ id, secret: secrets[id], apiBaseUrl: opts.apiBaseUrl ?? null }]; const db = { select: () => ({ from: () => ({ - where: (_clause: unknown) => ({ - // The stub cannot read drizzle's clause, so the id is threaded - // through the only credential the test registers. - limit: () => Promise.resolve(row(Object.keys(secrets)[0] ?? "none")), + innerJoin: () => ({ + where: (_clause: unknown) => ({ + // The stub cannot read drizzle's clause, so the id is threaded + // through the only credential the test registers. + limit: () => + Promise.resolve(row(Object.keys(secrets)[0] ?? "none")), + }), }), }), }), }; const cipher = { decrypt: (value: string) => Promise.resolve(value) }; // Only the narrow `db.select` chain and `cipher.decrypt` are exercised here. - const opts = { + const mountOpts = { db, cipher, requireGrant: async (_c: unknown, next: () => Promise) => { await next(); }, + extraOrigins: opts.extraOrigins, } as unknown as MountMcpDiscoveryOpts; - mountMcpDiscovery(app, opts); + mountMcpDiscovery(app, mountOpts); return app; } @@ -86,10 +98,13 @@ describe("POST /mcp/discover", () => { test("a credential's secret is sent as a bearer", async () => { handle = startTestMcpServer({ requireAuth: "Bearer tok-1" }); - const { status } = await post(appWith({ cred_1: "tok-1" }), { - url: handle.url, - credentialId: "cred_1", - }); + const { status } = await post( + appWith({ cred_1: "tok-1" }, { apiBaseUrl: handle.url }), + { + url: handle.url, + credentialId: "cred_1", + }, + ); expect(status).toBe(200); expect( handle.requestsSeen.every( @@ -100,10 +115,13 @@ describe("POST /mcp/discover", () => { test("the keyless sentinel sends no authorization header", async () => { handle = startTestMcpServer(); - const { status } = await post(appWith({ cred_1: MCP_NO_TOKEN_SENTINEL }), { - url: handle.url, - credentialId: "cred_1", - }); + const { status } = await post( + appWith({ cred_1: MCP_NO_TOKEN_SENTINEL }, { apiBaseUrl: handle.url }), + { + url: handle.url, + credentialId: "cred_1", + }, + ); expect(status).toBe(200); expect( handle.requestsSeen.every((r) => !r.headers.has("authorization")), @@ -125,11 +143,110 @@ describe("POST /mcp/discover", () => { test("a server that fails to initialize is a 4xx that never echoes a secret", async () => { handle = startTestMcpServer({ requireAuth: "Bearer right" }); - const { status, json } = await post(appWith({ cred_1: "wrong" }), { + const { status, json } = await post( + appWith({ cred_1: "wrong" }, { apiBaseUrl: handle.url }), + { + url: handle.url, + credentialId: "cred_1", + }, + ); + expect(status).toBe(422); + expect(JSON.stringify(json)).not.toContain("wrong"); + }); + + test("a credential is never sent off its origin unless the host allows it", async () => { + handle = startTestMcpServer({ requireAuth: "Bearer tok-1" }); + const pinned = "https://mcp.example.test"; + const refused = await post( + appWith({ cred_1: "tok-1" }, { apiBaseUrl: `${pinned}/api` }), + { url: handle.url, credentialId: "cred_1" }, + ); + expect(refused.status).toBe(422); + expect(String(refused.json["error"])).toContain( + `credential is pinned to ${pinned}`, + ); + expect(handle.requestsSeen).toHaveLength(0); + + const otherPin = await post( + appWith( + { cred_1: "tok-1" }, + { + apiBaseUrl: pinned, + extraOrigins: { "https://other.example.test": [handle.url] }, + }, + ), + { url: handle.url, credentialId: "cred_1" }, + ); + expect(otherPin.status).toBe(422); + expect(handle.requestsSeen).toHaveLength(0); + + const allowed = await post( + appWith( + { cred_1: "tok-1" }, + { + apiBaseUrl: pinned, + extraOrigins: { [`${pinned}/`]: [handle.url] }, + }, + ), + { url: handle.url, credentialId: "cred_1" }, + ); + expect(allowed.status).toBe(200); + expect(handle.requestsSeen.length).toBeGreaterThan(0); + }); + + test("a credential whose provider has no API origin is refused", async () => { + handle = startTestMcpServer(); + const { status } = await post(appWith({ cred_1: "tok-1" }), { url: handle.url, credentialId: "cred_1", }); expect(status).toBe(422); - expect(JSON.stringify(json)).not.toContain("wrong"); + expect(handle.requestsSeen).toHaveLength(0); + }); + + test("a keyless credential needs no API origin", async () => { + handle = startTestMcpServer(); + const { status } = await post(appWith({ cred_1: MCP_NO_TOKEN_SENTINEL }), { + url: handle.url, + credentialId: "cred_1", + }); + expect(status).toBe(200); + }); + + test("a keyless credential pins to the URL's origin, not its provider's", async () => { + handle = startTestMcpServer(); + const { status } = await post( + appWith( + { cred_1: MCP_NO_TOKEN_SENTINEL }, + { apiBaseUrl: "https://api.example.test" }, + ), + { url: handle.url, credentialId: "cred_1" }, + ); + expect(status).toBe(200); + expect(handle.requestsSeen.length).toBeGreaterThan(0); + }); + + test("a redirect is refused even to an allowed origin", async () => { + handle = startTestMcpServer(); + const target = handle.url; + const redirector = Bun.serve({ + port: 0, + fetch: () => Response.redirect(target, 307), + }); + try { + const pinned = redirector.url.origin; + const { status, json } = await post( + appWith( + { cred_1: "tok-1" }, + { apiBaseUrl: pinned, extraOrigins: { [pinned]: [target] } }, + ), + { url: new URL("/mcp", pinned).href, credentialId: "cred_1" }, + ); + expect(status).toBe(422); + expect(String(json["error"])).toContain("redirect"); + expect(handle.requestsSeen).toHaveLength(0); + } finally { + await redirector.stop(true); + } }); }); diff --git a/src/hub/discover.ts b/src/hub/discover.ts index 0edc03d..3d9b72b 100644 --- a/src/hub/discover.ts +++ b/src/hub/discover.ts @@ -1,17 +1,17 @@ // Server-side MCP catalog discovery. The browser never holds an MCP server's // token, so the only place an OAuth-protected server's `tools/list` can be // read is here, behind the hub's own tenant-member gate and with the secret -// decrypted in this process and sent only to the server's own origin. +// decrypted in this process and sent only to the credential's own origin. import type { DB } from "@intx/db"; -import { credential } from "@intx/db/schema"; +import { credential, provider } from "@intx/db/schema"; import type { FetchLike } from "@intx/harness"; import type { TenantEnv } from "@intx/hub-api"; import { credentialAad, type CredentialCipher } from "@intx/types"; import { + createOriginPinnedFetch, MCP_NO_TOKEN_SENTINEL, - mcpOriginPinnedFetch, -} from "@corbits/credential-mcp"; +} from "@corbits/credential-http"; import { type } from "arktype"; import { and, eq } from "drizzle-orm"; import type { Hono, MiddlewareHandler } from "hono"; @@ -34,23 +34,42 @@ export type MountMcpDiscoveryOpts = { readonly cipher: CredentialCipher; /** The host's stock grant middleware, so authority is checked exactly once, its way. */ readonly requireGrant: MiddlewareHandler; + /** + * Further origins a credential may be sent to, keyed by the credential's + * pinned origin, for a server whose MCP endpoint is not on that origin. + * Host-wide: an entry applies to every tenant's credential pinned to that + * origin. Passed through to `@corbits/credential-http`. Empty by default. + */ + readonly extraOrigins?: Readonly>; /** Reported when a discovery attempt fails; the caller only sees a message. */ readonly onError?: (error: unknown, context: { url: string }) => void; }; +export type McpCredential = { + readonly secret: string; + /** The provider's API origin the secret is pinned to, when it has one. */ + readonly origin?: string; +}; + /** - * Read a tenant credential's decrypted secret. Scoped by tenant so a - * credential id from another tenant reads as absent, not as a secret. + * Read a tenant credential's decrypted secret and the origin it is pinned to. + * Scoped by tenant so a credential id from another tenant reads as absent, + * not as a secret. */ -export async function readCredentialSecret(opts: { +export async function readCredential(opts: { readonly db: DB["db"]; readonly cipher: CredentialCipher; readonly tenantId: string; readonly credentialId: string; -}): Promise { +}): Promise { const [row] = await opts.db - .select() + .select({ + id: credential.id, + secret: credential.secret, + apiBaseUrl: provider.apiBaseUrl, + }) .from(credential) + .innerJoin(provider, eq(provider.id, credential.providerId)) .where( and( eq(credential.id, opts.credentialId), @@ -58,8 +77,14 @@ export async function readCredentialSecret(opts: { ), ) .limit(1); - if (row === undefined || row.secret === null) return undefined; - return opts.cipher.decrypt(row.secret, credentialAad(row.id, "secret")); + if (row === undefined) return undefined; + const secret = await opts.cipher.decrypt( + row.secret, + credentialAad(row.id, "secret"), + ); + return row.apiBaseUrl === null + ? { secret } + : { secret, origin: new URL(row.apiBaseUrl).origin }; } /** A 3xx would send the bearer onward, so the pinned fetch's manual redirect @@ -81,22 +106,36 @@ export type McpDiscovery = { readonly tools: readonly McpTool[]; }; -/** Handshake and read a server's catalog over an origin-pinned fetch. */ +/** + * Handshake and read a server's catalog over a fetch pinned to the + * credential's origin, or to the URL's origin when no secret is sent. + */ export async function discoverMcpServer(args: { readonly url: string; - readonly secret?: string; + readonly credential?: McpCredential; + readonly extraOrigins?: Readonly>; readonly fetch?: FetchLike; }): Promise { const target = parseMcpEndpoint(args.url); + const secret = args.credential?.secret; const token = - args.secret === undefined || args.secret === MCP_NO_TOKEN_SENTINEL + secret === undefined || secret === MCP_NO_TOKEN_SENTINEL ? undefined - : args.secret; + : secret; + const origin = token === undefined ? target.origin : args.credential?.origin; + if (origin === undefined) { + throw new Error("the credential's provider has no API origin to pin to"); + } + const extraOrigins = Object.entries(args.extraOrigins ?? {}).flatMap( + ([pinned, extra]) => (new URL(pinned).origin === origin ? extra : []), + ); const pinned = refusingRedirects( - mcpOriginPinnedFetch({ - pinnedOrigin: target.origin, - readToken: () => token, - ...(args.fetch !== undefined ? { fetch: args.fetch } : {}), + createOriginPinnedFetch({ + origin, + header: "authorization", + readValue: () => (token === undefined ? undefined : `Bearer ${token}`), + extraOrigins, + fetch: args.fetch ?? globalThis.fetch, }), ); const serverInfo = await mcpInitialize(args.url, { fetch: pinned }); @@ -129,21 +168,24 @@ export function mountMcpDiscovery( } try { - const secret = + const found = body.credentialId === undefined ? undefined - : await readCredentialSecret({ + : await readCredential({ db: opts.db, cipher: opts.cipher, tenantId: c.get("tenant").id, credentialId: body.credentialId, }); - if (body.credentialId !== undefined && secret === undefined) { + if (body.credentialId !== undefined && found === undefined) { return c.json({ error: "credential not found" }, 404); } const data = await discoverMcpServer({ url: body.url, - ...(secret !== undefined ? { secret } : {}), + ...(found !== undefined ? { credential: found } : {}), + ...(opts.extraOrigins !== undefined + ? { extraOrigins: opts.extraOrigins } + : {}), }); return c.json({ data }); } catch (cause) { diff --git a/src/hub/index.ts b/src/hub/index.ts index 487b77e..a2d8064 100644 --- a/src/hub/index.ts +++ b/src/hub/index.ts @@ -4,7 +4,8 @@ export { mountMcpDiscovery, discoverMcpServer, - readCredentialSecret, + readCredential, + type McpCredential, type McpDiscovery, type MountMcpDiscoveryOpts, } from "./discover.js"; diff --git a/src/oauth-discovery.test.ts b/src/oauth-discovery.test.ts index 67ffdf0..f4cea17 100644 --- a/src/oauth-discovery.test.ts +++ b/src/oauth-discovery.test.ts @@ -26,6 +26,10 @@ const asMetadata = { code_challenge_methods_supported: ["S256"], }; +// Served at the resource itself (RFC 8414 fallback), so it names the resource +// URL as its issuer. +const fallbackAsMetadata = { ...asMetadata, issuer: resourceUrl }; + // Real metadata (checked 2026-09-22) from two live MCP servers, used to keep // the negotiation logic honest against shapes actual authorization servers // send. No provider-specific behavior lives in src — these are fixtures only. @@ -131,7 +135,7 @@ describe("MCP OAuth discovery", () => { const { fetchImpl } = fakeFetch({ "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { status: 200, - body: asMetadata, + body: fallbackAsMetadata, }, }); const entry = await discoverMcpLoginEntry({ resourceUrl, fetchImpl }); @@ -140,6 +144,32 @@ describe("MCP OAuth discovery", () => { ); }); + test("accepts fallback metadata whose issuer differs only by a trailing slash", async () => { + const { fetchImpl } = fakeFetch({ + "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { + status: 200, + body: { ...fallbackAsMetadata, issuer: `${resourceUrl}/` }, + }, + }); + const entry = await discoverMcpLoginEntry({ resourceUrl, fetchImpl }); + expect(entry.authorizationServer.issuer).toBe(`${resourceUrl}/`); + }); + + test("rejects fallback metadata naming an issuer other than the resource", async () => { + // Load-bearing: RFC 8414 §3.3; metadata served at the resource must be + // for the issuer that resource URL identifies, or a resource could hand + // out another server's endpoints. + const { fetchImpl } = fakeFetch({ + "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { + status: 200, + body: asMetadata, + }, + }); + await expect( + discoverMcpLoginEntry({ resourceUrl, fetchImpl }), + ).rejects.toThrow(/issuer mismatch/); + }); + test("rejects protected-resource metadata naming a different resource", async () => { // Load-bearing: accepting metadata for another resource would send the // user to authorize against the wrong server. @@ -192,7 +222,7 @@ describe("MCP OAuth discovery", () => { const { code_challenge_methods_supported: _unused, ...metadataWithoutPkce - } = asMetadata; + } = fallbackAsMetadata; const { fetchImpl } = fakeFetch({ "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { status: 200, @@ -210,7 +240,10 @@ describe("MCP OAuth discovery", () => { const { fetchImpl } = fakeFetch({ "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { status: 200, - body: { ...asMetadata, code_challenge_methods_supported: ["plain"] }, + body: { + ...fallbackAsMetadata, + code_challenge_methods_supported: ["plain"], + }, }, }); await expect( @@ -226,7 +259,7 @@ describe("MCP OAuth discovery", () => { "https://mcp.example.com/.well-known/oauth-authorization-server/mcp": { status: 200, body: { - ...asMetadata, + ...fallbackAsMetadata, token_endpoint_auth_methods_supported: ["client_secret_basic"], }, }, diff --git a/src/oauth-discovery.ts b/src/oauth-discovery.ts index 57c6c38..f11be76 100644 --- a/src/oauth-discovery.ts +++ b/src/oauth-discovery.ts @@ -148,6 +148,15 @@ function wellKnownUrl(raw: string, suffix: string): string { return parsed.toString(); } +// RFC 8414 §3: the issuer a well-known URL was derived from, with the same +// trailing-slash normalization `wellKnownUrl` applies. +function normalizedIssuer(raw: string): string { + const parsed = new URL(raw); + const path = + parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, ""); + return `${parsed.origin}${path}`; +} + async function getJson( url: string, fetchImpl: FetchLike, @@ -275,10 +284,15 @@ export async function discoverMcpLoginEntry( throw new OAuthDiscoveryError( `authorization-server metadata at ${asMetadataUrl} is malformed: ${metadata.summary}`, ); - // RFC 8414 §3.3: metadata must name the issuer it was fetched for. - if (issuer !== undefined && metadata.issuer !== issuer) + // RFC 8414 §3.3: metadata must name the issuer it was fetched for. In the + // fallback that issuer is the one the resource URL itself identifies. + const expectedIssuer = issuer ?? normalizedIssuer(resourceUrl); + if ( + !URL.canParse(metadata.issuer) || + normalizedIssuer(metadata.issuer) !== normalizedIssuer(expectedIssuer) + ) throw new OAuthDiscoveryError( - `authorization-server metadata issuer mismatch: expected ${issuer}, got ${metadata.issuer}.`, + `authorization-server metadata issuer mismatch: expected ${expectedIssuer}, got ${metadata.issuer}.`, ); assertSupportsS256(asMetadataUrl, metadata.code_challenge_methods_supported); diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts index aec4e7e..463117b 100644 --- a/src/sidecar-bundle.test.ts +++ b/src/sidecar-bundle.test.ts @@ -72,7 +72,7 @@ function envWith( } /** Mediated fetch: resolves a relative path against the pinned origin and - * injects the bearer, exactly as `@corbits/credential-mcp` shapes it. */ + * injects the bearer, exactly as `@corbits/credential-http` shapes it. */ function pinnedFetch(origin: string, bearer?: string) { const seen: Headers[] = []; const impl: FetchStub = (input, init) => {