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
15 changes: 15 additions & 0 deletions .changeset/web-standard-bearer-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/express': patch
'@modelcontextprotocol/codemod': patch
---

Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
`requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
`bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
here. The Express middleware adapts the same core and is unchanged in
behavior, except that `WWW-Authenticate` challenge values are now RFC 7235
quoted-string sanitized (quotes and backslashes escaped, control and
non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports
`OAuthTokenVerifier` as before.
13 changes: 13 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export default defineConfig({
title: 'MCP TypeScript SDK',
description: 'The TypeScript SDK implementation of the Model Context Protocol specification.',
base: '/v2/',
// VitePress does not base-prefix head hrefs, so /v2/ is spelled out here.
head: [['link', { rel: 'icon', type: 'image/svg+xml', href: '/v2/favicon.svg' }]],
srcExclude: ['v1/**', '_meta/**', 'behavior-surface-pins.md'],
sitemap: { hostname: `${siteUrl}/` },
markdown: {
Expand All @@ -51,6 +53,17 @@ export default defineConfig({
buildEnd(siteConfig) {
generateLlmsArtifacts(docsDir, siteConfig.outDir, siteUrl);
},
transformPageData(pageData) {
// Every guide page has a markdown rendition next to its HTML (llms.ts);
// advertise it to tools via a rel=alternate link. The API reference has none.
if (!pageData.relativePath.startsWith('api/')) {
pageData.frontmatter.head ??= [];
pageData.frontmatter.head.push([
'link',
{ rel: 'alternate', type: 'text/markdown', href: `${siteUrl}/${pageData.relativePath}` }
]);
}
},
themeConfig: {
nav: [
{ text: 'Get started', link: '/get-started/first-server', activeMatch: '^/get-started/' },
Expand Down
17 changes: 17 additions & 0 deletions docs/.vitepress/theme/MarkdownSource.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useData, withBase } from 'vitepress';
import { computed } from 'vue';

// Every guide page has a markdown rendition generated next to its HTML (see
// ../llms.ts); the generated API reference does not.
const { page } = useData();
const mdPath = computed(() => (page.value.relativePath.startsWith('api/') ? undefined : withBase(`/${page.value.relativePath}`)));
</script>

<template>
<div v-if="mdPath" class="markdown-source">
Are you an LLM (or feeding one)? This page as
<a :href="mdPath" target="_self">markdown</a> · <a :href="withBase('/llms.txt')" target="_self">llms.txt</a> ·
<a :href="withBase('/llms-full.txt')" target="_self">llms-full.txt</a>
</div>
</template>
26 changes: 25 additions & 1 deletion docs/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@
* which forces horizontal scrolling on most of our ~100-column code blocks.
* Let the layout breathe and the column grow so typical snippets fit;
* genuinely long lines still scroll inside their own block.
*
* Clamped to the viewport because the default theme's `(min-width: 1440px)`
* rules size the sidebar and nav title as
* `calc((100% - (var(--vp-layout-max-width) - 64px)) / 2 + ...)`, which goes
* negative on viewports narrower than the max width: the sidebar collapses
* and the site title spills over the search bar. With the clamp, those rules
* resolve to their stock sub-1440px values on narrower viewports instead.
*/
:root {
--vp-layout-max-width: 1680px;
--vp-layout-max-width: min(100vw, 1680px);
}

/* !important: the default rule is a scoped component style ([data-v-…]), which
Expand Down Expand Up @@ -164,3 +171,20 @@
text-decoration: underline;
margin-left: 4px;
}

/* --------------------------------------------------- markdown-source footer */

.markdown-source {
margin-bottom: 16px;
padding-top: 16px;
border-top: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-3);
font-size: 12px;
line-height: 1.5;
}

.markdown-source a {
color: var(--vp-c-text-2);
text-decoration: underline;
font-weight: 400;
}
4 changes: 3 additions & 1 deletion docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import DefaultTheme from 'vitepress/theme';
import { h } from 'vue';

import Banner from './Banner.vue';
import MarkdownSource from './MarkdownSource.vue';
import './custom.css';

export default {
extends: DefaultTheme,
Layout() {
return h(DefaultTheme.Layout, null, {
'layout-top': () => h(Banner)
'layout-top': () => h(Banner),
'doc-footer-before': () => h(MarkdownSource)
});
}
} satisfies Theme;
11 changes: 7 additions & 4 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ The codemod ([`@modelcontextprotocol/codemod`](https://github.com/modelcontextpr
mechanically applies every rename whose mapping is fixed. The mappings are the
**source of truth** — they live in the codemod package and are not reproduced here:

| Mapping | Source file |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Mapping | Source file |
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) |
| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) |
| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) |
Expand Down Expand Up @@ -395,7 +395,9 @@ A few transports need a decision the codemod can't make:
import path changes.
- **Server auth split.** Resource Server helpers (`requireBearerAuth`,
`mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`)
→ `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`,
→ `@modelcontextprotocol/express`; the runtime-neutral core (`requireBearerAuth`
for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`,
`OAuthTokenVerifier`) is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`,
`OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`,
`authenticateClient`, `metadataHandler`, `createOAuthMetadata`,
`authorizationHandler` / `tokenHandler` / `revocationHandler` /
Expand Down Expand Up @@ -1036,7 +1038,8 @@ if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient)
```

⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from
`@modelcontextprotocol/express`) classifies the error your
`@modelcontextprotocol/express`, or from `@modelcontextprotocol/server` on
web-standard hosts) classifies the error your
`OAuthTokenVerifier.verifyAccessToken()` throws: a v2
`OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` +
`WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from
Expand Down
11 changes: 11 additions & 0 deletions docs/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/serving/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
shape: how-to
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-tool scopes.'
---

# Require authorization

Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md).
Expand Down Expand Up @@ -42,6 +43,25 @@ A request with a missing, malformed, or expired token gets `401` with the OAuth
The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half.
:::

## Require a bearer token on a web-standard host

On hosts whose HTTP surface is a `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono — the gate is `requireBearerAuth` from `@modelcontextprotocol/server`: no framework, only web-standard `Request` and `Response`.

```ts source="../../examples/guides/serving/authorization.web.examples.ts#requireBearerAuth_webStandard"
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
const handler = createMcpHandler(buildServer);

export default {
async fetch(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
}
};
```

The gate resolves to the verified `AuthInfo` — pass it to the handler as `{ authInfo }` and handlers read it as `ctx.http.authInfo` — or to the ready-to-return challenge `Response`. Status codes, error bodies, and the `WWW-Authenticate` challenge (including `resourceMetadataUrl`) are identical to the Express middleware: both are adapters over one core, so a verifier written for one serves the other unchanged.

## Verify tokens your way

`verifyAccessToken` is the one function you supply: take the raw token string, return an `AuthInfo`. Local JWT verification, [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) introspection, or a call to your identity provider all fit behind it.
Expand Down Expand Up @@ -107,6 +127,7 @@ Responding `403 insufficient_scope` at the HTTP layer instead triggers the clien

## Recap

- `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core.
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge.
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.
Expand Down
3 changes: 3 additions & 0 deletions docs/v1/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export default defineConfig({
title: 'MCP TypeScript SDK (v1)',
description: 'Documentation for v1.x of the MCP TypeScript SDK.',
base: '/',
// The favicon is copied into content/public/ by scripts/build-docs-site.sh
// (content/ is generated; the committed source is docs/public/favicon.svg).
head: [['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }]],
sitemap: { hostname: 'https://ts.sdk.modelcontextprotocol.io' },
srcDir: 'content',
markdown: {
Expand Down
3 changes: 2 additions & 1 deletion examples/guides/serving/authorization.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
/**
* Companion example for `docs/serving/authorization.md`.
*
* Every `ts` fence on that page is synced from a `//#region` in this file
* Every `ts` fence on that page except the web-standard one (sourced from
* `authorization.web.examples.ts`) is synced from a `//#region` in this file
* (`pnpm sync:snippets --check`). The Express middleware needs a listening
* HTTP server and a real authorization server to exercise, so this file only
* typechecks:
Expand Down
44 changes: 44 additions & 0 deletions examples/guides/serving/authorization.web.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// docs: typecheck-only
/**
* Companion example for the web-standard section of
* `docs/serving/authorization.md`.
*
* Lives beside `authorization.examples.ts` in its own module because both
* packages export a `requireBearerAuth`: the Express one is demonstrated
* there, the web-standard one from `@modelcontextprotocol/server` here. Like
* its sibling, this file only typechecks:
*
* pnpm --filter @modelcontextprotocol/examples typecheck
*
* @module
*/
import type { AuthInfo, OAuthTokenVerifier } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server';

declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>;

const verifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<AuthInfo> {
const payload = await verifyJwt(token).catch(() => {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
});
return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp };
}
};

function buildServer(): McpServer {
return new McpServer({ name: 'protected-server', version: '1.0.0' });
}

//#region requireBearerAuth_webStandard
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
const handler = createMcpHandler(buildServer);

export default {
async fetch(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
}
};
//#endregion requireBearerAuth_webStandard
12 changes: 8 additions & 4 deletions packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export interface ImportMapping {
}

/**
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`;
* the server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`
* (with the runtime-neutral core — `requireBearerAuth` for web-standard hosts and
* `OAuthTokenVerifier` — also exported from `@modelcontextprotocol/server`); the
* server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
* and re-export sites get a marker prompting a deliberate re-point.
*/
export const RS_ONLY_AUTH_SYMBOLS: ReadonlySet<string> = new Set([
Expand Down Expand Up @@ -141,7 +143,8 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
'@modelcontextprotocol/sdk/server/auth/provider.js': {
target: '@modelcontextprotocol/server-legacy/auth',
status: 'moved',
migrationHint: 'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
migrationHint:
'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express (or, on web-standard hosts, from @modelcontextprotocol/server).'
},
'@modelcontextprotocol/sdk/server/auth/router.js': {
target: '@modelcontextprotocol/server-legacy/auth',
Expand All @@ -151,7 +154,8 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
'@modelcontextprotocol/sdk/server/auth/middleware.js': {
target: '@modelcontextprotocol/server-legacy/auth',
status: 'moved',
migrationHint: 'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
migrationHint:
'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express (or, on web-standard hosts, from @modelcontextprotocol/server).'
},
'@modelcontextprotocol/sdk/server/auth/errors.js': {
target: '@modelcontextprotocol/server-legacy/auth',
Expand Down
Loading
Loading