From d2f1bed893cdeebdeace721255f6b0c2c275fa07 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:24:13 +0100 Subject: [PATCH 1/5] Fix import ordering in client auth (#2442) --- packages/client/src/client/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 25bed6d2f5..9ebc6fd251 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -15,7 +15,6 @@ import type { import { brandedHasInstance, checkResourceAllowed, - stampErrorBrands, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, @@ -25,7 +24,8 @@ import { OAuthProtectedResourceMetadataSchema, OAuthTokensSchema, OpenIdProviderDiscoveryMetadataSchema, - resourceUrlFromServerUrl + resourceUrlFromServerUrl, + stampErrorBrands } from '@modelcontextprotocol/core-internal'; import pkceChallenge from 'pkce-challenge'; From e8de519d3129f46b7528d2999b7641f55be1f091 Mon Sep 17 00:00:00 2001 From: Sehlani042 Date: Tue, 7 Jul 2026 02:34:24 +0800 Subject: [PATCH 2/5] fix: keep validator providers off root declarations (#2425) Co-authored-by: Sehlani042 <257166922+Sehlani042@users.noreply.github.com> --- .changeset/silent-validators-wave.md | 6 ++++++ packages/client/test/client/barrelClean.test.ts | 16 ++++++++++++++++ .../core-internal/src/exports/public/index.ts | 7 +++---- packages/core-internal/src/index.ts | 7 +++---- packages/server/test/server/barrelClean.test.ts | 16 ++++++++++++++++ scripts/smoke-dist-types.mjs | 4 ++-- 6 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 .changeset/silent-validators-wave.md diff --git a/.changeset/silent-validators-wave.md b/.changeset/silent-validators-wave.md new file mode 100644 index 0000000000..7394c4c1ea --- /dev/null +++ b/.changeset/silent-validators-wave.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths. diff --git a/packages/client/test/client/barrelClean.test.ts b/packages/client/test/client/barrelClean.test.ts index a0f716581e..a218c49a0c 100644 --- a/packages/client/test/client/barrelClean.test.ts +++ b/packages/client/test/client/barrelClean.test.ts @@ -12,6 +12,7 @@ const requireDist = createRequire(join(pkgDir, 'package.json')); const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\b/; // Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match. const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m; +const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft']; function chunkImportsOf(entryPath: string): string[] { const visited = new Set(); @@ -29,6 +30,12 @@ function chunkImportsOf(entryPath: string): string[] { return [...visited]; } +function rootExportBlockOf(content: string): string { + const blocks = content.match(/(?:^|\n)export \{[\s\S]*?\};/g); + expect(blocks).toBeTruthy(); + return blocks!.at(-1)!; +} + describe('@modelcontextprotocol/client root entry is browser-safe', () => { beforeAll(() => { if (!existsSync(join(distDir, 'index.mjs')) || !existsSync(join(distDir, 'stdio.mjs'))) { @@ -80,4 +87,13 @@ describe('@modelcontextprotocol/client root entry is browser-safe', () => { expect(new AjvJsonSchemaValidator(ajv)).toBeInstanceOf(AjvJsonSchemaValidator); }); + + test('root declarations do not advertise validator provider classes', () => { + for (const declaration of ['index.d.mts', 'index.d.cts']) { + const rootExportBlock = rootExportBlockOf(readFileSync(join(distDir, declaration), 'utf8')); + for (const symbol of ROOT_VALIDATOR_EXPORTS) { + expect(rootExportBlock).not.toMatch(new RegExp(`\\b(?:type\\s+)?${symbol}\\b`)); + } + } + }); }); diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 3fd909d69d..0a50e00dd5 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -131,10 +131,9 @@ export { export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema'; export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema'; export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } from '../../util/standardSchema'; -// Validator providers are type-only here — import the runtime classes from the explicit -// `@modelcontextprotocol/{client,server}/validators/{ajv,cf-worker}` subpaths to customise. -export type { AjvJsonSchemaValidator } from '../../validators/ajvProvider'; -export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider'; +// Validator provider classes stay subpath-only. Re-exporting them here, even as +// `type`, can make generated root declarations advertise runtime-shaped root +// exports that the package root does not provide. // fromJsonSchema is intentionally NOT exported here — the server and client packages // provide runtime-aware wrappers that default to the appropriate validator via _shims. export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '../../validators/types'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 50238566cf..7ea56117c7 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -33,9 +33,8 @@ export * from './util/standardSchema'; export * from './util/zodCompat'; export { codecForVersion, MODERN_WIRE_REVISION } from './wire/codec'; -// Validator providers are type-only here — import the runtime classes from the explicit -// `@modelcontextprotocol/{client,server}/validators/{ajv,cf-worker}` subpaths to customise. -export type { AjvJsonSchemaValidator } from './validators/ajvProvider'; -export type { CfWorkerJsonSchemaValidator, CfWorkerSchemaDraft } from './validators/cfWorkerProvider'; +// Validator provider classes stay subpath-only. Re-exporting them here, even as +// `type`, can make generated client/server root declarations advertise +// runtime-shaped root exports that the package root does not provide. export * from './validators/fromJsonSchema'; export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types'; diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts index 184a590d10..7b1e4898c7 100644 --- a/packages/server/test/server/barrelClean.test.ts +++ b/packages/server/test/server/barrelClean.test.ts @@ -12,6 +12,7 @@ const requireDist = createRequire(join(pkgDir, 'package.json')); const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\b/; // Anchored at start-of-line so JSDoc-example `from 'ajv'` strings in vendored chunks don't match. const VALIDATOR_BACKEND_IMPORT = /^import[^\n]*?from\s+["'](?:ajv|ajv-formats|@cfworker\/json-schema)["']/m; +const ROOT_VALIDATOR_EXPORTS = ['AjvJsonSchemaValidator', 'CfWorkerJsonSchemaValidator', 'CfWorkerSchemaDraft']; function chunkImportsOf(entryPath: string): string[] { const visited = new Set(); @@ -29,6 +30,12 @@ function chunkImportsOf(entryPath: string): string[] { return [...visited]; } +function rootExportBlockOf(content: string): string { + const blocks = content.match(/(?:^|\n)export \{[\s\S]*?\};/g); + expect(blocks).toBeTruthy(); + return blocks!.at(-1)!; +} + describe('@modelcontextprotocol/server root entry is browser-safe', () => { beforeAll(() => { if (!existsSync(join(distDir, 'index.mjs')) || !existsSync(join(distDir, 'stdio.mjs'))) { @@ -81,4 +88,13 @@ describe('@modelcontextprotocol/server root entry is browser-safe', () => { expect(new AjvJsonSchemaValidator(ajv)).toBeInstanceOf(AjvJsonSchemaValidator); }); + + test('root declarations do not advertise validator provider classes', () => { + for (const declaration of ['index.d.mts', 'index.d.cts']) { + const rootExportBlock = rootExportBlockOf(readFileSync(join(distDir, declaration), 'utf8')); + for (const symbol of ROOT_VALIDATOR_EXPORTS) { + expect(rootExportBlock).not.toMatch(new RegExp(`\\b(?:type\\s+)?${symbol}\\b`)); + } + } + }); }); diff --git a/scripts/smoke-dist-types.mjs b/scripts/smoke-dist-types.mjs index 16af6605f4..7effbb92cd 100644 --- a/scripts/smoke-dist-types.mjs +++ b/scripts/smoke-dist-types.mjs @@ -14,7 +14,7 @@ try { path.join(dir, 'consumer.ts'), [ "import { Client } from '@modelcontextprotocol/client';", - "import type { AjvJsonSchemaValidator as ClientAjv } from '@modelcontextprotocol/client';", + "import type { JsonSchemaType as ClientSchema } from '@modelcontextprotocol/client';", "import { AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv';", "import { CfWorkerJsonSchemaValidator as ClientCf } from '@modelcontextprotocol/client/validators/cf-worker';", "import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';", @@ -24,7 +24,7 @@ try { "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", "export const c = new Client({ name: 'smoke', version: '1.0.0' });", "export const s = new McpServer({ name: 'smoke', version: '1.0.0' });", - 'export type T = ClientAjv;', + 'export type T = ClientSchema;', 'export { AjvJsonSchemaValidator, ServerAjv, ClientCf, ServerCf, StdioClientTransport, StdioServerTransport };', '' ].join('\n') From 7015d2129bc91e8cf8f143e2cca3ee2ba92808d3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:58:41 +0100 Subject: [PATCH 3/5] examples: web-standard twin of the bearer-auth story (#2424) --- examples/README.md | 1 + examples/bearer-auth-web/README.md | 18 +++++++ examples/bearer-auth-web/client.ts | 32 ++++++++++++ examples/bearer-auth-web/package.json | 27 +++++++++++ examples/bearer-auth-web/server.ts | 70 +++++++++++++++++++++++++++ pnpm-lock.yaml | 22 +++++++++ 6 files changed, 170 insertions(+) create mode 100644 examples/bearer-auth-web/README.md create mode 100644 examples/bearer-auth-web/client.ts create mode 100644 examples/bearer-auth-web/package.json create mode 100644 examples/bearer-auth-web/server.ts diff --git a/examples/README.md b/examples/README.md index db8812871a..a35c8bf494 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,6 +48,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual | | [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) | | [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual | +| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual | | [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual | | [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual | | [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern | diff --git a/examples/bearer-auth-web/README.md b/examples/bearer-auth-web/README.md new file mode 100644 index 0000000000..a8dad5e8b8 --- /dev/null +++ b/examples/bearer-auth-web/README.md @@ -0,0 +1,18 @@ +# bearer-auth-web + +The web-standard twin of [`bearer-auth`](../bearer-auth/): the same minimal +Resource-Server-only story built entirely from `@modelcontextprotocol/server` +exports, with no framework. + +Host and origin validation plus `requireBearerAuth` gate `createMcpHandler`, +composed as one `fetch(request)` handler. On Cloudflare Workers, Deno, or Bun +that handler is the whole server; `toNodeHandler` bridges it onto `node:http` +so the story runs in this repo's example matrix. + +No Authorization Server and no discovery documents here, matching the sibling +— see [`oauth`](../oauth/) for the full RS + AS dance. + +```sh +pnpm --filter @mcp-examples/bearer-auth-web server -- --http --port 3000 +pnpm --filter @mcp-examples/bearer-auth-web client -- --http http://127.0.0.1:3000/mcp +``` diff --git a/examples/bearer-auth-web/client.ts b/examples/bearer-auth-web/client.ts new file mode 100644 index 0000000000..fbcd2d2456 --- /dev/null +++ b/examples/bearer-auth-web/client.ts @@ -0,0 +1,32 @@ +/** + * Asserts a bare request is `401` with a `WWW-Authenticate` challenge (parsed + * with the SDK's `extractWWWAuthenticateParams`), and that a request with + * `Authorization: Bearer demo-token` reaches the `whoami` tool with the + * verified `authInfo`. + */ +import { check, parseExampleArgs } from '@mcp-examples/shared'; +import { Client, extractWWWAuthenticateParams, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const { url, era } = parseExampleArgs(); + +// Unauthenticated → 401 + WWW-Authenticate. +const unauth = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }) +}); +check.equal(unauth.status, 401); +check.equal(extractWWWAuthenticateParams(unauth).error, 'invalid_token'); + +// Authenticated → 200 and the tool sees the authInfo. Bearer auth is +// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`. +const client = new Client( + { name: 'bearer-auth-web-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); +await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } })); + +const result = await client.callTool({ name: 'whoami', arguments: {} }); +check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client'); + +await client.close(); diff --git a/examples/bearer-auth-web/package.json b/examples/bearer-auth-web/package.json new file mode 100644 index 0000000000..c45898c159 --- /dev/null +++ b/examples/bearer-auth-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mcp-examples/bearer-auth-web", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + }, + "example": { + "transports": [ + "http" + ], + "era": "dual", + "path": "/mcp", + "//": "The web-standard twin of bearer-auth: requireBearerAuth from @modelcontextprotocol/server composed as one fetch handler (toNodeHandler bridges for the matrix); era-agnostic like its sibling." + } +} diff --git a/examples/bearer-auth-web/server.ts b/examples/bearer-auth-web/server.ts new file mode 100644 index 0000000000..a758565bf3 --- /dev/null +++ b/examples/bearer-auth-web/server.ts @@ -0,0 +1,70 @@ +/** + * The web-standard counterpart of `examples/bearer-auth`: the same + * Resource-Server-only auth built entirely from `@modelcontextprotocol/server` + * exports — `requireBearerAuth` gating the MCP handler, behind the same + * DNS-rebinding guards the Express sibling gets from `createMcpExpressApp` — + * composed as one `fetch(request)` handler. + * + * On Cloudflare Workers, Deno, or Bun that handler is the whole server + * (`export default { fetch: fetchHandler }`); on Node, `toNodeHandler` bridges + * it onto `node:http`. HTTP-only by definition. + */ +import { createServer } from 'node:http'; + +import { parseExampleArgs } from '@mcp-examples/shared'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo, McpServerFactory, OAuthTokenVerifier } from '@modelcontextprotocol/server'; +import { + createMcpHandler, + hostHeaderValidationResponse, + localhostAllowedHostnames, + localhostAllowedOrigins, + McpServer, + OAuthError, + OAuthErrorCode, + originValidationResponse, + requireBearerAuth +} from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const buildServer: McpServerFactory = ctx => { + const server = new McpServer({ name: 'bearer-auth-web-example', version: '1.0.0' }); + server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({ + content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }] + })); + return server; +}; + +const { port } = parseExampleArgs(); + +// Replace with JWT verification, RFC 7662 introspection, etc. +const staticTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + if (token !== 'demo-token') { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + } + return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + } +}; + +const gate = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'] }); +const handler = createMcpHandler(buildServer); + +async function fetchHandler(request: Request): Promise { + const rejected = + hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? originValidationResponse(request, localhostAllowedOrigins()); + if (rejected) { + return rejected; + } + const auth = await gate(request); + if (auth instanceof Response) { + return auth; + } + return handler.fetch(request, { authInfo: auth }); +} + +// On a web-standard runtime the composition above is the whole server; +// `toNodeHandler` accepts any `{ fetch }` shape and bridges it onto node:http. +createServer(toNodeHandler({ fetch: fetchHandler })).listen(port, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb4442a9f4..a1e4a1afcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -403,6 +403,28 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/bearer-auth-web: + dependencies: + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/caching: dependencies: '@mcp-examples/shared': From 561c6d83456ef98d6c713bbda9837e64337f22c9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:29:58 +0100 Subject: [PATCH 4/5] fix(server): validate Content-Type by parsed media type instead of substring match (#2441) --- .../content-type-media-type-validation.md | 23 +++ common/eslint-config/eslint.config.mjs | 12 ++ docs/migration/upgrade-to-v2.md | 12 ++ examples/json-response/client.ts | 4 +- packages/client/src/client/streamableHttp.ts | 8 +- .../client/test/client/middleware.test.ts | 3 +- packages/core-internal/package.json | 1 + .../core-internal/src/exports/public/index.ts | 3 + packages/core-internal/src/index.ts | 1 + .../core-internal/src/shared/mediaType.ts | 57 ++++++ .../test/shared/mediaType.test.ts | 68 +++++++ packages/middleware/hono/src/hono.ts | 11 +- packages/middleware/hono/test/hono.test.ts | 28 +++ packages/server-legacy/package.json | 2 +- .../server/src/server/createMcpHandler.ts | 27 ++- .../server/src/server/perRequestTransport.ts | 4 + packages/server/src/server/streamableHttp.ts | 9 +- .../test/server/contentTypeValidation.test.ts | 186 ++++++++++++++++++ pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 2 +- 20 files changed, 452 insertions(+), 20 deletions(-) create mode 100644 .changeset/content-type-media-type-validation.md create mode 100644 packages/core-internal/src/shared/mediaType.ts create mode 100644 packages/core-internal/test/shared/mediaType.test.ts create mode 100644 packages/server/test/server/contentTypeValidation.test.ts diff --git a/.changeset/content-type-media-type-validation.md b/.changeset/content-type-media-type-validation.md new file mode 100644 index 0000000000..cdef983ece --- /dev/null +++ b/.changeset/content-type-media-type-validation.md @@ -0,0 +1,23 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/hono': patch +'@modelcontextprotocol/node': patch +--- + +POSTs whose `Content-Type` media type is not `application/json` are now +rejected with `415 Unsupported Media Type`; the header is parsed instead of +substring-matched. Previously any value merely containing the substring +passed the check (for example `text/plain; a=application/json`), case +variants were wrongly rejected, and the 2026-07-28 entry did not inspect +`Content-Type` at all — requests with a missing or non-JSON header that used +to be served on that path now also answer 415. Values with parameters +(`application/json; charset=utf-8`, including malformed parameter sections +like `application/json;`) continue to work. SDK clients always send the +correct header and are unaffected. + +The new `isJsonContentType(header)` helper is exported for transport and +framework-adapter authors — custom entries composing the exported building +blocks (`classifyInboundRequest`, `PerRequestHTTPServerTransport`) must apply +it themselves. The hono adapter's JSON body pre-parse and the client's +response dispatch now use the same parsed-media-type comparison. diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index 808fe1729d..368ced27f5 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -47,6 +47,18 @@ export default defineConfig( 'unicorn/prevent-abbreviations': 'off', 'unicorn/no-null': 'off', 'unicorn/prefer-add-event-listener': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: + ":matches(CallExpression[callee.property.name='includes'], CallExpression[callee.property.name='indexOf'], " + + "CallExpression[callee.property.name='startsWith'])[arguments.0.value='application/json']", + message: + "Substring-matching 'application/json' misclassifies Content-Type values whose media type is different " + + "(e.g. 'text/plain; a=application/json') and mishandles parameters and case. " + + 'Parse the media type instead: isJsonContentType() from core-internal.' + } + ], 'unicorn/no-useless-undefined': ['error', { checkArguments: false }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'n/prefer-node-protocol': 'error', diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index c3ca2b062b..89188aa893 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -817,6 +817,18 @@ same as every released v1.x — only the import path changes. Framework-agnostic (`validateHostHeader`, `localhostAllowedHostnames`, `hostHeaderValidationResponse`) are in `@modelcontextprotocol/server`. +Server entries validate the request `Content-Type` by its **parsed media type**, not a +substring: every POST whose media type is not `application/json` answers +`415 Unsupported Media Type`. Previously any value merely containing the substring +passed (for example `text/plain; a=application/json`), case variants were wrongly +rejected, and the 2026-07-28 entry did not inspect `Content-Type` at all — so +hand-rolled clients that omit the header (or send a non-JSON type) must now set +`Content-Type: application/json`. Parameters (`; charset=utf-8`) and unambiguous +values with malformed parameter sections (`application/json;`) keep working; SDK +clients always sent the correct header and are unaffected. Custom entries that +compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must +apply the same validation themselves — use the exported `isJsonContentType(header)`. + ### Errors The SDK now distinguishes three error kinds: diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 72189fe9c2..033f454657 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -8,7 +8,7 @@ * the stateless legacy fallback unaffected. */ import { check, parseExampleArgs } from '@mcp-examples/shared'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Client, isJsonContentType, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const { url } = parseExampleArgs(); @@ -39,7 +39,7 @@ const probe = await fetch(url, { } }) }); -check.match(probe.headers.get('content-type') ?? '', /application\/json/); +check.equal(isJsonContentType(probe.headers.get('content-type')), true); check.equal(probe.status, 200); // High-level: the regular Client works unchanged. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 04c7ac6f84..0d8eff917b 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -10,6 +10,7 @@ import { isJSONRPCResultResponse, isModernProtocolVersion, JSONRPCMessageSchema, + mediaTypeEssence, normalizeHeaders, PROTOCOL_VERSION_META_KEY, SdkError, @@ -1080,11 +1081,12 @@ export class StreamableHTTPClientTransport implements Transport { const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg && msg.id !== undefined); - // Check the response type + // Check the response type (parsed media type — see mediaTypeEssence) const contentType = response.headers.get('content-type'); + const responseMediaType = mediaTypeEssence(contentType); if (hasRequests) { - if (contentType?.includes('text/event-stream')) { + if (responseMediaType === 'text/event-stream') { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID @@ -1097,7 +1099,7 @@ export class StreamableHTTPClientTransport implements Transport { }, false ); - } else if (contentType?.includes('application/json')) { + } else if (responseMediaType === 'application/json') { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); const responseMessages = Array.isArray(data) diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index c0c0886952..70922f7d01 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -1,4 +1,5 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal'; +import { isJsonContentType } from '@modelcontextprotocol/core-internal'; import type { Mocked, MockedFunction, MockInstance } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth'; @@ -1016,7 +1017,7 @@ describe('createMiddleware', () => { const transformMiddleware = createMiddleware(async (next, input, init) => { const response = await next(input, init); - if (response.headers.get('content-type')?.includes('application/json')) { + if (isJsonContentType(response.headers.get('content-type'))) { const data = (await response.json()) as Record; const transformed = { ...data, timestamp: 123_456_789 }; diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 0514d36112..d38e0088d1 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -51,6 +51,7 @@ "test:watch": "vitest" }, "dependencies": { + "content-type": "catalog:runtimeShared", "json-schema-typed": "catalog:runtimeShared", "zod": "catalog:runtimeShared" }, diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 0a50e00dd5..06f9b829f4 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -39,6 +39,9 @@ export type { // Auth utilities export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/authUtils'; +// Media-type utilities (for transport and framework-adapter authors) +export { isJsonContentType } from '../../shared/mediaType'; + // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 7ea56117c7..058de16174 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -10,6 +10,7 @@ export * from './shared/inputRequired'; export * from './shared/inputRequiredDriver'; export * from './shared/inputRequiredEngine'; export * from './shared/mcpParamHeaders'; +export * from './shared/mediaType'; export * from './shared/metadataUtils'; export * from './shared/protocol'; export * from './shared/protocolEras'; diff --git a/packages/core-internal/src/shared/mediaType.ts b/packages/core-internal/src/shared/mediaType.ts new file mode 100644 index 0000000000..aaa7a3533f --- /dev/null +++ b/packages/core-internal/src/shared/mediaType.ts @@ -0,0 +1,57 @@ +import contentType from 'content-type'; + +/** + * Extracts the media type (the lowercased `type/subtype` pair, without + * parameters) from a raw `Content-Type` header value, or `undefined` when the + * header is missing or empty. + * + * Content-Type comparisons must use the parsed media type, never a substring + * search of the raw header: a value like `text/plain; a=application/json` + * contains the substring `application/json` but its media type is + * `text/plain`, and case variants or parameters make naive string comparison + * wrong in both directions. + * + * "Essence" is the WHATWG MIME Sniffing standard's term for the bare + * `type/subtype` pair (https://mimesniff.spec.whatwg.org/#mime-type-essence); + * the Fetch standard's request classification is defined against it + * (https://fetch.spec.whatwg.org/#cors-safelisted-request-header). + * + * Parsing is RFC 9110 (`content-type` package) first. When the parameter + * section is malformed (`application/json;`, `application/json; charset=`), + * browsers and most HTTP stacks still derive the media type from the segment + * before the first `;` — the fallback matches that widely-implemented + * behavior, so a header whose media type is unambiguous is not rejected for + * a sloppy parameter section. + */ +export function mediaTypeEssence(header: string | null | undefined): string | undefined { + if (!header) { + return undefined; + } + try { + return contentType.parse(header).type; + } catch { + const essence = (header.split(';', 1)[0] ?? '').trim().toLowerCase(); + // A comma in the parameter tail of an unparseable value indicates + // joined duplicate headers — ambiguous, so no essence at all (keeps + // duplicate-header handling uniform whether or not the first copy + // carries parameters). + if (essence === '' || header.slice(essence.length).includes(',')) { + return undefined; + } + return essence; + } +} + +/** + * Whether a raw `Content-Type` header value denotes `application/json`. + * Parameters (for example `charset=utf-8`) are allowed and ignored; malformed + * parameter sections do not reject a header whose media type is unambiguously + * `application/json` (see `mediaTypeEssence` for the exact grammar). + */ +export function isJsonContentType(header: string | null | undefined): boolean { + // Fast path: the exact literal is what SDK clients send on every POST. + if (header === 'application/json') { + return true; + } + return mediaTypeEssence(header) === 'application/json'; +} diff --git a/packages/core-internal/test/shared/mediaType.test.ts b/packages/core-internal/test/shared/mediaType.test.ts new file mode 100644 index 0000000000..98e65e5b9b --- /dev/null +++ b/packages/core-internal/test/shared/mediaType.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { isJsonContentType, mediaTypeEssence } from '../../src/shared/mediaType'; + +describe('mediaTypeEssence', () => { + it('parses well-formed headers', () => { + expect(mediaTypeEssence('application/json')).toBe('application/json'); + expect(mediaTypeEssence('text/event-stream; charset=utf-8')).toBe('text/event-stream'); + expect(mediaTypeEssence('Application/JSON; charset=utf-8')).toBe('application/json'); + }); + + it('falls back to the pre-parameter segment for malformed parameter sections', () => { + expect(mediaTypeEssence('application/json;')).toBe('application/json'); + expect(mediaTypeEssence('application/json; charset=')).toBe('application/json'); + expect(mediaTypeEssence('text/plain;')).toBe('text/plain'); + }); + + it('returns undefined for missing or empty headers', () => { + expect(mediaTypeEssence(null)).toBeUndefined(); + expect(mediaTypeEssence(undefined)).toBeUndefined(); + expect(mediaTypeEssence('')).toBeUndefined(); + expect(mediaTypeEssence(' ')).toBeUndefined(); + expect(mediaTypeEssence(';charset=utf-8')).toBeUndefined(); + }); + + it('yields no essence for joined duplicate headers, with or without parameters', () => { + // Headers.get() joins repeated headers with ', '. Without parameters + // the comma lands in the first segment; with parameters it hides in + // the tail — both must behave the same. + expect(mediaTypeEssence('application/json, application/json')).toBe('application/json, application/json'); + expect(mediaTypeEssence('application/json; charset=utf-8, text/plain')).toBeUndefined(); + expect(mediaTypeEssence('application/json; charset=utf-8, application/json')).toBeUndefined(); + }); +}); + +describe('isJsonContentType', () => { + it('accepts application/json with or without parameters', () => { + expect(isJsonContentType('application/json')).toBe(true); + expect(isJsonContentType('application/json; charset=utf-8')).toBe(true); + expect(isJsonContentType('Application/JSON')).toBe(true); + expect(isJsonContentType(' application/json ; charset=utf-8')).toBe(true); + }); + + it('accepts unambiguous media types with malformed parameter sections', () => { + expect(isJsonContentType('application/json;')).toBe(true); + expect(isJsonContentType('application/json; charset=')).toBe(true); + expect(isJsonContentType('application/json; charset=utf-8; charset=x')).toBe(true); + }); + + it('never matches on substrings: parameters and sibling types are not application/json', () => { + expect(isJsonContentType('text/plain; a=application/json')).toBe(false); + expect(isJsonContentType('text/plain;')).toBe(false); + expect(isJsonContentType('text/plain, application/json')).toBe(false); + expect(isJsonContentType('application/json, application/json')).toBe(false); + expect(isJsonContentType('application/json; charset=utf-8, text/plain')).toBe(false); + expect(isJsonContentType('text/plain; charset=utf-8, application/json')).toBe(false); + expect(isJsonContentType('application/json-patch+json')).toBe(false); + expect(isJsonContentType('application/jsonp')).toBe(false); + }); + + it('rejects missing, empty, and non-JSON types', () => { + expect(isJsonContentType(null)).toBe(false); + expect(isJsonContentType(undefined)).toBe(false); + expect(isJsonContentType('')).toBe(false); + expect(isJsonContentType('text/plain')).toBe(false); + expect(isJsonContentType('multipart/form-data')).toBe(false); + }); +}); diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index 80237f7d4e..8a822fe3be 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -1,3 +1,4 @@ +import { isJsonContentType } from '@modelcontextprotocol/server'; import type { Context } from 'hono'; import { Hono } from 'hono'; @@ -45,8 +46,8 @@ export interface CreateMcpHonoAppOptions { * DNS rebinding attacks on localhost servers. * * This also installs a small JSON body parsing middleware (similar to `express.json()`) - * that stashes the parsed body into `c.set('parsedBody', ...)` when `Content-Type` includes - * `application/json`. + * that stashes the parsed body into `c.set('parsedBody', ...)` when the `Content-Type` + * media type is `application/json`. * * @param options - Configuration options * @returns A configured Hono application @@ -63,8 +64,10 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { return await next(); } - const ct = c.req.header('content-type') ?? ''; - if (!ct.includes('application/json')) { + // Parsed media type, never a substring match — see isJsonContentType. + // A body left unparsed here is answered 415 by the transport's own + // Content-Type check downstream. + if (!isJsonContentType(c.req.header('content-type'))) { return await next(); } diff --git a/packages/middleware/hono/test/hono.test.ts b/packages/middleware/hono/test/hono.test.ts index 1c743277b0..ea924cbba4 100644 --- a/packages/middleware/hono/test/hono.test.ts +++ b/packages/middleware/hono/test/hono.test.ts @@ -90,6 +90,34 @@ describe('@modelcontextprotocol/hono', () => { expect(await res.text()).toBe('Invalid JSON'); }); + test('createMcpHonoApp does not parse a non-JSON media type whose parameters contain application/json', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.json({ parsed: c.get('parsedBody') ?? null })); + + // `text/plain; a=application/json` contains the substring but its media + // type is text/plain — it must never be treated as a JSON body. + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'text/plain; a=application/json' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ parsed: null }); + }); + + test('createMcpHonoApp parses application/json with parameters', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.json(c.get('parsedBody'))); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ a: 1 }); + }); + test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => { const app = createMcpHonoApp(); app.use('/echo', async (c: Context, next) => { diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index f571a3af95..acc880f6f6 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -85,7 +85,7 @@ "dependencies": { "zod": "catalog:runtimeShared", "raw-body": "catalog:runtimeServerOnly", - "content-type": "catalog:runtimeServerOnly", + "content-type": "catalog:runtimeShared", "cors": "catalog:runtimeServerOnly", "express-rate-limit": "^8.2.1", "pkce-challenge": "catalog:runtimeShared" diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3f7632d570..708bb818ff 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -41,6 +41,8 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, httpStatusForErrorCode, + isJsonContentType, + mediaTypeEssence, missingClientCapabilities, MissingRequiredClientCapabilityError, modernOnlyStrictRejection, @@ -330,7 +332,7 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er ...(options?.authInfo !== undefined && { authInfo: options.authInfo }), ...(options?.parsedBody !== undefined && { parsedBody: options.parsedBody }) }); - if (response.body === null || !(response.headers.get('content-type') ?? '').includes('text/event-stream')) { + if (response.body === null || mediaTypeEssence(response.headers.get('content-type')) !== 'text/event-stream') { // Non-streaming exchange (a buffered JSON body or a body-less // ack): the response is complete, release the pair now. teardown(); @@ -483,7 +485,11 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno * This is the entry's own classification step exported as a predicate — it * runs exactly the code `createMcpHandler` runs to make the routing decision, * not a re-implementation — so a hand-wired composition that branches on it - * can never disagree with the entry. Use it to keep an existing legacy + * can never disagree with the entry. It is classification only: hand-wired + * compositions must validate Content-Type themselves (415 for POSTs whose + * media type is not `application/json`, via {@linkcode isJsonContentType}) + * before dispatching either leg — routing the legacy leg into the SDK + * transports gets their built-in check, but a custom modern leg has none. Use it to keep an existing legacy * deployment (for example a sessionful streamable HTTP wiring) serving 2025 * traffic next to a strict modern endpoint, now that the entry has no * handler-valued `legacy` option: @@ -574,7 +580,10 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P * pattern. Power users composing transport-neutral routing can also use the * exported building blocks directly: {@linkcode classifyInboundRequest} for * the era decision and `PerRequestHTTPServerTransport` for single-exchange - * serving. + * serving — such compositions must reject POSTs whose Content-Type media type + * is not `application/json` (415) before parsing the body, using + * {@linkcode isJsonContentType}; neither building block performs this + * validation itself. * * The entry performs no token verification: `authInfo` given to `fetch` is * passed through to handlers and the factory as-is and is never derived from @@ -817,6 +826,18 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise { const authInfo = requestOptions?.authInfo; + + // Content-Type check, answered before the body is read (parsed media + // type — see isJsonContentType). Load-bearing for the modern leg, + // whose ladder does not inspect Content-Type; the legacy transport + // keeps its own check for hand-wired use, so via this entry a + // doubly-invalid request answers 415 here before the transport's 406 + // Accept check would. + if (request.method.toUpperCase() === 'POST' && !isJsonContentType(request.headers.get('content-type'))) { + reportError(new Error('Unsupported Media Type: Content-Type must be application/json')); + return jsonRpcErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); + } + const classified = await classifyEntryRequest(request, requestOptions?.parsedBody); if (classified.step === 'unreadable-body') { diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index 4d96bc71c7..5003946404 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -32,6 +32,10 @@ * `createMcpHandler`) inherit the spec's per-error HTTP mandate with it: a * terminal `MissingRequiredClientCapabilityError` (-32021) MUST answer 400, * which this transport applies whenever the response is still uncommitted. + * A custom entry must also reject POSTs whose Content-Type media type is not + * `application/json` (415) before parsing the body — use `isJsonContentType` + * from the package root; this transport never sees the HTTP headers, so it + * cannot perform that validation itself. */ import type { AuthInfo, diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 1f2cf94b00..7da5fb853c 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -11,6 +11,7 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, + isJsonContentType, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, @@ -680,6 +681,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Validate the Accept header const acceptHeader = req.headers.get('accept'); // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. + // Accept is a comma-separated list, so a substring check is the intended semantics here (unlike Content-Type below). + // eslint-disable-next-line no-restricted-syntax if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); return this.createJsonErrorResponse( @@ -689,8 +692,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { ); } + // Parsed media type, never a substring match — see + // isJsonContentType. This check stays here for hand-wired + // transports; via createMcpHandler the entry's own check answers + // first. const ct = req.headers.get('content-type'); - if (!ct || !ct.includes('application/json')) { + if (!isJsonContentType(ct)) { this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); } diff --git a/packages/server/test/server/contentTypeValidation.test.ts b/packages/server/test/server/contentTypeValidation.test.ts new file mode 100644 index 0000000000..4c699e8868 --- /dev/null +++ b/packages/server/test/server/contentTypeValidation.test.ts @@ -0,0 +1,186 @@ +/** + * Content-Type validation at the HTTP entries — the parsed media type decides, + * never a substring search of the raw header. + * + * The shape pinned here: `Content-Type: text/plain; a=application/json` + * contains the substring `application/json`, but its media type is + * `text/plain` — every entry must answer it (and any other non-JSON media + * type) with 415 before the body is dispatched, while values whose media type + * is `application/json` keep working regardless of parameters or case. + */ +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; +import { beforeEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { McpServer } from '../../src/server/mcp'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; + +const MODERN_REVISION = '2026-07-28'; + +const executed: string[] = []; + +function factory(): McpServer { + const mcpServer = new McpServer({ name: 'ct-fixture', version: '1.0.0' }); + mcpServer.registerTool('run', { description: 'records each dispatch', inputSchema: { cmd: z.string() } }, async ({ cmd }) => { + executed.push(cmd); + return { content: [{ type: 'text', text: `ran: ${cmd}` }] }; + }); + return mcpServer; +} + +const LEGACY_CALL = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'run', arguments: { cmd: 'hello' } } +}; + +const MODERN_CALL = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'run', + arguments: { cmd: 'hello' }, + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'ct-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } +}; + +function postRequest(body: unknown, headers: Record): Request { + return new Request('http://127.0.0.1/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); +} + +beforeEach(() => { + executed.length = 0; +}); + +describe('createMcpHandler Content-Type validation (default options, legacy stateless fallback active)', () => { + it('serves an application/json POST (control)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json' })); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('accepts application/json with parameters', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json; charset=utf-8' })); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('rejects text/plain with 415', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'text/plain' })); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('rejects a non-JSON media type whose parameters contain `application/json` and does not dispatch', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'text/plain; a=application/json' })); + expect(response.status).toBe(415); + const body = (await response.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32_000); + expect(body.error.message).toContain('Content-Type must be application/json'); + expect(executed).toEqual([]); + }); + + it('rejects a POST with no Content-Type header with 415', async () => { + const handler = createMcpHandler(factory); + // A string body makes Request auto-attach `text/plain;charset=UTF-8`; + // delete it so this actually exercises the absent-header branch. + const request = postRequest(LEGACY_CALL, {}); + request.headers.delete('content-type'); + expect(request.headers.get('content-type')).toBeNull(); + const response = await handler.fetch(request); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('accepts an unambiguous media type with a malformed parameter section (trailing semicolon)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json;' })); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('rejects joined duplicate Content-Type headers', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(postRequest(LEGACY_CALL, { 'Content-Type': 'application/json, application/json' })); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('validates the modern leg too: enveloped request with a non-JSON media type is answered 415 before dispatch', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch( + postRequest(MODERN_CALL, { + 'Content-Type': 'text/plain; a=application/json', + 'mcp-protocol-version': MODERN_REVISION, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'run' + }) + ); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('still serves the modern leg for application/json (control)', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch( + postRequest(MODERN_CALL, { + 'Content-Type': 'application/json', + 'mcp-protocol-version': MODERN_REVISION, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'run' + }) + ); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); + + it('does not apply the POST check to bodyless methods', async () => { + const handler = createMcpHandler(factory); + const response = await handler.fetch(new Request('http://127.0.0.1/mcp', { method: 'DELETE' })); + expect(response.status).not.toBe(415); + }); +}); + +describe('WebStandardStreamableHTTPServerTransport Content-Type validation (direct wiring)', () => { + async function postToTransport(headers: Record): Promise { + const server = factory(); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + await server.connect(transport); + const response = await transport.handleRequest(postRequest(LEGACY_CALL, headers)); + await server.close(); + return response; + } + + it('rejects a non-JSON media type whose parameters contain `application/json` and does not dispatch', async () => { + const response = await postToTransport({ 'Content-Type': 'text/plain; a=application/json' }); + expect(response.status).toBe(415); + expect(executed).toEqual([]); + }); + + it('serves application/json (control)', async () => { + const response = await postToTransport({ 'Content-Type': 'application/json' }); + expect(response.status).toBe(200); + expect(executed).toEqual(['hello']); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1e4a1afcb..cd813db79a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,9 +98,6 @@ catalogs: '@hono/node-server': specifier: ^1.19.9 version: 1.19.11 - content-type: - specifier: ^1.0.5 - version: 1.0.5 cors: specifier: ^2.8.5 version: 2.8.6 @@ -126,6 +123,9 @@ catalogs: ajv-formats: specifier: ^3.0.1 version: 3.0.1 + content-type: + specifier: ^1.0.5 + version: 1.0.5 json-schema-typed: specifier: ^8.0.2 version: 8.0.2 @@ -1425,6 +1425,9 @@ importers: packages/core-internal: dependencies: + content-type: + specifier: catalog:runtimeShared + version: 1.0.5 json-schema-typed: specifier: catalog:runtimeShared version: 8.0.2 @@ -1789,7 +1792,7 @@ importers: packages/server-legacy: dependencies: content-type: - specifier: catalog:runtimeServerOnly + specifier: catalog:runtimeShared version: 1.0.5 cors: specifier: catalog:runtimeServerOnly diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3923d58ef0..3303ad0607 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,6 @@ catalogs: jose: ^6.1.3 runtimeServerOnly: '@hono/node-server': ^1.19.9 - content-type: ^1.0.5 cors: ^2.8.5 express: ^5.2.1 fastify: ^5.2.0 @@ -51,6 +50,7 @@ catalogs: '@cfworker/json-schema': ^4.1.1 ajv: ^8.17.1 ajv-formats: ^3.0.1 + content-type: ^1.0.5 json-schema-typed: ^8.0.2 pkce-challenge: ^5.0.0 zod: ^4.2.0 From 78fabea44557bd49f5a050b92e57ccd22dab14ad Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:03:15 +0100 Subject: [PATCH 5/5] Use protected HTTP wiring in examples and guides (#2445) --- .changeset/examples-protected-wiring.md | 5 + docs/serving/http.md | 14 ++- examples/CONTRIBUTING.md | 19 ++- examples/README.md | 2 + examples/caching/package.json | 3 +- examples/caching/server.ts | 11 +- examples/custom-methods/package.json | 3 +- examples/custom-methods/server.ts | 10 +- examples/custom-version/package.json | 3 +- examples/custom-version/server.ts | 11 +- examples/dual-era/package.json | 3 +- examples/dual-era/server.ts | 11 +- examples/elicitation/server.ts | 18 ++- examples/extension-capabilities/package.json | 3 +- examples/extension-capabilities/server.ts | 11 +- examples/gateway/server.ts | 14 ++- examples/guides/serving/http.examples.ts | 10 +- examples/json-response/package.json | 3 +- examples/json-response/server.ts | 11 +- examples/mrtr/package.json | 3 +- examples/mrtr/server.ts | 11 +- examples/oauth/simpleOAuthClient.ts | 8 +- examples/parallel-calls/package.json | 3 +- examples/parallel-calls/server.ts | 10 +- examples/prompts/package.json | 3 +- examples/prompts/server.ts | 10 +- examples/resources/package.json | 3 +- examples/resources/server.ts | 11 +- examples/sampling/package.json | 3 +- examples/sampling/server.ts | 11 +- examples/schema-validators/package.json | 3 +- examples/schema-validators/server.ts | 10 +- examples/shared/src/authServer.ts | 5 +- examples/stateless-legacy/package.json | 3 +- examples/stateless-legacy/server.ts | 11 +- examples/stickynotes/package.json | 3 +- examples/stickynotes/server.ts | 11 +- examples/streaming/package.json | 3 +- examples/streaming/server.ts | 11 +- examples/subscriptions/package.json | 3 +- examples/subscriptions/server.ts | 11 +- examples/todos-server/package.json | 3 +- examples/todos-server/server.ts | 11 +- examples/tools/package.json | 3 +- examples/tools/server.ts | 11 +- packages/middleware/node/README.md | 17 ++- pnpm-lock.yaml | 126 +++++++++++++------ 47 files changed, 339 insertions(+), 147 deletions(-) create mode 100644 .changeset/examples-protected-wiring.md diff --git a/.changeset/examples-protected-wiring.md b/.changeset/examples-protected-wiring.md new file mode 100644 index 0000000000..dce27b8666 --- /dev/null +++ b/.changeset/examples-protected-wiring.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Document composing the host and origin validation guards in front of `toNodeHandler` for hand-wired `node:http` servers, matching the protected wiring the examples and serving guide now demonstrate. diff --git a/docs/serving/http.md b/docs/serving/http.md index 228712d2f9..e95552656a 100644 --- a/docs/serving/http.md +++ b/docs/serving/http.md @@ -66,19 +66,25 @@ Because no state lives on the instance, the endpoint is stateless and scales hor ## Mount it on your runtime -On a web-standard runtime — Cloudflare Workers, Deno, Bun — `export default handler` is the entire mount. Node frameworks wrap the handler once with `toNodeHandler` from `@modelcontextprotocol/node`; on plain `node:http`: +On a web-standard runtime — Cloudflare Workers, Deno, Bun — `export default handler` is the entire mount. Node frameworks wrap the handler once with `toNodeHandler` from `@modelcontextprotocol/node`; on plain `node:http`, bind loopback explicitly and compose the `localhostHostValidation` / `localhostOriginValidation` guards (also from `@modelcontextprotocol/node`) in front of it, matching the framework factories' defaults: ```ts source="../../examples/guides/serving/http.examples.ts#mountNode" -createServer(toNodeHandler(handler)).listen(3000); +const nodeHandler = toNodeHandler(handler); +const validateHost = localhostHostValidation(); +const validateOrigin = localhostOriginValidation(); +createServer((req, res) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) return; + void nodeHandler(req, res); +}).listen(3000, '127.0.0.1'); ``` -`POST http://localhost:3000/mcp` now reaches the factory. The same wrapped handler mounts under [Express](./express.md), [Fastify](./fastify.md), and [Hono](./hono.md); [Serve on web-standard runtimes](./web-standard.md) covers the `export default` side. +`POST http://127.0.0.1:3000/mcp` now reaches the factory; the guards answer anything else with `403` before the handler sees it — [the next section](#validate-host-and-origin-in-front-of-it) explains why they belong in front. The same wrapped handler mounts under [Express](./express.md), [Fastify](./fastify.md), and [Hono](./hono.md); [Serve on web-standard runtimes](./web-standard.md) covers the `export default` side. ## Validate Host and Origin in front of it The handler trusts its caller: it validates no `Host` header, no `Origin` header, and no token. Mount those checks in front of it — on a localhost bind, the `Host` check is what stops **DNS rebinding**, a malicious page resolving its own domain to `127.0.0.1` so the browser treats your local server as same-origin. -Under a framework you never wire either check by hand: `createMcpExpressApp`, `createMcpHonoApp`, and `createMcpFastifyApp` all arm both by default on localhost binds — the [Express](./express.md), [Hono](./hono.md), and [Fastify](./fastify.md) recipes start there. On a bare fetch runtime, put `hostHeaderValidationResponse` and `originValidationResponse` (from `@modelcontextprotocol/server`) in front of `handler.fetch` — [Serve on web-standard runtimes](./web-standard.md#protect-against-dns-rebinding) builds that wrapper. +Under a framework you never wire either check by hand: `createMcpExpressApp`, `createMcpHonoApp`, and `createMcpFastifyApp` all arm both by default on localhost binds — the [Express](./express.md), [Hono](./hono.md), and [Fastify](./fastify.md) recipes start there. On plain `node:http`, compose `localhostHostValidation` and `localhostOriginValidation` (from `@modelcontextprotocol/node`) in front of the wrapped handler, as [the mount above](#mount-it-on-your-runtime) does. On a bare fetch runtime, put `hostHeaderValidationResponse` and `originValidationResponse` (from `@modelcontextprotocol/server`) in front of `handler.fetch` — [Serve on web-standard runtimes](./web-standard.md#protect-against-dns-rebinding) builds that wrapper. ## Pass authentication through diff --git a/examples/CONTRIBUTING.md b/examples/CONTRIBUTING.md index ccd1443423..aabbb833c8 100644 --- a/examples/CONTRIBUTING.md +++ b/examples/CONTRIBUTING.md @@ -26,10 +26,9 @@ it (HTTP-only auth, sessionful transports, framework adapters, etc.). ### `server.ts` ```ts -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -46,12 +45,24 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost Host/Origin validation by default. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } ``` +The HTTP leg binds loopback explicitly and mounts the handler behind +`createMcpHonoApp()`, which applies host/origin validation by default — +matching the framework factories' defaults. A story whose point is the raw +`node:http` wiring (e.g. `gateway/`, `elicitation/`) keeps +`createServer` + `toNodeHandler` but must then bind +`.listen(port, '127.0.0.1')` and compose the `localhostHostValidation()` / +`localhostOriginValidation()` guards from `@modelcontextprotocol/node` in +front of the handler. Either way, no example teaches an unguarded HTTP mount. + ### `client.ts` ```ts diff --git a/examples/README.md b/examples/README.md index a35c8bf494..fc57f2de7d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,8 @@ pnpm --filter @mcp-examples/ client -- --http http://127.0.0.1:3000/mcp Add `-- --legacy` to the client command for the 2025-era handshake. +Every HTTP leg serves the handler behind host/origin validation — via `createMcpHonoApp()` (or another framework app factory, which arm the checks by default on localhost binds), or, for raw `node:http` stories, via the `localhostHostValidation()` / `localhostOriginValidation()` guards from `@modelcontextprotocol/node` composed in front of `toNodeHandler` on an explicit loopback bind. New stories follow the canonical skeleton — see [CONTRIBUTING.md](./CONTRIBUTING.md). + The one exception to the generic commands is the reference pair: [`cli-client/`](./cli-client/README.md) and [`todos-server/`](./todos-server/README.md) have their own entry points (`pnpm --filter @mcp-examples/cli-client start`, `pnpm --filter @mcp-examples/todos-server start:http`) — see their READMEs. ## Start here diff --git a/examples/caching/package.json b/examples/caching/package.json index 173bbe05eb..2a3b89d8a3 100644 --- a/examples/caching/package.json +++ b/examples/caching/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/caching/server.ts b/examples/caching/server.ts index fe93e66e2c..7e762a6ba3 100644 --- a/examples/caching/server.ts +++ b/examples/caching/server.ts @@ -13,10 +13,9 @@ * The fields are emitted ONLY toward 2026-era clients — a 2025-era response * is byte-for-byte unchanged. One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -96,7 +95,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/custom-methods/package.json b/examples/custom-methods/package.json index 3d5761985b..950607681c 100644 --- a/examples/custom-methods/package.json +++ b/examples/custom-methods/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/custom-methods/server.ts b/examples/custom-methods/server.ts index b21e8e4963..91f1438fdd 100644 --- a/examples/custom-methods/server.ts +++ b/examples/custom-methods/server.ts @@ -5,10 +5,9 @@ * One binary, either transport — selected by `--http --port ` (defaults to * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { z } from 'zod/v4'; @@ -36,7 +35,10 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/custom-version/package.json b/examples/custom-version/package.json index d4f51326d9..c072eb7a34 100644 --- a/examples/custom-version/package.json +++ b/examples/custom-version/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/custom-version/server.ts b/examples/custom-version/server.ts index ae31799dea..fff4dc99d6 100644 --- a/examples/custom-version/server.ts +++ b/examples/custom-version/server.ts @@ -3,10 +3,9 @@ * The first version in the list is the fallback when the client requests an * unsupported one. One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -33,7 +32,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/dual-era/package.json b/examples/dual-era/package.json index a9b0fe8737..d188f6d4df 100644 --- a/examples/dual-era/package.json +++ b/examples/dual-era/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/dual-era/server.ts b/examples/dual-era/server.ts index 9313acb436..7af3260d46 100644 --- a/examples/dual-era/server.ts +++ b/examples/dual-era/server.ts @@ -13,10 +13,9 @@ * (`createMcpHandler(buildServer)` on its default posture — modern served per * request, 2025-era traffic served stateless from the same factory). */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -49,7 +48,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index 37808e9d21..9157a07a01 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -23,7 +23,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer } from 'node:http'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node'; +import { + localhostHostValidation, + localhostOriginValidation, + NodeStreamableHTTPServerTransport, + toNodeHandler, + toWebRequest +} from '@modelcontextprotocol/node'; import type { CallToolResult, ElicitRequestFormParams, @@ -274,7 +280,15 @@ if (transport === 'stdio') { } }; + // Host/Origin guards for the hand-wired `node:http` server — plain + // `createServer` has no middleware chain, so compose the boolean-returning + // guards from `@modelcontextprotocol/node` in front of both arms, + // matching the framework factories' localhost defaults. + const validateHost = localhostHostValidation(); + const validateOrigin = localhostOriginValidation(); + createServer((req, res) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) return; void (async () => { // `toWebRequest` reads the Node body into a web-standard `Request`, // so the body now lives in `request`, not `req`. Ask the predicate @@ -289,7 +303,7 @@ if (transport === 'stdio') { console.error('[server] request error:', error instanceof Error ? error.message : error); if (!res.headersSent) res.writeHead(500).end(); }); - }).listen(port, () => { + }).listen(port, '127.0.0.1', () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/extension-capabilities/package.json b/examples/extension-capabilities/package.json index 1acb70d384..78c6c469c9 100644 --- a/examples/extension-capabilities/package.json +++ b/examples/extension-capabilities/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/extension-capabilities/server.ts b/examples/extension-capabilities/server.ts index 9b6ac21ace..13c2bb5efc 100644 --- a/examples/extension-capabilities/server.ts +++ b/examples/extension-capabilities/server.ts @@ -6,10 +6,9 @@ * One binary, either transport — selected by `--http --port ` (defaults to * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -31,7 +30,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/gateway/server.ts b/examples/gateway/server.ts index ec0d657d27..08fc368032 100644 --- a/examples/gateway/server.ts +++ b/examples/gateway/server.ts @@ -9,7 +9,7 @@ import { createServer } from 'node:http'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; @@ -44,6 +44,16 @@ function buildServer(): McpServer { const { port } = parseExampleArgs(); const handler = createMcpHandler(buildServer); -createServer(toNodeHandler(handler)).listen(port, () => { +const nodeHandler = toNodeHandler(handler); +// Bind loopback explicitly and apply host/origin validation in front of the +// handler, matching the framework factories' defaults. The guards answer +// rejected requests themselves and never reach `createMcpHandler`, so the +// per-request factory count the client asserts on is unchanged. +const validateHost = localhostHostValidation(); +const validateOrigin = localhostOriginValidation(); +createServer((req, res) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) return; + void nodeHandler(req, res); +}).listen(port, '127.0.0.1', () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/guides/serving/http.examples.ts b/examples/guides/serving/http.examples.ts index 77979ec3d3..1a8c9666de 100644 --- a/examples/guides/serving/http.examples.ts +++ b/examples/guides/serving/http.examples.ts @@ -14,7 +14,7 @@ /* eslint-disable no-console */ import { createServer } from 'node:http'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node'; import type { McpServerFactory } from '@modelcontextprotocol/server'; // --------------------------------------------------------------------------- @@ -62,7 +62,13 @@ const perCaller = createMcpHandler(({ authInfo }) => { /** Example: mounting the handler on plain `node:http`. */ function mountNode(): void { //#region mountNode - createServer(toNodeHandler(handler)).listen(3000); + const nodeHandler = toNodeHandler(handler); + const validateHost = localhostHostValidation(); + const validateOrigin = localhostOriginValidation(); + createServer((req, res) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) return; + void nodeHandler(req, res); + }).listen(3000, '127.0.0.1'); //#endregion mountNode } void mountNode; diff --git a/examples/json-response/package.json b/examples/json-response/package.json index 4ec6e1c857..2c81cbb969 100644 --- a/examples/json-response/package.json +++ b/examples/json-response/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/json-response/server.ts b/examples/json-response/server.ts index d954db09d1..90abb4792a 100644 --- a/examples/json-response/server.ts +++ b/examples/json-response/server.ts @@ -7,10 +7,9 @@ * HTTP-only — `responseMode` shapes the HTTP response body; there is no stdio * equivalent and a stdio leg would not exercise the option. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; @@ -29,6 +28,10 @@ const { port } = parseExampleArgs(); // `responseMode: 'json'` is the point of this story — applies to the modern // (2026-07-28) per-request HTTP path. const handler = createMcpHandler(buildServer, { responseMode: 'json' }); -createServer(toNodeHandler(handler)).listen(port, () => { +// `createMcpHonoApp()` binds the endpoint behind localhost host/origin +// validation by default, matching the framework factories' defaults. +const app = createMcpHonoApp(); +app.all('/mcp', c => handler.fetch(c.req.raw)); +serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/mrtr/package.json b/examples/mrtr/package.json index eca6285a84..35c336590c 100644 --- a/examples/mrtr/package.json +++ b/examples/mrtr/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts index 66fe7bb82d..1f89fe236d 100644 --- a/examples/mrtr/server.ts +++ b/examples/mrtr/server.ts @@ -22,10 +22,9 @@ * One binary, either transport — selected by `--http --port ` (defaults to * stdio). See `examples/CONTRIBUTING.md` for the canonical shape. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'; import { acceptedContent, createMcpHandler, createRequestStateCodec, inputRequired, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -114,7 +113,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/oauth/simpleOAuthClient.ts b/examples/oauth/simpleOAuthClient.ts index 8bea598f1f..cab70d5d2a 100644 --- a/examples/oauth/simpleOAuthClient.ts +++ b/examples/oauth/simpleOAuthClient.ts @@ -13,7 +13,7 @@ import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider'; // Configuration const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp'; const CALLBACK_PORT = 8090; // Use different port than auth server (3001) -const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; +const CALLBACK_URL = `http://127.0.0.1:${CALLBACK_PORT}/callback`; /** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */ function escHtml(s: string): string { @@ -128,8 +128,10 @@ class InteractiveOAuthClient { } }); - server.listen(CALLBACK_PORT, () => { - console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`); + // Bind loopback explicitly — the callback target only ever needs to be + // reachable from the local browser, matching the framework factories' defaults. + server.listen(CALLBACK_PORT, '127.0.0.1', () => { + console.log(`OAuth callback server started on ${CALLBACK_URL}`); }); }); } diff --git a/examples/parallel-calls/package.json b/examples/parallel-calls/package.json index 7bf6831f81..be186df74d 100644 --- a/examples/parallel-calls/package.json +++ b/examples/parallel-calls/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/parallel-calls/server.ts b/examples/parallel-calls/server.ts index c2b390b32b..f02e578929 100644 --- a/examples/parallel-calls/server.ts +++ b/examples/parallel-calls/server.ts @@ -4,10 +4,9 @@ * calls (both transports), asserting in-flight notifications are attributed * back to the right caller. One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; @@ -43,7 +42,10 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/prompts/package.json b/examples/prompts/package.json index 4f46b52436..2f46943c16 100644 --- a/examples/prompts/package.json +++ b/examples/prompts/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/prompts/server.ts b/examples/prompts/server.ts index 8732d3d748..60cdab6172 100644 --- a/examples/prompts/server.ts +++ b/examples/prompts/server.ts @@ -5,10 +5,9 @@ * `completable(...)` so the client's `complete()` call returns suggestions. * One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { completable, createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; @@ -48,7 +47,10 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default; bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/resources/package.json b/examples/resources/package.json index 5fb5df3b83..a7ac5f38f6 100644 --- a/examples/resources/package.json +++ b/examples/resources/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*" }, "devDependencies": { diff --git a/examples/resources/server.ts b/examples/resources/server.ts index 2ac10338d6..89c1234ada 100644 --- a/examples/resources/server.ts +++ b/examples/resources/server.ts @@ -9,10 +9,9 @@ * on the handler's notifier for clients on other requests. One binary, either * transport — selected from argv below. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -91,7 +90,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(reqCtx => buildServer(reqCtx, uri => handler.notify.resourceUpdated(uri))); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/sampling/package.json b/examples/sampling/package.json index f4981410af..7d0b10477e 100644 --- a/examples/sampling/package.json +++ b/examples/sampling/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/sampling/server.ts b/examples/sampling/server.ts index cf45c3a356..d8a5da5f98 100644 --- a/examples/sampling/server.ts +++ b/examples/sampling/server.ts @@ -15,10 +15,9 @@ * One binary, either transport. Logs go to stderr only — stdio's stdout is * the JSON-RPC stream. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { CallToolResult, InputRequiredResult, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, inputRequired, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -71,7 +70,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/schema-validators/package.json b/examples/schema-validators/package.json index cc94d3b645..0ec03d44f1 100644 --- a/examples/schema-validators/package.json +++ b/examples/schema-validators/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "@valibot/to-json-schema": "catalog:devTools", "arktype": "catalog:devTools", diff --git a/examples/schema-validators/server.ts b/examples/schema-validators/server.ts index 95408951ce..326a945004 100644 --- a/examples/schema-validators/server.ts +++ b/examples/schema-validators/server.ts @@ -5,10 +5,9 @@ * Valibot needs the `@valibot/to-json-schema` wrapper to expose JSON Schema * conversion. One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { toStandardJsonSchema } from '@valibot/to-json-schema'; @@ -81,7 +80,10 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts index 51606aa795..49b507e4b8 100644 --- a/examples/shared/src/authServer.ts +++ b/examples/shared/src/authServer.ts @@ -316,9 +316,10 @@ export function setupAuthServer(options: SetupAuthServerOptions): void { } }); - // Start the auth server + // Start the auth server, bound to loopback explicitly — this demo server is + // meant to be reached only from the same machine. const authPort = Number.parseInt(authServerUrl.port, 10); - authApp.listen(authPort, (error?: Error) => { + authApp.listen(authPort, '127.0.0.1', (error?: Error) => { if (error) { console.error('Failed to start auth server:', error); // eslint-disable-next-line unicorn/no-process-exit diff --git a/examples/stateless-legacy/package.json b/examples/stateless-legacy/package.json index 8052f36367..190b8460aa 100644 --- a/examples/stateless-legacy/package.json +++ b/examples/stateless-legacy/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/stateless-legacy/server.ts b/examples/stateless-legacy/server.ts index a51ee5ab48..e141be0147 100644 --- a/examples/stateless-legacy/server.ts +++ b/examples/stateless-legacy/server.ts @@ -11,10 +11,9 @@ * hosting concern; a stdio leg would bypass it. See `dual-era/` for the stdio * analogue. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; @@ -31,6 +30,10 @@ function buildServer(): McpServer { const { port } = parseExampleArgs(); const handler = createMcpHandler(buildServer); -createServer(toNodeHandler(handler)).listen(port, () => { +// `createMcpHonoApp()` binds the endpoint behind localhost host/origin +// validation by default, matching the framework factories' defaults. +const app = createMcpHonoApp(); +app.all('/mcp', c => handler.fetch(c.req.raw)); +serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); diff --git a/examples/stickynotes/package.json b/examples/stickynotes/package.json index 2e5ce67e09..61a98e8b29 100644 --- a/examples/stickynotes/package.json +++ b/examples/stickynotes/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/stickynotes/server.ts b/examples/stickynotes/server.ts index c3baf45b48..441ff2915f 100644 --- a/examples/stickynotes/server.ts +++ b/examples/stickynotes/server.ts @@ -17,10 +17,9 @@ * * One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { RegisteredResource } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -121,7 +120,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/streaming/package.json b/examples/streaming/package.json index 381e19a7e6..61dceffbff 100644 --- a/examples/streaming/package.json +++ b/examples/streaming/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/streaming/server.ts b/examples/streaming/server.ts index 0e45e320a7..219c6ad57f 100644 --- a/examples/streaming/server.ts +++ b/examples/streaming/server.ts @@ -6,10 +6,9 @@ * (when the server has the `logging` capability), and stops promptly when the * client cancels (`ctx.mcpReq.signal.aborted`). One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; @@ -65,7 +64,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, hostname: '127.0.0.1', port }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/subscriptions/package.json b/examples/subscriptions/package.json index 880618ea28..cd30b2ee16 100644 --- a/examples/subscriptions/package.json +++ b/examples/subscriptions/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/subscriptions/server.ts b/examples/subscriptions/server.ts index 4681de1693..3fd1693fe6 100644 --- a/examples/subscriptions/server.ts +++ b/examples/subscriptions/server.ts @@ -16,10 +16,9 @@ * so the client decides when to mutate (no timer race with the runner). The * canonical-shape transport branch below assigns `publish` per entry. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -83,7 +82,11 @@ if (transport === 'stdio') { const notify: ServerNotifier = handler.notify; void bus; // (the typed publish facade `notify` wraps `bus.publish`) publish = () => notify.toolsChanged(); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` arms localhost host/origin validation by default; + // bind loopback explicitly to match. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/todos-server/package.json b/examples/todos-server/package.json index a7798a2abf..f55c1f16dd 100644 --- a/examples/todos-server/package.json +++ b/examples/todos-server/package.json @@ -7,8 +7,9 @@ "start:http": "tsx server.ts --http" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/todos-server/server.ts b/examples/todos-server/server.ts index 72ea371608..1c3271219d 100644 --- a/examples/todos-server/server.ts +++ b/examples/todos-server/server.ts @@ -3,10 +3,9 @@ * todos.ts). Same dual-transport skeleton as every other example: stdio by default * (cli-client spawns it as a child process), Streamable HTTP behind `--http`. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -23,7 +22,11 @@ if (transport === 'stdio') { // events (the board changing) are published through the handler's notifier instead. onBoardChanged(() => handler.notify.resourcesChanged()); onBoardUpdated(uri => handler.notify.resourceUpdated(uri)); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[todos] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/examples/tools/package.json b/examples/tools/package.json index d54e05fc22..8aafe0598e 100644 --- a/examples/tools/package.json +++ b/examples/tools/package.json @@ -7,9 +7,10 @@ "client": "tsx client.ts" }, "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", - "@modelcontextprotocol/node": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/tools/server.ts b/examples/tools/server.ts index 3456aa1027..c10fc16996 100644 --- a/examples/tools/server.ts +++ b/examples/tools/server.ts @@ -6,10 +6,9 @@ * `structuredContent` from `outputSchema`, `annotations` for behavioral hints * (`readOnlyHint`, `destructiveHint`). One binary, either transport. */ -import { createServer } from 'node:http'; - +import { serve } from '@hono/node-server'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import type { CallToolResult } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; @@ -59,7 +58,11 @@ if (transport === 'stdio') { console.error('[server] serving over stdio'); } else { const handler = createMcpHandler(buildServer); - createServer(toNodeHandler(handler)).listen(port, () => { + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); } diff --git a/packages/middleware/node/README.md b/packages/middleware/node/README.md index ceedbcc144..bc279cae3a 100644 --- a/packages/middleware/node/README.md +++ b/packages/middleware/node/README.md @@ -17,6 +17,8 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/node - `NodeStreamableHTTPServerTransport` - `StreamableHTTPServerTransportOptions` (type alias for `WebStandardStreamableHTTPServerTransportOptions`) - `toNodeHandler(handler, opts?)` — adapt a web-standard `{ fetch }` MCP handler to a Node `(req, res, parsedBody?)` handler +- `hostHeaderValidation(allowedHostnames)` / `localhostHostValidation()` — `Host` header guards for hand-wired `node:http` servers +- `originValidation(allowedOriginHostnames)` / `localhostOriginValidation()` — `Origin` header guards for hand-wired `node:http` servers - `ToNodeHandlerOptions`, `FetchLikeMcpHandler`, `NodeMcpRequestHandler` (types for `toNodeHandler`) - `toWebRequest(req, parsedBody?, opts?)` — the Node `IncomingMessage` → web-standard `Request` conversion `toNodeHandler` performs internally, exported on its own (for example to feed `isLegacyRequest()` from a hand-wired `(req, res)` handler) - `ToWebRequestOptions` (options type for `toWebRequest`) @@ -45,16 +47,27 @@ app.post('/mcp', async (req, res) => { ### Node.js `http` server +Plain `node:http` has no middleware chain, so bind loopback explicitly and +compose the `Host`/`Origin` guards in front of the transport — matching the +defaults the framework app factories (`createMcpExpressApp`, +`createMcpHonoApp`, `createMcpFastifyApp`) apply for you. The guards answer +rejected requests with `403` themselves and return `false`, so the handler +must not touch the request further. + ```ts import { createServer } from 'node:http'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { localhostHostValidation, localhostOriginValidation, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +const validateHost = localhostHostValidation(); +const validateOrigin = localhostOriginValidation(); + createServer(async (req, res) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) return; const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res); -}).listen(3000); +}).listen(3000, '127.0.0.1'); ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd813db79a..82fdee06b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,15 +427,18 @@ importers: examples/caching: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -496,15 +499,18 @@ importers: examples/custom-methods: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -518,15 +524,18 @@ importers: examples/custom-version: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -537,15 +546,18 @@ importers: examples/dual-era: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -581,15 +593,18 @@ importers: examples/extension-capabilities: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -647,15 +662,18 @@ importers: examples/json-response: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -703,15 +721,18 @@ importers: examples/mrtr: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -784,15 +805,18 @@ importers: examples/parallel-calls: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -806,15 +830,18 @@ importers: examples/prompts: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -862,15 +889,18 @@ importers: examples/resources: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -881,15 +911,18 @@ importers: examples/sampling: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -903,15 +936,18 @@ importers: examples/schema-validators: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -1107,15 +1143,18 @@ importers: examples/stateless-legacy: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -1129,15 +1168,18 @@ importers: examples/stickynotes: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -1151,15 +1193,18 @@ importers: examples/streaming: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -1173,15 +1218,18 @@ importers: examples/subscriptions: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -1195,12 +1243,15 @@ importers: examples/todos-server: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -1214,15 +1265,18 @@ importers: examples/tools: dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) '@mcp-examples/shared': specifier: workspace:* version: link:../shared '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client - '@modelcontextprotocol/node': + '@modelcontextprotocol/hono': specifier: workspace:* - version: link:../../packages/middleware/node + version: link:../../packages/middleware/hono '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server