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
6 changes: 6 additions & 0 deletions .changeset/cjs-ajv-validator-subpath.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/client': patch
---

Fix the CommonJS `validators/ajv` subpath so reading the exported `Ajv` class no longer throws `ReferenceError: import_ajv is not defined`. The subpath now re-exports the bundled provider's concrete `Ajv` value in CJS output, matching the existing ESM behavior.
6 changes: 6 additions & 0 deletions .changeset/cross-bundle-error-instanceof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

`instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`, `UnauthorizedError`, and OAuth-client-flow error family — `OAuthClientFlowError` and its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (via `Symbol.hasInstance` and a registry symbol) instead of prototype identity, so a process that uses both `@modelcontextprotocol/client` and `@modelcontextprotocol/server` - a gateway, host, or in-process test - can check errors constructed by either package against the class re-exported by the other. Ordinary prototype-based `instanceof` is preserved as a fallback; user-defined subclasses keep plain prototype semantics. Notes: cross-bundle matching requires both copies to be at or after this release; brands assert identity, not field shape, across versions - keep reading fields defensively. As a side effect, a foreign-bundle `SdkError` used as an abort reason is now rethrown as-is instead of being wrapped as a `RequestTimeout`. Branded hierarchies additionally expose an explicit static guard, `X.isInstance(value)`, that reads the same brand and narrows in TypeScript — an alternative for codebases that prefer predicate-style checks over `instanceof`. Also: `UnauthorizedError` now sets `error.name` to `'UnauthorizedError'` (previously `'Error'`), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizes `UnauthorizedError` (previously a dead name-string check) and propagates it unchanged, so `connect()` on an auth-gated server rejects with the original `UnauthorizedError` (previously wrapped as the `cause` of an `SdkError(EraNegotiationFailed)`) — run `finishAuth()` and reconnect, and the retry probes with the token.
17 changes: 17 additions & 0 deletions .changeset/web-standard-oauth-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/express': patch
---

Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`:
`oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and
RFC 8414 Authorization Server metadata documents from web-standard
`fetch(request)` hosts, built on the exported
`buildOAuthProtectedResourceMetadata`, with
`getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata
router adapts the same core and is unchanged in behavior; the insecure-issuer
escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the
neutral core instead of a module-scope environment read. The web-standard
matcher validates lazily so unmatched traffic always falls through, tolerates
a trailing slash, supports HEAD, and marks reflected CORS preflights with
`Vary`.
2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
client-conformance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
Expand All @@ -35,7 +35,7 @@ jobs:
server-conformance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
name: examples (build + e2e)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand All @@ -41,7 +41,7 @@ jobs:
node-version: [20, 22, 24]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand All @@ -67,7 +67,7 @@ jobs:
node-version: [20, 22, 24]

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down Expand Up @@ -95,7 +95,7 @@ jobs:
- runtime: deno
version: v2.x
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
outputs:
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down Expand Up @@ -50,7 +50,7 @@ jobs:
contents: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-spec-types.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
Expand Down
29 changes: 27 additions & 2 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@ A few transports need a decision the codemod can't make:
`mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`)
→ `@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`,
`OAuthTokenVerifier`, and the discovery serving `oauthMetadataResponse` /
`buildOAuthProtectedResourceMetadata` / `getOAuthProtectedResourceMetadataUrl`)
is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`,
`OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`,
`authenticateClient`, `metadataHandler`, `createOAuthMetadata`,
`authorizationHandler` / `tokenHandler` / `revocationHandler` /
Expand Down Expand Up @@ -825,6 +827,28 @@ The SDK now distinguishes three error kinds:
3. **`SdkHttpError`** (extends `SdkError`) — HTTP transport errors with typed `.status`
and `.statusText`.

These classes (and `OAuthError`, the client's `SseError`, `UnauthorizedError`, and the
OAuth-client-flow error family) brand-match under `instanceof`, so checks work across
separately bundled copies of the SDK — e.g. a process using both
`@modelcontextprotocol/client` and `@modelcontextprotocol/server`. Each branded
hierarchy also exposes the same check as an explicit static guard
(`SdkError.isInstance(err)`, `ProtocolError.isInstance(err)`, …) that narrows in
TypeScript — use whichever style your codebase prefers; both read the same brand.
Fine print (applies equally to `instanceof` and `isInstance`):

- **Version skew** — matching needs *both* copies at a brand-aware release; against an
older copy, behavior degrades to plain prototype `instanceof` (false across bundles).
During mixed-version rollouts, recognize errors without class identity: match
`error.name` plus the class's discriminant field (`code`, `status`), or reconstruct
typed protocol errors with `ProtocolError.fromError(code, message, data)`.
- **Worker boundaries** — `structuredClone`/`postMessage` drop the (symbol-keyed) brand,
so a rehydrated error no longer brand-matches; recognize forwarded errors by
`code`/`data` instead.
- **Brands assert identity, not shape** — a matched instance from another SDK version
may lack newer fields; read fields defensively.
- **Re-bundling with property mangling** (`mangle.props` and similar) breaks the brand
statics; default esbuild/webpack/terser settings are safe.

