Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/aie-1355-cli-mcp-dual-era.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"clerk": patch
---

Support MCP 2026-07-28 servers in `clerk doctor` and `clerk mcp run`.

- `clerk doctor` now probes with the modern `server/discover` handshake first and falls back to the legacy `initialize` handshake, so servers speaking only the 2026-07-28 revision are reported as healthy while older servers keep probing correctly.
- `clerk mcp run` now sends the request-metadata headers the 2026-07-28 revision requires (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` mirroring for `x-mcp-header` tool parameters), so strict new-spec servers no longer reject relayed requests, and it stays connected to stateless servers that never issue a session id.
22 changes: 11 additions & 11 deletions packages/cli-core/src/commands/doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues

## Checks

| Check | Category | What it verifies |
| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Authentication token | Authentication | Credential store has a stored token |
| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) |
| Project linkage | Project | Current directory is linked to a Clerk app |
| Linked application | Project | Linked application ID is accessible via the API |
| Instances | Project | Configured dev/prod instance IDs match the application's instances |
| Environment variables | Environment | .env.local or .env has Clerk keys |
| CLI configuration | Configuration | CLI config file exists and parses |
| Shell completion | Configuration | Shell autocompletion is installed for the detected shell |
| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) |
| Check | Category | What it verifies |
| --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Authentication token | Authentication | Credential store has a stored token |
| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) |
| Project linkage | Project | Current directory is linked to a Clerk app |
| Linked application | Project | Linked application ID is accessible via the API |
| Instances | Project | Configured dev/prod instance IDs match the application's instances |
| Environment variables | Environment | .env.local or .env has Clerk keys |
| CLI configuration | Configuration | CLI config file exists and parses |
| Shell completion | Configuration | Shell autocompletion is installed for the detected shell |
| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers a dual-era handshake probe (modern `server/discover` first, body-aware fallback to the legacy `initialize`); warns on an unreadable client config (skipped when nothing is installed; warns, never fails) |

### Keyless applications

Expand Down
57 changes: 45 additions & 12 deletions packages/cli-core/src/commands/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ no flags or profile setup (see [Development](#development) for the override
order).

No Clerk API endpoints are called. To verify the server is reachable, run
`clerk doctor` — its MCP check performs the `initialize` handshake against each
distinct configured URL whenever a Clerk MCP entry is installed.
`clerk doctor` — its MCP check performs a dual-era handshake probe against each
distinct configured URL whenever a Clerk MCP entry is installed (see the
Reachability note under [`clerk mcp uninstall`](#clerk-mcp-uninstall)).

## Supported clients

Expand Down Expand Up @@ -176,11 +177,32 @@ writes replies to stdout. stdout carries **only** JSON-RPC frames; all
diagnostics go to stderr. It takes no flags — the server URL is resolved from
`CLERK_MCP_URL` / the active env profile / the hosted default at spawn time.

Transport-only: a `401`/`403` from the upstream before a session exists (the
initial handshake) throws `mcp_client_config_invalid` and kills the bridge;
once a session id exists, the same status is instead answered per-request as a
JSON-RPC error (`-32001`, "requires authentication") and the bridge keeps
running.
Every relayed POST carries the MCP 2026-07-28 request-metadata headers,
derived from the body so strict servers never reject with `HeaderMismatch`
(`-32020`):

- `Mcp-Method` — the body's `method`, on every request.
- `Mcp-Name` — `params.name` / `params.uri` on `tools/call`, `resources/read`,
and `prompts/get`; values that aren't header-safe ASCII are carried in the
spec's Base64 sentinel form (`=?base64?…?=`).
- `MCP-Protocol-Version` — the request's own
`_meta["io.modelcontextprotocol/protocolVersion"]` when the client declares
one (modern clients), otherwise the version negotiated at the legacy
`initialize`.
- `Mcp-Param-*` — tool parameters designated by `x-mcp-header` annotations in
the tool's `inputSchema`, learned from `tools/list` responses as they pass
through the bridge. A tool whose annotations violate the spec's constraints
is excluded from the forwarded `tools/list` result (with a stderr warning),
as the spec requires of Streamable HTTP clients.

Session tracking is tolerant of stateless servers: 2026-07-28 servers never
mint `Mcp-Session-Id`, so the bridge treats "any request has succeeded" — not
"a session id exists" — as the sign of a working connection.

Transport-only: a `401`/`403` from the upstream before any request has
succeeded throws `mcp_client_config_invalid` and kills the bridge; after that,
the same status is instead answered per-request as a JSON-RPC error (`-32001`,
"requires authentication") and the bridge keeps running.

### `clerk mcp uninstall`

Expand All @@ -205,11 +227,22 @@ editor session, so (in human mode) it prints a next step to reload each affected
editor.

> **Reachability:** there is no `mcp doctor` subcommand. Server health is part
> of `clerk doctor`, which probes each distinct configured MCP URL via the
> `initialize` handshake when an entry is installed (warns, does not fail, when
> any is unreachable). A `401`/`403` answer counts as reachable — the server is
> there, it just gates the handshake behind the OAuth flow the editor runs
> itself — and is reported as "authentication required".
> of `clerk doctor`, which probes each distinct configured MCP URL when an
> entry is installed (warns, does not fail, when any is unreachable). The probe
> implements the MCP 2026-07-28 backward-compatibility algorithm: a modern
> `server/discover` request first (with per-request `_meta` and the
> `MCP-Protocol-Version`/`Mcp-Method` headers); on a `400`, the response body
> is inspected — a recognized modern JSON-RPC error (`-32020`
> `HeaderMismatch`, `-32021` `MissingRequiredClientCapability`, `-32022`
> `UnsupportedProtocolVersion`) proves a modern server, so the probe never
> falls back: a capability rejection counts as reachable (the server is
> demonstrably there, just gated), and an unsupported-version rejection is
> retried once with an advertised supported version. Anything else (including
> reserved MCP codes on non-400 statuses, e.g. proxy error pages) falls back
> to the legacy `initialize` handshake so pre-2026 servers still probe
> correctly. A `401`/`403` answer at either stage counts as reachable —
> the server is there, it just gates the handshake behind the OAuth flow the
> editor runs itself — and is reported as "authentication required".

## Development

Expand Down
183 changes: 183 additions & 0 deletions packages/cli-core/src/commands/mcp/headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, test } from "bun:test";
import {
MCP_ERROR_CODE,
encodeHeaderValue,
extractToolHeaderAnnotations,
headerValueForParam,
isModernErrorCode,
isHeaderSafe,
} from "./headers.ts";

describe("encodeHeaderValue", () => {
test.each([
["us-west1", "us-west1"],
["file:///projects/myapp/config.json", "file:///projects/myapp/config.json"],
["Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?="],
[" padded ", "=?base64?IHBhZGRlZCA=?="],
["line1\nline2", "=?base64?bGluZTEKbGluZTI=?="],
["=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="],
])("encodes %j as %j", (input, expected) => {
expect(encodeHeaderValue(input)).toBe(expected);
});
});

describe("isHeaderSafe", () => {
test.each([
["tools/call", true],
["server/discover", true],
["", false],
["a\nb", false],
["café", false],
[" leading", false],
["trailing ", false],
["mid space ok", true],
])("%j -> %p", (value, expected) => {
expect(isHeaderSafe(value)).toBe(expected);
});
});

describe("isModernErrorCode", () => {
test.each([
[MCP_ERROR_CODE.HEADER_MISMATCH, true],
[MCP_ERROR_CODE.MISSING_REQUIRED_CLIENT_CAPABILITY, true],
[MCP_ERROR_CODE.UNSUPPORTED_PROTOCOL_VERSION, true],
[-32601, false],
[-32000, false],
])("%i -> %p", (code, expected) => {
expect(isModernErrorCode(code)).toBe(expected);
});
});

describe("extractToolHeaderAnnotations", () => {
const tool = (inputSchema: unknown): unknown => ({ name: "t", inputSchema });

test("collects annotations on top-level primitive properties", () => {
const result = extractToolHeaderAnnotations(
tool({
type: "object",
properties: {
region: { type: "string", "x-mcp-header": "Region" },
query: { type: "string" },
},
}),
);
expect(result).toEqual({
ok: true,
annotations: [{ header: "Region", path: ["region"] }],
});
});

test("collects annotations on nested properties reachable via properties chains", () => {
const result = extractToolHeaderAnnotations(
tool({
type: "object",
properties: {
options: {
type: "object",
properties: { tenant: { type: "string", "x-mcp-header": "Tenant" } },
},
},
}),
);
expect(result).toEqual({
ok: true,
annotations: [{ header: "Tenant", path: ["options", "tenant"] }],
});
});

test("a tool with no annotations is valid with none", () => {
const result = extractToolHeaderAnnotations(
tool({ type: "object", properties: { q: { type: "string" } } }),
);
expect(result).toEqual({ ok: true, annotations: [] });
});

test.each([
["empty name", { type: "string", "x-mcp-header": "" }],
["non-string name", { type: "string", "x-mcp-header": 7 }],
["invalid token chars", { type: "string", "x-mcp-header": "bad name" }],
["CR/LF in name", { type: "string", "x-mcp-header": "a\r\nb" }],
["number type", { type: "number", "x-mcp-header": "N" }],
["object type", { type: "object", "x-mcp-header": "O" }],
])("rejects the tool on %s", (_label, property) => {
const result = extractToolHeaderAnnotations(
tool({ type: "object", properties: { p: property } }),
);
expect(result.ok).toBe(false);
});

test("rejects duplicate names case-insensitively", () => {
const result = extractToolHeaderAnnotations(
tool({
type: "object",
properties: {
a: { type: "string", "x-mcp-header": "Region" },
b: { type: "string", "x-mcp-header": "region" },
},
}),
);
expect(result.ok).toBe(false);
});

test.each([
["items", { type: "array", items: { type: "string", "x-mcp-header": "X" } }],
["oneOf", { oneOf: [{ type: "string", "x-mcp-header": "X" }] }],
["anyOf", { anyOf: [{ type: "string", "x-mcp-header": "X" }] }],
["allOf", { allOf: [{ type: "string", "x-mcp-header": "X" }] }],
["not", { not: { type: "string", "x-mcp-header": "X" } }],
["if", { if: { type: "string", "x-mcp-header": "X" } }],
["$defs", { $defs: { d: { type: "string", "x-mcp-header": "X" } } }],
])("rejects an annotation reached through %s", (_label, property) => {
const result = extractToolHeaderAnnotations(
tool({ type: "object", properties: { p: property } }),
);
expect(result.ok).toBe(false);
});

test("a properties chain nested under items is not reachable", () => {
const result = extractToolHeaderAnnotations(
tool({
type: "object",
properties: {
list: {
type: "array",
items: {
type: "object",
properties: { x: { type: "string", "x-mcp-header": "X" } },
},
},
},
}),
);
expect(result.ok).toBe(false);
});

test("a tool without an object inputSchema is valid with no annotations", () => {
expect(extractToolHeaderAnnotations({ name: "t" })).toEqual({ ok: true, annotations: [] });
});
});

describe("headerValueForParam", () => {
test.each([
[{ region: "us-west1" }, ["region"], "us-west1"],
[{ flag: true }, ["flag"], "true"],
[{ flag: false }, ["flag"], "false"],
[{ n: 42 }, ["n"], "42"],
[{ n: -7 }, ["n"], "-7"],
[{ opts: { tenant: "acme" } }, ["opts", "tenant"], "acme"],
])("extracts %j at %j as %j", (args, path, expected) => {
expect(headerValueForParam(args, path)).toBe(expected);
});

test.each([
["missing value", {}, ["region"]],
["null value", { region: null }, ["region"]],
["non-integer number", { n: 1.5 }, ["n"]],
["unsafe integer", { n: 2 ** 53 }, ["n"]],
["object value", { region: {} }, ["region"]],
["missing intermediate", { a: "x" }, ["a", "b"]],
["non-record arguments", "nope", ["a"]],
])("omits on %s", (_label, args, path) => {
expect(headerValueForParam(args, path)).toBeUndefined();
});
});
Loading