diff --git a/docs/agent-skills/pdpp-data-access/references/troubleshooting.md b/docs/agent-skills/pdpp-data-access/references/troubleshooting.md index 9aa2e09be..8729b3ca7 100644 --- a/docs/agent-skills/pdpp-data-access/references/troubleshooting.md +++ b/docs/agent-skills/pdpp-data-access/references/troubleshooting.md @@ -106,7 +106,7 @@ When the flow breaks, work the failure top-down: discovery -> agent-connect -> a - The grant doesn't cover this stream/field/filter combination. Don't fall back to a broader grant in the background. Request an upgrade and explain to the user why. -**Symptom:** `403 grant_revoked`. +**Symptom:** `401 invalid_token` with `grant_revoked` in the error body. - The owner revoked. Stop. Don't request a replacement automatically; ask the user. diff --git a/reference-implementation/server/auth-middleware.ts b/reference-implementation/server/auth-middleware.ts index bb8b90a29..74cd7d624 100644 --- a/reference-implementation/server/auth-middleware.ts +++ b/reference-implementation/server/auth-middleware.ts @@ -73,12 +73,15 @@ function httpQuotedString(value: unknown): string { return String(value).replace(/["\\]/g, "\\$&"); } -function setProtectedResourceMetadataChallenge(res: ResponseLike): void { +function setProtectedResourceMetadataChallenge(res: ResponseLike, invalidToken = false): void { const metadataUrl = getProtectedResourceMetadataUrl(res); if (!metadataUrl) { return; } - res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${httpQuotedString(metadataUrl)}"`); + res.setHeader( + "WWW-Authenticate", + `Bearer ${invalidToken ? 'error="invalid_token", ' : ""}resource_metadata="${httpQuotedString(metadataUrl)}"` + ); } // ─── Auth-gate query context ───────────────────────────────────────────────── @@ -187,7 +190,8 @@ async function emitInactiveTokenQuery(req: RequestLike, res: ResponseLike, info: Object.assign(new Error("Token rejected at auth gate"), { code: info.inactive_reason || "authentication_error", message: inactiveTokenMessage(info.inactive_reason), - }) + }), + 401 ); } @@ -195,21 +199,20 @@ async function respondToInactiveToken(req: RequestLike, res: ResponseLike, info: if (info.trace_id) { setReferenceTraceId(res, info.trace_id); } + setProtectedResourceMetadataChallenge(res, true); await emitInactiveTokenQuery(req, res, info); if (info.inactive_reason === "grant_revoked") { - return pdppError(res, 403, "grant_revoked", "Grant has been revoked"); + return pdppError(res, 401, "grant_revoked", "Grant has been revoked"); } if (info.inactive_reason === "grant_expired") { - return pdppError(res, 403, "grant_expired", "Grant has expired"); + return pdppError(res, 401, "grant_expired", "Grant has expired"); } if (info.inactive_reason === "grant_invalid") { - return pdppError(res, 403, "grant_invalid", "Grant is malformed or no longer valid"); + return pdppError(res, 401, "authentication_error", "Invalid or expired token"); } if (info.inactive_reason === "authorization_state.unsupported_legacy_shape") { - setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, info.inactive_reason, "Fresh consent is required"); } - setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, "authentication_error", "Invalid or expired token"); } diff --git a/reference-implementation/server/index.ts b/reference-implementation/server/index.ts index a2389d2ba..4eddaa5a3 100644 --- a/reference-implementation/server/index.ts +++ b/reference-implementation/server/index.ts @@ -269,7 +269,7 @@ import { selfCallUrlHostForBindHost, validateReachabilityContract, } from "./reachability-contract.ts"; -import { createPackageRsClient, createRsClient } from "./package-rs-client.ts"; +import { createPackageProfile, createRsClient } from "./package-rs-client.ts"; import { reconcilePolyfillManifests } from "./polyfill-manifest-reconcile.ts"; import { postgresPersistContentAddressedBlob } from "./postgres-records.ts"; import { @@ -1489,12 +1489,15 @@ function getProtectedResourceMetadataUrl(res: ResLike) { return typeof metadataUrl === "string" && metadataUrl ? metadataUrl : null; } -function setProtectedResourceMetadataChallenge(res: ResLike) { +function setProtectedResourceMetadataChallenge(res: ResLike, invalidToken = false) { const metadataUrl = getProtectedResourceMetadataUrl(res); if (!metadataUrl) { return; } - res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${httpQuotedString(metadataUrl)}"`); + res.setHeader( + "WWW-Authenticate", + `Bearer ${invalidToken ? 'error="invalid_token", ' : ""}resource_metadata="${httpQuotedString(metadataUrl)}"` + ); } function sha256Hex(value: string) { @@ -1780,13 +1783,13 @@ function inferAuthGateQueryContext(req: ReqLike, _tokenInfo: TokenInfo = {}) { return null; } -async function emitQueryRejected(context: QueryContext, req: ReqLike, err: ApiError) { +async function emitQueryRejected(context: QueryContext, req: ReqLike, err: ApiError, statusOverride?: number) { // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. if (!context?.queryId) { return; } const code = err.code || "api_error"; - const status = codeToStatus[code] || 500; + const status = statusOverride ?? codeToStatus[code] ?? 500; const data: Record = { ...(context.queryData || {}), error: { @@ -2093,6 +2096,7 @@ async function requireTokenWithIntrospection( } } if (!info.active) { + setProtectedResourceMetadataChallenge(res, true); if (info.trace_id) { setReferenceTraceId(res, info.trace_id); } @@ -2130,29 +2134,25 @@ async function requireTokenWithIntrospection( info.inactive_reason === "grant_invalid" ? "Grant is malformed or no longer valid" : "Invalid or expired token"; - const gateErr = Object.assign(new Error(gateErrMsg), { - code: info.inactive_reason || "authentication_error", - }) as ApiError; - await emitQueryRejected(authGateContext, req, gateErr); + const inactiveReason = info.inactive_reason || "authentication_error"; + const gateErr = Object.assign(new Error(gateErrMsg), { code: inactiveReason }) as ApiError; + await emitQueryRejected(authGateContext, req, gateErr, 401); } if (info.inactive_reason === "grant_revoked") { - return pdppError(res, 403, "grant_revoked", "Grant has been revoked"); + return pdppError(res, 401, "grant_revoked", "Grant has been revoked"); } if (info.inactive_reason === "grant_expired") { - return pdppError(res, 403, "grant_expired", "Grant has expired"); + return pdppError(res, 401, "grant_expired", "Grant has expired"); } if (info.inactive_reason === "grant_invalid") { - return pdppError(res, 403, "grant_invalid", "Grant is malformed or no longer valid"); + return pdppError(res, 401, "authentication_error", "Invalid or expired token"); } if (info.inactive_reason?.startsWith("context.")) { - setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, info.inactive_reason, "Token introspection failed closed"); } if (info.inactive_reason === "authorization_state.unsupported_legacy_shape") { - setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, info.inactive_reason, "Fresh consent is required"); } - setProtectedResourceMetadataChallenge(res); return pdppError(res, 401, "authentication_error", "Invalid or expired token"); } req.tokenInfo = info; @@ -7469,10 +7469,10 @@ function buildRsApp(opts: ServerOpts = {}) { // extraction: same `requireTrustedHostedMcpResource` host guard, same // `setHostedMcpProtectedResourceMetadata` middleware, same // `requireToken` + `requireClientOrMcpPackage` auth posture, same - // package-token → PackageRsClient fan-out, same single-bearer path, + // package-token → grant_id-selected child, same single-bearer client path, // same response envelope and headers. mountRsHostedMcp(app, { - createPackageRsClient, + createPackageProfile, createRsClient, explicitResource, getGrantPackageAccess, diff --git a/reference-implementation/server/package-rs-client.ts b/reference-implementation/server/package-rs-client.ts index 56002f72c..f800da300 100644 --- a/reference-implementation/server/package-rs-client.ts +++ b/reference-implementation/server/package-rs-client.ts @@ -1,153 +1,25 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -/** - * PackageRsClient — package-aware RS client for the hosted MCP adapter. - * - * Conforms to the same surface as `@pdpp/mcp-server` RsClient - * (`getJson`, `getRaw`, `postJson`, `patchJson`, `deleteJson`, `buildUrl`) - * but does not hold a single bearer. Instead it holds the package's active - * child-grant members and routes each call to one or more children: - * - * - `GET /v1/schema` — fan out, merge streams + granted_connections. - * - `GET /.well-known/oauth-protected-resource` — server-global passthrough. - * - `GET /v1/streams` — fan out, tag each row with source identity. - * - `GET /v1/streams/:s/records` — require source selector → single child. - * - `GET /v1/streams/:s/records/:id` — same as above. - * - `GET /v1/streams/:s` — same as above. - * - `GET /v1/search[/...]` — fan out, tailor streams[] per child - * grant, merge results. - * - `GET /v1/blobs/:id` (getRaw) — require source selector → single child. - * - * - `POST /v1/event-subscriptions` — require source selector. - * - `GET /v1/event-subscriptions` — fan out, merge data[]. - * - `GET /v1/event-subscriptions/:id` — locate owning child. - * - `PATCH /v1/event-subscriptions/:id` — locate owning child. - * - `DELETE /v1/event-subscriptions/:id` — locate owning child. - * - `POST /v1/event-subscriptions/:id/test-event` — locate owning child. - * - * Each child read passes through the same RS endpoint the MCP adapter - * selected; this preserves the spec's "MCP is an adapter over the same - * REST contract" guarantee. Every record/blob/event-subscription read is - * executed under exactly one child grant's bearer. - * - * Spec: openspec/changes/add-hosted-mcp-grant-packages/ - */ - -import { - type HeaderParams, - type QueryParams, - type QueryValue, - RsClient, - type RsResponse, -} from "../vendor/mcp-server/src/rs-client.ts"; -import { schemaSourceOptions } from "../operations/rs-schema-get/compact-view.ts"; -import type { ConnectorSchemaItem } from "../operations/rs-schema-get/index.ts"; +import type { PackageProfile } from "../vendor/mcp-server/src/package-profile.ts"; +import { RsClient } from "../vendor/mcp-server/src/rs-client.ts"; -const AMBIGUOUS_CONNECTION_LIST_LIMIT = 12; -const EVENT_SUB_PATH_PATTERN = /^\/v1\/event-subscriptions\/([^/]+)$/; -const PARSABLE_POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/; -const STREAM_PATH_PATTERN = /^\/v1\/streams\/([^/]+)/; -const TEST_EVENT_PATH_PATTERN = /^\/v1\/event-subscriptions\/([^/]+)\/test-event$/; -const TRAILING_SLASH_PATTERN = /\/$/; - -type JsonObject = Record; -type JsonRow = JsonObject; -type PackageRsResponse = RsResponse; -type PackageRsFetch = typeof globalThis.fetch; -interface PackageRsMember { - connection_id?: string | null; - grant?: { streams?: Array<{ instance_ids?: string[]; name?: string }> }; - grant_id: string; - source?: { display_name?: string; id?: string; [key: string]: unknown }; - token: string; -} -interface PackageChild { - client: RsClient; - member: PackageRsMember; +interface PackageMember { + grant_id: string; + token: string; } -interface RequestOptions { - body?: unknown; - headers?: HeaderParams | undefined; - query?: QueryParams | undefined; -} -interface PackageClientOptions { - fetch?: PackageRsFetch; - providerUrl: string; - userAgent?: string; -} -type PackageClientFactoryOptions = PackageClientOptions & { accessToken: string }; -type PackageClientPackageOptions = PackageClientOptions & { members: PackageRsMember[] }; -type Route = - | { - kind: - | "passthrough" - | "fanout_schema" - | "fanout_streams" - | "fanout_search" - | "fanout_event_sub_list" - | "event_sub_create" - | "source_required_get"; - } - | { kind: "locate_event_sub" | "event_sub_test_event"; id: string }; -type ChildLookup = { child: PackageChild } | { error: PackageRsResponse }; -interface TypedErrorOptions { - availableConnectionLimit?: number; - availableConnections?: JsonRow[]; - includeAvailableConnections?: boolean; - param?: string; - retryWith?: string | null; - status?: number; - unavailableChildren?: Array<{ child: PackageChild; error: { code?: string; message?: string; status?: number } }>; -} -interface SourceTag { - connection_id: string | null; - connector_id: string | null; - connector_key: string | null; - display_name?: string; - grant_id: string; -} -type SchemaEntry = { connections: JsonRow[]; stream: JsonRow } | { connections: JsonRow[] }; -interface SchemaChildEnvelope { - connectorItems: JsonRow[]; - entries: SchemaEntry[]; - member: PackageRsMember; - sourceTag: SourceTag; -} -interface SchemaAggregate { - connections: JsonRow[]; - connectorItems: JsonRow[]; - seenConnection: Set; - seenStream: Set; - streams: JsonRow[]; -} -type SearchHit = JsonObject; -interface SchemaResponseContract { - bearer: unknown; - connectors: ConnectorSchemaItem[]; - object: "schema"; - [key: string]: unknown; -} -type SchemaSourceOption = ReturnType[number]; -function rsRequestOptions({ body, headers, query }: RequestOptions): { - body?: unknown; - headers?: HeaderParams; - query?: QueryParams; -} { - return { - ...(body === undefined ? {} : { body }), - ...(headers === undefined ? {} : { headers }), - ...(query === undefined ? {} : { query }), - }; +interface ClientOptions { + fetch?: typeof globalThis.fetch; + providerUrl: string; + userAgent?: string; } /** * Build one single-bearer RsClient. Thin factory so the hosted-MCP route * adapter can construct a single-grant (`client`-token) RsClient against a * chosen fetch base (e.g. the internal RS base for self-calls) without - * importing `@pdpp/mcp-server` internals directly. Mirrors how - * `createPackageRsClient` builds each child client. + * importing `@pdpp/mcp-server` internals directly. * * @param {object} args * @param {string} args.providerUrl — fetch base for HTTP self-calls. @@ -156,1189 +28,47 @@ function rsRequestOptions({ body, headers, query }: RequestOptions): { * @param {string} [args.userAgent] */ export function createRsClient({ - providerUrl, - accessToken, - fetch = globalThis.fetch, - userAgent, -}: PackageClientFactoryOptions): RsClient { - if (!providerUrl) { - throw new TypeError("createRsClient requires providerUrl"); - } - return new RsClient({ accessToken, fetch, providerUrl, ...(userAgent === undefined ? {} : { userAgent }) }); -} - -/** - * Build one PackageRsClient. - * - * @param {object} args - * @param {string} args.providerUrl - * @param {Array} args.members — active grant-package members. - * @param {Function} [args.fetch] — fetch implementation. - * @param {string} [args.userAgent] - */ -export function createPackageRsClient({ - providerUrl, - members, - fetch = globalThis.fetch, - userAgent, -}: PackageClientPackageOptions): PackageRsClient { - if (!providerUrl) { - throw new TypeError("PackageRsClient requires providerUrl"); - } - if (!Array.isArray(members) || members.length === 0) { - throw new TypeError("PackageRsClient requires at least one active member"); - } - - // Build a child RsClient per member. - const children = members.map((member) => ({ - client: new RsClient({ - accessToken: member.token, - fetch, - providerUrl, - ...(userAgent === undefined ? {} : { userAgent }), - }), - member, - })); - - return new PackageRsClient({ children, providerUrl }); -} - -class PackageRsClient { - private readonly children: PackageChild[]; - private readonly providerUrl: string; - - constructor({ providerUrl, children }: { providerUrl: string; children: PackageChild[] }) { - this.providerUrl = providerUrl.replace(TRAILING_SLASH_PATTERN, ""); - this.children = children; - } - - // -------- public RsClient-compatible surface -------- - - getJson(path: string, { query, headers }: RequestOptions = {}): Promise { - const route = routeFor("GET", path); - if (route.kind === "passthrough") { - return this.passthroughGet(path, { headers, query }); - } - if (route.kind === "fanout_schema") { - return this.fanoutSchema({ headers, query }); - } - if (route.kind === "fanout_streams") { - return this.fanoutStreams({ headers, query }); - } - if (route.kind === "fanout_search") { - return this.fanoutSearch(path, { headers, query }); - } - if (route.kind === "fanout_event_sub_list") { - return this.fanoutEventSubList({ headers, query }); - } - if (route.kind === "locate_event_sub") { - return this.locateEventSubAndGet(route.id, { headers, query }); - } - if (route.kind === "source_required_get") { - return this.sourceRequiredJson("GET", path, { headers, query }); - } - // Fallback: not all paths are mapped (e.g., streams resource template). - return this.sourceRequiredJson("GET", path, { headers, query }); - } - - // Server-global public discovery is not scoped to a package child. - // Reusing one child client preserves the RsClient-compatible response shape. - passthroughGet(path: string, { query, headers }: RequestOptions = {}): Promise { - const [child] = this.children; - if (!child) { - throw new Error("PackageRsClient has no active children"); - } - return child.client.getJson(path, rsRequestOptions({ headers, query })); - } - - getRaw(path: string, { query, headers }: RequestOptions = {}): Promise { - // Blob / binary reads always require a source selector. - return this.sourceRequiredRaw("GET", path, { headers, query }); - } - - postJson(path: string, { body, query, headers }: RequestOptions = {}): Promise { - const route = routeFor("POST", path); - if (route.kind === "event_sub_create") { - return this.createEventSubForChild({ body, headers, query }); - } - if (route.kind === "event_sub_test_event") { - return this.locateEventSubAndForward(route.id, "POST", `${path}`, { body, headers, query }); - } - return this.sourceRequiredJson("POST", path, { body, headers, query }); - } - - patchJson(path: string, { body, query, headers }: RequestOptions = {}): Promise { - const route = routeFor("PATCH", path); - if (route.kind === "locate_event_sub") { - return this.locateEventSubAndForward(route.id, "PATCH", path, { body, headers, query }); - } - return this.sourceRequiredJson("PATCH", path, { body, headers, query }); - } - - deleteJson(path: string, { query, headers }: RequestOptions = {}): Promise { - const route = routeFor("DELETE", path); - if (route.kind === "locate_event_sub") { - return this.locateEventSubAndForward(route.id, "DELETE", path, { headers, query }); - } - return this.sourceRequiredJson("DELETE", path, { headers, query }); - } - - buildUrl(path: string, query?: QueryParams): string { - const url = new URL(path.startsWith("/") ? path : `/${path}`, `${this.providerUrl}/`); - if (query && typeof query === "object") { - for (const [key, value] of Object.entries(query)) { - if (value === undefined || value === null) { - continue; - } - if (Array.isArray(value)) { - for (const e of value) { - url.searchParams.append(key, String(e)); - } - } else if (typeof value === "object") { - throw new TypeError( - `query parameter '${key}' must be a scalar or array; encode nested query shapes explicitly before calling PackageRsClient` - ); - } else { - url.searchParams.append(key, String(value)); - } - } - } - return url.toString(); - } - - // -------- fanout strategies -------- - - async fanoutSchema({ query, headers }: RequestOptions): Promise { - if (query?.connection_id) { - const selected = this.selectChildOrError(query.connection_id, { - sourceId: sourceIdFromQuery(query), - streamNames: queryStringValues(query.stream), - }); - if ("error" in selected) { - return selected.error; - } - const result = await selected.child.client.getJson( - "/v1/schema", - rsRequestOptions({ headers, query: stripPackageSelectors(query) }) - ); - return mergeSchemaEnvelopes([selected.child], [result]); - } - - if (query?.detail === "full") { - if (!query?.stream) { - return typedError( - "invalid_request", - 'schema detail "full" requires `stream`; call /v1/schema?view=compact for global discovery, then /v1/schema?stream=&connection_id=&detail=full for exhaustive detail.', - this.children, - { includeAvailableConnections: false, param: "detail", status: 400 } - ); - } - - const preflightQuery: QueryParams = { ...query, view: "compact" }; - preflightQuery.detail = undefined; - const preflight = await Promise.all( - this.children.map(({ client }) => - client.getJson("/v1/schema", rsRequestOptions({ headers, query: preflightQuery })) - ) - ); - const matches: Array<{ child: PackageChild; options: SchemaSourceOption[] }> = []; - const available: SchemaSourceOption[] = []; - preflight.forEach((result, index) => { - if (!result.ok) { - return; - } - const options = schemaSourceOptions(schemaDocument(responseBody(result)) as SchemaResponseContract, { - stream: String(query.stream), - }); - if (options.length === 0) { - return; - } - const child = this.children[index]; - if (!child) { - return; - } - matches.push({ child, options }); - available.push(...options); - }); - if (available.length > 1) { - return typedError( - "ambiguous_schema_detail", - `schema detail "full" for stream "${query.stream}" matches ${available.length} sources; retry with connection_id to fetch one source's exhaustive schema.`, - this.children, - { - availableConnections: available.map((option) => ({ ...option })), - param: "connection_id", - retryWith: "connection_id", - } - ); - } - if (matches.length === 1) { - const [match] = matches; - if (!match) { - return typedError("not_found", "No schema source matched the requested stream", this.children); - } - const result = await match.child.client.getJson("/v1/schema", rsRequestOptions({ headers, query })); - return mergeSchemaEnvelopes([match.child], [result]); - } - } - - const results = await Promise.all( - this.children.map(({ client }) => client.getJson("/v1/schema", rsRequestOptions({ headers, query }))) - ); - return mergeSchemaEnvelopes(this.children, results); - } - - async fanoutStreams({ query, headers }: RequestOptions): Promise { - // If caller scoped to one connection_id, route to that child only. - if (query?.connection_id) { - const selected = this.selectChildOrError(query.connection_id, { - sourceId: sourceIdFromQuery(query), - }); - if ("error" in selected) { - return selected.error; - } - return selected.child.client.getJson( - "/v1/streams", - rsRequestOptions({ headers, query: stripPackageSelectors(query) }) - ); - } - - const results = await Promise.all( - this.children.map(({ client }) => client.getJson("/v1/streams", rsRequestOptions({ headers, query }))) - ); - return mergeListEnvelopes(this.children, results, "/v1/streams"); - } - - async fanoutSearch(path: string, { query, headers }: RequestOptions): Promise { - if (query?.connection_id) { - const selected = this.selectChildOrError(query.connection_id, { - sourceId: sourceIdFromQuery(query), - streamNames: requestedStreamsFromQuery(query), - }); - if ("error" in selected) { - return selected.error; - } - return selected.child.client.getJson(path, rsRequestOptions({ headers, query: stripPackageSelectors(query) })); - } - - const results = await Promise.all( - this.children.map(({ member, client }) => { - const childQuery = searchQueryForChild(query, member); - if (childQuery === null) { - return emptySearchResponse(); - } - return client.getJson(path, rsRequestOptions({ headers, query: childQuery })); - }) - ); - return mergeSearchEnvelopes(this.children, results, path, query); - } - - async sourceRequiredJson( - method: "GET" | "POST" | "PATCH" | "DELETE", - path: string, - opts: RequestOptions - ): Promise { - const child = await this.resolveChildOrError(path, opts); - if ("error" in child) { - return child.error; - } - let clientMethod: "getJson" | "postJson" | "patchJson" | "deleteJson"; - if (method === "GET") { - clientMethod = "getJson"; - } else if (method === "POST") { - clientMethod = "postJson"; - } else if (method === "PATCH") { - clientMethod = "patchJson"; - } else { - clientMethod = "deleteJson"; - } - return child.child.client[clientMethod]( - path, - rsRequestOptions({ ...opts, query: stripPackageSelectors(opts.query) }) - ); - } - - async sourceRequiredRaw(_method: "GET", path: string, opts: RequestOptions): Promise { - const child = await this.resolveChildOrError(path, opts); - if ("error" in child) { - return child.error; - } - return child.child.client.getRaw(path, rsRequestOptions({ ...opts, query: stripPackageSelectors(opts.query) })); - } - - async resolveChildOrError(path: string, { query }: RequestOptions): Promise { - const connectionId = query?.connection_id; - if (connectionId) { - return this.selectChildOrError(connectionId, { - sourceId: sourceIdFromQuery(query), - streamNames: queryStringValues(streamNameFromPath(path)), - }); - } - if (this.children.length === 1 && this.children[0]) { - return { child: this.children[0] }; - } - return { - error: await this.ambiguousConnectionError( - "This hosted MCP package contains multiple sources. Pass `connection_id` to select one. Use `schema` to discover available connections." - ), - }; - } - - ambiguousConnectionError(message: string): Promise { - return Promise.resolve(typedError("ambiguous_connection", message, this.children)); - } - - selectChildOrError( - connectionId: QueryValue, - { sourceId = null, streamNames = [] }: { sourceId?: string | null; streamNames?: string[] } = {} - ): ChildLookup { - const matches = matchingChildrenByConnectionId(this.children, connectionId, streamNames, sourceId); - if (matches.length === 1 && matches[0]) { - return { child: matches[0] }; - } - if (matches.length > 1) { - return { - error: typedError( - "ambiguous_connection", - `connection_id "${String(connectionId)}" exists under multiple package sources; pass source_id to select one`, - matches, - { param: "source_id", retryWith: "source_id" } - ), - }; - } - return { - error: typedError( - "not_found", - `connection_id "${String(connectionId)}" is not part of the selected package source and stream`, - this.children - ), - }; - } - - // -------- event subscriptions -------- - - async createEventSubForChild({ body, query, headers }: RequestOptions): Promise { - // Selector can come from query (?connection_id) OR top-level body.connection_id. - const sel = - query?.connection_id ?? - (isRecord(body) && typeof body.connection_id === "string" ? body.connection_id : undefined); - let child: PackageChild | undefined; - if (sel) { - const selected = this.selectChildOrError(sel, { - sourceId: sourceIdFromRequest(query, body), - }); - if ("error" in selected) { - return selected.error; - } - ({ child } = selected); - } else if (this.children.length === 1) { - [child] = this.children; - } else { - return await this.ambiguousConnectionError( - "This hosted MCP package contains multiple sources. Pass `connection_id` when creating an event subscription so it binds to exactly one child grant." - ); - } - const childBody: JsonObject = isRecord(body) ? { ...body } : {}; - childBody.connection_id = undefined; - childBody.connector_id = undefined; - childBody.source_id = undefined; - if (!child) { - throw new Error("PackageRsClient has no active children"); - } - return child.client.postJson( - "/v1/event-subscriptions", - rsRequestOptions({ body: childBody, headers, query: stripPackageSelectors(query, { connectionId: true }) }) - ); - } - - async fanoutEventSubList({ query, headers }: RequestOptions): Promise { - const results = await Promise.all( - this.children.map(({ client }) => client.getJson("/v1/event-subscriptions", rsRequestOptions({ headers, query }))) - ); - return mergeEventSubListEnvelopes(this.children, results); - } - - async locateEventSubAndGet(id: string, opts: RequestOptions): Promise { - const found = await this.locateEventSubOwner(id); - if ("error" in found) { - return found.error; - } - return found.child.client.getJson(`/v1/event-subscriptions/${encodeURIComponent(id)}`, rsRequestOptions(opts)); - } - - async locateEventSubAndForward( - id: string, - method: "POST" | "PATCH" | "DELETE", - path: string, - opts: RequestOptions - ): Promise { - const found = await this.locateEventSubOwner(id); - if ("error" in found) { - return found.error; - } - let clientMethod: "postJson" | "patchJson" | "deleteJson"; - if (method === "POST") { - clientMethod = "postJson"; - } else if (method === "PATCH") { - clientMethod = "patchJson"; - } else { - clientMethod = "deleteJson"; - } - return found.child.client[clientMethod](path, rsRequestOptions(opts)); - } - - async locateEventSubOwner(id: string): Promise { - const probes = await Promise.all( - this.children.map(async ({ member, client }) => { - try { - const r = await client.getJson(`/v1/event-subscriptions/${encodeURIComponent(id)}`); - return { client, member, ok: r.ok && r.status === 200 }; - } catch { - return { client, member, ok: false }; - } - }) - ); - const owner = probes.find((p) => p.ok); - if (owner) { - return { child: { client: owner.client, member: owner.member } }; - } - return { - error: { - contentType: "application/json", - error: { - code: "not_found", - message: `event subscription "${id}" is not owned by any active member of this hosted MCP package`, - type: "not_found", - }, - ok: false, - requestId: null, - status: 404, - }, - }; - } -} - -// -------- routing classifier -------- - -function routeFor(method: string, path: string): Route { - const clean = path.split("?")[0] ?? path; - - if (method === "GET" && clean === "/.well-known/oauth-protected-resource") { - return { kind: "passthrough" }; - } - - if (method === "GET" && clean === "/v1/schema") { - return { kind: "fanout_schema" }; - } - if (method === "GET" && clean === "/v1/streams") { - return { kind: "fanout_streams" }; - } - if ( - method === "GET" && - (clean === "/v1/search" || clean === "/v1/search/semantic" || clean === "/v1/search/hybrid") - ) { - return { kind: "fanout_search" }; - } - if (method === "GET" && clean === "/v1/event-subscriptions") { - return { kind: "fanout_event_sub_list" }; - } - - if (method === "POST" && clean === "/v1/event-subscriptions") { - return { kind: "event_sub_create" }; - } - - const eventSubMatch = clean.match(EVENT_SUB_PATH_PATTERN); - if (eventSubMatch) { - return { id: decodeURIComponent(eventSubMatch[1] ?? ""), kind: "locate_event_sub" }; - } - const testEventMatch = clean.match(TEST_EVENT_PATH_PATTERN); - if (testEventMatch && method === "POST") { - return { id: decodeURIComponent(testEventMatch[1] ?? ""), kind: "event_sub_test_event" }; - } - - return { kind: "source_required_get" }; -} - -// -------- selectors -------- - -function matchingChildrenByConnectionId( - children: PackageChild[], - connectionId: string | QueryValue, - streamNames: string[] = [], - sourceId: string | null = null -): PackageChild[] { - if (!connectionId) { - return []; - } - return children.filter( - ({ member }) => - (!sourceId || member.source?.id === sourceId) && grantedInstanceIds(member, streamNames).has(String(connectionId)) - ); -} - -function grantedInstanceIds(member: PackageRsMember, streamNames: string[] = []): Set { - const requestedStreams = new Set(streamNames); - const streams = Array.isArray(member.grant?.streams) ? member.grant.streams : []; - return new Set( - streams - .filter( - (stream) => - requestedStreams.size === 0 || (typeof stream.name === "string" && requestedStreams.has(stream.name)) - ) - .flatMap((stream) => (Array.isArray(stream.instance_ids) ? stream.instance_ids : [])) - .filter((instanceId) => typeof instanceId === "string" && instanceId.length > 0) - ); -} - -function streamNameFromPath(path: string): string | null { - const match = path.split("?")[0]?.match(STREAM_PATH_PATTERN); - return match?.[1] ? decodeURIComponent(match[1]) : null; -} - -function stripPackageSelectors( - query: QueryParams | undefined, - { connectionId = false }: { connectionId?: boolean } = {} -): QueryParams | undefined { - if (!query || typeof query !== "object") { - return query; - } - const { connector_id: _connectorId, source_id: _sourceId, ...rest } = query; - if (connectionId) { - rest.connection_id = undefined; - } - return rest; -} - -function sourceIdFromQuery(query: QueryParams | undefined): string | null { - return firstNonEmptyString(query?.source_id, query?.connector_id) ?? null; -} - -function sourceIdFromRequest(query: QueryParams | undefined, body: unknown): string | null { - const requestBody = isRecord(body) ? body : {}; - return ( - firstNonEmptyString(query?.source_id, query?.connector_id, requestBody.source_id, requestBody.connector_id) ?? null - ); -} - -function queryStringValues(value: QueryValue | string | null | undefined): string[] { - const values: string[] = []; - collectStreamQueryValues(values, value as QueryValue); - return values; -} - -function searchQueryForChild(query: QueryParams | undefined, member: PackageRsMember): QueryParams | null | undefined { - const requested = requestedStreamsFromQuery(query); - if (requested.length === 0) { - return query; - } - const granted = grantedStreamNames(member); - if (!granted) { - return query; - } - - const selected = granted.has("*") ? requested : requested.filter((stream) => granted.has(stream)); - if (selected.length === 0) { - return null; - } - - const next: QueryParams = query && typeof query === "object" ? { ...query } : {}; - next.streams = undefined; - next["streams[]"] = undefined; - next.streams = selected; - return next; -} - -function requestedStreamsFromQuery(query: QueryParams | undefined): string[] { - if (!query || typeof query !== "object") { - return []; - } - const values: string[] = []; - collectStreamQueryValues(values, query.streams); - collectStreamQueryValues(values, query["streams[]"]); - - const seen = new Set(); - return values.filter((value) => { - if (seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); -} - -function collectStreamQueryValues(out: string[], value: QueryValue): void { - if (Array.isArray(value)) { - for (const entry of value) { - collectStreamQueryValues(out, entry); - } - return; - } - if (typeof value !== "string") { - return; - } - const trimmed = value.trim(); - if (trimmed.length > 0) { - out.push(trimmed); - } -} - -function grantedStreamNames(member: PackageRsMember): Set | null { - const streams = member.grant?.streams; - if (!Array.isArray(streams)) { - return null; - } - const names = streams - .map((stream) => (typeof stream?.name === "string" ? stream.name.trim() : "")) - .filter((name) => name.length > 0); - return new Set(names); -} - -function emptySearchResponse(): PackageRsResponse { - return { - body: { data: [], has_more: false, object: "list" }, - contentType: "application/json", - ok: true, - requestId: null, - status: 200, - }; -} - -// -------- envelope helpers -------- - -function memberSourceTag(member: PackageRsMember): SourceTag { - const connectorKey = member.source?.id ?? null; - return { - connection_id: member.connection_id ?? null, - connector_id: connectorKey, - connector_key: connectorKey, - grant_id: member.grant_id, - ...(member.source?.display_name ? { display_name: member.source.display_name } : {}), - }; -} - -function availableConnectionsList( - children: PackageChild[], - { limit = Number.POSITIVE_INFINITY }: { limit?: number } = {} -): JsonRow[] { - return children.slice(0, limit).map(({ member }) => ({ - connection_id: member.connection_id ?? null, - connector_key: member.source?.id ?? null, - grant_id: member.grant_id, - ...(member.source?.display_name ? { display_name: member.source.display_name } : {}), - })); -} - -function unavailableConnectionsList( - entries: Array<{ child: PackageChild; error: { code?: string; message?: string; status?: number } }> -): JsonRow[] { - return entries.map(({ child, error }) => ({ - ...availableConnectionsList([child])[0], - error: { - code: error?.code ?? "source_unavailable", - message: error?.message ?? "Source is unavailable", - }, - status: error?.status ?? null, - })); -} - -function typedError( - code: string, - message: string, - children: PackageChild[], - options: TypedErrorOptions = {} -): PackageRsResponse { - const unavailableConnections = unavailableConnectionsList(options.unavailableChildren ?? []); - const limit = options.availableConnectionLimit ?? AMBIGUOUS_CONNECTION_LIST_LIMIT; - let availableConnections: JsonRow[]; - const { availableConnections: configuredConnections } = options; - if (Array.isArray(configuredConnections)) { - availableConnections = configuredConnections; - } else if (options.includeAvailableConnections === false) { - availableConnections = []; - } else { - availableConnections = availableConnectionsList(children, { limit }); - } - const error: JsonObject = { - code, - message, - type: code, - ...(options.param ? { param: options.param } : {}), - ...(availableConnections.length > 0 ? { available_connections: availableConnections } : {}), - ...(options.includeAvailableConnections === false ? {} : { available_connection_count: children.length }), - ...(options.retryWith === null ? {} : { retry_with: options.retryWith ?? "connection_id" }), - }; - if ( - options.includeAvailableConnections !== false && - !Array.isArray(options.availableConnections) && - availableConnections.length < children.length - ) { - error.available_connections_truncated = true; - error.available_connections_omitted = children.length - availableConnections.length; - error.discovery_hint = "Call `schema` for the full granted connection index before retrying with `connection_id`."; - } - if (unavailableConnections.length > 0) { - error.unavailable_connections = unavailableConnections; - } - return { - contentType: "application/json", - error, - ok: false, - requestId: null, - status: options.status ?? (code === "not_found" ? 404 : 409), - }; -} - -function schemaDocument(body: unknown): JsonObject { - if (isRecord(body) && isRecord(body.data)) { - return body.data; - } - return isRecord(body) ? body : {}; -} - -function responseBody(response: PackageRsResponse): unknown { - return response.ok ? response.body : undefined; -} - -function rowsFromResponseBody(body: unknown): JsonRow[] { - if (isRecord(body)) { - return rowsFromUnknown(body.data); - } - if (Array.isArray(body)) { - return rowsFromUnknown(body); - } - return []; -} - -function firstResponse(results: PackageRsResponse[]): PackageRsResponse { - const [first] = results; - if (!first) { - throw new Error("PackageRsClient received no child responses"); - } - return first; -} - -function normalizeSchemaChildEnvelope(child: PackageChild, result: PackageRsResponse): SchemaChildEnvelope { - const rawBody = responseBody(result); - const resultBody: JsonObject = isRecord(rawBody) ? rawBody : {}; - const childData = isRecord(resultBody.data) ? resultBody.data : resultBody; - const sourceTag = memberSourceTag(child.member); - const connectorItems: JsonRow[] = []; - const entries: SchemaEntry[] = []; - const connectors = rowsFromUnknown(childData.connectors); - - if (connectors.length > 0) { - for (const connector of connectors) { - const connectorSource = isRecord(connector.source) ? { ...sourceTag, ...connector.source } : sourceTag; - connectorItems.push({ ...connector, source: connectorSource }); - for (const stream of rowsFromUnknown(connector.streams)) { - entries.push({ - connections: rowsFromUnknown(stream.granted_connections), - stream, - }); - } - } - } else { - for (const stream of rowsFromUnknown(childData.streams)) { - entries.push({ connections: [], stream }); - } - for (const connection of rowsFromUnknown(childData.granted_connections)) { - entries.push({ connections: [connection] }); - } - } - - return { connectorItems, entries, member: child.member, sourceTag }; -} - -function aggregateSchemaChildEnvelope(aggregate: SchemaAggregate, envelope: SchemaChildEnvelope): void { - for (const connectorItem of envelope.connectorItems) { - aggregate.connectorItems.push(connectorItem); - } - for (const entry of envelope.entries) { - if ("stream" in entry) { - const key = `${entry.stream.name}::${envelope.member.grant_id}::${envelope.member.connection_id}`; - if (aggregate.seenStream.has(key)) { - continue; - } - aggregate.seenStream.add(key); - aggregate.streams.push({ ...entry.stream, source: envelope.sourceTag }); - } - for (const connection of entry.connections) { - const key = `${connection?.connection_id ?? ""}::${envelope.member.grant_id}`; - if (aggregate.seenConnection.has(key)) { - continue; - } - aggregate.seenConnection.add(key); - aggregate.connections.push({ ...connection, source: envelope.sourceTag }); - } - } -} - -function mergeSchemaEnvelopes(children: PackageChild[], results: PackageRsResponse[]): PackageRsResponse { - // The canonical /v1/schema response shape is - // { data: { object: 'schema', connectors: [{ object:'connector', - // source, streams:[{ name, granted_connections?, ...}], stream_count }], - // connector_count, stream_count, source } } - // Each per-child fan-out call returns one connector item (the child's - // bound source). We merge by: - // - flattening streams from every child's connectors[] into a single - // `data.streams` array tagged with the child's source identity, - // so MCP consumers get one stream list to iterate without having - // to walk a `connectors[]` shape that only exists in the per-child - // envelope; - // - flattening per-stream `granted_connections` into a single - // `data.granted_connections` for the same reason; - // - attaching `data.package = { grant_package, member_count, sources }` - // so consumers can tell they are looking at a package fanout. - // The original `connectors[]` array is preserved verbatim under - // `data.connectors` (concatenated across children) so callers that - // already speak the canonical schema envelope keep working. - const ok = results.find((r) => r.ok); - if (!ok) { - return firstResponse(results); - } - - const rawBody = responseBody(ok); - const baseBody: JsonObject = isRecord(rawBody) ? { ...rawBody } : { data: {} }; - const data: JsonObject = isRecord(baseBody.data) ? { ...baseBody.data } : {}; - - const aggregate: SchemaAggregate = { - connections: [], - connectorItems: [], - seenConnection: new Set(), - seenStream: new Set(), - streams: [], - }; - - results.forEach((r, i) => { - if (!r.ok) { - return; - } - const child = children[i]; - if (!child) { - return; - } - aggregateSchemaChildEnvelope(aggregate, normalizeSchemaChildEnvelope(child, r)); - }); - - data.streams = aggregate.streams; - data.granted_connections = aggregate.connections; - if (aggregate.connectorItems.length > 0) { - data.connectors = aggregate.connectorItems; - data.connector_count = aggregate.connectorItems.length; - data.stream_count = aggregate.streams.length; - } - data.package = { - grant_package: true, - member_count: children.length, - sources: children.map(({ member }) => memberSourceTag(member)), - }; - baseBody.data = data; - baseBody.meta = { - ...(baseBody.meta || {}), - package: { - member_count: children.length, - partial: results.some((r) => !r.ok), - }, - }; - return { ...ok, body: baseBody }; -} - -function mergeListEnvelopes(children: PackageChild[], results: PackageRsResponse[], _path: string): PackageRsResponse { - // For /v1/streams: shape is { data: [...] }. Concatenate; tag each row. - const ok = results.find((r) => r.ok); - if (!ok) { - return firstResponse(results); - } - - const merged: JsonRow[] = []; - const meta: JsonObject = { package: { member_count: children.length, partial: false } }; - const warnings: JsonRow[] = []; - - results.forEach((r, i) => { - const child = children[i]; - if (!child) { - return; - } - if (!r.ok) { - const packageMeta = isRecord(meta.package) ? meta.package : {}; - packageMeta.partial = true; - meta.package = packageMeta; - warnings.push({ - code: "source_unavailable", - message: `Source ${child.member.connection_id || child.member.source?.id || child.member.grant_id} returned ${r.status}`, - source: memberSourceTag(child.member), - }); - return; - } - const body = responseBody(r); - const rows = rowsFromResponseBody(body); - for (const row of rows) { - merged.push({ ...row, source: memberSourceTag(child.member) }); - } - }); - - const rawBody = responseBody(ok); - const baseBody: JsonObject = isRecord(rawBody) ? { ...rawBody } : {}; - baseBody.data = merged; - baseBody.meta = { ...(baseBody.meta || {}), ...meta }; - if (warnings.length > 0) { - const warningMeta = isRecord(baseBody.meta) ? baseBody.meta : {}; - warningMeta.warnings = [...(Array.isArray(warningMeta.warnings) ? warningMeta.warnings : []), ...warnings]; - baseBody.meta = warningMeta; - } - return { ...ok, body: baseBody }; -} - -function placeSearchHitsInEnvelope(baseBody: JsonObject, limitedHits: SearchHit[], totalScanned: number): void { - const data = isRecord(baseBody.data) ? baseBody.data : null; - if (Array.isArray(baseBody.data)) { - baseBody.data = limitedHits; - } else if (data && Array.isArray(data.data)) { - baseBody.data = { ...data, data: limitedHits }; - } else if (data && Array.isArray(data.results)) { - baseBody.data = { ...data, results: limitedHits, ...(totalScanned > 0 ? { scanned: totalScanned } : {}) }; - } else if (Array.isArray(baseBody.results)) { - baseBody.results = limitedHits; - } else { - baseBody.data = { results: limitedHits }; - } -} - -function mergeSearchEnvelopes( - children: PackageChild[], - results: PackageRsResponse[], - _path: string, - query: QueryParams = {} -): PackageRsResponse { - const ok = results.find((r) => r.ok); - if (!ok) { - return firstResponse(results); - } - - const requestedLimit = parsePositiveInt(query.limit) ?? 25; - const mergedHits: SearchHit[] = []; - const warnings: JsonRow[] = []; - let totalScanned = 0; - let childHasMore = false; - - results.forEach((r, i) => { - const child = children[i]; - if (!child) { - return; - } - if (!r.ok) { - warnings.push({ - code: "source_unavailable", - message: `Source ${child.member.connection_id || child.member.source?.id || child.member.grant_id} returned ${r.status}`, - source: memberSourceTag(child.member), - }); - return; - } - const hits = extractSearchHits(responseBody(r)); - for (const hit of hits) { - mergedHits.push(decorateSearchHitWithSource(hit, memberSourceTag(child.member))); - } - const body = responseBody(r); - const data = isRecord(body) && isRecord(body.data) ? body.data : {}; - if (typeof data.scanned === "number") { - totalScanned += data.scanned; - } - if (searchBodyHasMore(body)) { - childHasMore = true; - } - }); - - const dedupedHits = dedupeSearchHits(mergedHits); - const limitedHits = dedupedHits.slice(0, requestedLimit); - const truncated = dedupedHits.length > limitedHits.length; - const sourceMix = sourceMixForHits(limitedHits); - const rawBody = responseBody(ok); - const baseBody: JsonObject = isRecord(rawBody) ? { ...rawBody } : {}; - placeSearchHitsInEnvelope(baseBody, limitedHits, totalScanned); - baseBody.has_more = truncated || childHasMore || baseBody.has_more === true; - baseBody.meta = { - ...(baseBody.meta || {}), - package: { - fanout_limit: requestedLimit, - member_count: children.length, - merged_hit_count: dedupedHits.length, - partial: warnings.length > 0, - returned_hit_count: limitedHits.length, - source_mix: sourceMix, - }, - }; - if (warnings.length > 0) { - const warningMeta = isRecord(baseBody.meta) ? baseBody.meta : {}; - warningMeta.warnings = [...(Array.isArray(warningMeta.warnings) ? warningMeta.warnings : []), ...warnings]; - baseBody.meta = warningMeta; - } - return { ...ok, body: baseBody }; -} - -function extractSearchHits(body: unknown): SearchHit[] { - if (!isRecord(body)) { - return []; - } - if (Array.isArray(body.results)) { - return rowsFromUnknown(body.results); - } - if (Array.isArray(body.data)) { - return rowsFromUnknown(body.data); - } - if (isRecord(body.data) && Array.isArray(body.data.data)) { - return rowsFromUnknown(body.data.data); - } - if (isRecord(body.data) && Array.isArray(body.data.results)) { - return rowsFromUnknown(body.data.results); - } - return []; -} - -function parsePositiveInt(value: QueryValue): number | null { - if (typeof value === "number" && Number.isInteger(value) && value > 0) { - return value; - } - if (typeof value === "string" && PARSABLE_POSITIVE_INTEGER_PATTERN.test(value)) { - return Number.parseInt(value, 10); - } - return null; -} - -function decorateSearchHitWithSource(hit: SearchHit, fallbackSource: SourceTag): SearchHit { - const source = isRecord(hit.source) ? { ...fallbackSource, ...hit.source } : fallbackSource; - return { - ...hit, - connection_id: firstNonEmptyString(hit.connection_id, hit.connector_instance_id, source.connection_id), - connector_key: firstNonEmptyString(hit.connector_key, hit.connector_id, source.connector_key, source.connector_id), - source, - ...(firstNonEmptyString(hit.display_name, source.display_name) - ? { display_name: firstNonEmptyString(hit.display_name, source.display_name) } - : {}), - }; -} - -function dedupeSearchHits(hits: SearchHit[]): SearchHit[] { - const seen = new Set(); - const out: SearchHit[] = []; - for (const hit of hits) { - const key = searchHitDedupeKey(hit); - if (seen.has(key)) { - continue; - } - seen.add(key); - out.push(hit); - } - return out; -} - -function searchHitDedupeKey(hit: SearchHit): string { - const source = isRecord(hit.source) ? hit.source : {}; - return [ - firstNonEmptyString(hit.connection_id, source.connection_id), - firstNonEmptyString(hit.connector_key, hit.connector_id, source.connector_key, source.connector_id), - firstNonEmptyString(hit.stream, hit.stream_name, hit.streamName), - firstNonEmptyString(hit.record_key, hit.recordKey, hit.record_id, hit.recordId, hit.id, hit.url), - ] - .map((part) => part ?? "") - .join("\0"); -} - -function sourceMixForHits(hits: SearchHit[]): JsonRow[] { - const byConnection = new Map(); - for (const hit of hits) { - const source = isRecord(hit.source) ? hit.source : {}; - const connectionId = firstNonEmptyString(hit.connection_id, source.connection_id) ?? null; - const connectorKey = - firstNonEmptyString(hit.connector_key, hit.connector_id, source.connector_key, source.connector_id) ?? null; - const displayName = firstNonEmptyString(hit.display_name, source.display_name) ?? null; - const key = `${connectionId ?? ""}\0${connectorKey ?? ""}`; - const existing = byConnection.get(key); - if (existing) { - existing.count += 1; - } else { - byConnection.set(key, { - connection_id: connectionId, - connector_key: connectorKey, - ...(displayName ? { display_name: displayName } : {}), - count: 1, - }); - } - } - return [...byConnection.values()]; -} - -function searchBodyHasMore(body: unknown): boolean { - if (!isRecord(body)) { - return false; - } - if (body.has_more === true) { - return true; - } - if (isRecord(body.data) && body.data.has_more === true) { - return true; - } - return false; -} - -function firstNonEmptyString(...values: unknown[]): string | undefined { - let first: string | undefined; - for (const value of values) { - if (typeof value === "string" && value.length > 0) { - first = value; - break; - } - } - return first; -} - -function mergeEventSubListEnvelopes(children: PackageChild[], results: PackageRsResponse[]): PackageRsResponse { - const ok = results.find((r) => r.ok); - if (!ok) { - return firstResponse(results); - } - - const merged: JsonRow[] = []; - const warnings: JsonRow[] = []; - - results.forEach((r, i) => { - const child = children[i]; - if (!child) { - return; - } - if (!r.ok) { - warnings.push({ - code: "source_unavailable", - message: `Source ${child.member.connection_id || child.member.source?.id || child.member.grant_id} returned ${r.status}`, - source: memberSourceTag(child.member), - }); - return; - } - const body = responseBody(r); - const rows = rowsFromResponseBody(body); - for (const row of rows) { - merged.push({ ...row, source: memberSourceTag(child.member) }); - } - }); - - const rawBody = responseBody(ok); - const baseBody: JsonObject = isRecord(rawBody) ? { ...rawBody } : {}; - baseBody.data = merged; - baseBody.meta = { - ...(baseBody.meta || {}), - package: { member_count: children.length, partial: warnings.length > 0 }, - }; - if (warnings.length > 0) { - const warningMeta = isRecord(baseBody.meta) ? baseBody.meta : {}; - warningMeta.warnings = [...(Array.isArray(warningMeta.warnings) ? warningMeta.warnings : []), ...warnings]; - baseBody.meta = warningMeta; - } - return { ...ok, body: baseBody }; -} - -function isRecord(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function rowsFromUnknown(value: unknown): JsonRow[] { - return Array.isArray(value) ? value.filter(isRecord) : []; + providerUrl, + accessToken, + fetch = globalThis.fetch, + userAgent, +}: ClientOptions & { + accessToken: string; +}): RsClient { + if (!providerUrl) { + throw new TypeError("createRsClient requires providerUrl"); + } + return new RsClient({ + accessToken, + fetch, + providerUrl, + ...(userAgent === undefined ? {} : { userAgent }), + }); +} + +export function createPackageProfile({ + providerUrl, + members, + fetch = globalThis.fetch, + userAgent, +}: ClientOptions & { members: readonly PackageMember[] }): PackageProfile { + if (!providerUrl) { + throw new TypeError("createPackageProfile requires providerUrl"); + } + if (members.length === 0) { + throw new TypeError( + "createPackageProfile requires at least one active member", + ); + } + return { + children: members.map(({ grant_id, token }) => ({ + grant_id, + rs: new RsClient({ + accessToken: token, + fetch, + providerUrl, + ...(userAgent === undefined ? {} : { userAgent }), + }), + })), + }; } diff --git a/reference-implementation/server/request-helpers.ts b/reference-implementation/server/request-helpers.ts index 898e31f17..a77fd266a 100644 --- a/reference-implementation/server/request-helpers.ts +++ b/reference-implementation/server/request-helpers.ts @@ -120,13 +120,18 @@ export function pdppError( // ─── Query spine emission ──────────────────────────────────────────────────── -export async function emitQueryRejected(context: QueryContext, req: RequestLike, err: ErrorWithCode): Promise { +export async function emitQueryRejected( + context: QueryContext, + req: RequestLike, + err: ErrorWithCode, + statusOverride?: number +): Promise { // biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior. if (!context?.queryId) { return; } const code = err.code || "api_error"; - const status = codeToStatus[code] || 500; + const status = statusOverride ?? codeToStatus[code] ?? 500; const data: Record = { ...(context.queryData || {}), error: { diff --git a/reference-implementation/server/routes/ref-error-status.ts b/reference-implementation/server/routes/ref-error-status.ts index 9a26aab04..2dbd0677b 100644 --- a/reference-implementation/server/routes/ref-error-status.ts +++ b/reference-implementation/server/routes/ref-error-status.ts @@ -140,9 +140,9 @@ export const codeToStatus: Readonly> = { field_not_granted: 403, field_not_text: 422, grant_consumed: 403, - grant_expired: 403, + grant_expired: 401, grant_invalid: 403, - grant_revoked: 403, + grant_revoked: 401, grant_stream_not_allowed: 403, ingest_batch_storage_error: 503, insufficient_scope: 403, diff --git a/reference-implementation/server/routes/rs-hosted-mcp.ts b/reference-implementation/server/routes/rs-hosted-mcp.ts index 32a359c29..332c24447 100644 --- a/reference-implementation/server/routes/rs-hosted-mcp.ts +++ b/reference-implementation/server/routes/rs-hosted-mcp.ts @@ -7,15 +7,15 @@ // Behaviour-preserving extraction from `server/index.js` per the OpenSpec // change `split-reference-server-by-route-family` (§5.4). These three routes // proxy inbound MCP requests through `@pdpp/mcp-server` using either a -// single-bearer `RsClient` (standard client token) or a fan-out -// `PackageRsClient` (mcp_package token). Auth posture: +// single-bearer `RsClient` (standard client token) or a selected-child +// `PackageProfile` (mcp_package token). Auth posture: // - `requireToken` — rejects unauthenticated and owner-token requests // - `requireClientOrMcpPackage` — only `client` or `mcp_package` token kinds // may reach the MCP surface; owner tokens are always rejected. // - `requireTrustedHostedMcpResource` — host-based guard identical to the // one that guards the canonical provider protected-resource metadata. // -// `handleStreamableHttpRequest` and `createPackageRsClient` are injected via +// `handleStreamableHttpRequest` and `createPackageProfile` are injected via // context because `@pdpp/mcp-server` ships as a JS-only workspace package // without `.d.ts` declarations, so they cannot be imported directly from a // strict-mode `.ts` file. @@ -82,6 +82,7 @@ interface McpServerOptions { readonly accessToken?: string; readonly fetch: typeof globalThis.fetch; readonly providerUrl: string; + readonly packageProfile?: unknown; readonly rsClient?: unknown; readonly serverIcons?: readonly McpServerIcon[]; readonly serverName: string; @@ -90,10 +91,10 @@ interface McpServerOptions { export interface MountRsHostedMcpContext { /** - * `createPackageRsClient` from `./package-rs-client.js`. + * `createPackageProfile` from `./package-rs-client.js`. * Injected for the same reason — called with a JS-only signature. */ - createPackageRsClient: (options: { + createPackageProfile: (options: { providerUrl: string; members: readonly GrantPackageMember[]; fetch: typeof globalThis.fetch; @@ -287,10 +288,17 @@ export function mountRsHostedMcp(app: AppLike, ctx: MountRsHostedMcpContext): vo ): Promise { const access = await ctx.getGrantPackageAccess(req.tokenInfo?.grant_package_id as string); if (!access || access.members.length === 0) { - ctx.pdppError(res, 403, "package_revoked", "Grant package is revoked or has no active members"); + const metadataUrl = res.locals[PROTECTED_RESOURCE_METADATA_URL_LOCAL]; + if (typeof metadataUrl === "string" && metadataUrl) { + res.setHeader( + "WWW-Authenticate", + `Bearer error="invalid_token", resource_metadata="${metadataUrl.replace(/["\\]/g, "\\$&")}"` + ); + } + ctx.pdppError(res, 401, "authentication_error", "Grant package is revoked or has no active members"); return null; } - const rsClient = ctx.createPackageRsClient({ + const packageProfile = ctx.createPackageProfile({ fetch: globalThis.fetch, members: access.members, // Child self-calls use the internal base, not the public edge. @@ -299,7 +307,7 @@ export function mountRsHostedMcp(app: AppLike, ctx: MountRsHostedMcpContext): vo const mcpServerOptions = { fetch: globalThis.fetch, providerUrl: resource, - rsClient, + packageProfile, serverIcons: hostedMcpIcons(resource), serverName: "pdpp-reference-mcp", serverVersion: referenceRevision, diff --git a/reference-implementation/test/cli.test.ts b/reference-implementation/test/cli.test.ts index 210cf7a6a..c65f044c9 100644 --- a/reference-implementation/test/cli.test.ts +++ b/reference-implementation/test/cli.test.ts @@ -89,18 +89,18 @@ const TOP_LEVEL_REGEX_57 = /Request ID: (req_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_58 = /Reference trace ID: (trc_[A-Za-z0-9]+)/; const TOP_LEVEL_REGEX_59 = /view and fields are mutually exclusive/; const TOP_LEVEL_REGEX_60 = /view and fields are mutually exclusive/; -const TOP_LEVEL_REGEX_61 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_61 = /Invalid or expired token/; const TOP_LEVEL_REGEX_62 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_63 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_64 = /Grant is malformed or no longer valid/; -const TOP_LEVEL_REGEX_65 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_64 = /Invalid or expired token/; +const TOP_LEVEL_REGEX_65 = /Invalid or expired token/; const TOP_LEVEL_REGEX_66 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_67 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_68 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_68 = /Invalid or expired token/; const TOP_LEVEL_REGEX_69 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_70 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_71 = /Grant is malformed or no longer valid/; -const TOP_LEVEL_REGEX_72 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_71 = /Invalid or expired token/; +const TOP_LEVEL_REGEX_72 = /Invalid or expired token/; const TOP_LEVEL_REGEX_73 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_74 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_CLIENT_FILTER_UNSUPPORTED = @@ -123,7 +123,7 @@ const TOP_LEVEL_REGEX_92 = /Unknown connector: missing_spotify_connector/; const TOP_LEVEL_REGEX_93 = /view and fields are mutually exclusive/; const TOP_LEVEL_REGEX_94 = /Request ID: req_/; const TOP_LEVEL_REGEX_95 = /Reference trace ID: trc_/; -const TOP_LEVEL_REGEX_96 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_96 = /Invalid or expired token/; const TOP_LEVEL_REGEX_97 = /Request ID: req_/; const TOP_LEVEL_REGEX_98 = /Reference trace ID: trc_/; const TOP_LEVEL_REGEX_99 = /Grant has been revoked/; @@ -132,15 +132,15 @@ const TOP_LEVEL_REGEX_101 = /Reference trace ID: trc_/; const TOP_LEVEL_REGEX_102 = /Grant has expired/; const TOP_LEVEL_REGEX_103 = /Request ID: req_/; const TOP_LEVEL_REGEX_104 = /Reference trace ID: trc_/; -const TOP_LEVEL_REGEX_105 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_105 = /Invalid or expired token/; const TOP_LEVEL_REGEX_106 = /Grant has been revoked/; const TOP_LEVEL_REGEX_107 = /Grant has expired/; -const TOP_LEVEL_REGEX_108 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_108 = /Invalid or expired token/; const TOP_LEVEL_REGEX_109 = /Grant has been revoked/; const TOP_LEVEL_REGEX_110 = /Grant has expired/; const TOP_LEVEL_REGEX_111 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_112 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; -const TOP_LEVEL_REGEX_113 = /Grant is malformed or no longer valid/; +const TOP_LEVEL_REGEX_113 = /Invalid or expired token/; const TOP_LEVEL_REGEX_114 = /Request ID: (req_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_115 = /Reference trace ID: (trc_[A-Za-z0-9_]+)/; const TOP_LEVEL_REGEX_116 = /view and fields are mutually exclusive/; @@ -4208,7 +4208,7 @@ test("PDPP CLI smoke", async (t) => { const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const requestId = rejectedResp.headers.get("Request-Id"); const traceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(requestId, "malformed polyfill client stream-metadata read should surface a request id"); @@ -4216,7 +4216,7 @@ test("PDPP CLI smoke", async (t) => { assert.ok(requestId.startsWith("req_")); assert.ok(traceId.startsWith("trc_")); const rejectedBody = asRecord(await rejectedResp.json()); - assert.equal(asRecord(rejectedBody.error).code, "grant_invalid"); + assert.equal(asRecord(rejectedBody.error).code, "authentication_error"); assert.match(String(asRecord(rejectedBody.error).message ?? ""), TOP_LEVEL_REGEX_64); const timeline = await runCli([ @@ -4349,10 +4349,10 @@ test("PDPP CLI smoke", async (t) => { const rejectedResp = await fetch(`${rsUrl}/v1/streams/top_artists`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const rejectedBody = asRecord(await rejectedResp.json()); const rejectedError = asRecord(rejectedBody.error); - assert.equal(rejectedError.code, "grant_invalid"); + assert.equal(rejectedError.code, "authentication_error"); assert.match(String(rejectedError.message ?? ""), TOP_LEVEL_REGEX_71); return { requestId: rejectedResp.headers.get("Request-Id"), @@ -4385,7 +4385,7 @@ test("PDPP CLI smoke", async (t) => { for await (const scenario of scenarios) { await withMalformedPolyfillClientGrant(async ({ asUrl, rsUrl, approved, visibleRecord, missingConnectorId }) => { const failure = await scenario.trigger({ approved, rsUrl, visibleRecord }); - assert.match(failure.stderr || "Grant is malformed or no longer valid", TOP_LEVEL_REGEX_72); + assert.match(failure.stderr || "Invalid or expired token", TOP_LEVEL_REGEX_72); const requestId = failure.requestId || failure.stderr?.match(TOP_LEVEL_REGEX_73)?.[1]; const traceId = failure.traceId || failure.stderr?.match(TOP_LEVEL_REGEX_74)?.[1]; assert.ok(requestId, `malformed polyfill client ${scenario.label} should surface a request id`); @@ -7987,7 +7987,7 @@ rl.on('line', (line) => { PDPP_CLIENT_TOKEN: approved.token, }); - assert.equal(result.code, 4); + assert.equal(result.code, 3); assert.match(result.stderr, TOP_LEVEL_REGEX_96); assert.match(result.stderr, TOP_LEVEL_REGEX_97); assert.match(result.stderr, TOP_LEVEL_REGEX_98); @@ -8025,7 +8025,7 @@ rl.on('line', (line) => { PDPP_CLIENT_TOKEN: approved.token, }); - assert.equal(result.code, 4); + assert.equal(result.code, 3); assert.match(result.stderr, TOP_LEVEL_REGEX_99); assert.match(result.stderr, TOP_LEVEL_REGEX_100); assert.match(result.stderr, TOP_LEVEL_REGEX_101); @@ -8060,7 +8060,7 @@ rl.on('line', (line) => { PDPP_CLIENT_TOKEN: approved.token, }); - assert.equal(result.code, 4); + assert.equal(result.code, 3); assert.match(result.stderr, TOP_LEVEL_REGEX_102); assert.match(result.stderr, TOP_LEVEL_REGEX_103); assert.match(result.stderr, TOP_LEVEL_REGEX_104); @@ -8151,7 +8151,7 @@ rl.on('line', (line) => { PDPP_CLIENT_TOKEN: approved.token, }); - assert.equal(queryFailure.code, 4); + assert.equal(queryFailure.code, 3); assert.match(queryFailure.stderr, scenario.expectedMessage); const timeline = await runCli([ @@ -8281,7 +8281,7 @@ rl.on('line', (line) => { { PDPP_CLIENT_TOKEN: approved.token } ); - assert.equal(queryFailure.code, 4); + assert.equal(queryFailure.code, 3); assert.match(queryFailure.stderr, scenario.expectedMessage); const requestId = queryFailure.stderr.match(TOP_LEVEL_REGEX_111)?.[1]; const traceId = queryFailure.stderr.match(TOP_LEVEL_REGEX_112)?.[1]; @@ -8406,7 +8406,7 @@ rl.on('line', (line) => { { PDPP_CLIENT_TOKEN: approved.token } ); - assert.equal(queryFailure.code, 4); + assert.equal(queryFailure.code, 3); assert.match(queryFailure.stderr, TOP_LEVEL_REGEX_113); const requestId = queryFailure.stderr.match(TOP_LEVEL_REGEX_114)?.[1]; const traceId = queryFailure.stderr.match(TOP_LEVEL_REGEX_115)?.[1]; diff --git a/reference-implementation/test/error-code-status-table-exhaustive.test.ts b/reference-implementation/test/error-code-status-table-exhaustive.test.ts index 8c314ddbc..cad8a5f76 100644 --- a/reference-implementation/test/error-code-status-table-exhaustive.test.ts +++ b/reference-implementation/test/error-code-status-table-exhaustive.test.ts @@ -83,9 +83,9 @@ const EXPECTED_CODE_TO_STATUS = { field_not_granted: 403, field_not_text: 422, grant_consumed: 403, - grant_expired: 403, + grant_expired: 401, grant_invalid: 403, - grant_revoked: 403, + grant_revoked: 401, grant_stream_not_allowed: 403, ingest_batch_storage_error: 503, insufficient_scope: 403, diff --git a/reference-implementation/test/event-spine.test.ts b/reference-implementation/test/event-spine.test.ts index 02fbaa539..3897b8cef 100644 --- a/reference-implementation/test/event-spine.test.ts +++ b/reference-implementation/test/event-spine.test.ts @@ -1503,7 +1503,7 @@ test("event spine", async (t) => { const rejectedResp = await fetch(`${rsUrl}/v1/streams`, { headers: { Authorization: `Bearer ${approval.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const requestId = rejectedResp.headers.get("Request-Id"); const traceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(requestId?.startsWith("req_")); diff --git a/reference-implementation/test/hosted-mcp-oauth.test.ts b/reference-implementation/test/hosted-mcp-oauth.test.ts index f35cc856d..d3523fc85 100644 --- a/reference-implementation/test/hosted-mcp-oauth.test.ts +++ b/reference-implementation/test/hosted-mcp-oauth.test.ts @@ -184,22 +184,6 @@ function schemaStreamRows(schemaBody: Record): SchemaStreamRow[ }); } -interface SchemaPackageSource extends Record { - connector_id: string; - grant_id: string; -} - -interface SchemaPackageMetadata extends Record { - grant_package: unknown; - member_count: number; - sources: SchemaPackageSource[]; -} - -function schemaPackageMetadata(schemaBody: Record): SchemaPackageMetadata | null { - const data = (schemaBody.data ?? schemaBody) as Record; - return (data.package as SchemaPackageMetadata | undefined) ?? null; -} - function structuredContentData(result: Record): Record { const structuredContent = result.structuredContent as Record; return structuredContent.data as Record; @@ -1927,9 +1911,26 @@ test("/mcp rejects missing and owner bearers", async () => { try { const missing = await fetchJson(`${rsUrl}/mcp`, { method: "POST" }); assert.equal(missing.status, 401); + assert.equal( + missing.resp.headers.get("www-authenticate"), + `Bearer resource_metadata="${rsUrl}/.well-known/oauth-protected-resource/mcp"` + ); assert.equal(missing.resp.headers.get("link"), `<${rsUrl}/icon.svg>; rel="icon"; type="image/svg+xml"`); assert.equal(errorOf(missing).resource_metadata, `${rsUrl}/.well-known/oauth-protected-resource/mcp`); + const badToken = await postMcpJson(rsUrl, "not-a-real-token", { + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + assert.equal(badToken.status, 401); + assert.equal( + badToken.resp.headers.get("www-authenticate"), + `Bearer error="invalid_token", resource_metadata="${rsUrl}/.well-known/oauth-protected-resource/mcp"` + ); + assert.equal(errorOf(badToken).resource_metadata, `${rsUrl}/.well-known/oauth-protected-resource/mcp`); + const mcpMetadata = await fetchProtectedResourceMetadata(stringField(errorOf(missing), "resource_metadata")); assert.equal(mcpMetadata.resource, `${rsUrl}/mcp`); assert.deepEqual(mcpMetadata.pdpp_token_kinds_supported, ["client", "mcp_package"]); @@ -1977,6 +1978,74 @@ test("/mcp rejects missing and owner bearers", async () => { } }); +test("/mcp challenges revoked grants as invalid tokens", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + const metadataUrl = `${rsUrl}/.well-known/oauth-protected-resource/mcp`; + + try { + const manifest = await registerAuthorizedSpotify(asUrl); + const client = await registerAuthCodeClient(asUrl); + const revoked = await completeOauthCodeFlow({ asUrl, client, manifest }); + await revokeGrant(mustExist(revoked.grantId, "authorization code flow must issue a grant id"), { + request_id: "mcp-invalid-token-revoked-test", + }); + const revokedResponse = await postMcpJson(rsUrl, revoked.accessToken, { + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + assert.equal(revokedResponse.status, 401); + assert.equal( + revokedResponse.resp.headers.get("www-authenticate"), + `Bearer error="invalid_token", resource_metadata="${metadataUrl}"` + ); + assert.equal(errorOf(revokedResponse).code, "grant_revoked"); + assert.equal(errorOf(revokedResponse).resource_metadata, metadataUrl); + + } finally { + await closeServer(server); + } +}); + +test("/mcp challenges expired grants as invalid tokens", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + const metadataUrl = `${rsUrl}/.well-known/oauth-protected-resource/mcp`; + + try { + const manifest = await registerAuthorizedSpotify(asUrl); + const client = await registerAuthCodeClient(asUrl); + const expired = await completeOauthCodeFlow({ asUrl, client, manifest }); + const expiredGrantId = mustExist(expired.grantId, "authorization code flow must issue a grant id"); + const expiredAt = new Date(Date.now() - 60_000).toISOString(); + getDb() + .prepare("UPDATE grants SET expires_at = ?, grant_json = json_set(grant_json, '$.expires_at', ?) WHERE grant_id = ?") + .run(expiredAt, expiredAt, expiredGrantId); + getDb().prepare("UPDATE tokens SET expires_at = ? WHERE grant_id = ?").run(expiredAt, expiredGrantId); + + const expiredResponse = await postMcpJson(rsUrl, expired.accessToken, { + id: 2, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + + assert.equal(expiredResponse.status, 401); + assert.equal( + expiredResponse.resp.headers.get("www-authenticate"), + `Bearer error="invalid_token", resource_metadata="${metadataUrl}"` + ); + assert.equal(errorOf(expiredResponse).code, "grant_expired"); + assert.equal(errorOf(expiredResponse).resource_metadata, metadataUrl); + } finally { + await closeServer(server); + } +}); + // Reproduces a real GDC-demo discrepancy: the Core Docker image used to bake // PDPP_REFERENCE_ORIGIN=http://localhost:3000 (Dockerfile core-browser // stage), and deploy/docker/docker-compose.yml repeated the same stale @@ -2330,7 +2399,7 @@ test("CIMD native loopback redirect matching ignores only runtime port", async ( // revocation. The unit suite in `package-rs-client.test.js` covers the // adapter routing in isolation; this suite proves the live wiring. -test("multi-source hosted MCP picker issues a package token usable at /mcp with source-tagged reads", async () => { +test("multi-source hosted MCP picker requires explicit grant_id selection for package reads", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -2340,14 +2409,32 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with const github = await registerAuthorizedGithub(asUrl); const client = await registerAuthCodeClient(asUrl); - const { accessToken, refreshToken, packageId } = await completeMultiSourcePackageFlow({ - asUrl, - client, - connectorIds: [spotify.connector_id, github.connector_id], - }); + const { accessToken, refreshToken, packageId } = + await completeMultiSourcePackageFlow({ + asUrl, + client, + connectorIds: [spotify.connector_id, github.connector_id], + }); assert.ok(refreshToken, "multi-source package issues a refresh token"); assert.equal(refreshToken.startsWith("rt_"), true); + const missingGrantId = await postMcpJson(rsUrl, accessToken, { + id: 2, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: {}, name: "schema" }, + }); + assert.equal(missingGrantId.status, 200); + assert.equal( + resultOf(missingGrantId).isError, + true, + "package reads require explicit grant_id selection", + ); + assert.match( + JSON.stringify(resultOf(missingGrantId)), + /list_package_children/, + ); + const initialize = await postMcpJson(rsUrl, accessToken, { id: 1, jsonrpc: "2.0", @@ -2359,44 +2446,64 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with }, }); assert.equal(initialize.status, 200); - assert.equal((resultOf(initialize).serverInfo as Record).name, "pdpp-reference-mcp"); + assert.equal( + (resultOf(initialize).serverInfo as Record).name, + "pdpp-reference-mcp", + ); - // schema fan-out: streams from both children should appear, each - // tagged with the source's connector_id and grant_id. - const schemaCall = await postMcpJson(rsUrl, accessToken, { + const packageAccess = mustExist( + await getGrantPackageAccess(packageId), + "package access is available", + ) as { members: GrantPackageMember[] }; + assert.equal(packageAccess.members.length, 2); + const childGrantIds = packageAccess.members.map( + (member) => member.grant_id, + ); + + const missingSelector = await postMcpJson(rsUrl, accessToken, { id: 2, jsonrpc: "2.0", method: "tools/call", params: { arguments: {}, name: "schema" }, }); - assert.equal(schemaCall.status, 200); - assert.equal(resultOf(schemaCall).isError, undefined); - const schemaData = structuredContentData(resultOf(schemaCall)); - const schemaPackage = mustExist(schemaPackageMetadata(schemaData), "schema response carries package metadata"); - assert.ok(schemaPackage.grant_package, "schema response carries package metadata"); - assert.equal(schemaPackage.member_count, 2); - const schemaConnectors = (schemaData.connectors as Record[] | undefined) || []; - const schemaStreams = schemaConnectors.flatMap((connector) => - ((connector.streams as Record[] | undefined) || []).map((stream) => ({ - ...stream, - source: stream.source || connector.source, - })) - ); - const schemaConnectorIds = new Set( - schemaStreams - .map((s) => { - const source = s.source as Record | undefined; - const connectorId = source?.connector_id || source?.connector_key; - return typeof connectorId === "string" ? (canonicalConnectorKey(connectorId) ?? connectorId) : connectorId; - }) - .filter(Boolean) - ); - assert.ok(schemaConnectorIds.has(spotify.connector_id), "schema fanout includes spotify streams"); - assert.ok(schemaConnectorIds.has(github.connector_id), "schema fanout includes github streams"); - const schemaGrantIds = new Set( - schemaStreams.map((s) => (s.source as Record | undefined)?.grant_id).filter(Boolean) + assert.equal(missingSelector.status, 200); + assert.equal(resultOf(missingSelector).isError, true); + assert.match( + JSON.stringify(resultOf(missingSelector).content), + /list_package_children/, ); - assert.equal(schemaGrantIds.size, 2, "each stream is tagged with its child grant_id"); + for (const grant_id of childGrantIds) { + // biome-ignore lint/performance/noAwaitInLoops: Each child needs an isolated live schema control. + const selectedSchema = await postMcpJson(rsUrl, accessToken, { + id: 3, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { grant_id }, name: "schema" }, + }); + assert.equal(selectedSchema.status, 200); + assert.equal(resultOf(selectedSchema).isError, undefined); + const schemaData = structuredContentData(resultOf(selectedSchema)); + assert.equal( + schemaData.package, + undefined, + "schema contains only the selected child, with no merged package summary", + ); + } + const packageTools = await postMcpJson(rsUrl, accessToken, { + id: 4, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + for (const tool of resultOf(packageTools).tools as Array<{ + inputSchema: { properties: Record; required: string[] }; + }>) { + assert.equal( + (tool.inputSchema.properties.grant_id as { type?: string }).type, + "string", + ); + assert.ok(tool.inputSchema.required.includes("grant_id")); + } // The package token MUST NOT reach a non-/mcp REST surface. The // canonical REST surfaces are gated by `requireClient` (returns 403 @@ -2407,10 +2514,14 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with const restProbe = await fetchJson(`${rsUrl}/v1/schema`, { headers: { Authorization: `Bearer ${accessToken}` }, }); - assert.notEqual(restProbe.status, 200, "package tokens MUST NOT serve REST /v1/schema"); + assert.notEqual( + restProbe.status, + 200, + "package tokens MUST NOT serve REST /v1/schema", + ); assert.ok( restProbe.status === 403 || restProbe.status === 404, - `expected REST surface to reject package token, got ${restProbe.status}` + `expected REST surface to reject package token, got ${restProbe.status}`, ); // Refresh-token exchange must succeed and return a fresh package token. @@ -2432,19 +2543,26 @@ test("multi-source hosted MCP picker issues a package token usable at /mcp with assert.notEqual(refreshed.body.access_token, accessToken); const refreshedAccessToken = stringField(refreshed.body, "access_token"); - // The refreshed package token still reaches /mcp with both children. - const refreshedSchema = await postMcpJson(rsUrl, refreshedAccessToken, { - id: 4, - jsonrpc: "2.0", - method: "tools/call", - params: { arguments: {}, name: "schema" }, - }); - assert.equal(refreshedSchema.status, 200); - const refreshedSchemaData = structuredContentData(resultOf(refreshedSchema)); - assert.equal( - mustExist(schemaPackageMetadata(refreshedSchemaData), "schema response carries package metadata").member_count, - 2 - ); + // Refresh retains both child identities; each read still names one child. + for (const member of packageAccess.members) { + // biome-ignore lint/performance/noAwaitInLoops: Each refreshed child needs an isolated live schema control. + const refreshedSchema = await postMcpJson(rsUrl, refreshedAccessToken, { + id: 4, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { grant_id: member.grant_id }, name: "schema" }, + }); + assert.equal(refreshedSchema.status, 200); + assert.equal(resultOf(refreshedSchema).isError, undefined); + const connectorId = member.grant_storage_binding?.connector_id; + assert.ok(connectorId, "refreshed child has its own connector identity"); + assert.ok( + schemaStreamRows(structuredContentData(resultOf(refreshedSchema))).some( + (stream) => stream.source.connector_id === connectorId, + ), + `refreshed grant ${member.grant_id} still reads ${connectorId}`, + ); + } } finally { await closeServer(server); } @@ -2513,7 +2631,7 @@ test("package refresh replay deactivates every family-linked package bearer", as } }); -test("revoking one child grant silently removes that source from the package /mcp fanout", async () => { +test("revoked package child selectors fail while an explicitly selected sibling remains usable", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; @@ -2540,61 +2658,59 @@ test("revoking one child grant silently removes that source from the package /mc }, }); - // Baseline: both sources present. - const before = await postMcpJson(rsUrl, accessToken, { - id: 1, - jsonrpc: "2.0", - method: "tools/call", - params: { arguments: {}, name: "schema" }, - }); - assert.equal(before.status, 200); - const beforeData = structuredContentData(resultOf(before)); - const beforePackage = mustExist(schemaPackageMetadata(beforeData), "schema response carries package metadata"); - assert.equal(beforePackage.member_count, 2); - const childGrants = beforePackage.sources.map((s) => ({ - connector_id: canonicalConnectorKey(s.connector_id) ?? s.connector_id, - grant_id: s.grant_id, - })); + const packageAccess = mustExist( + await getGrantPackageAccess(packageId), + "package access is available", + ) as { members: GrantPackageMember[] }; const spotifyChild = mustExist( - childGrants.find((c) => c.connector_id === spotify.connector_id), - "spotify child must exist" + packageAccess.members.find( + (member) => + canonicalConnectorKey(member.grant.source.id) === + spotify.connector_id, + ), + "spotify child must exist", ); - const githubChild = childGrants.find((c) => c.connector_id === github.connector_id); - assert.ok(spotifyChild && githubChild, "package exposes one child grant per source"); + const githubChild = mustExist( + packageAccess.members.find( + (member) => + canonicalConnectorKey(member.grant.source.id) === github.connector_id, + ), + "github child must exist", + ); + const selectSchema = (grant_id: string, id: number) => + postMcpJson(rsUrl, accessToken, { + id, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { grant_id }, name: "schema" }, + }); + const ownControl = await selectSchema(spotifyChild.grant_id, 1); + assert.equal(resultOf(ownControl).isError, undefined); // Revoke just the spotify child grant. The package and the github child // stay active. - await revokeGrant(spotifyChild.grant_id, { request_id: "multi-source-child-revoke-test" }); - - const after = await postMcpJson(rsUrl, accessToken, { - id: 2, - jsonrpc: "2.0", - method: "tools/call", - params: { arguments: {}, name: "schema" }, + await revokeGrant(spotifyChild.grant_id, { + request_id: "multi-source-child-revoke-test", }); - assert.equal(after.status, 200); - const afterData = structuredContentData(resultOf(after)); - const afterPackage = mustExist(schemaPackageMetadata(afterData), "schema response carries package metadata"); - assert.equal(afterPackage.member_count, 1, "revoked child is no longer counted in the package fanout"); - const afterConnectorIds = new Set( - schemaStreamRows(afterData).map((stream) => { - const connectorId = stream.source?.connector_id; - return canonicalConnectorKey(connectorId) ?? connectorId; - }) - ); - assert.ok( - !afterConnectorIds.has(spotify.connector_id), - "spotify streams are absent after its child grant is revoked" - ); - assert.ok(afterConnectorIds.has(github.connector_id), "github streams still present"); - const afterSourceConnectorIds = afterPackage.sources.map( - (source) => canonicalConnectorKey(source.connector_id) ?? source.connector_id - ); - assert.deepEqual(afterSourceConnectorIds, [github.connector_id]); - // The package token itself stays valid because the package is still - // active and has one active member. - assert.equal(resultOf(after).isError, undefined); + const revokedSelection = await selectSchema(spotifyChild.grant_id, 2); + 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); + assert.equal(siblingSelection.status, 200); + assert.equal(resultOf(siblingSelection).isError, undefined); // Sanity: the package is still active, only the child is revoked. assert.ok(packageId); @@ -2603,6 +2719,50 @@ test("revoking one child grant silently removes that source from the package /mc } }); +test("revoking a package's last child challenges its token as invalid", async () => { + const server = await startOpenTestServer(); + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + + try { + const spotify = await registerAuthorizedSpotify(asUrl); + const client = await registerAuthCodeClient(asUrl); + const { accessToken } = await completeMultiSourcePackageFlow({ + asUrl, + client, + connectorIds: [spotify.connector_id], + }); + const packageAccess = await introspectAccessToken(asUrl, accessToken); + const packageId = stringField(packageAccess, "grant_package_id"); + const packageMembers = (await getGrantPackageAccess(packageId)) as { members?: Array<{ grant_id?: string }> } | null; + const packageMember = packageMembers?.members?.[0]; + assert.ok(packageMember?.grant_id); + assert.equal(packageMembers?.members?.length, 1); + 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); + + const after = await postMcpJson(rsUrl, accessToken, { + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }); + assert.equal(after.status, 401); + assert.equal( + after.resp.headers.get("www-authenticate"), + `Bearer error="invalid_token", resource_metadata="${rsUrl}/.well-known/oauth-protected-resource/mcp"` + ); + assert.equal(errorOf(after).code, "authentication_error"); + assert.equal(errorOf(after).resource_metadata, `${rsUrl}/.well-known/oauth-protected-resource/mcp`); + } finally { + await closeServer(server); + } +}); + test("revoking the package invalidates /mcp access and the refresh-token exchange", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; @@ -2636,9 +2796,8 @@ test("revoking the package invalidates /mcp access and the refresh-token exchang await revokeGrantPackage(packageId, { request_id: "multi-source-package-revoke-test" }); // /mcp must now reject the package bearer. introspection marks the - // token inactive with `inactive_reason = 'package_revoked'`; that - // does not map to a grant_revoked/grant_expired/grant_invalid 403, - // so requireToken falls through to a 401 challenge. + // token inactive with `inactive_reason = 'package_revoked'`; requireToken + // reports the generic authentication error with an invalid-token challenge. const after = await postMcpJson(rsUrl, accessToken, { id: 2, jsonrpc: "2.0", @@ -2682,185 +2841,199 @@ test("revoking the package invalidates /mcp access and the refresh-token exchang // The unit suite in package-rs-client.test.js stubs fetch; this test // exercises the full stack from MCP tool call → PackageRsClient → live RI RS. -test("query_records with connection_id routes to one source only (G1 source-targeted routing)", async () => { +// G1 live authorization check: selecting one package child cannot read a sibling's record. + +const PACKAGE_SHARED_KEYS = ["profile-shared-1"]; + +function packageChildManifest(connectorId: string): ConnectorManifest { + const manifest = structuredClone( + pinConnectorManifest(), + ) as ConnectorManifest & { + source_declaration: { + source: { id: string }; + streams: Record[]; + }; + }; + manifest.connector_id = connectorId; + manifest.display_name = `Package child ${connectorId}`; + manifest.source_declaration.source.id = `https://registry.pdpp.dev/connectors/${connectorId}`; + for (const stream of manifest.source_declaration.streams) { + stream.query = { search: { lexical_fields: ["subject"] } }; + } + for (const stream of manifest.streams) { + stream.query = { search: { lexical_fields: ["subject"] } }; + } + return manifest; +} + +function packageChildSentinel(connectionId: string): string { + return `sentinel-${connectionId}`; +} + +async function registerPackageChild( + asUrl: string, + connectorId: string, +): Promise { + const manifest = packageChildManifest(connectorId); + const registration = await fetchJson(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(registration.status, 201); + const connectionId = await seedDefaultHostedInstance(manifest); + for (const [index, key] of PACKAGE_SHARED_KEYS.entries()) { + const receivedAt = `2026-05-18T12:0${index}:00.000Z`; + // biome-ignore lint/performance/noAwaitInLoops: Seed each child's record before the package is issued. + await ingestRecord( + { connector_id: connectorId, connector_instance_id: connectionId }, + { + data: { + id: key, + received_at: receivedAt, + subject: `${packageChildSentinel(connectionId)} ${key}`, + }, + emitted_at: receivedAt, + key, + stream: PIN_STREAM, + }, + ); + } + return connectionId; +} + +test("package grant_id selection isolates a real sibling record", async () => { const server = await startOpenTestServer(); const asUrl = `http://localhost:${server.asPort}`; const rsUrl = `http://localhost:${server.rsPort}`; try { - const spotify = await registerSpotify(asUrl); - const github = await registerGithub(asUrl); - - // Seed one named connection per connector so the picker renders - // per-connection rows and the resulting members carry connection_id. - const store = createSqliteConnectorInstanceStore(); - const now = new Date().toISOString(); - const spotifyConnId = "cin_g1_spotify"; - const githubConnId = "cin_g1_github"; - await store.upsert({ - connectorId: spotify.connector_id, - connectorInstanceId: spotifyConnId, - createdAt: now, - displayName: "My Spotify", - ownerSubjectId: "owner_local", - sourceBinding: { account: "spotify@example.com" }, - sourceBindingKey: "spotify@example.com", - sourceKind: "account", - status: "active", - updatedAt: now, - }); - await store.upsert({ - connectorId: github.connector_id, - connectorInstanceId: githubConnId, - createdAt: now, - displayName: "My GitHub", - ownerSubjectId: "owner_local", - sourceBinding: { account: "github@example.com" }, - sourceBindingKey: "github@example.com", - sourceKind: "account", - status: "active", - updatedAt: now, - }); - + const connectionA = await registerPackageChild(asUrl, "profile-child-a"); + const connectionB = await registerPackageChild(asUrl, "profile-child-b"); const client = await registerAuthCodeClient(asUrl); - const verifier = randomBytes(32).toString("base64url"); - const codeChallenge = pkceChallenge(verifier); - const state = "g1-routing-test"; - - // The picker branch parks the request under a consent challenge; the - // console's render model publishes one row per named connection. Approving - // every stream of both rows is the same connection-scoped whole-source - // approval the picker form used to submit — the routing contract under test - // depends on the resulting members carrying a connection_id, which is what - // the per-connection rows produce. - const challenge = await startPickerConsentChallenge({ + const { accessToken, packageId } = await completeMultiSourcePackageFlow({ asUrl, - clientId: client.client_id, - codeChallenge, - state, + client, + connectorIds: ["profile-child-a", "profile-child-b"], }); - const model = await fetchPickerConsentModel(asUrl, challenge); - const bySelection = new Map(model.sources.map((source) => [source.selectionValue, source])); - const spotifySourceRow = mustExist( - bySelection.get(encodeHostedMcpSelection({ connectionId: spotifyConnId, connectorId: spotify.connector_id })), - "the model must publish a row for the named spotify connection" + const access = mustExist( + await getGrantPackageAccess(packageId), + "package access exists", + ) as { members: GrantPackageMember[] }; + const memberFor = (connectionId: string) => + mustExist( + access.members.find((member) => member.connection_id === connectionId), + `package contains ${connectionId}`, + ); + const childA = memberFor(connectionA); + const childB = memberFor(connectionB); + const call = (grant_id: string, id: string, requestId: number) => + postMcpJson(rsUrl, accessToken, { + id: requestId, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { grant_id, id }, name: "fetch" }, + }); + + const ownA = await call( + childA.grant_id, + `${connectionA}/${PIN_STREAM}:${PACKAGE_SHARED_KEYS[0]}`, + 1, ); - const githubSourceRow = mustExist( - bySelection.get(encodeHostedMcpSelection({ connectionId: githubConnId, connectorId: github.connector_id })), - "the model must publish a row for the named github connection" + assert.equal( + resultOf(ownA).isError, + undefined, + "child A can read its own real record", ); - const chosen = [ - { source: spotifySourceRow, streams: spotifySourceRow.streams }, - { source: githubSourceRow, streams: githubSourceRow.streams }, - ]; - - const approve = await postPickerConsentChallenge( - asUrl, - challenge, - "accept", - pickerConsentAcceptBody({ chosen, clientId: client.client_id, model }) + assert.ok( + JSON.stringify(resultOf(ownA)).includes( + packageChildSentinel(connectionA), + ), + ); + const ownB = await call( + childB.grant_id, + `${connectionB}/${PIN_STREAM}:${PACKAGE_SHARED_KEYS[0]}`, + 2, + ); + assert.equal( + resultOf(ownB).isError, + undefined, + "child B can read its own real record", + ); + assert.ok( + JSON.stringify(resultOf(ownB)).includes( + packageChildSentinel(connectionB), + ), ); - assert.equal(approve.status, 200, JSON.stringify(approve.body)); - const callback = new URL(stringField(approve.body, "redirect_url")); - const code = mustExist(callback.searchParams.get("code"), "redirect must carry an authorization code"); - assert.ok(code); - - const { body: tokenBody } = await fetchJson(`${asUrl}/oauth/token`, { - body: new URLSearchParams({ - client_id: client.client_id, - code, - code_verifier: verifier, - grant_type: "authorization_code", - redirect_uri: "https://client.example/callback", - }).toString(), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - method: "POST", - }); - assert.ok(tokenBody.grant_package_id, "connection-scoped multi-source package issued"); - const accessToken = stringField(tokenBody, "access_token"); - await postMcpJson(rsUrl, accessToken, { - id: 0, - jsonrpc: "2.0", - method: "initialize", - params: { - capabilities: {}, - clientInfo: { name: "g1-routing-test", version: "0.0.0" }, - protocolVersion: "2025-06-18", - }, - }); + const crossChild = await call( + childA.grant_id, + `${connectionB}/${PIN_STREAM}:${PACKAGE_SHARED_KEYS[0]}`, + 3, + ); + assert.equal(crossChild.status, 200); + assert.equal( + resultOf(crossChild).isError, + true, + "child A cannot read child B's record", + ); + assert.ok( + !JSON.stringify(resultOf(crossChild)).includes( + packageChildSentinel(connectionB), + ), + ); - // Step 1: fan-out schema to confirm both connection_id values are present. - const schemaCall = await postMcpJson(rsUrl, accessToken, { - id: 1, - jsonrpc: "2.0", - method: "tools/call", - params: { arguments: {}, name: "schema" }, - }); - assert.equal(schemaCall.status, 200); - assert.equal(resultOf(schemaCall).isError, undefined); - const schemaData = structuredContentData(resultOf(schemaCall)); - // biome-ignore lint/suspicious/noUnnecessaryConditions: the helper intentionally returns null for absent package metadata. - const sources = schemaPackageMetadata(schemaData)?.sources ?? []; - assert.equal(sources.length, 2, "package exposes two sources"); - - const spotifySource = sources.find((s) => s.connection_id === spotifyConnId); - const githubSource = sources.find((s) => s.connection_id === githubConnId); - assert.ok(spotifySource, "spotify connection is present in package sources"); - assert.ok(githubSource, "github connection is present in package sources"); - - // Step 2: schema fan-out exposes stream names and source tags for both - // children. Pick one stream per source for targeted read calls. - const allRows = schemaStreamRows(schemaData); - assert.ok(allRows.length > 0, "schema returns streams from package sources"); - const allConnectorIds = new Set(allRows.map((r) => r.source?.connector_id).filter(Boolean)); - assert.ok(allConnectorIds.has(spotify.connector_id), "schema fan-out includes spotify streams"); - assert.ok(allConnectorIds.has(github.connector_id), "schema fan-out includes github streams"); - const spotifyRow = allRows.find((r) => r.source?.connection_id === spotifyConnId); - const githubRow = allRows.find((r) => r.source?.connection_id === githubConnId); - assert.ok(spotifyRow?.name, "spotify connection has a stream to query"); - assert.ok(githubRow?.name, "github connection has a stream to query"); - - // Step 3: query_records scoped to spotify's connection_id routes to that child. - const spotifyQuery = await postMcpJson(rsUrl, accessToken, { - id: 3, - jsonrpc: "2.0", - method: "tools/call", - params: { - arguments: { connection_id: spotifyConnId, limit: 1, stream: spotifyRow.name }, - name: "query_records", - }, + const replacement = await completeMultiSourcePackageFlow({ + asUrl, + client, + connectorIds: ["profile-child-a", "profile-child-b"], }); - assert.equal(spotifyQuery.status, 200); - assert.equal(resultOf(spotifyQuery).isError, undefined, "spotify-scoped query_records must not be an error"); - - // Step 4: query_records scoped to github's connection_id routes to that child. - const githubQuery = await postMcpJson(rsUrl, accessToken, { + const replacementAccess = mustExist( + await getGrantPackageAccess(replacement.packageId), + "replacement package access exists", + ) as { members: GrantPackageMember[] }; + assert.deepEqual( + replacementAccess.members.map((member) => member.connection_id).sort(), + access.members.map((member) => member.connection_id).sort(), + "re-consent preserves the approved instance handles", + ); + assert.ok( + access.members.every( + (member) => + !replacementAccess.members.some( + (next) => next.grant_id === member.grant_id, + ), + ), + "re-consent creates new child grant identities", + ); + const staleSelector = await postMcpJson(rsUrl, replacement.accessToken, { id: 4, jsonrpc: "2.0", method: "tools/call", - params: { - arguments: { connection_id: githubConnId, limit: 1, stream: githubRow.name }, - name: "query_records", - }, + params: { arguments: { grant_id: childA.grant_id }, name: "schema" }, }); - assert.equal(githubQuery.status, 200); - assert.equal(resultOf(githubQuery).isError, undefined, "github-scoped query_records must not be an error"); - - // Step 5: unknown connection_id must return a structured MCP error - // (isError: true), not a server crash — proves the PackageRsClient - // not_found error path is wired through the full live stack. - const unknownQuery = await postMcpJson(rsUrl, accessToken, { - id: 5, - jsonrpc: "2.0", - method: "tools/call", - params: { - arguments: { connection_id: "cin_does_not_exist", limit: 1, stream: spotifyRow.name }, - name: "query_records", + assert.equal(resultOf(staleSelector).isError, true); + assert.equal( + ( + (resultOf(staleSelector).structuredContent as Record) + .error as Record + ).code, + "unknown_grant_selector", + ); + const replacementSelector = await postMcpJson( + rsUrl, + replacement.accessToken, + { + id: 5, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: { grant_id: replacementAccess.members[0]!.grant_id }, + name: "schema", + }, }, - }); - assert.equal(unknownQuery.status, 200, "unknown connection_id returns HTTP 200 (MCP error envelope)"); - assert.equal(resultOf(unknownQuery).isError, true, "unknown connection_id returns isError: true"); + ); + assert.equal(resultOf(replacementSelector).isError, undefined); } finally { await closeServer(server); } diff --git a/reference-implementation/test/package-rs-client.test.ts b/reference-implementation/test/package-rs-client.test.ts index a7c5c930f..ff69bd011 100644 --- a/reference-implementation/test/package-rs-client.test.ts +++ b/reference-implementation/test/package-rs-client.test.ts @@ -1,1384 +1,41 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -// Focused unit tests for the hosted-MCP PackageRsClient fan-out adapter. -// -// These tests stub `fetch` and verify the per-route behavior of -// reference-implementation/server/package-rs-client.js without standing -// up the full hosted MCP OAuth flow. Behaviors covered: -// -// - schema fan-out: per-source stream tagging + package metadata -// - protected-resource metadata: server-global passthrough -// - list_streams fan-out: rows tagged with source identity -// - search fan-out: hits tagged with source identity -// - search scoped by connection_id: single child call only -// - query_records ambiguous: typed 409 + available_connections -// - query_records with selector: routes to one child -// - fetch_blob (getRaw) requires selector -// - event subscription create: selector required when >1 child -// - event subscription create: single-child package infers child -// - event subscription list: fans out and merges -// - event subscription get/patch/delete: locates owning child -// - selector not in members: typed not_found -// -// Spec: openspec/changes/add-hosted-mcp-grant-packages - import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { createPackageRsClient as createPackageRsClientUntyped } from "../server/package-rs-client.ts"; - -// server/package-rs-client.js is untyped JS (allowJs, checkJs:false). Every -// test in this file constructs a different ad-hoc JSON response/error shape -// (there is no single closed RS response schema this fan-out adapter -// enforces), so `body`/`error` on the result stay `unknown` at the boundary -// and each test narrows the specific fields it reads with a local `as` -// cast — matching this suite's own intent (per-route fan-out behavior), -// not a full protocol schema. -interface PackageRsMember { - connection_id: string; - grant?: { streams: { instance_ids: string[]; name: string }[] }; - grant_id: string; - source: { kind: string; id: string }; - token: string; -} - -interface PackageRsResult { - body: unknown; - error: unknown; - ok: boolean; - status: number; -} - -interface FakeFetchRequest { - body: unknown; - method: string; - path: string; - query: URLSearchParams; - token: string; - url: URL; -} - -type FakeFetch = typeof fetch; - -function resultWithError( - result: Awaited> extends infer Client - ? Client extends { getJson: (path: string) => Promise } - ? Result - : never - : never -): PackageRsResult { - if (result.ok) { - return { body: result.body, error: null, ok: true, status: result.status }; - } - return { body: null, error: result.error, ok: false, status: result.status }; -} - -function createPackageRsClient(opts: Parameters[0]) { - const client = createPackageRsClientUntyped(opts); - return { - deleteJson: async (path: string) => resultWithError(await client.deleteJson(path)), - getJson: async (path: string, options?: Parameters[1]) => - resultWithError(await client.getJson(path, options)), - getRaw: async (path: string) => resultWithError(await client.getRaw(path)), - patchJson: async (path: string, options: { body: Record }) => - resultWithError(await client.patchJson(path, options)), - postJson: async (path: string, options: Parameters[1]) => - resultWithError(await client.postJson(path, options)), - }; -} - -const PROVIDER = "https://pdpp.test"; - -function jsonResponse(status: number, body: unknown, headers: Record = {}): Response { - const responseBody = status === 204 ? null : JSON.stringify(body); - return new Response(responseBody, { headers: { "content-type": "application/json", ...headers }, status }); -} - -function makeRouter(routes: (req: FakeFetchRequest) => Promise): FakeFetch { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - return async function fakeFetch(input, init = {}) { - const u = new URL(input instanceof Request ? input.url : input.toString()); - const headers = new Headers(init.headers); - // biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract. - const token = (headers.get("authorization") || "").replace(/^Bearer\s+/, ""); - const body = typeof init.body === "string" ? init.body : undefined; - const req: FakeFetchRequest = { - body: body ? JSON.parse(body) : undefined, - method: init.method || "GET", - path: u.pathname, - query: u.searchParams, - token, - url: u, - }; - return routes(req); - }; -} - -function memberA(): PackageRsMember { - return { - connection_id: "gh_main", - grant: { - streams: [ - { instance_ids: ["gh_main"], name: "repos" }, - { instance_ids: ["gh_main"], name: "issues" }, - ], - }, - grant_id: "grant_A", - source: { id: "github", kind: "connector" }, - token: "tok_A", - }; -} -function memberB(): PackageRsMember { - return { - connection_id: "slack_main", - grant: { - streams: [ - { instance_ids: ["slack_main"], name: "messages" }, - { instance_ids: ["slack_main"], name: "repos" }, - ], - }, - grant_id: "grant_B", - source: { id: "slack", kind: "connector" }, - token: "tok_B", - }; -} - -// Recurring shape of the `error` half of a PackageRsResult across the -// ambiguous_connection / not_found envelopes this suite pins. -interface PackageRsErrorEnvelope { - available_connection_count?: number; - available_connections?: { - connection_id?: string; - grant_id?: string; - connector_key?: string; - connector_id?: string; - }[]; - available_connections_omitted?: number; - available_connections_truncated?: boolean; - code?: string; - discovery_hint?: string; - message?: string; - retry_with?: string; - type?: string; - unavailable_connections?: unknown; -} - -test("schema fan-out merges streams per source and tags package metadata", async () => { - const calls: { token: string; path: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, token: req.token }); - if (req.token === "tok_A") { - return jsonResponse(200, { - data: { - granted_connections: [{ connection_id: "gh_main" }], - streams: [{ name: "repos" }, { name: "issues" }], - }, - }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - granted_connections: [{ connection_id: "slack_main" }], - streams: [{ name: "messages" }], - }, - }); - } - return jsonResponse(500, { error: "unknown_token" }); - }); - - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/schema"); - assert.equal(out.ok, true); - const body = out.body as { - data: { - streams: { source?: { grant_id?: string; connector_key?: string; connector_id?: string } }[]; - package: { member_count: number; sources: unknown[] }; - }; - meta: { package: { member_count: number } }; - }; - assert.equal(body.data.streams.length, 3); - for (const s of body.data.streams) { - assert.ok(s.source?.grant_id, "every stream carries source identity"); - assert.equal( - s.source.connector_key, - s.source.connector_id, - "source tag keeps connector_id compatibility while adding connector_key" - ); - } - assert.equal(body.data.package.member_count, 2); - assert.equal(body.data.package.sources.length, 2); - assert.equal(body.meta.package.member_count, 2); - assert.equal(calls.length, 2); -}); - -test("schema fan-out understands the canonical { data: { connectors: [{ streams }] } } shape", async () => { - // Mirror the real RS /v1/schema envelope: each child returns one - // connector item under data.connectors[] with its streams nested inside. - // This is the shape exercised by reference-implementation/test/hosted-mcp-oauth.test.js - // ("multi-source hosted MCP picker..."), here as a focused unit test so a - // future PackageRsClient regression is caught without the OAuth scaffold. - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { - data: { - connector_count: 1, - connectors: [ - { - connector_id: "github", - object: "connector", - source: { id: "github", kind: "connector" }, - stream_count: 2, - streams: [{ granted_connections: [{ connection_id: "gh_main" }], name: "repos" }, { name: "issues" }], - }, - ], - object: "schema", - stream_count: 2, - }, - }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - connector_count: 1, - connectors: [ - { - connector_id: "slack", - object: "connector", - source: { id: "slack", kind: "connector" }, - stream_count: 1, - streams: [{ name: "messages" }], - }, - ], - object: "schema", - stream_count: 1, - }, - }); - } - return jsonResponse(500, { error: "unknown_token" }); - }); - - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/schema"); - assert.equal(out.ok, true); - const body = out.body as { - data: { - streams: { source?: { grant_id?: string } }[]; - package: { member_count: number; sources: unknown[] }; - connectors: unknown[]; - granted_connections: unknown[]; - }; - meta: { package: { member_count: number } }; - }; - assert.equal(body.data.streams.length, 3, "streams from every child connector are flattened"); - for (const s of body.data.streams) { - assert.ok(s.source?.grant_id, "each stream carries source identity"); - } - assert.equal(body.data.package.member_count, 2); - assert.equal(body.data.package.sources.length, 2); - assert.equal(body.meta.package.member_count, 2); - // Canonical connectors[] is preserved so callers that already speak the - // schema envelope keep working. - assert.equal(body.data.connectors.length, 2); - // Per-stream granted_connections are flattened to the top-level - // package-fanout `granted_connections` so consumers get one list. - assert.ok(Array.isArray(body.data.granted_connections)); - assert.equal(body.data.granted_connections.length, 1); -}); - -test("schema scoped to connection_id calls only that child and forwards the instance selector", async () => { - const calls: { token: string; path: string; query: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, query: req.query.toString(), token: req.token }); - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - granted_connections: [{ connection_id: "slack_main" }], - streams: [{ name: "messages" }], - }, - }); - } - return jsonResponse(500, { error: "wrong_child" }); - }); - - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/schema", { query: { connection_id: "slack_main", stream: "messages" } }); - - assert.equal(out.ok, true); - const body = out.body as { data: { streams: { name: string; source?: { connection_id?: string } }[] } }; - assert.equal(body.data.streams.length, 1); - assert.equal(body.data.streams[0]?.name, "messages"); - assert.equal(body.data.streams[0]?.source?.connection_id, "slack_main"); - assert.deepEqual(calls, [{ path: "/v1/schema", query: "connection_id=slack_main&stream=messages", token: "tok_B" }]); -}); - -test("schema with unknown connection_id returns not_found without fanout", async () => { - let called = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async () => { - called += 1; - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/schema", { query: { connection_id: "unknown" } }); - assert.equal(out.ok, false); - assert.equal(out.status, 404); - assert.equal((out.error as PackageRsErrorEnvelope).code, "not_found"); - assert.equal(called, 0, "unknown connection_id is rejected before touching child grants"); -}); - -test("schema detail=full rejects shared stream names before package full-schema fanout", async () => { - const calls: { token: string; query: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ query: req.query.toString(), token: req.token }); - if (req.query.get("view") !== "compact") { - return jsonResponse(500, { error: "full_schema_should_not_be_called" }); - } - if (req.token === "tok_A") { - return jsonResponse(200, { - data: { - connectors: [ - { - connector_id: "github", - streams: [{ granted_connections: [{ connection_id: "gh_main" }], name: "messages" }], - }, - ], - object: "schema", - }, - }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - connectors: [ - { - connector_id: "slack", - streams: [{ granted_connections: [{ connection_id: "slack_main" }], name: "messages" }], - }, - ], - object: "schema", - }, - }); - } - return jsonResponse(500, {}); - }); - - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/schema", { query: { detail: "full", stream: "messages" } }); - - assert.equal(out.ok, false); - assert.equal(out.status, 409); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "ambiguous_schema_detail"); - assert.equal(error.retry_with, "connection_id"); - assert.deepEqual((error.available_connections ?? []).map((entry) => entry.connection_id).sort(), [ - "gh_main", - "slack_main", - ]); - assert.deepEqual( - // biome-ignore lint/suspicious/useArraySortCompare: localized test assertion preserves its explicit contract. - calls.map((call) => call.query).sort(), - ["stream=messages&view=compact", "stream=messages&view=compact"], - "package client should only perform compact preflight before returning ambiguity" - ); -}); - -test("schema detail=full with a single matching package source routes to that child only", async () => { - const calls: { token: string; query: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ query: req.query.toString(), token: req.token }); - if (req.token === "tok_A") { - return jsonResponse(200, { - data: { - connectors: [ - { - connector_id: "github", - streams: [], - }, - ], - object: "schema", - }, - }); - } - if (req.token === "tok_B" && req.query.get("view") === "compact") { - return jsonResponse(200, { - data: { - connectors: [ - { - connector_id: "slack", - streams: [{ granted_connections: [{ connection_id: "slack_main" }], name: "messages" }], - }, - ], - object: "schema", - }, - }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - connectors: [ - { - connector_id: "slack", - streams: [{ field_capabilities: { id: { schema: { type: "string" } } }, name: "messages" }], - }, - ], - object: "schema", - }, - }); - } - return jsonResponse(500, {}); - }); - - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/schema", { query: { detail: "full", stream: "messages" } }); - - assert.equal(out.ok, true); - const body = out.body as { data: { connectors: { connector_id: string }[] } }; - assert.equal(body.data.connectors.length, 1); - assert.equal(body.data.connectors[0]?.connector_id, "slack"); - assert.deepEqual(calls, [ - { query: "stream=messages&view=compact", token: "tok_A" }, - { query: "stream=messages&view=compact", token: "tok_B" }, - { query: "detail=full&stream=messages", token: "tok_B" }, - ]); -}); - -test("protected-resource metadata is a server-global passthrough, not a source-required read", async () => { - const calls: { token: string; path: string; query: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, query: req.query.toString(), token: req.token }); - return jsonResponse(200, { - capabilities: { - client_event_subscriptions: { - endpoint: "/v1/event-subscriptions", - supported: true, - }, - }, - resource: "https://pdpp.test/mcp", - }); - }); - - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/.well-known/oauth-protected-resource", { - query: { resource: "https://pdpp.test/mcp" }, - }); - assert.equal(out.ok, true); - const body = out.body as { capabilities: { client_event_subscriptions: { supported: boolean } } }; - assert.equal(body.capabilities.client_event_subscriptions.supported, true); - assert.deepEqual(calls, [ - { - path: "/.well-known/oauth-protected-resource", - query: "resource=https%3A%2F%2Fpdpp.test%2Fmcp", - token: "tok_A", - }, - ]); -}); - -test("list_streams fan-out tags rows and exposes meta.package.member_count", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { data: [{ name: "repos" }, { name: "issues" }] }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { data: [{ name: "messages" }] }); - } - return jsonResponse(500, { error: "unknown_token" }); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams"); - assert.equal(out.ok, true); - const body = out.body as { data: { source?: unknown }[]; meta: { package: { member_count: number } } }; - assert.equal(body.data.length, 3); - for (const row of body.data) { - assert.ok(row.source); - } - assert.equal(body.meta.package.member_count, 2); -}); - -test("list_streams scoped to connection_id calls only that child", async () => { - let aCalled = 0; - let bCalled = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - aCalled += 1; - } - if (req.token === "tok_B") { - bCalled += 1; - } - return jsonResponse(200, { data: [{ name: "messages" }] }); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - await rs.getJson("/v1/streams", { query: { connection_id: "slack_main" } }); - assert.equal(aCalled, 0); - assert.equal(bCalled, 1); -}); - -test("list_streams with unknown connection_id returns not_found without fanout", async () => { - let called = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async () => { - called += 1; - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams", { query: { connection_id: "unknown" } }); - assert.equal(out.ok, false); - assert.equal(out.status, 404); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "not_found"); - assert.equal((error.available_connections ?? []).length, 2); - assert.equal(called, 0, "unknown connection_id is rejected before touching child grants"); -}); - -test("search fan-out merges hits across children", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { data: { results: [{ id: "a:1", title: "Repo One" }] } }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - results: [ - { id: "b:1", title: "Msg One" }, - { id: "b:2", title: "Msg Two" }, - ], - }, - }); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/search", { query: { q: "one" } }); - assert.equal(out.ok, true); - const body = out.body as { data: { results: { source?: unknown }[] } }; - assert.equal(body.data.results.length, 3); - for (const hit of body.data.results) { - assert.ok(hit.source); - } -}); - -test("search fan-out applies limit globally and exposes source mix", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { - data: { - results: [ - { id: "repo:1", stream: "repos", title: "Repo One" }, - { id: "repo:2", stream: "repos", title: "Repo Two" }, - ], - }, - }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: { - results: [ - { id: "msg:1", stream: "messages", title: "Msg One" }, - { id: "msg:2", stream: "messages", title: "Msg Two" }, - ], - }, - }); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/search", { query: { limit: 3, q: "one" } }); - - assert.equal(out.ok, true); - const body = out.body as { - data: { results: { connection_id?: string }[] }; - has_more: boolean; - meta: { package: { source_mix: unknown[] } }; - }; - assert.equal(body.data.results.length, 3, "limit must apply to merged fan-in result, not per child"); - assert.equal(body.has_more, true, "truncated merged fan-in result must advertise has_more"); - assert.deepEqual( - body.data.results.map((hit) => hit.connection_id), - ["gh_main", "gh_main", "slack_main"] - ); - assert.deepEqual(body.meta.package.source_mix, [ - { connection_id: "gh_main", connector_key: "github", count: 2 }, - { connection_id: "slack_main", connector_key: "slack", count: 1 }, - ]); -}); - -test("search fan-out merges canonical list-envelope data arrays across children", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { - data: [{ connection_id: "gh_main", object: "search_result", record_key: "a" }], - object: "list", - }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { - data: [{ connection_id: "slack_main", object: "search_result", record_key: "b" }], - object: "list", - }); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/search", { query: { q: "one" } }); - assert.equal(out.ok, true); - const body = out.body as { data: { record_key: string; source?: unknown }[] }; - // biome-ignore lint/suspicious/useArraySortCompare: localized test assertion preserves its explicit contract. - assert.deepEqual(body.data.map((hit) => hit.record_key).sort(), ["a", "b"]); - for (const hit of body.data) { - assert.ok(hit.source); - } -}); - -test("search fan-out merges nested data.data envelopes across children", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { data: { data: [{ object: "search_result", record_key: "a" }] } }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { data: { data: [{ object: "search_result", record_key: "b" }] } }); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/search", { query: { q: "one" } }); - assert.equal(out.ok, true); - const body = out.body as { data: { data: { record_key: string }[] } }; - // biome-ignore lint/suspicious/useArraySortCompare: localized test assertion preserves its explicit contract. - assert.deepEqual(body.data.data.map((hit) => hit.record_key).sort(), ["a", "b"]); -}); - -test("search fan-out intersects requested streams with each child grant", async () => { - const calls: { token: string; streams: string[] }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - const streams = req.query.getAll("streams"); - calls.push({ streams, token: req.token }); - if (req.token === "tok_A") { - assert.deepEqual(streams, ["conversations"]); - return jsonResponse(200, { - data: [{ object: "search_result", record_key: "c1", stream: "conversations" }], - object: "list", - }); - } - if (req.token === "tok_B") { - assert.deepEqual(streams, ["messages"]); - return jsonResponse(200, { - data: [{ object: "search_result", record_key: "m1", stream: "messages" }], - object: "list", - }); - } - return jsonResponse(500, {}); - }); - const members = [ - { ...memberA(), grant: { streams: [{ instance_ids: ["gh_main"], name: "conversations" }] } }, - { ...memberB(), grant: { streams: [{ instance_ids: ["slack_main"], name: "messages" }] } }, - ]; - const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); - - const out = await rs.getJson("/v1/search", { - query: { q: "redactable", streams: ["messages", "conversations", "comments"] }, - }); - - assert.equal(out.ok, true); - const body = out.body as { data: { record_key: string }[] }; - // biome-ignore lint/suspicious/useArraySortCompare: localized test assertion preserves its explicit contract. - assert.deepEqual(body.data.map((hit) => hit.record_key).sort(), ["c1", "m1"]); - assert.deepEqual( - calls.map((call) => call.streams), - [["conversations"], ["messages"]] - ); -}); - -test("search fan-out skips children with no requested streams in their grant", async () => { - let aCalled = 0; - let bCalled = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - aCalled += 1; - return jsonResponse(400, { error: { code: "grant_stream_not_allowed", type: "permission_error" } }); - } - if (req.token === "tok_B") { - bCalled += 1; - assert.deepEqual(req.query.getAll("streams"), ["messages"]); - return jsonResponse(200, { - data: [{ object: "search_result", record_key: "m1", stream: "messages" }], - object: "list", - }); - } - return jsonResponse(500, {}); - }); - const members = [ - { ...memberA(), grant: { streams: [{ instance_ids: ["gh_main"], name: "conversations" }] } }, - { ...memberB(), grant: { streams: [{ instance_ids: ["slack_main"], name: "messages" }] } }, - ]; - const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); - - const out = await rs.getJson("/v1/search", { query: { q: "redactable", streams: ["messages"] } }); - - assert.equal(out.ok, true); - const body = out.body as { data: { record_key: string }[] }; - assert.deepEqual( - body.data.map((hit) => hit.record_key), - ["m1"] - ); - assert.equal(aCalled, 0); - assert.equal(bCalled, 1); -}); - -test("search with unknown connection_id returns not_found without fanout", async () => { - let called = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async () => { - called += 1; - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/search", { query: { connection_id: "unknown", q: "one" } }); - assert.equal(out.ok, false); - assert.equal(out.status, 404); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "not_found"); - assert.equal((error.available_connections ?? []).length, 2); - assert.equal(called, 0, "unknown connection_id is rejected before touching child grants"); -}); - -test("query_records without selector returns ambiguous_connection 409 with candidates", async () => { - const calls: { path: string; token: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, token: req.token }); - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/repos/records", { query: { limit: 10 } }); - assert.equal(out.ok, false); - assert.equal(out.status, 409); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "ambiguous_connection"); - assert.equal((error.available_connections ?? []).length, 2); - assert.equal(error.available_connection_count, 2); - assert.deepEqual(calls, [], "ambiguity is computed from package membership without probing child grants"); -}); - -test("query_records with connection_id routes to one child only", async () => { - let aCalled = 0; - let bCalled = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - aCalled += 1; - } - if (req.token === "tok_B") { - bCalled += 1; - } - return jsonResponse(200, { data: [{ record_id: "r1" }] }); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/repos/records", { query: { connection_id: "gh_main" } }); - assert.equal(out.ok, true); - assert.equal(aCalled, 1); - assert.equal(bCalled, 0); -}); - -test("package routing derives every authorized instance from the relevant child grant", async () => { - const tokensSeen: string[] = []; - // biome-ignore lint/suspicious/useAwait: localized test router preserves the Promise-based fetch contract. - const fetch = makeRouter(async (req) => { - tokensSeen.push(req.token); - return jsonResponse(200, { data: [] }); - }); - const multiInstanceMember = { - ...memberA(), - connection_id: "stale-display-only", - grant: { - streams: [ - { instance_ids: ["gh_main", "gh_work"], name: "repos" }, - { instance_ids: ["gh_messages"], name: "messages" }, - ], - }, - }; - const rs = createPackageRsClient({ fetch, members: [multiInstanceMember, memberB()], providerUrl: PROVIDER }); - - const secondInstance = await rs.getJson("/v1/streams/repos/records", { - query: { connection_id: "gh_work" }, - }); - assert.equal(secondInstance.ok, true); - assert.deepEqual(tokensSeen, ["tok_A"]); - - tokensSeen.length = 0; - const wrongStream = await rs.getJson("/v1/streams/messages/records", { - query: { connection_id: "gh_main" }, - }); - assert.equal(wrongStream.ok, false); - assert.equal(wrongStream.status, 404); - assert.deepEqual(tokensSeen, []); - - const forgedMetadata = await rs.getJson("/v1/streams/repos/records", { - query: { connection_id: "stale-display-only" }, - }); - assert.equal(forgedMetadata.ok, false); - assert.equal(forgedMetadata.status, 404); - assert.deepEqual(tokensSeen, []); -}); - -test("package routing treats instance handles as source- and stream-scoped", async () => { - const calls: Array<{ path: string; query: string; token: string }> = []; - const observedCalls = () => calls; - // biome-ignore lint/suspicious/useAwait: localized test router preserves the Promise-based fetch contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, query: req.query.toString(), token: req.token }); - if (req.path === "/v1/schema") { - return jsonResponse(200, { data: { connectors: [], object: "schema" } }); - } - if (req.path === "/v1/streams") { - return jsonResponse(200, { data: [] }); - } - if (req.path === "/v1/search") { - return jsonResponse(200, { data: [], object: "list" }); - } - if (req.path === "/v1/event-subscriptions") { - return jsonResponse(201, { subscription_id: "sub_shared" }); - } - return jsonResponse(200, { data: [] }); - }); - const members = [ - { - ...memberA(), - connection_id: "display-a", - grant: { streams: [{ instance_ids: ["shared"], name: "repos" }] }, - }, - { - ...memberB(), - connection_id: "display-b", - grant: { streams: [{ instance_ids: ["shared"], name: "repos" }] }, - }, - ]; - const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); - - const ambiguous = await rs.getJson("/v1/streams/repos/records", { - query: { connection_id: "shared" }, - }); - assert.equal(ambiguous.ok, false); - assert.equal(ambiguous.status, 409); - assert.equal((ambiguous.error as PackageRsErrorEnvelope).code, "ambiguous_connection"); - assert.equal((ambiguous.error as PackageRsErrorEnvelope).retry_with, "source_id"); - assert.deepEqual(calls, []); - - await Promise.all( - ( - [ - ["/v1/schema", { connection_id: "shared", stream: "repos" }], - ["/v1/streams", { connection_id: "shared" }], - ["/v1/search", { connection_id: "shared", q: "term", streams: ["repos"] }], - ] as const - ).map(async ([path, query]) => { - const response = await rs.getJson(path, { query }); - assert.equal(response.ok, false, path); - assert.equal(response.status, 409, path); - assert.equal((response.error as PackageRsErrorEnvelope).retry_with, "source_id", path); - assert.deepEqual(calls, [], path); - }) - ); - const ambiguousEvent = await rs.postJson("/v1/event-subscriptions", { - body: { callback_url: "https://x/y", connection_id: "shared" }, - }); - assert.equal(ambiguousEvent.ok, false); - assert.equal(ambiguousEvent.status, 409); - assert.equal((ambiguousEvent.error as PackageRsErrorEnvelope).retry_with, "source_id"); - assert.deepEqual(calls, []); - - const selected = await rs.getJson("/v1/streams/repos/records", { - query: { connection_id: "shared", source_id: "github" }, - }); - assert.equal(selected.ok, true); - assert.deepEqual( - (calls as Array<{ token: string }>).map((call) => call.token), - ["tok_A"] - ); - assert.equal( - (calls as Array<{ query: string }>)[0]?.query, - "connection_id=shared", - "package-only source selector is not forwarded" - ); - - calls.length = 0; - const mismatch = await rs.getJson("/v1/streams/repos/records", { - query: { connection_id: "shared", source_id: "missing" }, - }); - assert.equal(mismatch.ok, false); - assert.equal(mismatch.status, 404); - assert.deepEqual(calls, []); - - const assertSelectedSurface = async (path: string, query: Record) => { - calls.length = 0; - const response = await rs.getJson(path, { query }); - assert.equal(response.ok, true, `${path}: ${JSON.stringify(response.error)}`); - assert.deepEqual( - (calls as Array<{ token: string }>).map((call) => call.token), - ["tok_A"], - path - ); - const [firstCall] = observedCalls(); - assert.ok(firstCall); - assert.equal(firstCall.query.includes("source_id"), false, path); - }; - await assertSelectedSurface("/v1/schema", { - connection_id: "shared", - source_id: "github", - stream: "repos", - }); - await assertSelectedSurface("/v1/streams", { connection_id: "shared", source_id: "github" }); - await assertSelectedSurface("/v1/search", { - connection_id: "shared", - q: "term", - source_id: "github", - streams: ["repos"], - }); - - calls.length = 0; - const event = await rs.postJson("/v1/event-subscriptions", { - body: { callback_url: "https://x/y", connection_id: "shared", source_id: "github" }, - }); - assert.equal(event.ok, true); - assert.deepEqual( - (calls as Array<{ token: string }>).map((call) => call.token), - ["tok_A"] - ); -}); - -test("query_records ambiguity is fast and does not probe child health", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - assert.fail(`ambiguous read should not touch child grant ${req.token} ${req.path}`); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/repos/records", { query: { limit: 10 } }); - - assert.equal(out.ok, false); - assert.equal(out.status, 409); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "ambiguous_connection"); - assert.deepEqual( - (error.available_connections ?? []).map((entry) => entry.connection_id), - ["gh_main", "slack_main"] - ); - assert.equal(error.unavailable_connections, undefined); -}); - -test("query_records with selected invalid child preserves the child grant_invalid response", async () => { - let aCalled = 0; - let bCalled = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - aCalled += 1; - } - if (req.token === "tok_B") { - bCalled += 1; - } - if (req.token === "tok_B" && req.path === "/v1/streams/repos/records") { - return jsonResponse(403, { - error: { code: "grant_invalid", message: "Grant is no longer usable", type: "grant_invalid" }, - }); - } - return jsonResponse(200, { data: [] }); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/repos/records", { query: { connection_id: "slack_main" } }); - - assert.equal(out.ok, false); - assert.equal(out.status, 403); - assert.equal((out.error as PackageRsErrorEnvelope).code, "grant_invalid"); - assert.equal(aCalled, 0); - assert.equal(bCalled, 1); -}); - -test("query_records with unknown connection_id returns not_found", async () => { - const fetch = makeRouter(async () => jsonResponse(200, { data: [] })); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/repos/records", { query: { connection_id: "unknown" } }); - assert.equal(out.ok, false); - assert.equal(out.status, 404); - assert.equal((out.error as PackageRsErrorEnvelope).code, "not_found"); -}); - -test("fetch_blob (getRaw) requires selector and never returns multi-source default", async () => { - const calls: { path: string; token: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, token: req.token }); - return jsonResponse(200, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getRaw("/v1/blobs/blob-xyz"); - assert.equal(out.ok, false); - assert.equal(out.status, 409); - assert.deepEqual(calls, [], "blob ambiguity is computed without child health probes"); -}); - -test("create_event_subscription with multi-source package requires connection_id", async () => { - const calls: { path: string; token: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - calls.push({ path: req.path, token: req.token }); - return jsonResponse(201, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.postJson("/v1/event-subscriptions", { body: { callback_url: "https://x/y" } }); - assert.equal(out.ok, false); - assert.equal(out.status, 409); - assert.deepEqual(calls, [], "event-sub ambiguity is computed without child health probes"); -}); - -test("create_event_subscription with single-source package infers the child", async () => { - const tokensSeen: string[] = []; - const bodiesSeen: { callback_url?: string; connection_id?: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - tokensSeen.push(req.token); - const body = req.body as { callback_url?: string; connection_id?: string }; - bodiesSeen.push(body); - return jsonResponse(201, { - callback_url: body.callback_url, - created_at: "2026-05-27T00:00:00Z", - secret: "whsec_x", - status: "pending_verification", - subscription_id: "sub_1", - }); - }); - const rs = createPackageRsClient({ fetch, members: [memberA()], providerUrl: PROVIDER }); - const out = await rs.postJson("/v1/event-subscriptions", { body: { callback_url: "https://x/y" } }); - assert.equal(out.ok, true); - assert.deepEqual(tokensSeen, ["tok_A"]); - // connection_id key never forwarded to RS even if accidentally sent (single-source path doesn't pass one). - assert.equal(bodiesSeen[0]?.connection_id, undefined); -}); - -test("create_event_subscription routes to selected child and strips connection_id from RS body", async () => { - const tokensSeen: string[] = []; - const bodiesSeen: { callback_url?: string; connection_id?: string }[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - tokensSeen.push(req.token); - bodiesSeen.push(req.body as { callback_url?: string; connection_id?: string }); - return jsonResponse(201, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - await rs.postJson("/v1/event-subscriptions", { - body: { callback_url: "https://x/y", connection_id: "slack_main" }, - }); - assert.deepEqual(tokensSeen, ["tok_B"]); - assert.equal(bodiesSeen[0]?.connection_id, undefined, "PackageRsClient strips selector before forwarding"); -}); - -test("list_event_subscriptions fans out across children and merges with source tags", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.token === "tok_A") { - return jsonResponse(200, { data: [{ subscription_id: "sub_a" }] }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { data: [{ subscription_id: "sub_b1" }, { subscription_id: "sub_b2" }] }); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/event-subscriptions"); - assert.equal(out.ok, true); - const body = out.body as { data: { source?: { grant_id?: string } }[] }; - assert.equal(body.data.length, 3); - for (const row of body.data) { - assert.ok(row.source?.grant_id); - } -}); - -test("get_event_subscription locates owning child via per-member probe", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.path === "/v1/event-subscriptions/sub_xyz") { - if (req.token === "tok_A") { - return jsonResponse(404, { error: { code: "not_found", type: "not_found" } }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { status: "active", subscription_id: "sub_xyz" }); - } - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/event-subscriptions/sub_xyz"); - assert.equal(out.ok, true); - assert.equal((out.body as { subscription_id: string }).subscription_id, "sub_xyz"); -}); - -test("delete_event_subscription locates owning child and forwards under that bearer", async () => { - const deleteTokens: string[] = []; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - if (req.method === "GET" && req.path === "/v1/event-subscriptions/sub_xyz") { - if (req.token === "tok_A") { - return jsonResponse(404, { error: { type: "not_found" } }); - } - if (req.token === "tok_B") { - return jsonResponse(200, { subscription_id: "sub_xyz" }); - } - } - if (req.method === "DELETE" && req.path === "/v1/event-subscriptions/sub_xyz") { - deleteTokens.push(req.token); - return jsonResponse(204, null); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.deleteJson("/v1/event-subscriptions/sub_xyz"); - assert.equal(out.ok, true); - assert.deepEqual(deleteTokens, ["tok_B"]); -}); - -test("unknown event subscription returns adapter not_found without touching record/RS state", async () => { - const fetch = makeRouter(async () => jsonResponse(404, { error: { type: "not_found" } })); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/event-subscriptions/nope"); - assert.equal(out.ok, false); - assert.equal(out.status, 404); - assert.equal((out.error as PackageRsErrorEnvelope).code, "not_found"); -}); - -// Spec: openspec/changes/canonicalize-connector-keys/specs/agent-consent-bundling/spec.md -// available_connections entries MUST include grant_id, connector_key, connection_id (not connector_id). -// These are regression tests for task 5.3. - -test("ambiguous_connection error envelope includes grant_id and connector_key (not connector_id)", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - assert.fail(`ambiguous read should not touch child grant ${req.token} ${req.path}`); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/repos/records", { query: { limit: 10 } }); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "ambiguous_connection"); - const conns = error.available_connections ?? []; - assert.equal(conns.length, 2); - for (const entry of conns) { - assert.ok("grant_id" in entry, "available_connections entry must carry grant_id"); - assert.ok("connector_key" in entry, "available_connections entry must carry connector_key (not connector_id)"); - assert.ok("connection_id" in entry, "available_connections entry must carry connection_id"); - assert.ok(!("connector_id" in entry), "available_connections entry must NOT advertise connector_id"); - } - assert.equal(conns[0]?.grant_id, "grant_A"); - assert.equal(conns[0]?.connector_key, "github"); - assert.equal(conns[0]?.connection_id, "gh_main"); - assert.equal(conns[1]?.grant_id, "grant_B"); - assert.equal(conns[1]?.connector_key, "slack"); - assert.equal(conns[1]?.connection_id, "slack_main"); -}); - -test("ambiguous_connection caps large packages and points callers at schema for the full index", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - assert.fail(`ambiguous read should not touch child grant ${req.token} ${req.path}`); - }); - const members = Array.from({ length: 25 }, (_, index) => ({ - connection_id: `conn_${index}`, - grant_id: `grant_${index}`, - source: { id: index % 2 === 0 ? "slack" : "gmail", kind: "connector" }, - token: `tok_${index}`, - })); - const rs = createPackageRsClient({ fetch, members, providerUrl: PROVIDER }); - const out = await rs.getJson("/v1/streams/messages/records", { query: { limit: 10 } }); - - assert.equal(out.ok, false); - assert.equal(out.status, 409); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "ambiguous_connection"); - assert.equal((error.available_connections ?? []).length, 12); - assert.equal(error.available_connection_count, 25); - assert.equal(error.available_connections_truncated, true); - assert.equal(error.available_connections_omitted, 13); - // biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract. - assert.match(error.discovery_hint ?? "", /schema/); -}); - -test("not_found error envelope includes grant_id and connector_key for event subscription create", async () => { - const fetch = makeRouter(async () => jsonResponse(500, {})); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.postJson("/v1/event-subscriptions", { - body: { callback_url: "https://x/y", connection_id: "unknown_conn" }, - }); - assert.equal(out.ok, false); - assert.equal(out.status, 404); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "not_found"); - const conns = error.available_connections ?? []; - assert.equal(conns.length, 2); - for (const entry of conns) { - assert.ok("grant_id" in entry, "not_found envelope must carry grant_id per member"); - assert.ok("connector_key" in entry, "not_found envelope must carry connector_key (not connector_id)"); - assert.ok(!("connector_id" in entry), "not_found envelope must NOT advertise connector_id"); - } -}); - -test("create_event_subscription ambiguous envelope carries connector_key and grant_id", async () => { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - assert.fail(`ambiguous event-sub write should not touch child grant ${req.token} ${req.path}`); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PROVIDER }); - const out = await rs.postJson("/v1/event-subscriptions", { body: { callback_url: "https://x/y" } }); - const error = out.error as PackageRsErrorEnvelope; - assert.equal(error.code, "ambiguous_connection"); - const conns = error.available_connections ?? []; - assert.equal(conns.length, 2); - for (const entry of conns) { - assert.ok("grant_id" in entry); - assert.ok("connector_key" in entry); - assert.ok(!("connector_id" in entry)); - } -}); - -// --------------------------------------------------------------------------- -// F1 regression — route hosted-MCP package-adapter self-calls to the internal -// base, not the public edge that 405s PATCH. -// -// Spec: openspec/changes/route-hosted-mcp-adapter-self-calls-internally/ -// -// `createPackageRsClient` takes a single `providerUrl` that becomes every -// child RsClient's fetch base. Before the fix, `handleHostedMcp` passed the -// public `resource` (the externally-fronted origin) here, so a server-internal -// PATCH self-call hairpinned through the public edge — which 405s PATCH — -// producing a typed `rs_error` `http_405`. After the fix the adapter passes the -// internal RS base (`referenceTopology.rsInternalUrl`) here while the advertised -// resource/discovery/`providerUrl` stay public; that split is exercised at the -// `createPackageRsClient` boundary below. -const PUBLIC_EDGE = "https://pdpp.test"; // advertised resource; edge 405s PATCH -const INTERNAL_BASE = "http://localhost:7663"; // configured internal RS; method-routes PATCH - -// A 405 with no JSON body — exactly what a method-blocking reverse proxy -// returns. A non-JSON, empty body flows through rs-client's parseRsResponse → -// normalizeErrorEnvelope as the typed { type: 'rs_error', code: 'http_405' }. -function bodilessMethodNotAllowed(): Response { - return new Response("", { headers: { "content-type": "text/plain" }, status: 405 }); -} - -interface RecordedCall { - method: string; - origin: string; - token: string; -} - -// Host-aware router: the public edge 405s every PATCH; the internal base -// method-routes PATCH. GET (the locate probe) is allowed on either host. -function makeHostAwareRouter(recorder: RecordedCall[] | null): FakeFetch { - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - return makeRouter(async (req) => { - // biome-ignore lint/style/useDestructuring: localized test assertion preserves its explicit contract. - const origin = req.url.origin; - // Public edge drops PATCH regardless of path (measured behavior of the - // external reverse proxy fronting the public origin). - if (req.method === "PATCH" && origin === PUBLIC_EDGE) { - return bodilessMethodNotAllowed(); - } - // Locate probe: GET per child to find the owning grant. tok_B owns sub_xyz. - if (req.method === "GET" && req.path === "/v1/event-subscriptions/sub_xyz") { - if (req.token === "tok_B") { - return jsonResponse(200, { status: "active", subscription_id: "sub_xyz" }); - } - return jsonResponse(404, { error: { code: "not_found", type: "not_found" } }); - } - // The actual PATCH self-call against the internal base (method-routed). - if (req.method === "PATCH" && req.path === "/v1/event-subscriptions/sub_xyz" && origin === INTERNAL_BASE) { - if (recorder) { - recorder.push({ method: req.method, origin, token: req.token }); - } - return jsonResponse(200, { subscription: { status: "disabled", subscription_id: "sub_xyz" } }); - } - return jsonResponse(500, { error: { code: "unexpected_route", type: "unexpected" } }); - }); -} - -test("F1: package-token update routes PATCH to the internal base, not the public edge that 405s", async () => { - const recorded: RecordedCall[] = []; - const fetch = makeHostAwareRouter(recorded); - // The fix passes the INTERNAL base as the child fetch base while the - // advertised resource stays public; here we model that by constructing the - // client against the internal base. - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: INTERNAL_BASE }); - const out = await rs.patchJson("/v1/event-subscriptions/sub_xyz", { body: { enabled: false } }); - - assert.equal(out.ok, true, "PATCH succeeds when routed to the internal base"); - assert.equal(out.status, 200); - // The regression guard: we did NOT inherit the public edge's 405. - assert.notEqual((out.error as PackageRsErrorEnvelope | undefined)?.code, "http_405"); - // The self-call hit the internal base, not the public edge. - assert.equal(recorded.length, 1); - assert.equal(recorded[0]?.origin, INTERNAL_BASE); - assert.equal(recorded[0]?.method, "PATCH"); - // Owning-child bearer preserved — no authority widening. - assert.equal(recorded[0]?.token, "tok_B"); -}); - -test("F1 falsifiability: PATCH against the public edge yields http_405 (pre-fix behavior)", async () => { - const fetch = makeHostAwareRouter(null); - // Pre-fix: the adapter used the public resource as the child fetch base. - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PUBLIC_EDGE }); - const out = await rs.patchJson("/v1/event-subscriptions/sub_xyz", { body: { enabled: false } }); - - assert.equal(out.ok, false, "PATCH fails when routed through the public edge"); - assert.equal(out.status, 405); - assert.equal( - (out.error as PackageRsErrorEnvelope).code, - "http_405", - "public-edge PATCH surfaces the typed http_405 error" - ); -}); - -test("F1 fallback parity: GET/POST/DELETE still succeed through the public base (only PATCH is edge-blocked)", async () => { - // With the internal base unset the adapter falls back to the public resource. - // GET/POST/DELETE pass the edge today; only PATCH is dropped — so the public - // fallback preserves all non-PATCH behavior. Asserting GET here proves the - // fix is needed precisely (and only) for the method the edge blocks. - let getProbes = 0; - let deletes = 0; - // biome-ignore lint/suspicious/useAwait: localized test assertion preserves its explicit contract. - const fetch = makeRouter(async (req) => { - // biome-ignore lint/style/useDestructuring: localized test assertion preserves its explicit contract. - const origin = req.url.origin; - if (req.method === "PATCH" && origin === PUBLIC_EDGE) { - return bodilessMethodNotAllowed(); - } - if (req.method === "GET" && req.path === "/v1/event-subscriptions/sub_xyz") { - getProbes += 1; - if (req.token === "tok_B") { - return jsonResponse(200, { status: "active", subscription_id: "sub_xyz" }); - } - return jsonResponse(404, { error: { code: "not_found", type: "not_found" } }); - } - if (req.method === "DELETE" && req.path === "/v1/event-subscriptions/sub_xyz") { - deletes += 1; - return jsonResponse(204, null); - } - return jsonResponse(500, {}); - }); - const rs = createPackageRsClient({ fetch, members: [memberA(), memberB()], providerUrl: PUBLIC_EDGE }); - - const got = await rs.getJson("/v1/event-subscriptions/sub_xyz"); - assert.equal(got.ok, true, "GET passes the public edge (fallback parity)"); - assert.ok(getProbes >= 1); - - const deleted = await rs.deleteJson("/v1/event-subscriptions/sub_xyz"); - assert.equal(deleted.ok, true, "DELETE passes the public edge (fallback parity)"); - assert.equal(deletes, 1); +import test from "node:test"; +import { createPackageProfile } from "../server/package-rs-client.ts"; + +const PROVIDER = "https://rs.test"; + +test("package profile keeps each child bound to its own RS bearer", async () => { + const calls: Array<{ authorization: string | null; url: string }> = []; + const profile = createPackageProfile({ + fetch: async (input, init) => { + calls.push({ + authorization: new Headers(init?.headers).get("authorization"), + url: String(input), + }); + return Response.json({ object: "schema", streams: [] }); + }, + members: [ + { grant_id: "child-A", token: "token-A" }, + { grant_id: "child.B", token: "token-B" }, + ], + providerUrl: PROVIDER, + }); + + assert.equal(profile.children.length, 2); + await profile.children[0]!.rs.getJson("/v1/schema"); + await profile.children[1]!.rs.getJson("/v1/schema"); + assert.deepEqual( + calls.map(({ authorization }) => authorization), + ["Bearer token-A", "Bearer token-B"], + ); +}); + +test("package profile requires at least one active child", () => { + assert.throws( + () => createPackageProfile({ members: [], providerUrl: PROVIDER }), + /at least one active member/, + ); }); diff --git a/reference-implementation/test/pdpp.test.ts b/reference-implementation/test/pdpp.test.ts index eaad73df5..d5d34b250 100644 --- a/reference-implementation/test/pdpp.test.ts +++ b/reference-implementation/test/pdpp.test.ts @@ -135,10 +135,6 @@ const REGEXP_80 = /Unsupported pending stream selection fields on 'top_artists': const REGEXP_86 = /Access Denied/; const REGEXP_87 = /source\/id must match format "uri"/; const REGEXP_89 = /Grant is malformed or no longer valid/; -const REGEXP_91 = /Grant is malformed or no longer valid/; -const REGEXP_92 = /Grant is malformed or no longer valid/; -const REGEXP_93 = /Grant is malformed or no longer valid/; -const REGEXP_94 = /Grant is malformed or no longer valid/; const REGEXP_95 = /Token introspection failed closed/; const REGEXP_96 = /Grant is malformed or no longer valid/; const REGEXP_97 = /Grant is malformed or no longer valid/; @@ -406,6 +402,7 @@ interface SourceDescriptor { interface TraceEventError { code: string | null | undefined; + http_status: number | undefined; message: string | null | undefined; } @@ -810,6 +807,7 @@ function parseTraceEventError(value: unknown, description: string): TraceEventEr const error = requireJsonRecord(value, description); return { code: optionalString(error.code, `${description}.code`), + http_status: typeof error.http_status === "number" ? error.http_status : undefined, message: optionalString(error.message, `${description}.message`), }; } @@ -4884,29 +4882,29 @@ test("PDPP reference implementation integration", async (t) => { const streamsResp = await fetchJson(`${rsUrl}/v1/streams`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(streamsResp.status, 403); + assert.equal(streamsResp.status, 401); assert.ok(streamsResp.headers["request-id"]?.startsWith("req_")); assert.ok(streamsResp.headers["pdpp-reference-trace-id"]?.startsWith("trc_")); - assert.equal(streamsResp.body.error.code, "grant_invalid"); - assert.match(streamsResp.body.error.message, REGEXP_91); + assert.equal(streamsResp.body.error.code, "authentication_error"); + assert.match(streamsResp.body.error.message, /Invalid or expired token/); const metadataResp = await fetchJson(`${rsUrl}/v1/streams/pay_statements`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(metadataResp.status, 403); + assert.equal(metadataResp.status, 401); assert.ok(metadataResp.headers["request-id"]?.startsWith("req_")); assert.ok(metadataResp.headers["pdpp-reference-trace-id"]?.startsWith("trc_")); - assert.equal(metadataResp.body.error.code, "grant_invalid"); - assert.match(metadataResp.body.error.message, REGEXP_92); + assert.equal(metadataResp.body.error.code, "authentication_error"); + assert.match(metadataResp.body.error.message, /Invalid or expired token/); const recordResp = await fetchJson(`${rsUrl}/v1/streams/pay_statements/records/ps_2026_04_15`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(recordResp.status, 403); + assert.equal(recordResp.status, 401); assert.ok(recordResp.headers["request-id"]?.startsWith("req_")); assert.ok(recordResp.headers["pdpp-reference-trace-id"]?.startsWith("trc_")); - assert.equal(recordResp.body.error.code, "grant_invalid"); - assert.match(recordResp.body.error.message, REGEXP_93); + assert.equal(recordResp.body.error.code, "authentication_error"); + assert.match(recordResp.body.error.message, /Invalid or expired token/); } finally { await closeServer(server); cleanup(); @@ -5003,14 +5001,14 @@ test("PDPP reference implementation integration", async (t) => { const rejectedResp = await fetch(`${rsUrl}${path}`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejectedResp.status, 403); + assert.equal(rejectedResp.status, 401); const rejectedRequestId = rejectedResp.headers.get("Request-Id"); const rejectedTraceId = rejectedResp.headers.get("PDPP-Reference-Trace-Id"); assert.ok(rejectedRequestId?.startsWith("req_")); assert.ok(rejectedTraceId?.startsWith("trc_")); const rejectedBody = parseErrorResponse(await rejectedResp.json()); - assert.equal(rejectedBody.error.code, "grant_invalid"); - assert.match(rejectedBody.error.message, REGEXP_94); + assert.equal(rejectedBody.error.code, "authentication_error"); + assert.match(rejectedBody.error.message, /Invalid or expired token/); const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); const queryReceivedEvent = timeline.data.find( @@ -5429,9 +5427,9 @@ test("PDPP reference implementation integration", async (t) => { const metadataResp = await fetchJson(`${rsUrl}/v1/streams/top_artists`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(metadataResp.status, 403); - assert.equal(metadataResp.body.error.code, "grant_invalid"); - assert.match(metadataResp.body.error.message, REGEXP_101); + assert.equal(metadataResp.status, 401); + assert.equal(metadataResp.body.error.code, "authentication_error"); + assert.match(metadataResp.body.error.message, /Invalid or expired token/); const revokeResp = await fetchJson(`${asUrl}/grants/${approved.grant.grant_id}/revoke`, { headers: { @@ -5564,9 +5562,9 @@ test("PDPP reference implementation integration", async (t) => { const metadataResp = await fetchJson(`${rsUrl}/v1/streams/pay_statements`, { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(metadataResp.status, 403); - assert.equal(metadataResp.body.error.code, "grant_invalid"); - assert.match(metadataResp.body.error.message, REGEXP_105); + assert.equal(metadataResp.status, 401); + assert.equal(metadataResp.body.error.code, "authentication_error"); + assert.match(metadataResp.body.error.message, /Invalid or expired token/); const revokeResp = await fetchJson(`${asUrl}/grants/${approved.grant.grant_id}/revoke`, { headers: { @@ -9154,7 +9152,7 @@ test("PDPP reference implementation integration", async (t) => { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(revoked.status, 403); + assert.equal(revoked.status, 401); assert.equal(revoked.body.error.code, "grant_revoked"); assert.ok(revoked.headers["request-id"]?.startsWith("req_")); assert.equal(revoked.headers["pdpp-reference-trace-id"], timeline.trace_id); @@ -9188,13 +9186,67 @@ test("PDPP reference implementation integration", async (t) => { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(expired.status, 403); + assert.equal(expired.status, 401); assert.equal(expired.body.error.code, "grant_expired"); assert.ok(expired.headers["request-id"]?.startsWith("req_")); assert.equal(expired.headers["pdpp-reference-trace-id"], timeline.trace_id); }); }); + await t.test( + "inactive malformed grants return authentication_error and record the actual auth-gate status", + async () => { + await withNativeHarness(async ({ asUrl, rsUrl, nativeManifest }) => { + await seedNorthstar(nativeManifest); + const approved = await approveGrant(asUrl, "employee_1", { + access_mode: "continuous", + client_id: "longview", + purpose_code: "https://pdpp.dev/purpose/financial_planning", + purpose_description: "Malformed grant auth-gate status test", + source: { id: nativeManifest.provider_id, kind: "provider_native" }, + streams: [{ name: "pay_statements" }], + }); + + getDb() + .prepare(` + UPDATE grants + SET storage_binding_json = NULL + WHERE grant_id = ? + `) + .run(approved.grant.grant_id); + + const rejected = await fetchJson(`${rsUrl}/v1/streams`, { + headers: { Authorization: `Bearer ${approved.token}` }, + }); + const { body: timeline } = await fetchGrantTimeline(asUrl, approved.grant.grant_id); + const rejectedEvent = (timeline.data || []).find( + (event) => + event.event_type === "query.rejected" && + event.object_id === rejected.headers["request-id"] && + event.data?.auth_gate === true + ); + + assert.ok(rejectedEvent, "grant timeline should include the auth-gate rejection"); + assert.deepEqual( + { + bodyCode: rejected.body.error.code, + bodyMessage: rejected.body.error.message, + eventCode: rejectedEvent.data?.error?.code, + eventStatus: requireJsonRecord(rejectedEvent.data?.error, "rejected event error").http_status, + status: rejected.status, + }, + { + bodyCode: "authentication_error", + bodyMessage: "Invalid or expired token", + eventCode: "grant_invalid", + eventStatus: 401, + status: 401, + } + ); + }); + } + ); + await t.test( "auth-gate client read failures emit correlated query.rejected artifacts on the grant timeline", async () => { @@ -9257,8 +9309,15 @@ test("PDPP reference implementation integration", async (t) => { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejected.status, 403); - assert.equal(rejected.body.error.code, scenario.inactiveReason); + assert.equal(rejected.status, 401); + assert.equal( + rejected.body.error.code, + scenario.inactiveReason === "grant_invalid" ? "authentication_error" : scenario.inactiveReason + ); + assert.equal( + rejected.headers["www-authenticate"], + `Bearer error="invalid_token", resource_metadata="${rsUrl}/.well-known/oauth-protected-resource"` + ); assert.ok(rejected.headers["request-id"]?.startsWith("req_")); assert.ok(rejected.headers["pdpp-reference-trace-id"]?.startsWith("trc_")); @@ -9278,6 +9337,7 @@ test("PDPP reference implementation integration", async (t) => { ); assert.equal(rejectedEvent.trace_id, rejected.headers["pdpp-reference-trace-id"]); assert.equal(rejectedEvent.data?.error?.code, scenario.inactiveReason); + assert.equal(requireJsonRecord(rejectedEvent.data?.error, "rejected event error").http_status, 401); } }); } @@ -9342,8 +9402,8 @@ test("PDPP reference implementation integration", async (t) => { headers: { Authorization: `Bearer ${approved.token}` }, }); - assert.equal(rejected.status, 403); - assert.equal(rejected.body.error.code, "grant_invalid"); + assert.equal(rejected.status, 401); + assert.equal(rejected.body.error.code, "authentication_error"); assert.ok(rejected.headers["request-id"]?.startsWith("req_")); assert.ok(rejected.headers["pdpp-reference-trace-id"]?.startsWith("trc_")); observed.push({ @@ -9389,6 +9449,7 @@ test("PDPP reference implementation integration", async (t) => { assert.equal(rejectedEvent.data?.limit ?? null, route.limit ?? null); assert.equal(rejectedEvent.data?.requested_record_id ?? null, route.requestedRecordId ?? null); assert.equal(rejectedEvent.data?.error?.code, "grant_invalid"); + assert.equal(requireJsonRecord(rejectedEvent.data?.error, "rejected event error").http_status, 401); } }); } diff --git a/reference-implementation/test/provider-metadata.test.ts b/reference-implementation/test/provider-metadata.test.ts index ed7ab0df5..462936855 100644 --- a/reference-implementation/test/provider-metadata.test.ts +++ b/reference-implementation/test/provider-metadata.test.ts @@ -1709,6 +1709,7 @@ test("RS 401 responses advertise protected-resource metadata in header and body" const rsUrl = `http://localhost:${server.rsPort}`; const metadataUrl = `${rsUrl}/.well-known/oauth-protected-resource`; const expectedChallenge = `Bearer resource_metadata="${metadataUrl}"`; + const invalidTokenChallenge = `Bearer error="invalid_token", resource_metadata="${metadataUrl}"`; try { const missingResp = await fetch(`${rsUrl}/v1/schema`); @@ -1727,7 +1728,7 @@ test("RS 401 responses advertise protected-resource metadata in header and body" }); const invalidBody = (await invalidResp.json()) as JsonObject; assert.equal(invalidResp.status, 401); - assert.equal(invalidResp.headers.get("www-authenticate"), expectedChallenge); + assert.equal(invalidResp.headers.get("www-authenticate"), invalidTokenChallenge); assert.equal(invalidBody.error.resource_metadata, metadataUrl); // biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract. assert.match(invalidBody.error.next_step, /pdpp_agent_discovery\.cli/); diff --git a/reference-implementation/test/ref-error-status.test.ts b/reference-implementation/test/ref-error-status.test.ts index 104eb7c7f..ae5fc65f8 100644 --- a/reference-implementation/test/ref-error-status.test.ts +++ b/reference-implementation/test/ref-error-status.test.ts @@ -45,15 +45,11 @@ test("codeToStatus pins the acceptance-target typed-error envelope codes", () => assert.equal(codeToStatus.connector_invalid, 400); }); -test("codeToStatus pins grant/auth and connector-instance code statuses", () => { - for (const code of [ - "grant_expired", - "grant_revoked", - "grant_consumed", - "grant_invalid", - "field_not_granted", - "connector_instance_owner_mismatch", - ]) { +test("codeToStatus pins inactive grant and permission error statuses", () => { + for (const code of ["grant_expired", "grant_revoked"]) { + assert.equal(codeToStatus[code], 401, `${code} must be 401`); + } + for (const code of ["grant_consumed", "grant_invalid", "field_not_granted", "connector_instance_owner_mismatch"]) { assert.equal(codeToStatus[code], 403, `${code} must be 403`); } assert.equal(codeToStatus.connector_instance_busy, 503); 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 f43af1493..03a43f187 100644 --- a/reference-implementation/test/rs-hosted-mcp-internal-base.test.ts +++ b/reference-implementation/test/rs-hosted-mcp-internal-base.test.ts @@ -14,14 +14,14 @@ // The fix passes the EXPLICITLY-configured internal base (`opts.rsInternalUrl` // or the operator's `PDPP_RS_URL`, plumbed by `startServer` — NOT the bare // `referenceTopology` default `http://localhost:7663`, which is intentionally -// skipped) to `createPackageRsClient` as the child fetch base, falling back to +// skipped) to `createPackageProfile` as the child fetch base, falling back to // the public resource when no internal base is configured. Advertised identity // stays public. (`INTERNAL_BASE` below is an explicitly-configured value, as a // real deployment's `PDPP_RS_URL` would be.) // // This test drives `handleHostedMcp` through `mountRsHostedMcp` against a // hand-built fake app + context. It captures the `providerUrl` that reaches -// `createPackageRsClient` and the `providerUrl` advertised on +// `createPackageProfile` and the `providerUrl` advertised on // `mcpServerOptions`. PRE-fix both equal the public resource (this test fails // on `assertEqual(internal)`); POST-fix the child base is the internal base // and the advertised value remains public. @@ -137,7 +137,7 @@ interface Seen { singleGrantProviderUrl: string | null; } -// Build a context whose `createPackageRsClient` records the providerUrl it +// 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 }): { @@ -147,9 +147,9 @@ function makeContext({ internalResource }: { internalResource: string | null | u const seen: Seen = { advertisedProviderUrl: null, childProviderUrl: null, singleGrantProviderUrl: null }; return { ctx: { - createPackageRsClient({ providerUrl }) { + createPackageProfile({ providerUrl }) { seen.childProviderUrl = providerUrl; - return { __fakePackageRsClient: true }; + return { __fakePackageProfile: true }; }, createRsClient({ providerUrl }) { seen.singleGrantProviderUrl = providerUrl; @@ -252,7 +252,7 @@ test("F1 wiring (single-grant): client-token self-calls use the internal base; a "advertised providerUrl must remain the public origin (single-grant)" ); // The package-only recorder was not touched on the client path. - assert.equal(seen.childProviderUrl, null, "package createPackageRsClient must not be invoked for a client token"); + assert.equal(seen.childProviderUrl, null, "package createPackageProfile must not be invoked for a client token"); }); test("F1 wiring (single-grant) fallback: no internal base → client self-calls use the public resource", async () => { diff --git a/reference-implementation/vendor/mcp-server/src/package-profile.ts b/reference-implementation/vendor/mcp-server/src/package-profile.ts new file mode 100644 index 000000000..a633a66ed --- /dev/null +++ b/reference-implementation/vendor/mcp-server/src/package-profile.ts @@ -0,0 +1,182 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { z } from "zod"; +import type { RsClient } from "./rs-client.ts"; +import { buildTools } from "./tools.ts"; + +export interface PackageProfileChild { + grant_id: string; + rs: RsClient; +} + +export interface PackageProfile { + children: PackageProfileChild[]; +} + +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."; + +export function isPackageDataTool( + name: unknown, +): name is + | "schema" + | "query_records" + | "aggregate" + | "search" + | "fetch" + | "read_record_field" { + return ( + name === "schema" || + name === "query_records" || + name === "aggregate" || + name === "search" || + name === "fetch" || + name === "read_record_field" + ); +} + +export function resolvePackageChild( + profile: PackageProfile, + grantId: unknown, +): + | { child: PackageProfileChild } + | { error: ReturnType } { + if (typeof grantId !== "string" || !GRANT_ID_PATTERN.test(grantId)) { + return { + error: selectorError("invalid_grant_selector", SELECTOR_RECOVERY), + }; + } + const child = profile.children.find((entry) => entry.grant_id === grantId); + return child + ? { child } + : { + error: selectorError( + "unknown_grant_selector", + `Unknown grant_id. ${SELECTOR_RECOVERY}`, + ), + }; +} + +function selectorError(code: string, message: string) { + return { + isError: true as const, + content: [{ type: "text" as const, text: message }], + structuredContent: { error: { code, message } }, + }; +} + +function scopeFollowups(value: unknown, grantId: string): unknown { + if (Array.isArray(value)) { + return value.map((entry) => scopeFollowups(entry, grantId)); + } + if (!value || typeof value !== "object") { + return value; + } + const object = value as Record; + const scoped = Object.fromEntries( + Object.entries(object).map(([key, entry]) => [ + key, + scopeFollowups(entry, grantId), + ]), + ); + if ( + typeof object.tool === "string" && + object.args && + typeof object.args === "object" + ) { + scoped.args = { + ...(object.args as Record), + grant_id: grantId, + }; + } + return scoped; +} + +type ToolResult = Awaited< + ReturnType[number]["handler"]> +>; + +function scopePackageResult( + result: ToolResult, + name: string, + grantId: string, +): ToolResult { + const structured = result.structuredContent; + const contentLadder = structured?.content_ladder + ? scopeFollowups(structured.content_ladder, grantId) + : undefined; + const searchResults = + name === "search" && Array.isArray(structured?.results) + ? structured.results.map((entry) => { + const hit = entry as Record; + return { + ...hit, + ...(hit.match_windows + ? { match_windows: scopeFollowups(hit.match_windows, grantId) } + : {}), + }; + }) + : undefined; + return { + ...result, + content: [ + ...result.content, + { + type: "text" as const, + text: `Selected grant_id: ${grantId}. Include it in every follow-up tool call.`, + }, + ], + ...(structured + ? { + structuredContent: { + ...structured, + ...(contentLadder ? { content_ladder: contentLadder } : {}), + ...(searchResults ? { results: searchResults } : {}), + }, + } + : {}), + }; +} + +export function buildPackageProfileTools( + profile: PackageProfile, + providerUrl: string, +): ReturnType { + if (profile.children.length === 0) { + throw new TypeError("Package profile requires a covered child"); + } + const schemaTools = buildTools({ rs: profile.children[0]!.rs, providerUrl }); + return schemaTools.map((tool) => ({ + ...tool, + description: `${tool.description} For package tokens, pass one child grant_id; use list_package_children to discover children.`, + inputSchema: (tool.inputSchema as z.ZodObject).extend({ + grant_id: z + .string() + .describe( + "Required package child grant_id. Discover children with list_package_children.", + ), + }), + handler: async (args: Record) => { + const selection = resolvePackageChild(profile, args.grant_id); + if ("error" in selection) { + return selection.error; + } + const { child } = selection; + const grantId = child.grant_id; + const { grant_id: _selector, ...readArgs } = args; + 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( + await childTool.handler(readArgs), + tool.name, + grantId, + ); + }, + })); +} diff --git a/reference-implementation/vendor/mcp-server/src/server.ts b/reference-implementation/vendor/mcp-server/src/server.ts index 5bcfa2554..9bd9d8e9a 100644 --- a/reference-implementation/vendor/mcp-server/src/server.ts +++ b/reference-implementation/vendor/mcp-server/src/server.ts @@ -4,6 +4,12 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + buildPackageProfileTools, + isPackageDataTool, + type PackageProfile, + resolvePackageChild, +} from "./package-profile.ts"; import { RsClient } from "./rs-client.ts"; import { buildResourceTemplates, buildTools } from "./tools.ts"; @@ -15,8 +21,8 @@ export const DEFAULT_SERVER_VERSION = "0.0.0"; // Cross-tool details that would otherwise repeat across tool descriptions live // 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; 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`, not `grant_id`, across reconnects. Search result ids are self-contained handles; pass them to `fetch` for projected records or to `read_record_field` for bounded field windows. " + + "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. " + "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."; @@ -27,9 +33,10 @@ interface ServerIcon { } interface CreatePdppMcpServerOptions { - accessToken: string; + accessToken?: string; fetch?: typeof fetch; providerUrl: string; + packageProfile?: PackageProfile; rsClient?: RsClient; serverIcons?: ServerIcon[]; serverName?: string; @@ -47,16 +54,25 @@ interface CreatePdppMcpServerOptions { export function createPdppMcpServer({ providerUrl, accessToken, + packageProfile, rsClient, fetch: fetchImpl = globalThis.fetch, serverName = DEFAULT_SERVER_NAME, serverVersion = DEFAULT_SERVER_VERSION, serverIcons, }: CreatePdppMcpServerOptions): { server: McpServer; rs: RsClient } { - // Callers may inject a custom RsClient-compatible adapter (e.g. the hosted - // adapter's PackageRsClient fan-out). Otherwise we build a single-bearer - // RsClient from the supplied accessToken. - const rs = rsClient ?? new RsClient({ providerUrl, accessToken, fetch: fetchImpl }); + // Package calls receive a profile whose children each own one scoped client. + const rs = + rsClient ?? + packageProfile?.children[0]?.rs ?? + (accessToken + ? new RsClient({ providerUrl, accessToken, fetch: fetchImpl }) + : undefined); + if (!rs) { + throw new TypeError( + "CreatePdppMcpServer requires an accessToken, rsClient, or packageProfile", + ); + } const serverInfo: { name: string; version: string; icons?: ServerIcon[] } = { name: serverName, version: serverVersion, @@ -68,7 +84,9 @@ export function createPdppMcpServer({ instructions: PDPP_MCP_INSTRUCTIONS, }); - const tools = buildTools({ rs, providerUrl }); + const tools = packageProfile + ? buildPackageProfileTools(packageProfile, providerUrl) + : buildTools({ rs, providerUrl }); for (const tool of tools) { const config: { title: string; @@ -94,7 +112,9 @@ export function createPdppMcpServer({ }); } - for (const template of buildResourceTemplates({ rs, providerUrl })) { + for (const template of packageProfile + ? [] + : buildResourceTemplates({ rs, providerUrl })) { server.registerResource( template.name, new ResourceTemplate(template.uriTemplate, { list: undefined }), @@ -144,6 +164,36 @@ export async function handleStreamableHttpRequest( request: Request, options: CreatePdppMcpServerOptions ): Promise { + if (options.packageProfile) { + const rpc = await request + .clone() + .json() + .catch(() => null); + if ( + rpc && + typeof rpc === "object" && + (rpc as Record).method === "tools/call" + ) { + const params = ( + rpc as { + params?: { name?: unknown; arguments?: Record }; + } + ).params; + if (isPackageDataTool(params?.name)) { + const selection = resolvePackageChild( + options.packageProfile, + params?.arguments?.grant_id, + ); + if ("error" in selection) { + return Response.json({ + jsonrpc: "2.0", + id: (rpc as { id?: unknown }).id ?? null, + result: selection.error, + }); + } + } + } + } const { server } = createPdppMcpServer(options); // Explicitly omitting `sessionIdGenerator` (rather than setting it to // `undefined`) selects the SDK's stateless mode identically — diff --git a/reference-implementation/vendor/mcp-server/src/tools.ts b/reference-implementation/vendor/mcp-server/src/tools.ts index a8bb94c0d..e684ff665 100644 --- a/reference-implementation/vendor/mcp-server/src/tools.ts +++ b/reference-implementation/vendor/mcp-server/src/tools.ts @@ -212,7 +212,7 @@ const SUPPORTED_AGGREGATE_QUERY_KEYS = new Set([ ]); const CONNECTION_ID_DESCRIPTION = - "Optional connection scope. Omit to fan in across granted connections. Get it from `schema` or typed 409 `available_connections` entries with `connector_key` and `connection_id`; persist `connection_id`, not `grant_id`, across reconnects."; + "Client tokens may scope with a `connection_id` from `schema` or `available_connections`; candidates include `connector_key` and `connection_id`. Package tokens select one child with `grant_id`."; const LIMIT_DESCRIPTION = "Records per page. Omit for the default page of 25; the maximum is 100 (the spec-core §8 contract). Values above 100 are rejected here rather than silently clamped, so the page size you request is always the page size you get. Page forward with the returned `cursor` instead of asking for a larger page."; @@ -690,7 +690,7 @@ export function buildTools({ rs, providerUrl }: { rs: RsClient; providerUrl: str name: "schema", title: "Get PDPP schema", description: - 'Return the grant-scoped PDPP schema document from `GET /v1/schema`. This is the canonical capability source: streams, canonical connector-type metadata (`connector_key`), per-field filter operators (`field_capabilities`), expandable relations (`expand_capabilities`), projection support, search modes, pagination support, count support, and granted connection identities (`connection_id`, `display_name`). Defaults to a compact, token-efficient projection (`detail: "compact"`) so the `schema -> schema(stream) -> schema(stream, connection_id) -> query_records` discovery path stays cheap. Stream names are not globally unique; add `connection_id` to narrow a shared stream to one configured source. `detail: "full"` is allowed only with `stream` and returns deduped exhaustive schema for matching stream rows, preserving raw per-field JSON Schema without duplicate stream arrays. Call this before issuing other tools to discover valid filter, sort, expand, fields, count, aggregate, stream, and connection-disambiguation arguments. Read-only.', + 'Return the grant-scoped schema from `GET /v1/schema`: streams, canonical `connector_key` metadata, field filter/sort/count operators, expansions, aggregates, projections, search, pagination, and `connection_id` identities. Defaults to a compact token-efficient projection (`detail: "compact"`) so `schema -> schema(stream) -> schema(stream, connection_id) -> query_records` stays cheap for client tokens. Stream names may repeat; package tokens select one child with `grant_id`. `detail: "full"` is allowed only with `stream` and preserves raw per-field JSON Schema. Read this first to find valid arguments. Read-only.', annotations: READ_ONLY_ANNOTATIONS, inputSchema: z .object({ @@ -821,7 +821,7 @@ export function buildTools({ rs, providerUrl }: { rs: RsClient; providerUrl: str name: "search", title: "Search PDPP records", description: - "Search records via `GET /v1/search` (lexical), `/v1/search/semantic`, or `/v1/search/hybrid` per `mode`. Use lexical for exact known terms; semantic is approximate retrieval for conceptual matches. `structuredContent.results` carries the flattened page; `structuredContent.data` carries compact envelope metadata, not a duplicate hit array. Hit ids are self-contained `fetch` handles (the connection is encoded in the id); hits also carry `connection_id` and `connector_key`. Pass `connection_id` to scope, omit to fan in. Page default is 25 hits; `limit` is capped at 100 (enforced at input, and fan-in packages apply it globally). Page forward with `cursor` (lexical/semantic; hybrid does not page). Per-mode capability support is advertised by `GET /v1/schema`. Read-only.", + "Search lexical `/v1/search`, semantic, or hybrid records. Use lexical for exact terms and semantic for conceptual matches. Results contain self-contained `fetch` ids, `connection_id`, and `connector_key`; `structuredContent.data` holds compact metadata. Modes and query capabilities are in `GET /v1/schema`. Client tokens may scope by `connection_id`; package tokens must select one child with `grant_id`. Pages default to 25; `limit` is capped at 100. Page with the returned `cursor` (lexical/semantic; hybrid does not page). Read-only.", annotations: READ_ONLY_ANNOTATIONS, inputSchema: z .object({ @@ -2795,7 +2795,6 @@ function summarizeSearch(body: Json, results: SearchResult[]): string { const nextCursor = envelopeStringField(body, "next_cursor"); const cursorText = nextCursor ? ` next_cursor=${formatScalar(nextCursor)}.` : ""; const firstFetchText = results[0]?.id ? ` first_fetch_id=${formatInlineValue(results[0].id)}` : ""; - const sourceMixText = formatSearchSourceMix(body); const recallText = formatSearchRecallWarning(body); const previews = results.slice(0, SEARCH_TEXT_PREVIEW_LIMIT).map(formatSearchPreviewLine); const matchPreviews = results @@ -2807,7 +2806,7 @@ function summarizeSearch(body: Json, results: SearchResult[]): string { previews.length > 0 ? "\nFetch a hit with `fetch` using the shown id as-is; ids are self-contained. Pass connection_id only when shown separately." : ""; - return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${matchPreviewText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`; + return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${recallText}${matchPreviewText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`; } function formatSearchMatchWindowLines(result: SearchResult, index: number): string[] { @@ -2865,32 +2864,6 @@ function formatSearchRecallWarning(body: Json): string { return ""; } -function formatSearchSourceMix(body: Json): string { - const bodyObject = objectValue(body); - const meta = objectValue(bodyObject?.meta); - const pkg = objectValue(meta?.package); - const sourceMix = pkg?.source_mix; - if (!Array.isArray(sourceMix) || sourceMix.length === 0) { - return ""; - } - const rendered = sourceMix.slice(0, 8).map((entry: Json) => { - const entryObject = objectValue(entry); - const parts = [ - `connection_id:${formatInlineValue(entryObject?.connection_id)}`, - `connector_key:${formatInlineValue(entryObject?.connector_key)}`, - `count:${formatInlineValue(entryObject?.count)}`, - ]; - if (entryObject?.display_name) { - parts.push(`display_name:${formatInlineValue(entryObject.display_name)}`); - } - return `{${parts.join(",")}}`; - }); - if (sourceMix.length > 8) { - rendered.push(`more:${sourceMix.length - 8}`); - } - return ` source_mix=${rendered.join("|")}.`; -} - function formatSearchPreviewLine(result: SearchResult, index: number): string { const parts = [`${index + 1}. id=${formatInlineValue(truncateText(result.id, SEARCH_TEXT_ID_CHAR_LIMIT))}`]; // The connection is normally embedded in the self-contained id; repeat it as diff --git a/reference-implementation/vendor/mcp-server/test/hosted-schema-token-budget.test.ts b/reference-implementation/vendor/mcp-server/test/hosted-schema-token-budget.test.ts index 29243ec7c..9e7783aba 100644 --- a/reference-implementation/vendor/mcp-server/test/hosted-schema-token-budget.test.ts +++ b/reference-implementation/vendor/mcp-server/test/hosted-schema-token-budget.test.ts @@ -16,7 +16,7 @@ interface CompactViewModule { } const { projectSchemaCompactView, projectSchemaStreamScope, schemaSourceOptions } = (await import( - "../../../reference-implementation/operations/rs-schema-get/compact-view.ts" + "../../../operations/rs-schema-get/compact-view.ts" )) as CompactViewModule; // Hosted-transport parity for the `schema` MCP tool token budget. diff --git a/reference-implementation/vendor/mcp-server/test/package-profile.test.ts b/reference-implementation/vendor/mcp-server/test/package-profile.test.ts new file mode 100644 index 000000000..2b112b7e0 --- /dev/null +++ b/reference-implementation/vendor/mcp-server/test/package-profile.test.ts @@ -0,0 +1,264 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PackageProfile } from "../src/package-profile.ts"; +import { RsClient } from "../src/rs-client.ts"; +import { handleStreamableHttpRequest } from "../src/server.ts"; + +const TOOL_NAMES = [ + "schema", + "query_records", + "aggregate", + "search", + "fetch", + "read_record_field", +]; +const VALID_ARGS: Record> = { + schema: {}, + query_records: { stream: "notes" }, + aggregate: { metric: "count", stream: "notes" }, + search: { mode: "lexical", q: "term" }, + fetch: { id: "conn_a/notes:a" }, + read_record_field: { field_path: "text", id: "conn_a/notes:a" }, +}; + +function fixture(options: { invalidGrantId?: string } = {}) { + const reads: string[] = []; + const children = ["a.child~_1-", "b.child"].map((grant_id) => ({ + grant_id, + rs: new RsClient({ + accessToken: `token-${grant_id}`, + fetch: async (input) => { + const url = String(input); + reads.push(`${grant_id}:${url}`); + if (grant_id === options.invalidGrantId) { + return Response.json( + { + error: { + code: "grant_invalid", + message: "The selected child grant is invalid.", + }, + }, + { status: 403 }, + ); + } + if (url.includes("/field-window")) { + return Response.json({ + field: { path: "text" }, + record_id: "a", + stream: "notes", + window: { + complete: true, + end_chars: 5, + limit_chars: 16, + next_offset_chars: null, + previous_offset_chars: 0, + start_chars: 0, + text: "hello", + }, + }); + } + if (url.includes("/v1/search")) { + return Response.json({ + hits: [ + { + connection_id: "conn_a", + field_windows: [ + { + field_path: "text", + preview_status: "truncated", + preview_text: "preview", + size_chars: 9000, + }, + ], + id: "a", + record_key: "a", + stream: "notes", + }, + ], + }); + } + return Response.json({ data: [] }); + }, + providerUrl: "https://rs.test", + }), + })); + return { reads, profile: { children } satisfies PackageProfile }; +} + +async function request( + profile: PackageProfile | undefined, + method: string, + params: unknown, +) { + const response = await handleStreamableHttpRequest( + new Request("https://rs.test/mcp", { + body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }), + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }, + method: "POST", + }), + { + ...(profile + ? { packageProfile: profile } + : { accessToken: "client-token" }), + providerUrl: "https://rs.test", + }, + ); + return JSON.parse(await response.text()) as { + result: Record; + status?: number; + }; +} + +async function call( + profile: PackageProfile, + name: string, + args: Record, +) { + return request(profile, "tools/call", { arguments: args, name }); +} + +test("package tools require a valid known grant_id before any RS call", async () => { + const { profile, reads } = fixture(); + for (const name of TOOL_NAMES) { + for (const args of [ + VALID_ARGS[name]!, + { ...VALID_ARGS[name], grant_id: "" }, + { ...VALID_ARGS[name], grant_id: "bad/id" }, + { ...VALID_ARGS[name], grant_id: "unknown-child" }, + { ...VALID_ARGS[name], grant_id: 7 }, + { ...VALID_ARGS[name], grant_id: null }, + { ...VALID_ARGS[name], grant_id: [] }, + { ...VALID_ARGS[name], grant_id: {} }, + ]) { + const { result } = await call(profile, name, args); + assert.equal( + result.isError, + true, + `${name} rejects ${JSON.stringify(args.grant_id)}`, + ); + assert.match(JSON.stringify(result.content), /list_package_children/); + } + } + assert.deepEqual(reads, []); +}); + +test("each package data tool reads only the named child for either child", async () => { + const { profile, reads } = fixture(); + for (const child of profile.children) { + for (const name of TOOL_NAMES) { + reads.length = 0; + const { result } = await call(profile, name, { + ...VALID_ARGS[name]!, + grant_id: child.grant_id, + }); + assert.notEqual( + result.isError, + true, + `${name} accepts ${child.grant_id}: ${JSON.stringify(result.content)}`, + ); + assert.ok(reads.length > 0, `${name} reaches the selected RS client`); + assert.ok( + reads.every((read) => read.startsWith(`${child.grant_id}:`)), + `${name} reads only ${child.grant_id}`, + ); + } + } +}); + +test("selected child grant_invalid is returned without calling a sibling", async () => { + const invalidGrantId = "b.child"; + const { profile, reads } = fixture({ invalidGrantId }); + const { result } = await call(profile, "query_records", { + ...VALID_ARGS.query_records!, + grant_id: invalidGrantId, + }); + const structured = result.structuredContent as { + error: { code: string }; + http_status: number; + }; + assert.equal(result.isError, true); + assert.equal(structured.error.code, "grant_invalid"); + 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); +}); + +test("package tools/list requires grant_id and hides resource templates; client surface stays unchanged", async () => { + const { profile } = fixture(); + const packageList = await request(profile, "tools/list", {}); + assert.deepEqual( + packageList.result.tools && + (packageList.result.tools as Array<{ name: string }>).map( + (tool) => tool.name, + ), + TOOL_NAMES, + ); + for (const tool of packageList.result.tools as Array<{ + inputSchema: { properties: Record; required: string[] }; + }>) { + 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")); + } + 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 clientList = await request(undefined, "tools/list", {}); + assert.deepEqual( + (clientList.result.tools as Array<{ name: string }>).map( + (tool) => tool.name, + ), + TOOL_NAMES, + ); + for (const tool of clientList.result.tools as Array<{ + inputSchema: { properties: Record }; + }>) { + assert.equal("grant_id" in tool.inputSchema.properties, false); + } +}); + +test("package follow-up descriptors keep their selected grant_id", async () => { + const { profile } = fixture(); + const grantId = profile.children[0]!.grant_id; + const result = await call(profile, "search", { + mode: "lexical", + q: "term", + grant_id: grantId, + }); + assert.equal(result.result.isError, undefined); + const structured = result.result.structuredContent as { + content_ladder: { + records: Array<{ + field_windows: Array<{ read: { args: Record } }>; + }>; + }; + results: Array<{ + match_windows: Array<{ read: { args: Record } }>; + }>; + }; + assert.equal( + structured.content_ladder.records[0]!.field_windows[0]!.read.args.grant_id, + grantId, + ); + assert.equal( + structured.results[0]!.match_windows[0]!.read.args.grant_id, + grantId, + ); +}); diff --git a/reference-implementation/vendor/mcp-server/test/schema-token-budget.test.ts b/reference-implementation/vendor/mcp-server/test/schema-token-budget.test.ts index c8fb62c74..60e488d1b 100644 --- a/reference-implementation/vendor/mcp-server/test/schema-token-budget.test.ts +++ b/reference-implementation/vendor/mcp-server/test/schema-token-budget.test.ts @@ -44,7 +44,7 @@ interface CompactViewModule { } const { projectSchemaCompactView, projectSchemaStreamScope, schemaSourceOptions } = (await import( - "../../../reference-implementation/operations/rs-schema-get/compact-view.ts" + "../../../operations/rs-schema-get/compact-view.ts" )) as CompactViewModule; function mcpCompactSchemaExpected(schema: unknown, opts?: unknown): unknown { diff --git a/scripts/pdpp-interoperability-e2e.test.mjs b/scripts/pdpp-interoperability-e2e.test.mjs index 00486fc53..c1e7d380e 100644 --- a/scripts/pdpp-interoperability-e2e.test.mjs +++ b/scripts/pdpp-interoperability-e2e.test.mjs @@ -403,7 +403,7 @@ test("DataConnect PDPP interoperability: grant-scoped GitHub records survive res await restartedPorts.GrantScopedRecordsRepository.readAfterRevocation( redeemed.token ) - assert.equal(read.status, 403) + assert.equal(read.status, 401) assert.equal(read.body.error.code, "grant_revoked") } ) diff --git a/src/apps/timeline/timeline-data-source.test.ts b/src/apps/timeline/timeline-data-source.test.ts index 8eb5b5be4..32f8ac68f 100644 --- a/src/apps/timeline/timeline-data-source.test.ts +++ b/src/apps/timeline/timeline-data-source.test.ts @@ -455,7 +455,7 @@ describe("production Timeline PDPP data source", () => { message: "The grant is no longer valid", }, }, - 403 + 401 ) ) const consent = await dataSource.requestConsent?.()