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
8 changes: 6 additions & 2 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ The durable, sensitive authorization material that lets MCPX act for the user wi
_Avoid_: Auth config, token registry

**Credential Identity**:
The stable authorization identity shared by every server declaration and call that must use the same grant.
The stable authorization identity shared by every server declaration and call that must use the same grant. An OAuth grant belongs to one server declaration, so its Credential Identity is the server name.
_Avoid_: Server key, access token, session ID

**Authorization Challenge**:
A resource server's `401` or `403 insufficient_scope` answer with its `WWW-Authenticate` parameters. It is the only evidence that a server needs OAuth, and it can arrive on any request, not only when the server is added.
_Avoid_: Auth probe, auth discovery result

**Authentication Flow**:
One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls and `@refresh` join the same flow and wait for its outcome.
One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls and `@refresh` join the same flow and wait for its outcome; an Authorization Challenge starts or joins it too.
_Avoid_: Per-command login, token retry

**Caller Input**:
Expand Down
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,10 @@ mcpx @add "{ name: 'browser', url: 'http://127.0.0.1:9000/mcp' }"
> Chrome. It exposes a local MCP server that lets agents control the browser
> directly — navigating pages, extracting content, and interacting with web UIs.

mcpx stores server configuration in:

```text
~/.agents/mcpx/servers.json
```

OAuth tokens and client secrets are stored separately in the global token cache:

```text
~/.agents/mcpx/tokens.json
```
mcpx keeps its state under `~/.agents/mcpx/state-v2/`: server declarations in
`registry.json`, credentials (OAuth grants, bearer values, secret headers) in
`credentials.json`, and observed tool schemas in `schema-cache.json`. The MCP
Runtime is the only writer.

Project directories do not need MCP config files. A project should decide which
global servers are relevant by using schema selectors or a generated skill.
Expand Down Expand Up @@ -194,12 +187,21 @@ passed through the shell literally.
When several bearer credentials are registered, mcpx advances a local
round-robin cursor before each HTTP request.

When `@add` detects OAuth, mcpx tries to complete authentication immediately.
OAuth follows the MCP authorization spec through the official MCP SDK. A server
does not need to declare OAuth up front: whenever it answers a request with
`401` and a `WWW-Authenticate: Bearer resource_metadata=…` challenge, mcpx
discovers its authorization server from that challenge, authorizes, records
the server as OAuth, and retries the request. This covers servers whose tools
are partly anonymous — `@add` lists their tools without credentials, and the
first call to a protected tool starts sign-in. A `403 insufficient_scope`
challenge re-authorizes with the additional scope the same way. `@add` itself
never opens a browser; it reports `auth-required` when listing tools was
challenged.

For OAuth servers that support dynamic client registration, mcpx registers a
client automatically.
client automatically for each browser sign-in.

For OAuth servers that do not support dynamic client registration, such as
Slack, mcpx prompts for a manual `client_id` and `client_secret`. These providers
For OAuth servers that do not support dynamic client registration, mcpx prompts for a manual `client_id` and `client_secret`. These providers
usually require a preconfigured redirect URL. mcpx uses:

