From c66c91609b147639e345f325a90d9b6936534e5c Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 14 Oct 2026 21:21:37 -0500 Subject: [PATCH] feat(mcp): discover package children and report inactive grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted package adapter accepts both the current legacy access shape and the frozen member shape. Reject mixed or malformed member lists before MCP dispatch with a generic internal error, and preserve explicit inactive status. The current producer omits revoked children, so those selectors keep B1’s unknown-grant response. Restore package guidance for field navigation, document versus canonical-record reads, bounded previews, query metadata, source handles, search modes, paging, and aggregate truncation. Re-enable the real hosted package flows and verify a complete current-producer client replay. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../server/package-rs-client.ts | 25 +- .../server/routes/rs-hosted-mcp.ts | 119 +++++++++- .../test/hosted-mcp-oauth.test.ts | 119 ++++++++-- .../test/package-rs-client.test.ts | 24 +- .../test/rs-hosted-mcp-internal-base.test.ts | 182 +++++++++++++-- .../vendor/mcp-server/src/package-profile.ts | 170 +++++++++++++- .../vendor/mcp-server/src/server.ts | 6 +- .../mcp-server/test/package-profile.test.ts | 218 ++++++++++++++++-- 8 files changed, 796 insertions(+), 67 deletions(-) diff --git a/reference-implementation/server/package-rs-client.ts b/reference-implementation/server/package-rs-client.ts index f800da300..9cd9f32c3 100644 --- a/reference-implementation/server/package-rs-client.ts +++ b/reference-implementation/server/package-rs-client.ts @@ -5,7 +5,22 @@ import type { PackageProfile } from "../vendor/mcp-server/src/package-profile.ts import { RsClient } from "../vendor/mcp-server/src/rs-client.ts"; interface PackageMember { + access_mode: string; + active: boolean; + connection_id: string | null; + grant: { + purpose: string; + streams: readonly { readonly instance_ids: readonly string[]; readonly name: string }[]; + }; grant_id: string; + package_id: string; + source: { + connection_id?: string; + connector_display_name?: string; + display_name?: string; + id: string; + kind: "connector" | "provider_native"; + } | null; token: string; } @@ -61,8 +76,16 @@ export function createPackageProfile({ ); } return { - children: members.map(({ grant_id, token }) => ({ + children: members.map(({ access_mode, active, grant, grant_id, source, token }) => ({ + access_mode, + active, grant_id, + purpose: grant.purpose, + source: source ? { ...source } : null, + streams: grant.streams.map(({ name, instance_ids }) => ({ + name, + instance_ids: [...instance_ids], + })), rs: new RsClient({ accessToken: token, fetch, diff --git a/reference-implementation/server/routes/rs-hosted-mcp.ts b/reference-implementation/server/routes/rs-hosted-mcp.ts index bd3d02c79..ed5c8fd5e 100644 --- a/reference-implementation/server/routes/rs-hosted-mcp.ts +++ b/reference-implementation/server/routes/rs-hosted-mcp.ts @@ -77,12 +77,47 @@ interface AppLike { } export interface GrantPackageMember { - readonly accessToken: string; - readonly grantId: string; + readonly access_mode: string; + readonly active: boolean; + readonly connection_id: string | null; + readonly grant: { + readonly purpose: string; + readonly streams: readonly { + readonly instance_ids: readonly string[]; + readonly name: string; + }[]; + }; + readonly grant_id: string; + readonly package_id: string; + readonly source: { + readonly connection_id?: string; + readonly connector_display_name?: string; + readonly display_name?: string; + readonly id: string; + readonly kind: "connector" | "provider_native"; + } | null; + readonly token: string; +} + +interface GrantPackageMemberInput extends Omit { + readonly access_mode?: string; + readonly active?: boolean; + readonly grant: Omit & { + readonly access_mode?: string; + readonly purpose?: string | { readonly code?: string; readonly description?: string }; + readonly purpose_code?: string; + readonly purpose_description?: string; + }; +} + +interface GrantPackageAccessInput { + readonly members: readonly GrantPackageMemberInput[]; + readonly package: Readonly>; } export interface GrantPackageAccess { readonly members: readonly GrantPackageMember[]; + readonly package: Readonly>; } interface McpServerIcon { @@ -125,8 +160,8 @@ export interface MountRsHostedMcpContext { /** Resolved RS public URL (or null; adapter derives it per-request). */ readonly explicitResource: string | null | undefined; readonly dispatchApp: DispatchApp; - /** From auth.js: resolve active grant-package members for a package token. */ - getGrantPackageAccess: (packageId: string) => Promise; + /** Resolve every grant-package member, including inactive public members. */ + getGrantPackageAccess: (packageId: string) => Promise; /** * `handleStreamableHttpRequest` from `@pdpp/mcp-server/server`. * Injected because the package is JS-only without type declarations. @@ -251,6 +286,70 @@ function hostedMcpIconLink(resource: string): string { return `<${hostedMcpIconUrl(resource)}>; rel="icon"; type="image/svg+xml"`; } +function normalizeGrantPackageAccess(access: GrantPackageAccessInput): GrantPackageAccess { + const members = access.members; + if (!Array.isArray(members)) { + throw new TypeError("Malformed grant package access"); + } + const hasActiveKey = (member: GrantPackageMemberInput) => + Object.hasOwn(member, "active"); + const legacyShape = members.every((member) => !hasActiveKey(member)); + const frozenShape = members.every( + (member) => hasActiveKey(member) && typeof member.active === "boolean" + ); + if (!legacyShape && !frozenShape) { + throw new TypeError("Malformed grant package access"); + } + + const grantIds = new Set(); + for (const member of members) { + if (!member || typeof member !== "object" || typeof member.grant_id !== "string" || !member.grant_id) { + throw new TypeError("Malformed grant package access"); + } + if (grantIds.has(member.grant_id)) { + throw new TypeError("Malformed grant package access"); + } + grantIds.add(member.grant_id); + const memberMode = member.access_mode; + const grantMode = member.grant?.access_mode; + if ( + (memberMode !== undefined && typeof memberMode !== "string") || + (grantMode !== undefined && typeof grantMode !== "string") || + (memberMode !== undefined && grantMode !== undefined && memberMode !== grantMode) + ) { + throw new TypeError("Malformed grant package access"); + } + } + + return { + ...access, + members: members.map((member) => { + const accessMode = member.access_mode ?? member.grant.access_mode; + if (typeof accessMode !== "string") { + throw new TypeError("Malformed grant package access"); + } + const purpose = typeof member.grant.purpose === "string" + ? member.grant.purpose + : member.grant.purpose?.description ?? member.grant.purpose?.code ?? + member.grant.purpose_description ?? member.grant.purpose_code; + if (typeof purpose !== "string") { + throw new TypeError("Malformed grant package access"); + } + // The current producer returns only active legacy members. Revoked + // children are absent and keep B1's unknown-selector behavior. + return { + ...member, + access_mode: accessMode, + active: legacyShape ? true : member.active as boolean, + grant: { ...member.grant, purpose }, + source: member.source && member.connection_id + ? { ...member.source, connection_id: member.connection_id } + : member.source, + }; + }), + }; +} + export function mountRsHostedMcp(app: AppLike, ctx: MountRsHostedMcpContext): void { const { explicitResource, @@ -297,8 +396,16 @@ export function mountRsHostedMcp(app: AppLike, ctx: MountRsHostedMcpContext): vo resource: string, internalBase: string ): Promise { - const access = await ctx.getGrantPackageAccess(req.tokenInfo?.grant_package_id as string); - if (!access || access.members.length === 0) { + const resolvedAccess = await ctx.getGrantPackageAccess(req.tokenInfo?.grant_package_id as string); + let access: GrantPackageAccess | null; + try { + access = resolvedAccess ? normalizeGrantPackageAccess(resolvedAccess) : null; + } catch { + // Malformed member lists fail closed without logging or returning member data. + ctx.pdppError(res, 500, "api_error", "Unable to resolve package access"); + return null; + } + if (!access?.members.some((member) => member.active)) { const metadataUrl = res.locals[PROTECTED_RESOURCE_METADATA_URL_LOCAL]; if (typeof metadataUrl === "string" && metadataUrl) { res.setHeader( diff --git a/reference-implementation/test/hosted-mcp-oauth.test.ts b/reference-implementation/test/hosted-mcp-oauth.test.ts index e24c251ce..68ad380e7 100644 --- a/reference-implementation/test/hosted-mcp-oauth.test.ts +++ b/reference-implementation/test/hosted-mcp-oauth.test.ts @@ -3081,8 +3081,14 @@ test("multi-source hosted MCP picker requires explicit grant_id selection for pa params: {}, }); for (const tool of resultOf(packageTools).tools as Array<{ + name: string; inputSchema: { properties: Record; required: string[] }; }>) { + if (tool.name === "list_package_children") { + assert.deepEqual(tool.inputSchema.required ?? [], []); + assert.deepEqual(tool.inputSchema.properties ?? {}, {}); + continue; + } assert.equal( (tool.inputSchema.properties.grant_id as { type?: string }).type, "string", @@ -3216,7 +3222,7 @@ test("package refresh replay deactivates every family-linked package bearer", as } }); -test("revoked package child selectors fail while an explicitly selected sibling remains usable", async () => { +test("revoked legacy package children stay isolated while siblings remain usable", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -3278,22 +3284,31 @@ test("revoked package child selectors fail while an explicitly selected sibling request_id: "multi-source-child-revoke-test", }); - const revokedSelection = await selectSchema(spotifyChild.grant_id, 2); + const discovery = await postMcpJson(rsUrl, accessToken, { + id: 2, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: {}, name: "list_package_children" }, + }); + assert.equal(discovery.status, 200); + assert.equal(resultOf(discovery).isError, undefined); + const children = (resultOf(discovery).structuredContent as { children: Array> }).children; + // The current producer omits revoked children from its legacy member + // shape, so discovery cannot list them and B1's selector error applies. + assert.equal(children.some((child) => child.grant_id === spotifyChild.grant_id), false); + const discoveredGithub = mustExist( + children.find((child) => child.grant_id === githubChild.grant_id), + "active github child remains discoverable", + ); + assert.equal(discoveredGithub.active, true); + + const revokedSelection = await selectSchema(spotifyChild.grant_id, 3); assert.equal(revokedSelection.status, 200); assert.equal(resultOf(revokedSelection).isError, true); - assert.equal( - ( - ( - resultOf(revokedSelection).structuredContent as Record< - string, - unknown - > - ).error as Record - ).code, - "unknown_grant_selector", - "the later inactive-child result is not implemented in this change", - ); - const siblingSelection = await selectSchema(githubChild.grant_id, 3); + const revokedError = (resultOf(revokedSelection).structuredContent as { error: Record }).error; + assert.equal(revokedError.code, "unknown_grant_selector"); + assert.match(String(revokedError.message), /Unknown grant_id.*list_package_children/); + const siblingSelection = await selectSchema(githubChild.grant_id, 4); assert.equal(siblingSelection.status, 200); assert.equal(resultOf(siblingSelection).isError, undefined); @@ -3304,7 +3319,7 @@ test("revoked package child selectors fail while an explicitly selected sibling } }); -test("revoking a package's last child challenges its token as invalid", async () => { +test("revoking the last package child makes its token invalid", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -3323,12 +3338,21 @@ test("revoking a package's last child challenges its token as invalid", async () const packageMember = packageMembers?.members?.[0]; assert.ok(packageMember?.grant_id); assert.equal(packageMembers?.members?.length, 1); + + const before = await postMcpJson(rsUrl, accessToken, { + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + assert.equal(before.status, 200, "the package token serves MCP before its last child is revoked"); + await revokeGrant(packageMember.grant_id, { request_id: "mcp-package-last-child-revoked-test", }); assert.equal((await introspectAccessToken(asUrl, accessToken)).active, true); const emptyPackageAccess = await getGrantPackageAccess(packageId); - assert.equal((emptyPackageAccess?.members as unknown[] | undefined)?.length, 0); + assert.equal((emptyPackageAccess?.members as Array<{ active?: boolean }> | undefined)?.length, 0); const after = await postMcpJson(rsUrl, accessToken, { id: 1, @@ -3487,6 +3511,67 @@ async function registerPackageChild( return connectionId; } +test("current package sessions replay discovery and every read tool", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + + try { + const connectionId = await registerPackageChild(asUrl, "profile-replay-child"); + const client = await registerAuthCodeClient(asUrl); + const { accessToken } = await completeMultiSourcePackageFlow({ + asUrl, + client, + connectorIds: ["profile-replay-child"], + }); + let requestId = 1; + const call = async (name: string, args: Record = {}) => { + const response = await postMcpJson(rsUrl, accessToken, { + id: requestId++, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: args, name }, + }); + assert.equal(response.status, 200, `${name} stays on the MCP tool surface`); + assert.equal(resultOf(response).isError, undefined, `${name} succeeds`); + return resultOf(response); + }; + + const listed = await postMcpJson(rsUrl, accessToken, { + id: requestId++, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + assert.equal(listed.status, 200); + const tools = resultOf(listed).tools as Array<{ name: string }>; + assert.ok(tools.some((tool) => tool.name === "list_package_children")); + + const discovered = await call("list_package_children"); + const children = (discovered.structuredContent as { children: Array> }).children; + const child = mustExist( + children.find((candidate) => { + const source = candidate.source as Record; + return source.connection_id === connectionId; + }), + "current producer child is discoverable by its pairwise handle", + ); + const grantId = stringField(child, "grant_id"); + await call("schema", { grant_id: grantId }); + const searched = await call("search", { + grant_id: grantId, + q: packageChildSentinel(connectionId), + streams: [PIN_STREAM], + }); + const searchData = searched.structuredContent as { results: Array<{ id: string }> }; + const hit = mustExist(searchData.results[0], "search returns the seeded package child record"); + await call("fetch", { grant_id: grantId, id: hit.id }); + await call("query_records", { grant_id: grantId, stream: PIN_STREAM }); + } finally { + await closeServer(server); + } +}); + test("package grant_id selection isolates a real sibling record", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; diff --git a/reference-implementation/test/package-rs-client.test.ts b/reference-implementation/test/package-rs-client.test.ts index ff69bd011..c5f27b14b 100644 --- a/reference-implementation/test/package-rs-client.test.ts +++ b/reference-implementation/test/package-rs-client.test.ts @@ -7,6 +7,26 @@ import { createPackageProfile } from "../server/package-rs-client.ts"; const PROVIDER = "https://rs.test"; +function member(grant_id: string, token: string) { + return { + access_mode: "continuous", + active: true, + connection_id: `pairwise-${grant_id}`, + grant: { + purpose: "personal records", + streams: [{ name: "notes", instance_ids: [`pairwise-${grant_id}`] }], + }, + grant_id, + package_id: "package-test", + source: { + connection_id: `pairwise-${grant_id}`, + id: `source-${grant_id}`, + kind: "connector" as const, + }, + token, + }; +} + test("package profile keeps each child bound to its own RS bearer", async () => { const calls: Array<{ authorization: string | null; url: string }> = []; const profile = createPackageProfile({ @@ -18,8 +38,8 @@ test("package profile keeps each child bound to its own RS bearer", async () => return Response.json({ object: "schema", streams: [] }); }, members: [ - { grant_id: "child-A", token: "token-A" }, - { grant_id: "child.B", token: "token-B" }, + member("child-A", "token-A"), + member("child.B", "token-B"), ], providerUrl: PROVIDER, }); diff --git a/reference-implementation/test/rs-hosted-mcp-internal-base.test.ts b/reference-implementation/test/rs-hosted-mcp-internal-base.test.ts index 5aebbc6a8..661ab6024 100644 --- a/reference-implementation/test/rs-hosted-mcp-internal-base.test.ts +++ b/reference-implementation/test/rs-hosted-mcp-internal-base.test.ts @@ -28,7 +28,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { MountRsHostedMcpContext } from "../server/routes/rs-hosted-mcp.ts"; +import type { GrantPackageAccess, GrantPackageMember, MountRsHostedMcpContext } from "../server/routes/rs-hosted-mcp.ts"; import { mountRsHostedMcp } from "../server/routes/rs-hosted-mcp.ts"; type RouteHandler = (req: unknown, res: unknown) => unknown | Promise; @@ -57,6 +57,7 @@ function makeFakeApp(): FakeApp { // Minimal shape rs-hosted-mcp.ts consumes for a request's `get`/`headers` etc. interface FakeRequest { + body?: unknown; get: (name: string) => string | undefined; headers: Record; method: string; @@ -70,17 +71,19 @@ interface FakeResponse { end: () => void; headers: Record; locals: Record; - send: () => void; + send: (body: unknown) => void; setHeader: (name: string, value: string) => void; status: (code: number) => FakeResponse; statusCode: number | null; + payload?: unknown; } // Fake request mirroring the shape rs-hosted-mcp.ts consumes. With a // non-loopback `explicitResource` and no x-forwarded-* headers, resolvePublicUrl // returns the explicit URL verbatim — a deterministic public origin. -function makePackageRequest(): FakeRequest { +function makePackageRequest(body?: unknown): FakeRequest { return { + ...(body === undefined ? {} : { body }), get(name: string) { const lc = name.toLowerCase(); return lc === "host" ? "pdpp.test" : undefined; @@ -116,9 +119,11 @@ function makeFakeResponse(): FakeResponse { /* intentionally empty */ }, headers: {}, - locals: {}, - send() { - /* intentionally empty */ + locals: { + protectedResourceMetadataUrl: `${PUBLIC_RESOURCE}/.well-known/oauth-protected-resource/mcp`, + }, + send(body) { + this.payload = body; }, setHeader(name: string, value: string) { this.headers[name] = value; @@ -134,21 +139,54 @@ function makeFakeResponse(): FakeResponse { interface Seen { advertisedProviderUrl: string | null; childProviderUrl: string | null; + packageStatusCode: number | null; + receivedMembers: readonly GrantPackageMember[] | null; singleGrantProviderUrl: string | null; } // Build a context whose `createPackageProfile` records the providerUrl it // receives, and whose `handleStreamableHttpRequest` records the providerUrl it // is advertised. `internalResource` is the fix's injected internal base. -function makeContext({ internalResource }: { internalResource: string | null | undefined }): { +function packageAccess(active = true): GrantPackageAccess { + return { + members: ["grant_A", "grant_B"].map((grant_id, index) => ({ + access_mode: "continuous", + active: index === 0 ? active : false, + connection_id: `pairwise-${grant_id}`, + grant: { + purpose: "personal records", + streams: [{ name: "notes", instance_ids: [`pairwise-${grant_id}`] }], + }, + grant_id, + package_id: "package_public", + source: { + connection_id: `pairwise-${grant_id}`, + display_name: `Source ${index}`, + id: `source-${index}`, + kind: "connector" as const, + }, + token: `child-token-${index}`, + })), + package: { package_id: "package_public" }, + }; +} + +function makeContext({ internalResource, active = true, legacy = false, packageOverride }: { internalResource: string | null | undefined; active?: boolean; legacy?: boolean; packageOverride?: unknown }): { ctx: MountRsHostedMcpContext; seen: Seen; } { - const seen: Seen = { advertisedProviderUrl: null, childProviderUrl: null, singleGrantProviderUrl: null }; + const seen: Seen = { + advertisedProviderUrl: null, + childProviderUrl: null, + packageStatusCode: null, + receivedMembers: null, + singleGrantProviderUrl: null, + }; return { ctx: { - createPackageProfile({ providerUrl }) { + createPackageProfile({ members, providerUrl }) { seen.childProviderUrl = providerUrl; + seen.receivedMembers = members; return { __fakePackageProfile: true }; }, createRsClient({ providerUrl }) { @@ -163,11 +201,19 @@ function makeContext({ internalResource }: { internalResource: string | null | u explicitResource: PUBLIC_RESOURCE, // biome-ignore lint/suspicious/useAwait: Async callback preserves the dependency contract and rejection timing. async getGrantPackageAccess() { + const frozen = packageAccess(active); + if (packageOverride !== undefined) return packageOverride as GrantPackageAccess; + if (!legacy) return frozen; return { - members: [ - { accessToken: "tok_A", grantId: "grant_A" }, - { accessToken: "tok_B", grantId: "grant_B" }, - ], + ...frozen, + members: frozen.members.map((member) => ({ + connection_id: member.connection_id, + grant: { ...member.grant, access_mode: member.access_mode }, + grant_id: member.grant_id, + package_id: member.package_id, + source: member.source, + token: member.token, + })), }; }, // biome-ignore lint/suspicious/useAwait: Async callback preserves the dependency contract and rejection timing. @@ -194,13 +240,15 @@ function makeContext({ internalResource }: { internalResource: string | null | u async function driveHandler({ internalResource, + legacy = false, makeRequest = makePackageRequest, }: { internalResource: string | null | undefined; + legacy?: boolean; makeRequest?: () => FakeRequest; }): Promise { const app = makeFakeApp(); - const { ctx, seen } = makeContext({ internalResource }); + const { ctx, seen } = makeContext({ internalResource, legacy }); mountRsHostedMcp(app, ctx); // The handler is the last entry in the mounted chain for POST /mcp. const chain = app.routes["post /mcp"]; @@ -209,6 +257,7 @@ async function driveHandler({ assert.ok(handler, "post /mcp handler chain must not be empty"); const res = makeFakeResponse(); await handler(makeRequest(), res); + seen.packageStatusCode = res.statusCode; return seen; } @@ -241,6 +290,111 @@ test("F1 wiring fallback: with no internal base configured, child self-calls fal ); }); +test("hosted adapter normalizes the current legacy package-member shape", async () => { + const seen = await driveHandler({ internalResource: INTERNAL_BASE, legacy: true }); + assert.equal(seen.packageStatusCode, 200); + assert.deepEqual( + seen.receivedMembers?.map(({ grant_id, access_mode, active, connection_id, source }) => ({ + access_mode, + active, + connection_id, + grant_id, + sourceConnectionId: source?.connection_id, + })), + [ + { + access_mode: "continuous", + active: true, + connection_id: "pairwise-grant_A", + grant_id: "grant_A", + sourceConnectionId: "pairwise-grant_A", + }, + { + access_mode: "continuous", + active: true, + connection_id: "pairwise-grant_B", + grant_id: "grant_B", + sourceConnectionId: "pairwise-grant_B", + }, + ], + ); +}); + +test("hosted adapter preserves explicit active and inactive members from the frozen shape", async () => { + const seen = await driveHandler({ internalResource: INTERNAL_BASE }); + assert.deepEqual( + seen.receivedMembers?.map(({ grant_id, active }) => ({ grant_id, active })), + [ + { grant_id: "grant_A", active: true }, + { grant_id: "grant_B", active: false }, + ], + ); + assert.equal(seen.packageStatusCode, 200, "one active child keeps the package MCP endpoint available"); +}); + +test("hosted adapter rejects a package when every frozen member is inactive", async () => { + const app = makeFakeApp(); + const { ctx, seen } = makeContext({ internalResource: INTERNAL_BASE, active: false }); + mountRsHostedMcp(app, ctx); + const handler = app.routes["post /mcp"]?.at(-1); + assert.ok(handler); + const res = makeFakeResponse(); + await handler(makePackageRequest(), res); + assert.equal(res.statusCode, 401); + const challenge = Object.entries(res.headers).find(([name]) => name.toLowerCase() === "www-authenticate")?.[1]; + assert.match(challenge ?? "", /Bearer error="invalid_token"/); + assert.equal(seen.receivedMembers, null, "inactive package never constructs an MCP profile"); +}); + +test("hosted adapter fails closed on malformed package-member lists", async (t) => { + const base = packageAccess(true); + const [first, second] = base.members; + assert.ok(first && second); + const legacyFirst = { + ...first, + grant: { ...first.grant, access_mode: first.access_mode }, + } as Record; + delete legacyFirst.active; + delete legacyFirst.access_mode; + const missingGrantId = { ...first } as Record; + delete missingGrantId.grant_id; + + const cases: Array<[string, unknown]> = [ + ["mixed legacy and frozen shapes", { ...base, members: [legacyFirst, second] }], + ["conflicting member and grant access modes", { + ...base, + members: [{ ...first, grant: { ...first.grant, access_mode: "snapshot" } }, second], + }], + ["active null", { ...base, members: [{ ...first, active: null }, second] }], + ["duplicate grant ids", { ...base, members: [first, { ...second, grant_id: first.grant_id }] }], + ["missing grant id", { ...base, members: [missingGrantId, second] }], + ["non-boolean active", { ...base, members: [{ ...first, active: "true" }, second] }], + ]; + + for (const [label, packageOverride] of cases) { + await t.test(label, async () => { + const app = makeFakeApp(); + const { ctx, seen } = makeContext({ internalResource: INTERNAL_BASE, packageOverride }); + mountRsHostedMcp(app, ctx); + const handler = app.routes["post /mcp"]?.at(-1); + assert.ok(handler); + for (const body of [ + { id: 1, jsonrpc: "2.0", method: "tools/call", params: { arguments: {}, name: "list_package_children" } }, + { id: 2, jsonrpc: "2.0", method: "tools/call", params: { arguments: { grant_id: "grant_A" }, name: "schema" } }, + ]) { + const res = makeFakeResponse(); + await handler(makePackageRequest(body), res); + assert.equal(res.statusCode, 500); + assert.deepEqual(res.payload, { + error: { code: "api_error", message: "Unable to resolve package access" }, + }); + } + assert.equal(seen.receivedMembers, null, "malformed members never reach the data adapter"); + assert.equal(seen.advertisedProviderUrl, null, "malformed members never reach MCP dispatch"); + }); + } +}); + test("F1 wiring (single-grant): client-token self-calls use the internal base; advertised stays public", async () => { const seen = await driveHandler({ internalResource: INTERNAL_BASE, makeRequest: makeClientRequest }); // The single-grant RsClient uses the INTERNAL base for its provider URL. diff --git a/reference-implementation/vendor/mcp-server/src/package-profile.ts b/reference-implementation/vendor/mcp-server/src/package-profile.ts index a633a66ed..71cb4179f 100644 --- a/reference-implementation/vendor/mcp-server/src/package-profile.ts +++ b/reference-implementation/vendor/mcp-server/src/package-profile.ts @@ -6,8 +6,26 @@ import type { RsClient } from "./rs-client.ts"; import { buildTools } from "./tools.ts"; export interface PackageProfileChild { + access_mode: string; + active: boolean; grant_id: string; + purpose: string; rs: RsClient; + source: PackageSource | null; + streams: PackageStream[]; +} + +interface PackageSource { + connection_id?: string; + connector_display_name?: string; + display_name?: string; + id: string; + kind: "connector" | "provider_native"; +} + +interface PackageStream { + instance_ids: string[]; + name: string; } export interface PackageProfile { @@ -17,6 +35,14 @@ export interface PackageProfile { const GRANT_ID_PATTERN = /^[A-Za-z0-9._~-]+$/; const SELECTOR_RECOVERY = "Pass exactly one valid child grant_id. Use list_package_children to discover package children, then retry."; +const PACKAGE_TOOL_DESCRIPTIONS: Record = { + aggregate: "Prefer for counts, sums, min/max, distinct counts, or grouped/time rollups when record bodies are not needed. `field` is required for sum, min/max, and distinct count. Use one grouping (`group_by` XOR `group_by_time`; the latter needs `granularity`). `other_count` reveals top-N truncation. Find groupable fields in `schema`. Read-only.", + fetch: "Fetch a search result as an OpenAI-compatible document (`id`, `title`, `text`, `url`, `metadata`); use `query_records` for canonical PDPP record envelopes. Pass self-contained `connection_id/stream:record_id` ids unchanged, or legacy `stream:record_id` with `connection_id`. `fields` projects the source; if no `text`, `content`, `body`, or `summary` field remains, `text` is compact JSON of the projection. On `ambiguous_connection` (409), choose a `connection_id` from `available_connections`. Read-only.", + query_records: "Read records from the selected child's stream. Returns at most 25 by default (limit max 100); REST requests over 100 get `limit_clamped` in `meta.warnings[]`. `content` previews the first 5. Page with `cursor`, narrow with `fields`; projections retain operational handles (`id`, stream, `connection_id`, `connector_key`). `structuredContent.data` is the machine envelope. Read-only.", + read_record_field: "Read bounded text from a record field using its self-contained `connection_id/stream:record_id` id or `pdpp://record/...` URI, or explicit `connection_id` + `stream` + `record_id`, and `field_path`. Navigate with `offset_chars` or the returned `cursor`; use `q` to search within the field. Read-only.", + schema: "Read the selected child's schema: streams, canonical `connector_key`, field filter/sort/count, expansions, aggregates, projections, search, pagination, and `connection_id` identities. Refine with `schema`, `schema(stream)`, then `schema(stream, connection_id)` when names repeat. Use compact detail by default; full detail needs a stream. Read-only.", + search: "Use lexical for exact terms, semantic for conceptual matches, or hybrid to combine. Results include self-contained fetch ids and source handles (`connection_id`, `connector_key`); `structuredContent.data` holds compact metadata. Find modes and query capabilities in `schema`. Lexical/semantic page with `cursor`; hybrid does not page. Pages default to 25 (max 100). Read-only.", +}; export function isPackageDataTool( name: unknown, @@ -42,6 +68,7 @@ export function resolvePackageChild( grantId: unknown, ): | { child: PackageProfileChild } + | { error: ReturnType } | { error: ReturnType } { if (typeof grantId !== "string" || !GRANT_ID_PATTERN.test(grantId)) { return { @@ -49,14 +76,72 @@ export function resolvePackageChild( }; } const child = profile.children.find((entry) => entry.grant_id === grantId); - return child - ? { child } - : { + if (!child) { + return { error: selectorError( "unknown_grant_selector", `Unknown grant_id. ${SELECTOR_RECOVERY}`, ), }; + } + return child.active ? { child } : { error: inactiveChildError(child.grant_id) }; +} + +function inactiveChildError(grantId: string) { + return { + isError: true as const, + content: [ + { + type: "text" as const, + text: `Access to grant ${grantId} has ended. Do not retry this call. Ask the owner if this access is needed again.`, + }, + ], + structuredContent: { + error: { code: "package_child_inactive", grant_id: grantId }, + }, + }; +} + +function selectedChildError(result: ToolResult, grantId: string): ToolResult { + const error = result.structuredContent?.error as { code?: unknown } | undefined; + if ( + result.structuredContent?.http_status === 403 && + error && + ["package_child_inactive", "grant_revoked", "grant_expired"].includes( + String(error.code), + ) + ) { + return inactiveChildError(grantId); + } + return result; +} + +function discoveryChild(child: PackageProfileChild) { + return { + grant_id: child.grant_id, + source: child.source + ? { + id: child.source.id, + kind: child.source.kind, + ...(child.source.connection_id + ? { connection_id: child.source.connection_id } + : {}), + ...(child.source.display_name + ? { display_name: child.source.display_name } + : {}), + ...(child.source.connector_display_name + ? { connector_display_name: child.source.connector_display_name } + : {}), + } + : null, + purpose: child.purpose, + streams: child.streams.map(({ name, instance_ids }) => ({ + name, + instance_ids: [...instance_ids], + })), + access_mode: child.access_mode, + active: child.active, + }; } function selectorError(code: string, message: string) { @@ -147,16 +232,68 @@ export function buildPackageProfileTools( if (profile.children.length === 0) { throw new TypeError("Package profile requires a covered child"); } + const listChildren = { + name: "list_package_children", + title: "List package children", + description: + "List this package token's covered grants, source metadata, streams, access modes, and active status. Read-only.", + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + inputSchema: z.object({}).strict(), + outputSchema: z.object({ + children: z.array( + z.object({ + grant_id: z.string(), + source: z + .union([ + z.object({ + id: z.string(), + kind: z.enum(["connector", "provider_native"]), + connection_id: z.string().optional(), + display_name: z.string().optional(), + connector_display_name: z.string().optional(), + }), + z.null(), + ]) + .describe("Public source metadata covered by this child."), + purpose: z.string(), + streams: z.array( + z.object({ + name: z.string(), + instance_ids: z.array(z.string()), + }), + ), + access_mode: z.string(), + active: z.boolean(), + }), + ), + }), + handler: async () => ({ + content: [ + { + type: "text" as const, + text: `Package children: ${profile.children + .map((child) => `${child.grant_id} (${child.active ? "active" : "inactive"})`) + .join(", ")}`, + }, + ], + structuredContent: { + children: profile.children.map(discoveryChild), + }, + }), + }; const schemaTools = buildTools({ rs: profile.children[0]!.rs, providerUrl }); - return schemaTools.map((tool) => ({ + return [listChildren, ...schemaTools.map((tool) => ({ ...tool, - description: `${tool.description} For package tokens, pass one child grant_id; use list_package_children to discover children.`, + description: `${PACKAGE_TOOL_DESCRIPTIONS[tool.name]} Select one child with grant_id; discover children with list_package_children.`, inputSchema: (tool.inputSchema as z.ZodObject).extend({ grant_id: z .string() - .describe( - "Required package child grant_id. Discover children with list_package_children.", - ), + .describe("Required package child grant_id."), }), handler: async (args: Record) => { const selection = resolvePackageChild(profile, args.grant_id); @@ -165,18 +302,29 @@ export function buildPackageProfileTools( } const { child } = selection; const grantId = child.grant_id; - const { grant_id: _selector, ...readArgs } = args; + const readArgs = { ...args }; + delete readArgs.grant_id; const childTool = buildTools({ rs: child.rs, providerUrl }).find( (entry) => entry.name === tool.name, ); if (!childTool) { throw new Error(`Missing package tool: ${tool.name}`); } - return scopePackageResult( + const childResult = selectedChildError( await childTool.handler(readArgs), + grantId, + ); + if ( + (childResult.structuredContent?.error as { code?: unknown } | undefined) + ?.code === "package_child_inactive" + ) { + return childResult; + } + return scopePackageResult( + childResult, tool.name, grantId, ); }, - })); + }))]; } diff --git a/reference-implementation/vendor/mcp-server/src/server.ts b/reference-implementation/vendor/mcp-server/src/server.ts index 9bd9d8e9a..80814560d 100644 --- a/reference-implementation/vendor/mcp-server/src/server.ts +++ b/reference-implementation/vendor/mcp-server/src/server.ts @@ -22,7 +22,7 @@ export const DEFAULT_SERVER_VERSION = "0.0.0"; // here; tool descriptions stay concise and routing-specific. export const PDPP_MCP_INSTRUCTIONS = "PDPP tools are grant-scoped. Start with `schema`, then call `schema(stream)` after choosing a stream; on client tokens, add `connection_id` when a stream name appears under multiple sources or before full schema. Use `connection_id` from schema results or `available_connections` errors to disambiguate sources. Filters must be typed objects, not bracket strings. Page and narrow with `limit`, `cursor`, and `fields`; prefer `aggregate` or lexical `search` for exact terms. " + - "The configured bearer limits every result; do not use owner or control-plane tokens for normal MCP access. Schema advertises valid fields, filter operators, expand relations, sort/count support, connection identities, and connector keys. Persist `connection_id` for client-token reconnects. Package tokens require one `grant_id` on every data tool; choose the child with `grant_id` and keep it on follow-up calls. Search result ids are self-contained handles; pass them to `fetch` for projected records or to `read_record_field` for bounded field windows. " + + "The configured bearer limits every result; do not use owner or control-plane tokens for normal MCP access. Schema advertises valid fields, filter operators, expand relations, sort/count support, connection identities, and connector keys. Persist `connection_id` for client-token reconnects. Search result ids are self-contained handles; pass them to `fetch` for projected records or to `read_record_field` for bounded field windows. " + "When a preview is not enough, follow `structuredContent.content_ladder`: call `read_record_field` with the supplied arguments. Resource-aware hosts may also read hidden/returned resource URIs, but generic resource reads are not required for ordinary text evidence. " + "`content[]` is the reliable model-visible guide and includes next cursors/bookmarks when present; `structuredContent` is a host-dependent machine envelope, not the only place to find next-step handles."; @@ -81,7 +81,9 @@ export function createPdppMcpServer({ serverInfo.icons = serverIcons; } const server = new McpServer(serverInfo, { - instructions: PDPP_MCP_INSTRUCTIONS, + instructions: packageProfile + ? `${PDPP_MCP_INSTRUCTIONS} Call list_package_children to see package children and their statuses, then pass one grant_id on each data tool.` + : PDPP_MCP_INSTRUCTIONS, }); const tools = packageProfile diff --git a/reference-implementation/vendor/mcp-server/test/package-profile.test.ts b/reference-implementation/vendor/mcp-server/test/package-profile.test.ts index 2b112b7e0..1e755ede9 100644 --- a/reference-implementation/vendor/mcp-server/test/package-profile.test.ts +++ b/reference-implementation/vendor/mcp-server/test/package-profile.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { createPackageProfile } from "../../../server/package-rs-client.ts"; import type { PackageProfile } from "../src/package-profile.ts"; import { RsClient } from "../src/rs-client.ts"; import { handleStreamableHttpRequest } from "../src/server.ts"; @@ -15,6 +16,7 @@ const TOOL_NAMES = [ "fetch", "read_record_field", ]; +const PACKAGE_TOOL_NAMES = ["list_package_children", ...TOOL_NAMES]; const VALID_ARGS: Record> = { schema: {}, query_records: { stream: "notes" }, @@ -24,10 +26,28 @@ const VALID_ARGS: Record> = { read_record_field: { field_path: "text", id: "conn_a/notes:a" }, }; -function fixture(options: { invalidGrantId?: string } = {}) { +function fixture(options: { inactiveGrantId?: string; invalidGrantId?: string } = {}) { const reads: string[] = []; - const children = ["a.child~_1-", "b.child"].map((grant_id) => ({ + const children = [ + { grant_id: "a.child~_1-", active: options.inactiveGrantId !== "a.child~_1-" }, + { grant_id: "b.child", active: options.inactiveGrantId !== "b.child" }, + ].map(({ grant_id, active }) => ({ grant_id, + source: { + id: `source-${grant_id}`, + kind: "connector" as const, + display_name: `Source ${grant_id}`, + connection_id: `pairwise-${grant_id}`, + subject_id: "subject_private", + rs_instance: "rs_instance_private", + internal_instance: "cin_internal_1", + }, + purpose: "personal records", + streams: [ + { name: "notes", instance_ids: [`pairwise-${grant_id}`], private_field: "omit" }, + ], + access_mode: "continuous", + active, rs: new RsClient({ accessToken: `token-${grant_id}`, fetch: async (input) => { @@ -109,9 +129,9 @@ async function request( providerUrl: "https://rs.test", }, ); - return JSON.parse(await response.text()) as { - result: Record; - status?: number; + return { + ...(JSON.parse(await response.text()) as { result: Record }), + httpStatus: response.status, }; } @@ -149,7 +169,7 @@ test("package tools require a valid known grant_id before any RS call", async () }); test("each package data tool reads only the named child for either child", async () => { - const { profile, reads } = fixture(); + const { profile, reads } = fixture({ inactiveGrantId: "none" }); for (const child of profile.children) { for (const name of TOOL_NAMES) { reads.length = 0; @@ -172,7 +192,7 @@ test("each package data tool reads only the named child for either child", async }); test("selected child grant_invalid is returned without calling a sibling", async () => { - const invalidGrantId = "b.child"; + const invalidGrantId = "a.child~_1-"; const { profile, reads } = fixture({ invalidGrantId }); const { result } = await call(profile, "query_records", { ...VALID_ARGS.query_records!, @@ -187,7 +207,162 @@ test("selected child grant_invalid is returned without calling a sibling", async assert.equal(structured.http_status, 403); assert.equal(reads.length, 1); assert.ok(reads[0]!.startsWith(`${invalidGrantId}:`)); - assert.equal(reads.some((read) => read.startsWith("a.child~_1-:")), false); + assert.equal(reads.some((read) => read.startsWith("b.child:")), false); + const sibling = await call(profile, "query_records", { + ...VALID_ARGS.query_records!, + grant_id: "b.child", + }); + assert.equal(sibling.result.isError, undefined, "lost mapping does not block a sibling"); + assert.ok(reads.some((read) => read.startsWith("b.child:"))); +}); + +test("package discovery lists covered children, including inactive, with only declared public fields", async () => { + const { profile, reads } = fixture({ inactiveGrantId: "b.child" }); + const { result } = await call(profile, "list_package_children", {}); + assert.equal(result.isError, undefined); + assert.deepEqual(reads, []); + assert.deepEqual(result.structuredContent, { + children: [ + { + grant_id: "a.child~_1-", + purpose: "personal records", + streams: [{ name: "notes", instance_ids: ["pairwise-a.child~_1-"] }], + source: { + id: "source-a.child~_1-", + kind: "connector", + display_name: "Source a.child~_1-", + connection_id: "pairwise-a.child~_1-", + }, + access_mode: "continuous", + active: true, + }, + { + grant_id: "b.child", + purpose: "personal records", + streams: [{ name: "notes", instance_ids: ["pairwise-b.child"] }], + source: { + id: "source-b.child", + kind: "connector", + display_name: "Source b.child", + connection_id: "pairwise-b.child", + }, + access_mode: "continuous", + active: false, + }, + ], + }); + const serialized = JSON.stringify(result); + for (const secret of ["subject_private", "rs_instance_private", "cin_internal_1", "private_field", "token-"]) { + assert.equal(serialized.includes(secret), false, `discovery excludes ${secret}`); + } + const initialized = await request(profile, "initialize", { + capabilities: {}, + clientInfo: { name: "test", version: "1" }, + protocolVersion: "2025-03-26", + }); + const exposed = JSON.stringify({ result, instructions: initialized.result.instructions }); + assert.doesNotMatch(exposed, /subject_id|rs_instance|cin_/); + assert.equal(exposed.includes("child-bearer-secret"), false); + assert.match(String(initialized.result.instructions), /list_package_children.*statuses.*grant_id/); +}); + +test("frozen package members map to child metadata without exposing per-child tokens", async () => { + const profile = createPackageProfile({ + providerUrl: "https://rs.test", + fetch: async () => Response.json({ data: [] }), + members: [ + { + access_mode: "continuous", + active: true, + connection_id: "pairwise-a", + grant: { + purpose: "personal records", + streams: [{ name: "notes", instance_ids: ["pairwise-a"] }], + }, + grant_id: "grant-a", + package_id: "package-public", + source: { id: "source-a", kind: "connector", connection_id: "pairwise-a" }, + token: "child-bearer-secret", + }, + { + access_mode: "single_use", + active: false, + connection_id: "pairwise-b", + grant: { + purpose: "personal records", + streams: [{ name: "notes", instance_ids: ["pairwise-b"] }], + }, + grant_id: "grant-b", + package_id: "package-public", + source: { id: "source-b", kind: "provider_native", connection_id: "pairwise-b" }, + token: "inactive-bearer-secret", + }, + ], + }); + assert.deepEqual( + profile.children.map(({ grant_id, active, streams }) => ({ grant_id, active, streams })), + [ + { grant_id: "grant-a", active: true, streams: [{ name: "notes", instance_ids: ["pairwise-a"] }] }, + { grant_id: "grant-b", active: false, streams: [{ name: "notes", instance_ids: ["pairwise-b"] }] }, + ], + ); + assert.equal("token" in profile.children[0]!, false); + const { result } = await call(profile, "list_package_children", {}); + assert.equal(JSON.stringify(result).includes("bearer-secret"), false); +}); + +test("inactive selected child returns terminal package_child_inactive while its sibling stays readable", async () => { + const { profile, reads } = fixture({ inactiveGrantId: "b.child" }); + const inactiveId = "b.child"; + const inactive = await call(profile, "query_records", { + ...VALID_ARGS.query_records!, + grant_id: inactiveId, + }); + assert.equal(inactive.httpStatus, 200, "inactive child is a tool result, not HTTP status"); + assert.equal(inactive.result.isError, true); + assert.deepEqual(inactive.result.structuredContent, { + error: { code: "package_child_inactive", grant_id: inactiveId }, + }); + assert.deepEqual(inactive.result.content, [ + { + type: "text", + text: `Access to grant ${inactiveId} has ended. Do not retry this call. Ask the owner if this access is needed again.`, + }, + ]); + assert.doesNotMatch(JSON.stringify(inactive.result), /subject_id|rs_instance|cin_|child-bearer-secret/); + assert.equal(reads.length, 0); + const sibling = await call(profile, "query_records", { + ...VALID_ARGS.query_records!, + grant_id: "a.child~_1-", + }); + assert.equal(sibling.result.isError, undefined); + assert.ok(reads.some((read) => read.startsWith("a.child~_1-:"))); +}); + +test("selected downstream 403 package inactivity codes map to package_child_inactive", async () => { + for (const code of ["package_child_inactive", "grant_revoked", "grant_expired"]) { + const child = fixture().profile.children[0]!; + child.rs = new RsClient({ + accessToken: "child-token", + fetch: async () => + Response.json({ error: { code, message: "downstream detail" } }, { status: 403 }), + providerUrl: "https://rs.test", + }); + const { result } = await call({ children: [child] }, "query_records", { + ...VALID_ARGS.query_records!, + grant_id: child.grant_id, + }); + assert.equal(result.isError, true, code); + assert.deepEqual(result.structuredContent, { + error: { code: "package_child_inactive", grant_id: child.grant_id }, + }); + assert.deepEqual(result.content, [ + { + type: "text", + text: `Access to grant ${child.grant_id} has ended. Do not retry this call. Ask the owner if this access is needed again.`, + }, + ]); + } }); test("package tools/list requires grant_id and hides resource templates; client surface stays unchanged", async () => { @@ -198,27 +373,42 @@ test("package tools/list requires grant_id and hides resource templates; client (packageList.result.tools as Array<{ name: string }>).map( (tool) => tool.name, ), - TOOL_NAMES, + PACKAGE_TOOL_NAMES, ); for (const tool of packageList.result.tools as Array<{ - inputSchema: { properties: Record; required: string[] }; + name: string; + annotations?: { idempotentHint?: boolean; readOnlyHint?: boolean }; + inputSchema: { properties: Record; required?: string[] }; + outputSchema?: { properties?: Record }; }>) { + if (tool.name === "list_package_children") { + assert.deepEqual(tool.inputSchema.properties, {}); + assert.equal((tool.inputSchema.required ?? []).includes("grant_id"), false); + assert.equal(tool.annotations?.readOnlyHint, true); + assert.equal(tool.annotations?.idempotentHint, true); + assert.ok(tool.outputSchema?.properties?.children, "discovery declares its output schema"); + continue; + } assert.equal( tool.inputSchema.properties.grant_id && (tool.inputSchema.properties.grant_id as { type: string }).type, "string", ); - assert.ok(tool.inputSchema.required.includes("grant_id")); + assert.ok(tool.inputSchema.required?.includes("grant_id")); } + const unknown = await call(profile, "schema", { grant_id: "never-granted" }); + assert.equal( + (unknown.result.structuredContent as { error: { code: string } }).error.code, + "unknown_grant_selector", + ); const templates = await request(profile, "resources/templates/list", {}); assert.equal( templates.error !== undefined || templates.result.resourceTemplates === undefined, true, ); - assert.ok( - Buffer.byteLength(JSON.stringify(packageList.result.tools)) < 24_576, - ); + const packageToolListBytes = Buffer.byteLength(JSON.stringify(packageList.result.tools)); + assert.ok(packageToolListBytes <= 24_576, `package tools/list: ${packageToolListBytes} bytes`); const clientList = await request(undefined, "tools/list", {}); assert.deepEqual(