The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode`
(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and
`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof`
Expand Down Expand Up @@ -982,7 +1006,8 @@ peers as `-32602` — a server can no longer emit `-32002` on the wire.
`ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as
receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers.
`ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from
code + data alone, so it works across bundle boundaries where `instanceof` doesn't.
code + data alone — the version-agnostic path: it also works on plain wire shapes and
against SDK copies that predate brand-matched `instanceof`.
The default message text changed alongside: v1's unknown-resource error read
`Resource <uri> not found`; v2's `ResourceNotFoundError` default is
`Resource not found: <uri>` (the code is unchanged). Tests pinning the exact string
Expand Down
4 changes: 2 additions & 2 deletions docs/servers/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Three more subclasses cover the other structured protocol errors:
- `UnsupportedProtocolVersionError({ supported, requested })` — `-32022`; `data.supported` lets the peer pick a version and retry.
- `MissingRequiredClientCapabilityError({ requiredCapabilities })` — `-32021`; `data.requiredCapabilities` names exactly what the client must declare.

Match these by `code` and `data` shape, not by `instanceof` — `instanceof` fails across separately bundled copies of the SDK.
Match these by `code` and `data` shape when peers may run pre-brand SDK copies or hand you plain wire shapes; on brand-aware releases `instanceof` also matches across separately bundled copies of the SDK. The same check is available as an explicit static guard — `ProtocolError.isInstance(err)`, `ResourceNotFoundError.isInstance(err)` — which narrows in TypeScript and reads the same brand.

## Look up a protocol error code

Expand All @@ -205,5 +205,5 @@ Match these by `code` and `data` shape, not by `instanceof` — `instanceof` fai
- A tool handler that throws produces the same `isError: true` result; the exception's `message` becomes the `content` text.
- A tool handler cannot produce a protocol error — only `UrlElicitationRequiredError` escapes.
- `ProtocolError` and its subclasses, thrown from resource, prompt, and completion callbacks, become JSON-RPC error responses the model never sees.
- `ResourceNotFoundError` and the other subclasses pick the code and pack structured `data`; match them by `code` and `data`, not `instanceof`.
- `ResourceNotFoundError` and the other subclasses pick the code and pack structured `data`; match them by `code` and `data` — or, on brand-aware releases, by `instanceof`.
- The table above lists every `ProtocolErrorCode` member.
10 changes: 10 additions & 0 deletions docs/serving/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }

The router mounts two well-known routes: `/.well-known/oauth-protected-resource/mcp` — the path-aware RFC 9728 location, the same string `getOAuthProtectedResourceMetadataUrl(mcpServerUrl)` put into the challenge — and `/.well-known/oauth-authorization-server`, a mirror of `oauthMetadata` for clients that probe your origin directly. An unauthenticated client follows `401` → `resource_metadata` → `authorization_servers` to find your AS, obtains a token, and retries.

On a web-standard host, `oauthMetadataResponse` from `@modelcontextprotocol/server` serves the same two documents from a `fetch(request)` handler — it returns the matched document `Response` (with permissive CORS and `405` handling) or `undefined` to fall through to your own routing:

```ts source="../../examples/guides/serving/authorization.examples.ts#oauthMetadataResponse_webStandard"
import { oauthMetadataResponse } from '@modelcontextprotocol/server';

async function webStandardFetch(request: Request): Promise<Response> {
return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
}
```

## Read the caller in your handlers

`requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`, `toNodeHandler` forwards it, and tool handlers inside `buildServer` read it as `ctx.http.authInfo` — the exact object your verifier returned.
Expand Down
10 changes: 10 additions & 0 deletions examples/guides/serving/authorization.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,13 @@ function buildServer(): McpServer {

return server;
}

//#region oauthMetadataResponse_webStandard
import { oauthMetadataResponse } from '@modelcontextprotocol/server';

async function webStandardFetch(request: Request): Promise<Response> {
return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
}
//#endregion oauthMetadataResponse_webStandard

declare function serveMcp(request: Request): Promise<Response>;
11 changes: 6 additions & 5 deletions examples/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,12 @@ let challenged = false;
try {
await client.connect(firstTransport);
} catch (error) {
// Under `--legacy` the transport surfaces `UnauthorizedError` directly;
// under `mode: 'auto'` the version-negotiation probe is what got 401'd
// and wraps it in an EraNegotiationFailed `SdkError` whose `data.cause`
// is the original `UnauthorizedError`. Either way the auth driver has
// already run by the time we land here — DCR done, auth URL captured.
// Both `--legacy` and `mode: 'auto'` surface the original
// `UnauthorizedError` directly (the negotiation probe propagates it
// unchanged; older releases wrapped it as the `data.cause` of an
// EraNegotiationFailed `SdkError`, which the unwrap below still
// tolerates). Either way the auth driver has already run by the time we
// land here — DCR done, auth URL captured.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
challenged = true;
Expand Down
30 changes: 30 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import type {
StoredOAuthTokens
} from '@modelcontextprotocol/core-internal';
import {
brandedHasInstance,
checkResourceAllowed,
stampErrorBrands,
LATEST_PROTOCOL_VERSION,
OAuthClientInformationFullSchema,
OAuthError,
Expand Down Expand Up @@ -486,8 +488,36 @@ export interface OAuthDiscoveryState extends OAuthServerInfo {
export type AuthResult = 'AUTHORIZED' | 'REDIRECT';

export class UnauthorizedError extends Error {
static {
Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UnauthorizedError' });
}

static override [Symbol.hasInstance](value: unknown): boolean {
return brandedHasInstance(this, value);
}

/**
* Brand-based type guard: equivalent to `value instanceof this`, as an
* explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads
* the caller's own brand via `this`, so every branded subclass gets a
* correctly-scoped guard by inheritance. Must be invoked on the class —
* in callback position write `v => SdkError.isInstance(v)`, not
* `.filter(SdkError.isInstance)` (detached calls throw rather than
* silently matching nothing).
*/
static isInstance<T extends abstract new (...args: never[]) => unknown>(this: T, value: unknown): value is InstanceType<T> {
if (typeof this !== 'function') {
throw new TypeError(
'isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`'
);
}
return brandedHasInstance(this, value);
}

constructor(message?: string) {
super(message ?? 'Unauthorized');
this.name = 'UnauthorizedError';
stampErrorBrands(this, new.target);
}
}

Expand Down
Loading
Loading