```text
Expand All @@ -213,7 +215,7 @@ OAuth credentials repair themselves during ordinary tool calls:

- When a token is close to expiry, mcpx refreshes it silently before the call.
- When the server rejects a token with 401, mcpx refreshes it and retries the
call once.
call once. Another call that already replaced the token is not undone.
- When no refresh is possible (no token yet, or the refresh grant was revoked),
the call opens browser authorization, prints the authorization URL to stderr,
and waits up to 5 minutes for it to complete before continuing. Concurrent
Expand Down Expand Up @@ -271,7 +273,7 @@ For temporary agent guidance without writing `.agents/skills`, print a one-serve
skill to stdout:

```bash
mcpx @skill-install "{ show: 'slack' }"
mcpx @skill-install "{ show: 'sentry' }"
```

## Skill
Expand Down
172 changes: 10 additions & 162 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/adr/0008-calls-wait-for-shared-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ supersedes: 0007-authentication-is-explicit.md

# Calls Wait for Shared Authorization

When a Call's Credential Identity is unusable, the MCP Runtime makes it usable instead of returning `reauth-required`: it refreshes the token silently when a refresh grant exists, and otherwise starts or joins the single shared browser Authentication Flow (ADR 0004). The Call waits for that flow, bounded by the flow's 5 minute timeout, and then proceeds. A 401 from the server replaces the credential the same way and retries the request once, because a rejected request was never executed.
When a Call's Credential Identity is unusable, the MCP Runtime makes it usable instead of returning `reauth-required`: it refreshes the token silently when a refresh grant exists, and otherwise starts or joins the single shared browser Authentication Flow (ADR 0004). The Call waits for that flow, bounded by the flow's 5 minute timeout, and then proceeds. A 401 from the server replaces the credential the same way and retries the request once, because a rejected request was never executed. [ADR 0009](0009-a-challenge-is-the-oauth-discovery-entry.md) extends this to servers added without auth: the challenge, not `@add`, decides that a server needs OAuth.

[ADR 0007](0007-authentication-is-explicit.md) returned `reauth-required` for every unusable credential, which treated a non-interactive token refresh like an interactive login: routine hourly expiry failed ordinary Calls until the user ran `mcpx @refresh`. The recoverable case now recovers, and the interactive case waits rather than failing.

Expand Down
16 changes: 16 additions & 0 deletions docs/adr/0009-a-challenge-is-the-oauth-discovery-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
type: Decision
title: A Challenge Is the OAuth Discovery Entry
status: accepted
---

# A Challenge Is the OAuth Discovery Entry

Whether a server needs OAuth is decided by the resource server's challenge, at any time, not by a probe when the server is added. A `401` (or `403 insufficient_scope`) on any request — tool listing or a Call — hands its `WWW-Authenticate` challenge to the MCP Runtime, which joins the single shared Authentication Flow for the server (ADR 0004, ADR 0008), authorizes with the challenge's `resource_metadata` and `scope`, records the server as OAuth in the same commit as its first grant, and retries the request once.

`@add` used to `GET` the server URL and freeze the result as the server's auth kind. A mixed-auth server — anonymous tools beside protected ones, such as `whiteboardanimation.ai` — answers that `GET` with `405`, so it was recorded as unauthenticated, and its spec-compliant challenge on a protected tool could only end in `reauth-required`. The probe guessed from the wrong request; the challenge is the server's own answer.

- `@add` no longer probes. It lists tools; a challenged listing records the server as OAuth and reports `auth-required` without opening a browser.
- The transport carries an mcpx `authProvider` whose `onUnauthorized` surfaces the challenge instead of running OAuth inside the request, so the Runtime keeps single-flight, Caller Input, progress events, and registry ownership (ADR 0002).
- OAuth itself — discovery, dynamic registration, PKCE, resource indicators, `iss` validation, refresh — is the official MCP SDK's `auth()`. mcpx supplies its `OAuthClientProvider`: per-server grants in the Credential Store, a loopback callback per sign-in, and the fixed `http://127.0.0.1:65245/callback` for manually registered clients. A refresh never opens the browser; a new sign-in never replays stored tokens or a client registered for another port, and replaces the stored grant only once it succeeds.
- A bearer credential that is challenged still fails with `reauth-required`; static credentials cannot be repaired by the Runtime.
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcpx",
"version": "0.13.0",
"version": "0.14.0",
"license": "MIT",
"bin": {
"mcpx": "./src/main.ts"
Expand All @@ -18,14 +18,16 @@
"dependencies": {
"@celados/argc": "^7.11.2",
"@clack/prompts": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/client": "^2.4.0",
"@valibot/to-json-schema": "^1.6.0",
"valibot": "^1.3.1",
"yaml": "^2.9.0"
},
"devDependencies": {
"@modelcontextprotocol/server": "^2.4.0",
"@types/bun": "^1.3.12",
"oxfmt": "^0.46.0",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"zod": "^4.6.7"
}
}
80 changes: 28 additions & 52 deletions prototypes/mcp-cancellation/evidence.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"question": "Can one caller-owned AbortSignal promptly cancel Client.callTool while preserving stdio and Streamable HTTP connection reuse?",
"runtime": {
"bun": "1.3.14",
"sdk": "1.29.0",
"sdkLock": "@modelcontextprotocol/sdk@1.29.0"
"bun": "1.4.2",
"sdk": "2.4.0",
"sdkLock": "@modelcontextprotocol/client@2.4.0"
},
"abortReason": "originating CLI socket closed",
"transports": [
Expand All @@ -16,9 +16,9 @@
"status": "rejected",
"elapsedMs": 16,
"error": {
"name": "McpError",
"code": -32001,
"message": "MCP error -32001: Error: originating CLI socket closed"
"name": "SdkError",
"code": "REQUEST_TIMEOUT",
"message": "Error: originating CLI socket closed"
}
},
"reuseBeforeLate": "echo-ok",
Expand Down Expand Up @@ -76,11 +76,11 @@
"scenario": "ignore",
"settlement": {
"status": "rejected",
"elapsedMs": 15,
"elapsedMs": 17,
"error": {
"name": "McpError",
"code": -32001,
"message": "MCP error -32001: Error: originating CLI socket closed"
"name": "SdkError",
"code": "REQUEST_TIMEOUT",
"message": "Error: originating CLI socket closed"
}
},
"reuseBeforeLate": "echo-ok",
Expand Down Expand Up @@ -131,11 +131,11 @@
"scenario": "race",
"settlement": {
"status": "rejected",
"elapsedMs": 15,
"elapsedMs": 17,
"error": {
"name": "McpError",
"code": -32001,
"message": "MCP error -32001: Error: originating CLI socket closed"
"name": "SdkError",
"code": "REQUEST_TIMEOUT",
"message": "Error: originating CLI socket closed"
}
},
"reuseBeforeLate": "echo-ok",
Expand Down Expand Up @@ -186,7 +186,7 @@
"scenario": "complete-before-cancel",
"settlement": {
"status": "fulfilled",
"elapsedMs": 6,
"elapsedMs": 9,
"text": "complete-before-cancel"
},
"reuseBeforeLate": "echo-ok",
Expand All @@ -207,17 +207,6 @@
"id": 10,
"scenario": "complete-before-cancel"
},
{
"event": "message",
"method": "notifications/cancelled"
},
{
"event": "cancel",
"requestId": 10,
"reason": "Error: originating CLI socket closed",
"scenario": "complete-before-cancel",
"pending": false
},
{
"event": "message",
"method": "tools/call",
Expand Down Expand Up @@ -252,19 +241,18 @@
"local-session",
"local-session",
"local-session",
"local-session",
"local-session"
],
"scenarios": [
{
"scenario": "acknowledge",
"settlement": {
"status": "rejected",
"elapsedMs": 16,
"elapsedMs": 18,
"error": {
"name": "McpError",
"code": -32001,
"message": "MCP error -32001: Error: originating CLI socket closed"
"name": "SdkError",
"code": "REQUEST_TIMEOUT",
"message": "Error: originating CLI socket closed"
}
},
"reuseBeforeLate": "echo-ok",
Expand Down Expand Up @@ -315,11 +303,11 @@
"scenario": "ignore",
"settlement": {
"status": "rejected",
"elapsedMs": 16,
"elapsedMs": 17,
"error": {
"name": "McpError",
"code": -32001,
"message": "MCP error -32001: Error: originating CLI socket closed"
"name": "SdkError",
"code": "REQUEST_TIMEOUT",
"message": "Error: originating CLI socket closed"
}
},
"reuseBeforeLate": "echo-ok",
Expand Down Expand Up @@ -372,11 +360,11 @@
"scenario": "race",
"settlement": {
"status": "rejected",
"elapsedMs": 16,
"elapsedMs": 17,
"error": {
"name": "McpError",
"code": -32001,
"message": "MCP error -32001: Error: originating CLI socket closed"
"name": "SdkError",
"code": "REQUEST_TIMEOUT",
"message": "Error: originating CLI socket closed"
}
},
"reuseBeforeLate": "echo-ok",
Expand Down Expand Up @@ -429,7 +417,7 @@
"scenario": "complete-before-cancel",
"settlement": {
"status": "fulfilled",
"elapsedMs": 6,
"elapsedMs": 8,
"text": "complete-before-cancel"
},
"reuseBeforeLate": "echo-ok",
Expand All @@ -451,18 +439,6 @@
"id": 10,
"scenario": "complete-before-cancel"
},
{
"event": "message",
"method": "notifications/cancelled"
},
{
"event": "cancel",
"requestId": 10,
"reason": "Error: originating CLI socket closed",
"scenario": "complete-before-cancel",
"pending": false,
"originalRequestAborted": false
},
{
"event": "message",
"method": "tools/call",
Expand Down
10 changes: 10 additions & 0 deletions prototypes/mcp-cancellation/findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ description: Observed SDK 1.29.0 cancellation behavior and a proposed MCPX adapt
- Raw observations: `evidence.json`.
- Reproduction: `bun run prototype:cancellation:evidence`.

### Re-run on SDK v2

`evidence.json` now records Bun `1.4.2` with `@modelcontextprotocol/client@2.4.0`.
Every verdict below holds. Two observations differ: an aborted call rejects with
`SdkError` (`REQUEST_TIMEOUT`) instead of `McpError` `-32001` — MCPX already
owns the cancellation cause rather than inferring it from the SDK error — and
`complete-before-cancel` no longer sends a stray `notifications/cancelled` for
a request that already completed. The v1 source references below are kept as
the original evidence.

The fixture deliberately controls both compliant and adversarial server
outcomes. SDK implementation checks use the installed files at:

Expand Down
14 changes: 7 additions & 7 deletions prototypes/mcp-cancellation/run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import {
Client,
StreamableHTTPClientTransport,
} from '@modelcontextprotocol/client'
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
import fs from 'node:fs/promises'
import path from 'node:path'
import { createInterface } from 'node:readline/promises'
Expand Down Expand Up @@ -107,7 +109,7 @@ async function runMatrix(): Promise<Evidence> {
const sdkPackage = await Bun.file(
path.join(
prototypeDir,
'../../node_modules/@modelcontextprotocol/sdk/package.json',
'../../node_modules/@modelcontextprotocol/client/package.json',
),
).json()
const transports = await Promise.all([
Expand All @@ -120,7 +122,7 @@ async function runMatrix(): Promise<Evidence> {
runtime: {
bun: Bun.version,
sdk: sdkPackage.version,
sdkLock: '@modelcontextprotocol/sdk@1.29.0',
sdkLock: `@modelcontextprotocol/client@${sdkPackage.version}`,
},
abortReason,
transports,
Expand Down Expand Up @@ -157,7 +159,6 @@ async function runScenario(
const startedAt = performance.now()
const promise = fixture.client.callTool(
{ name: 'controlled', arguments: { scenario } },
undefined,
{ signal: controller.signal, timeout: 1_000 },
)

Expand Down Expand Up @@ -213,7 +214,6 @@ async function settle(
async function echo(client: Client): Promise<string> {
const result = await client.callTool(
{ name: 'echo', arguments: {} },
undefined,
{ timeout: 1_000 },
)
return resultText(result)
Expand Down
Loading
Loading