diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7f2a3..90e0ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Added +- [#365](https://github.com/mohanagy/miftah/issues/365) Added the MCP 2026-07-28 gateway header and cache contract. Modern Streamable HTTP relies on SDK validation of `Mcp-Method` and `Mcp-Name` against JSON-RPC before request-scoped broker construction, publishes explicit private zero-TTL cache hints, and returns canonically ordered resource, resource-template, and prompt catalogs with deterministic proxied template routes. - [#367](https://github.com/mohanagy/miftah/issues/367) Added MCP 2026-07-28 OAuth client registration hardening for native remote connections. Endpoint-first CLI and Console setup can persist a reviewed Client ID Metadata Document URL when the authorization server advertises CIMD, while the shared runtime presents that URL as the client identifier without a registration request. Dynamic Client Registration remains a documented, advertised-endpoint-only compatibility fallback through the 1.x line, with removal no earlier than 2.0.0 after compatibility evidence and a migration path. Native loopback clients now explicitly declare `application_type: native`. - [#363](https://github.com/mohanagy/miftah/issues/363) Added MCP `2026-07-28` serving alongside the existing legacy initialized era. STDIO now negotiates both eras through the SDK v2 serving entry; Streamable HTTP classifies each request before routing it to a fresh request-scoped modern server or the existing bounded sessionful legacy host. Modern requests use `server/discover`, carry per-request metadata, omit `Mcp-Session-Id`, propagate cancellation to the selected upstream, and return explicit supported-version diagnostics. The new public `createMiftahServerFactory` lets a trusted embedding host attach the authenticated stateless profile-context boundary without exposing Miftah's broker, policy, audit, OAuth, routing, or lifecycle internals. - [#377](https://github.com/mohanagy/miftah/issues/377) Added the opt-in production profile-context boundary for trusted modern stateless hosts. Short-lived authenticated-encrypted handles bind a named profile to the verified issuer, subject, audience, chat, deployment, and monotonic sealing-key epoch; deployment-wide revocation, exact expiry, removed-profile checks, bearer-free keyed audit correlation, audited rejection and replacement-before-revocation ordering, and fixed fail-closed errors apply on every request. Reserved tool or request metadata is stripped before audit argument capture and upstream forwarding, transition approvals are bound to the authenticated chat correlation, and modern discovery enforces identical client-visible tools across profiles and is independent of prior selection calls. The CLI host does not synthesize trusted chat claims; an embedding host enables the boundary through `createMiftahServerFactory`. @@ -17,6 +18,7 @@ All notable changes to this project will be documented in this file. The format ### Security +- [#365](https://github.com/mohanagy/miftah/issues/365) Fails closed on missing, malformed, duplicated, mismatched, or incorrectly encoded modern routing headers before routing, policy, audit, or upstream execution. Because Miftah's low-level proxy cannot yet validate schema-declared parameter headers end to end, modern HTTP strips `x-mcp-header` declarations and rejects every `Mcp-Param-*` header with a fixed non-reflective response; legacy HTTP and STDIO retain their existing argument behavior. Positive-TTL sharing remains disabled so catalogs cannot cross principals or survive profile, policy, upstream, or configuration changes. - [#367](https://github.com/mohanagy/miftah/issues/367) Bound client information, tokens, discovery, and callback completion to the exact configured authorization-server issuer. Missing, duplicated, or mismatched RFC 9207 `iss` callback values fail before code redemption; issuer changes require a fresh connection identity and authorization; fixed errors, callback pages, configuration, and audit output do not expose authorization codes, tokens, client secrets, or raw provider details. ## [1.0.0] - 2026-08-11 diff --git a/docs/config.md b/docs/config.md index 55e51f3..ab0e195 100644 --- a/docs/config.md +++ b/docs/config.md @@ -124,6 +124,12 @@ Only literal `127.0.0.1` and `::1` are loopback bind values. A hostname, `localh Each accepted HTTP session owns a fresh Miftah runtime, profile manager, routing/approval/lock/lease state, and upstream manager. HTTP sessions always use in-memory `session` profile state, even when the base configuration opts into durable state, so one client's selection cannot persist into or alter another client's session. +Modern `2026-07-28` HTTP requests must carry `Mcp-Method` and, for named operations such as `tools/call`, `Mcp-Name`. The SDK validates each value, including encoded names, against the parsed JSON-RPC request before Miftah creates a request-scoped broker or reaches routing, policy, audit, or an upstream. Missing, malformed, duplicated, or mismatched values fail with a generic HTTP 400 JSON-RPC error. These mirrored headers are routing hints, never credentials or authorization evidence. + +Miftah does not yet support schema-aware `Mcp-Param-*` forwarding. Its modern HTTP catalog removes `x-mcp-header` declarations so conforming clients do not advertise the extension, and any received parameter header fails with a fixed HTTP 400 response before broker execution. Header names and values are not reflected, audited, or sent upstream. STDIO and the legacy initialized HTTP path retain their existing JSON-RPC argument behavior. + +Modern cacheable results use `ttlMs: 0` and `cacheScope: "private"`. This is deliberate: the request-scoped gateway has no positive-TTL cache keyed by authenticated principal, profile, policy, and configuration revision, so it never shares a catalog across principals or serves a stale catalog after those inputs change. Resource, resource-template, and prompt lists are canonically ordered. Proxied resource-template route identifiers are derived deterministically from the upstream name, exposed name, and already-redacted catalog-visible URI template; no sensitive original template is used as identifier input. Legacy responses do not receive the modern cache fields. + ## Profile credential isolation `profiles..isolation` is an opt-in, POSIX-only filesystem boundary for a local STDIO target. Miftah derives a deterministic runtime tree from the canonical configuration file, profile, and upstream name; it never accepts an operator-selected runtime root. The tree contains `home`, `appdata`, `localappdata`, and `xdg/config`, `xdg/cache`, `xdg/data`, `xdg/state`, and `xdg/runtime`. On macOS and Linux, Miftah verifies owner control, uses restrictive `0700` directories and `0600` copied files where the platform supports those modes, and refuses a reused target without its matching Miftah marker. diff --git a/docs/library-api.md b/docs/library-api.md index 81a840e..d3ecadf 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -50,6 +50,8 @@ For a custom HTTP host, pass the same factory to `createMcpHandler` from `@model This matrix describes Miftah's tested serving boundary, not a promise that every optional feature added to any future MCP revision is implemented. The SDK v2 serving entry owns protocol-era negotiation; Miftah continues to own broker routing, policy, audit, OAuth, profile state, upstream lifecycle, and cancellation propagation. +For modern Streamable HTTP, the serving entry validates `Mcp-Method` and `Mcp-Name` against the parsed JSON-RPC request before constructing Miftah's per-request server. Miftah currently declines the optional `Mcp-Param-*` extension: its CLI-owned modern HTTP catalog strips `x-mcp-header` schema keywords and its ingress rejects parameter headers without reflecting or forwarding them. Modern cacheable results are explicitly private and immediately stale (`ttlMs: 0`); deterministic resource, resource-template, and prompt ordering makes repeated uncached catalogs stable. STDIO and legacy initialized HTTP behavior remain unchanged. + Confirmation-required tools, resource reads, prompt reads, and profile transitions return the MCP `input_required` result on the modern era. Miftah binds the continuation to the exact operation with bounded, integrity-protected, one-time state shared by the server factory, so a fresh request-scoped HTTP instance can safely finish the approval without retaining raw operation arguments. The SDK's legacy shim translates the same handler flow into form elicitation for initialized clients. ## Authenticated request context diff --git a/docs/security.md b/docs/security.md index e9d06aa..6ee9045 100644 --- a/docs/security.md +++ b/docs/security.md @@ -38,7 +38,9 @@ Windows profile credential isolation fails closed before it creates or copies a The server rejects duplicate or malformed `Host`, `Origin`, authorization, and MCP-session headers. It compares one `Authorization: Bearer` value to the resolved token in constant time, never places the token in a response or diagnostic, and does not use cookies. Browser requests are denied by default: an absent `Origin` is valid for MCP clients, but a supplied origin must exactly match an explicit `allowedOrigins` entry. Miftah does not emit permissive CORS headers. -Before it allocates a session, the host validates the exact `/mcp` endpoint, method, Host, Origin, authentication, JSON content type, body size, and one initialize request. Session capacity includes in-progress initializations and closing runtimes. Each session receives a distinct Miftah runtime, profile/session state, approval/lock/lease/routing state, and upstream manager. Idle expiry, DELETE, and graceful shutdown detach the session immediately and close its runtime, which closes retained upstream transports and their ordinary descendants; detached cleanup retains capacity until it succeeds, and a failure remains capacity-consuming and is reported. An HTTP reconnect reuses only its existing session ID; it cannot create or access another client's runtime. +Before it allocates a session, the host validates the exact `/mcp` endpoint, method, Host, Origin, authentication, JSON content type, body size, and one initialize request. For modern requests, the SDK additionally validates the required `Mcp-Method` and operation-specific `Mcp-Name` mirrors against the parsed JSON-RPC body before the request-scoped broker can route, authorize, meter, audit, or execute it. Miftah rejects the unsupported `Mcp-Param-*` extension with a fixed response and strips its schema keyword from modern HTTP catalogs, so parameter names and values cannot cross into policy, audit, diagnostics, or an upstream. Session capacity includes in-progress initializations and closing runtimes. Each session receives a distinct Miftah runtime, profile/session state, approval/lock/lease/routing state, and upstream manager. Idle expiry, DELETE, and graceful shutdown detach the session immediately and close its runtime, which closes retained upstream transports and their ordinary descendants; detached cleanup retains capacity until it succeeds, and a failure remains capacity-consuming and is reported. An HTTP reconnect reuses only its existing session ID; it cannot create or access another client's runtime. + +Modern cacheable results are private with a zero TTL. Miftah deliberately enables no positive-TTL shared cache until a cache can be keyed by authenticated principal plus profile, policy, and configuration revision and invalidated atomically on every relevant change. This prevents catalog reuse across accounts and makes profile or policy changes visible on the next request. The Console control plane is not this MCP HTTP server. `miftah dashboard` or the API-only `miftah console` command creates a separate listener with no `/mcp` route, MCP transport, upstream session registry, or MCP bearer. It requires the exact listener Host for all traffic. State-changing browser requests require the exact listener Origin, including scheme, host, and port, plus in-memory CSRF proof; authenticated `GET`/`HEAD` reads may omit Origin because normal same-origin browsers do not consistently send it. Its CSPRNG bootstrap code appears only in the launching terminal, expires after five minutes, and is consumed once; the resulting opaque HttpOnly same-site session and in-memory CSRF proof are bounded by idle and absolute lifetimes. Every durable mutation prepares a separate fail-closed owner-restricted Console audit journal first. Browser responses contain allowlisted metadata and redacted status only. See the [Console API contract](console-api.md). diff --git a/src/http/miftah-http-server.ts b/src/http/miftah-http-server.ts index c901451..de0c7dc 100644 --- a/src/http/miftah-http-server.ts +++ b/src/http/miftah-http-server.ts @@ -28,6 +28,7 @@ const decimalPattern = /^\d+$/u; const requestTimeoutMs = 60_000; const headersTimeoutMs = 10_000; const connectionsCheckingIntervalMs = 5_000; +const mcpHeaderMismatchErrorCode = -32020; type SessionRuntimeFactory = (configPath: string) => Promise; @@ -139,6 +140,34 @@ function isInitializeRequest(value: unknown): value is { readonly jsonrpc: "2.0" return message.jsonrpc === "2.0" && message.method === "initialize" && Object.hasOwn(message, "id"); } +function hasMcpParameterHeader(request: IncomingMessage): boolean { + for (let index = 0; index < request.rawHeaders.length; index += 2) { + if (request.rawHeaders[index]?.toLowerCase().startsWith("mcp-param-")) return true; + } + return false; +} + +function echoableRequestId(body: unknown): string | number | null { + if (typeof body !== "object" || body === null || Array.isArray(body)) return null; + const id = (body as Record).id; + return typeof id === "string" || typeof id === "number" || id === null ? id : null; +} + +function unsupportedMcpParameterHeader(body: unknown): HttpRequestError { + return new HttpRequestError( + 400, + JSON.stringify({ + jsonrpc: "2.0", + id: echoableRequestId(body), + error: { + code: mcpHeaderMismatchErrorCode, + message: "Bad Request: MCP parameter headers are not supported by this gateway." + } + }), + { "content-type": "application/json; charset=utf-8" } + ); +} + function writeResponse(response: ServerResponse, error: HttpRequestError): void { if (response.headersSent || response.writableEnded) { response.destroy(); @@ -259,6 +288,10 @@ class HttpServerHost implements MiftahHttpServer { const body = request.method === "POST" ? await this.parsePostBody(request) : undefined; const webRequest = await toWebRequest(request, body); if (!await isLegacyRequest(webRequest, body)) { + // Miftah's low-level upstream proxy cannot validate x-mcp-header schemas + // before policy and audit. Reject the extension without reflecting names + // or values until schema-aware forwarding can be supported end to end. + if (hasMcpParameterHeader(request)) throw unsupportedMcpParameterHeader(body); if (this.modernRequests >= this.settings.maxSessions) { throw new HttpRequestError(429, "Too Many Requests"); } diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 844868d..02fd18b 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -359,7 +359,8 @@ export class MiftahServer { private readonly runtimeConfigPath?: string, private readonly modernProfileContext?: ModernProfileContextRuntimeOptions, private readonly resourceSubscriptionsEnabled = true, - private readonly approvalContinuations = new ApprovalContinuationStore() + private readonly approvalContinuations = new ApprovalContinuationStore(), + private readonly stripMcpParameterHeaderAnnotations = false ) { if ( modernProfileContext !== undefined && @@ -398,6 +399,14 @@ export class MiftahServer { : {}) }, requestState: { verify: (state) => this.approvalContinuations.verify(state) }, + cacheHints: { + "tools/list": { ttlMs: 0, cacheScope: "private" }, + "prompts/list": { ttlMs: 0, cacheScope: "private" }, + "resources/list": { ttlMs: 0, cacheScope: "private" }, + "resources/templates/list": { ttlMs: 0, cacheScope: "private" }, + "resources/read": { ttlMs: 0, cacheScope: "private" }, + "server/discover": { ttlMs: 0, cacheScope: "private" } + }, instructions: [ "Miftah wraps an upstream MCP and routes requests through local credential profiles.", ...(this.resourcePromptProxy.available @@ -1053,11 +1062,14 @@ export class MiftahServer { : this.profileToolSnapshot(source.activeProfile, upstreamRequest.options) ); audit.update({ profile }); + const tools = [ + ...this.visibleManagementTools(), + ...snapshot.getTools().map((tool) => this.profileContextTool(tool, true)) + ]; return { - tools: [ - ...this.visibleManagementTools(), - ...snapshot.getTools().map((tool) => this.profileContextTool(tool, true)) - ] + tools: this.stripMcpParameterHeaderAnnotations + ? tools.map(stripMcpParameterHeaderAnnotations) + : tools }; } ); @@ -3602,24 +3614,57 @@ function aggregateProgressOptions( function redactDirectResourceList(result: ListResourcesResult): ListResourcesResult { return { ...result, - resources: result.resources.map(redactDirectResource) + resources: result.resources.map(redactDirectResource).sort(compareResources) }; } function redactDirectResourceTemplateList(result: ListResourceTemplatesResult): ListResourceTemplatesResult { return { ...result, - resourceTemplates: result.resourceTemplates.map(redactDirectResourceTemplate) + resourceTemplates: result.resourceTemplates.map(redactDirectResourceTemplate).sort(compareResourceTemplates) }; } function redactDirectPromptList(result: ListPromptsResult): ListPromptsResult { return { ...result, - prompts: result.prompts.map(redactDirectPrompt) + prompts: result.prompts.map(redactDirectPrompt).sort(comparePrompts) + }; +} + +function stripMcpParameterHeaderAnnotations(tool: Tool): Tool { + return { + ...tool, + inputSchema: stripJsonSchemaKeyword(tool.inputSchema, "x-mcp-header") }; } +function stripJsonSchemaKeyword(value: T, keyword: string): T { + if (Array.isArray(value)) return value.map((entry) => stripJsonSchemaKeyword(entry, keyword)) as T; + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value).flatMap(([name, entry]) => + name === keyword ? [] : [[name, stripJsonSchemaKeyword(entry, keyword)]] + ) + ) as T; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareResources(left: Resource, right: Resource): number { + return compareText(left.uri, right.uri) || compareText(left.name, right.name); +} + +function compareResourceTemplates(left: ResourceTemplateType, right: ResourceTemplateType): number { + return compareText(left.name, right.name) || compareText(left.uriTemplate, right.uriTemplate); +} + +function comparePrompts(left: Prompt, right: Prompt): number { + return compareText(left.name, right.name); +} + function redactDirectReadResult(result: ReadResourceResult): ReadResourceResult { return { ...result, diff --git a/src/mcp/server/resource-prompt-registry.ts b/src/mcp/server/resource-prompt-registry.ts index 71b0cbd..6c7b02b 100644 --- a/src/mcp/server/resource-prompt-registry.ts +++ b/src/mcp/server/resource-prompt-registry.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { UriTemplate } from "@modelcontextprotocol/server"; import type { GetPromptResult, @@ -193,7 +193,7 @@ export class ResourcePromptRegistry { ) ) ); - return { resources, ...(nextCursor ? { nextCursor } : {}) }; + return { resources: resources.sort(compareResources), ...(nextCursor ? { nextCursor } : {}) }; } async listResourceTemplates( @@ -267,7 +267,8 @@ export class ResourcePromptRegistry { profile, upstreamName, original.uriTemplate, - exposedName + exposedName, + template.uriTemplate ); names.set(exposedName, route.exposedUriBase); resourceTemplates.push({ ...template, uriTemplate: route.exposedUriTemplate, name: route.exposedName }); @@ -284,7 +285,10 @@ export class ResourcePromptRegistry { ) ) ); - return { resourceTemplates, ...(nextCursor ? { nextCursor } : {}) }; + return { + resourceTemplates: resourceTemplates.sort(compareResourceTemplates), + ...(nextCursor ? { nextCursor } : {}) + }; } async listPrompts( @@ -364,7 +368,7 @@ export class ResourcePromptRegistry { ) ) ); - return { prompts, ...(nextCursor ? { nextCursor } : {}) }; + return { prompts: prompts.sort(comparePrompts), ...(nextCursor ? { nextCursor } : {}) }; } resolveResource(profile: string, exposedUri: string): ResourceRoute | undefined { @@ -713,10 +717,14 @@ export class ResourcePromptRegistry { profile: string, upstreamName: string, originalUriTemplate: string, - exposedName: string + exposedName: string, + exposedUriTemplate: string ): ResourceTemplateRoute { const variables = resourceTemplateVariables(originalUriTemplate); - const exposedUriBase = namespaceResourceTemplateBase(upstreamName, randomUUID()); + const exposedUriBase = namespaceResourceTemplateBase( + upstreamName, + resourceTemplateRouteId(upstreamName, exposedName, exposedUriTemplate) + ); const route: ResourceTemplateRoute = { profile, upstreamName, @@ -802,6 +810,34 @@ function namespaceResourceTemplateBase(upstreamName: string, id: string): string return `miftah://template/${encodeURIComponent(upstreamName)}/${id}`; } +function resourceTemplateRouteId(upstreamName: string, exposedName: string, exposedUriTemplate: string): string { + // Hash only catalog-visible, already-redacted values. A stable route must not + // turn a sensitive original template into an offline-guessing oracle. + return createHash("sha256") + .update(upstreamName) + .update("\0") + .update(exposedName) + .update("\0") + .update(exposedUriTemplate) + .digest("base64url"); +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareResources(left: Resource, right: Resource): number { + return compareText(left.uri, right.uri) || compareText(left.name, right.name); +} + +function compareResourceTemplates(left: ResourceTemplateType, right: ResourceTemplateType): number { + return compareText(left.name, right.name) || compareText(left.uriTemplate, right.uriTemplate); +} + +function comparePrompts(left: Prompt, right: Prompt): number { + return compareText(left.name, right.name); +} + function namespaceResourceTemplateUri(base: string, variables: readonly string[]): string { return variables.length === 0 ? base : `${base}{?${variables.join(",")}}`; } diff --git a/src/runtime/create-miftah-runtime.ts b/src/runtime/create-miftah-runtime.ts index 4191594..8a4e007 100644 --- a/src/runtime/create-miftah-runtime.ts +++ b/src/runtime/create-miftah-runtime.ts @@ -26,6 +26,7 @@ interface MiftahRuntimeFactoryOptions extends MiftahRuntimeOptions { readonly profileState?: { readonly persistActiveProfile?: false; readonly scope?: "process" | "session" }; readonly resourceSubscriptionsEnabled?: boolean; readonly approvalContinuations?: ApprovalContinuationStore; + readonly stripMcpParameterHeaderAnnotations?: boolean; } async function createConfiguredMiftahServer( @@ -53,7 +54,8 @@ async function createConfiguredMiftahServer( runtimeConfigPath, options.modernProfileContext, options.resourceSubscriptionsEnabled, - options.approvalContinuations + options.approvalContinuations, + options.stripMcpParameterHeaderAnnotations ); return { config: runtime.config, server }; @@ -78,7 +80,12 @@ function configuredMiftahServerFactory( const approvalContinuations = new ApprovalContinuationStore(); return async (context) => { const eraOptions: MiftahRuntimeFactoryOptions = context.era === "modern" - ? { ...options, resourceSubscriptionsEnabled: false, approvalContinuations } + ? { + ...options, + resourceSubscriptionsEnabled: false, + approvalContinuations, + stripMcpParameterHeaderAnnotations: context.requestInfo !== undefined + } : { ...(options.profileState === undefined ? {} : { profileState: options.profileState }), resourceSubscriptionsEnabled: true, diff --git a/tests/helpers/fake-remote-upstream.ts b/tests/helpers/fake-remote-upstream.ts index eaab90f..a781150 100644 --- a/tests/helpers/fake-remote-upstream.ts +++ b/tests/helpers/fake-remote-upstream.ts @@ -4,7 +4,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse"; import { Server as McpServer, ProtocolError } from "@modelcontextprotocol/server"; -import type { FetchLike } from "@modelcontextprotocol/server"; +import type { FetchLike, Tool } from "@modelcontextprotocol/server"; interface StreamableSession { server: McpServer; @@ -82,6 +82,7 @@ export interface FakeRemoteUpstreamOptions { readonly callToolError?: { code: number; message: string }; readonly callToolDelayMs?: number; readonly emitCallToolProgress?: boolean; + readonly exposeMcpParameterHeader?: boolean; } interface FakeRemoteCallToolState { @@ -577,10 +578,19 @@ function createMcpServer( { capabilities: { tools: {}, resources: {}, prompts: {} } } ); + const whoamiTool: Tool = { + name: "whoami", + description: "Return the request profile.", + inputSchema: { + type: "object", + properties: options.exposeMcpParameterHeader + ? { account: { type: "string", "x-mcp-header": "account" } } + : {} + } + }; + server.setRequestHandler('tools/list', async () => ({ - tools: [ - { name: "whoami", description: "Return the request profile.", inputSchema: { type: "object", properties: {} } } - ] + tools: [whoamiTool] })); server.setRequestHandler('tools/call', async (request, ctx) => { callToolState.toolCallRequests += 1; diff --git a/tests/mcp-v2-serving.test.ts b/tests/mcp-v2-serving.test.ts index 40b4eea..872664a 100644 --- a/tests/mcp-v2-serving.test.ts +++ b/tests/mcp-v2-serving.test.ts @@ -58,6 +58,22 @@ async function waitFor(condition: () => boolean, timeoutMs = 4_000): Promise void, + observeResponse?: (responseBody: string) => void +): typeof fetch { + return async (input, init) => { + const request = new Request(input, init); + if (request.headers.get("mcp-method") !== targetMethod) return fetch(request); + const headers = new Headers(request.headers); + mutate(headers); + const response = await fetch(new Request(request, { headers })); + if (observeResponse !== undefined) observeResponse(await response.clone().text()); + return response; + }; +} + describe("MCP SDK v2 serving interoperability", () => { it("negotiates modern Streamable HTTP without initialize or a session id", async () => { const server = await startMiftahHttpServer(await configPath()); @@ -79,6 +95,154 @@ describe("MCP SDK v2 serving interoperability", () => { } }); + it("publishes truthful private no-cache catalog hints for request-scoped modern HTTP", async () => { + const server = await startMiftahHttpServer(await configPath()); + httpServers.push(server); + const transport = new StreamableHTTPClientTransport(server.url); + const client = new Client( + { name: "miftah-modern-cache-hint-test", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } } + ); + + try { + await client.connect(transport); + const tools = await client.listTools(); + const resources = await client.listResources(); + const prompts = await client.listPrompts(); + const resource = resources.resources[0]; + if (resource === undefined) throw new Error("Expected the cache-hint fixture to expose a resource."); + const read = await client.readResource({ uri: resource.uri }); + + for (const result of [tools, resources, prompts, read]) { + expect(result).toMatchObject({ ttlMs: 0, cacheScope: "private" }); + } + } finally { + await client.close(); + } + }); + + it.each([ + ["missing", (headers: Headers) => headers.delete("mcp-method")], + ["mismatched", (headers: Headers) => headers.set("mcp-method", "tools/call")], + ["duplicated", (headers: Headers) => headers.append("mcp-method", "tools/list")] + ])("fails closed on a %s Mcp-Method before starting an upstream", async (_case, mutate) => { + const directory = await mkdtemp(join(tmpdir(), "miftah-v2-method-header-")); + temporaryDirectories.push(directory); + const startCountPath = join(directory, "upstream-start-count"); + const server = await startMiftahHttpServer(await configPath({ + env: { TEST_START_COUNT_PATH: startCountPath } + })); + httpServers.push(server); + const transport = new StreamableHTTPClientTransport(server.url, { + fetch: mutateRequestHeaders("tools/list", mutate) + }); + const client = new Client( + { name: "miftah-modern-method-header-test", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } } + ); + + try { + await client.connect(transport); + await expect(client.listTools()).rejects.toMatchObject({ code: -32020 }); + await expect(access(startCountPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await client.close(); + } + }); + + it.each([ + ["missing", (headers: Headers) => headers.delete("mcp-name")], + ["mismatched", (headers: Headers) => headers.set("mcp-name", "other_tool")], + ["malformed encoded", (headers: Headers) => headers.set("mcp-name", "=?base64?%%%?=")], + ["duplicated", (headers: Headers) => headers.append("mcp-name", "whoami")] + ])("fails closed on a %s Mcp-Name before executing a tool", async (_case, mutate) => { + const upstream = await startFakeRemoteUpstream(); + remoteUpstreams.push(upstream); + const server = await startMiftahHttpServer(await configPath({ url: upstream.streamableHttpUrl })); + httpServers.push(server); + const transport = new StreamableHTTPClientTransport(server.url, { + fetch: mutateRequestHeaders("tools/call", mutate) + }); + const client = new Client( + { name: "miftah-modern-name-header-test", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } } + ); + + try { + await client.connect(transport); + await expect(client.callTool({ name: "whoami", arguments: {} })).rejects.toMatchObject({ code: -32020 }); + expect(upstream.toolCallRequests()).toBe(0); + } finally { + await client.close(); + } + }); + + it("accepts an encoded Mcp-Name only when it decodes to the executed tool", async () => { + const upstream = await startFakeRemoteUpstream(); + remoteUpstreams.push(upstream); + const server = await startMiftahHttpServer(await configPath({ url: upstream.streamableHttpUrl })); + httpServers.push(server); + const transport = new StreamableHTTPClientTransport(server.url, { + fetch: mutateRequestHeaders("tools/call", (headers) => { + headers.set("mcp-name", `=?base64?${Buffer.from("whoami").toString("base64")}?=`); + }) + }); + const client = new Client( + { name: "miftah-modern-encoded-name-test", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } } + ); + + try { + await client.connect(transport); + await expect(client.callTool({ name: "whoami", arguments: {} })).resolves.toMatchObject({ + content: [{ type: "text", text: "unknown" }] + }); + expect(upstream.toolCallRequests()).toBe(1); + } finally { + await client.close(); + } + }); + + it("does not advertise, accept, or leak unsupported Mcp-Param headers on modern HTTP", async () => { + const secretHeaderValue = "must-not-reach-the-upstream"; + const rejectionBodies: string[] = []; + const upstream = await startFakeRemoteUpstream({ exposeMcpParameterHeader: true }); + remoteUpstreams.push(upstream); + const server = await startMiftahHttpServer(await configPath({ url: upstream.streamableHttpUrl })); + httpServers.push(server); + const modernTransport = new StreamableHTTPClientTransport(server.url, { + fetch: mutateRequestHeaders( + "tools/call", + (headers) => headers.set("mcp-param-account", secretHeaderValue), + (body) => rejectionBodies.push(body) + ) + }); + const modernClient = new Client( + { name: "miftah-modern-parameter-header-test", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } } + ); + const legacyTransport = new StreamableHTTPClientTransport(server.url); + const legacyClient = new Client({ name: "miftah-legacy-parameter-header-test", version: "1.0.0" }); + + try { + await modernClient.connect(modernTransport); + const modernTool = (await modernClient.listTools()).tools.find((tool) => tool.name === "whoami"); + expect(JSON.stringify(modernTool?.inputSchema)).not.toContain("x-mcp-header"); + await expect(modernClient.callTool({ name: "whoami", arguments: { account: "work" } })) + .rejects.toMatchObject({ code: -32020 }); + expect(upstream.toolCallRequests()).toBe(0); + expect(rejectionBodies.join("\n")).not.toContain(secretHeaderValue); + expect(rejectionBodies.join("\n")).not.toContain("mcp-param-account"); + + await legacyClient.connect(legacyTransport); + const legacyTool = (await legacyClient.listTools()).tools.find((tool) => tool.name === "whoami"); + expect(JSON.stringify(legacyTool?.inputSchema)).toContain("x-mcp-header"); + } finally { + await modernClient.close(); + await legacyClient.close(); + } + }); + it("continues a modern HTTP form approval across request-scoped server instances", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-v2-approval-")); temporaryDirectories.push(directory); @@ -182,7 +346,10 @@ describe("MCP SDK v2 serving interoperability", () => { await client.connect(transport); expect(transport.protocolVersion).toBe("2025-11-25"); expect(transport.sessionId).toEqual(expect.any(String)); - expect((await client.listTools()).tools.map((tool) => tool.name)).toContain("whoami"); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain("whoami"); + expect(tools).not.toHaveProperty("ttlMs"); + expect(tools).not.toHaveProperty("cacheScope"); } finally { await client.close(); } @@ -213,7 +380,14 @@ describe("MCP SDK v2 serving interoperability", () => { } else { expect(client.getServerCapabilities()?.resources?.subscribe).not.toBe(true); } - expect((await client.listTools()).tools.map((tool) => tool.name)).toContain("whoami"); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain("whoami"); + if (expectedEra === "legacy") { + expect(tools).not.toHaveProperty("ttlMs"); + expect(tools).not.toHaveProperty("cacheScope"); + } else { + expect(tools).toMatchObject({ ttlMs: 0, cacheScope: "private" }); + } } finally { await client.close(); await handle.close(); diff --git a/tests/resource-prompt-registry.test.ts b/tests/resource-prompt-registry.test.ts index 6d98486..c5ac005 100644 --- a/tests/resource-prompt-registry.test.ts +++ b/tests/resource-prompt-registry.test.ts @@ -2,6 +2,54 @@ import { describe, expect, it } from "vitest"; import { ResourcePromptRegistry } from "../src/mcp/server/resource-prompt-registry.js"; describe("resource and prompt registry", () => { + it("returns deterministic catalogs when an upstream changes its discovery order", async () => { + const createRegistry = (reverse: boolean): ResourcePromptRegistry => { + const ordered = (first: T, second: T): T[] => reverse ? [second, first] : [first, second]; + return new ResourcePromptRegistry( + () => ["github"], + async () => ({ + resources: ordered( + { uri: "account://zeta", name: "zeta", mimeType: "text/plain" }, + { uri: "account://alpha", name: "alpha", mimeType: "text/plain" } + ) + }), + async () => ({ + prompts: ordered( + { name: "zeta", description: "zeta" }, + { name: "alpha", description: "alpha" } + ) + }), + (value) => value, + undefined, + "permissive", + async () => ({ + resourceTemplates: ordered( + { uriTemplate: "account://zeta/{id}", name: "zeta", mimeType: "text/plain" }, + { uriTemplate: "account://alpha/{id}", name: "alpha", mimeType: "text/plain" } + ) + }) + ); + }; + const firstRegistry = createRegistry(false); + const secondRegistry = createRegistry(true); + + const first = { + resources: await firstRegistry.listResources("work"), + templates: await firstRegistry.listResourceTemplates("work"), + prompts: await firstRegistry.listPrompts("work") + }; + const second = { + resources: await secondRegistry.listResources("work"), + templates: await secondRegistry.listResourceTemplates("work"), + prompts: await secondRegistry.listPrompts("work") + }; + + expect(second).toEqual(first); + expect(first.resources.resources.map((resource) => resource.name)).toEqual(["github__alpha", "github__zeta"]); + expect(first.templates.resourceTemplates.map((template) => template.name)).toEqual(["github__alpha", "github__zeta"]); + expect(first.prompts.prompts.map((prompt) => prompt.name)).toEqual(["github__alpha", "github__zeta"]); + }); + it("bounds opaque cursor storage with least-recently-used eviction", async () => { const registry = new ResourcePromptRegistry( () => ["github"],