Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/content-type-media-type-validation.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/examples-protected-wiring.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/silent-validators-wave.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions common/eslint-config/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
12 changes: 12 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions docs/serving/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions examples/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pnpm --filter @mcp-examples/<story> 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
Expand Down Expand Up @@ -48,6 +50,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 |
Expand Down
18 changes: 18 additions & 0 deletions examples/bearer-auth-web/README.md
Original file line number Diff line number Diff line change
@@ -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
```
32 changes: 32 additions & 0 deletions examples/bearer-auth-web/client.ts
Original file line number Diff line number Diff line change
@@ -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();
27 changes: 27 additions & 0 deletions examples/bearer-auth-web/package.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
70 changes: 70 additions & 0 deletions examples/bearer-auth-web/server.ts
Original file line number Diff line number Diff line change
@@ -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<AuthInfo> {
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<Response> {
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`);
});
3 changes: 2 additions & 1 deletion examples/caching/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
11 changes: 7 additions & 4 deletions examples/caching/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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`);
});
}
Loading