@12-apps/mcp 3.15.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADOPTING.md +41 -0
- package/README.md +1 -1
- package/dist/{chunk-VDD4YRNP.js → chunk-EANHJLDH.js} +13 -4
- package/dist/chunk-EANHJLDH.js.map +1 -0
- package/dist/chunk-F3LMK6OL.js +25 -0
- package/dist/chunk-F3LMK6OL.js.map +1 -0
- package/dist/{chunk-UIILEGAC.js → chunk-WCZC4TPX.js} +193 -48
- package/dist/chunk-WCZC4TPX.js.map +1 -0
- package/dist/{create-api-mcp-oauth-CsC0jlH7.d.ts → create-api-mcp-oauth-BEvYLRBV.d.ts} +96 -4
- package/dist/e2e/index.d.ts +109 -0
- package/dist/e2e/index.js +27 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.js +79 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/{guide-KQNcXlMG.d.ts → guide-CrzdsdNf.d.ts} +1 -1
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/index.d.ts +102 -4
- package/dist/index.js +71 -6
- package/dist/index.js.map +1 -1
- package/dist/{locales-eKE_OJw4.d.ts → locales-Cv0Pecvu.d.ts} +1 -1
- package/dist/manifest/index.d.ts +29 -7
- package/dist/manifest/index.js +2 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/server.d.ts +1 -1
- package/dist/manifest/server.js +2 -2
- package/dist/oauth/index.d.ts +19 -4
- package/dist/oauth/index.js +4 -2
- package/dist/react/index.d.ts +3 -3
- package/features/ai-connect.feature +46 -0
- package/package.json +25 -8
- package/prisma/mcp.prisma +10 -0
- package/prisma/migrations/20260910120000_add_refresh_grace_seal/migration.sql +37 -0
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +136 -0
- package/src/e2e/world.ts +84 -0
- package/src/index.ts +11 -0
- package/src/manifest/index.ts +24 -7
- package/src/oauth/access-token.ts +72 -10
- package/src/oauth/context.ts +20 -0
- package/src/oauth/index.ts +2 -0
- package/src/oauth/prisma-stores.ts +16 -5
- package/src/oauth/refresh-lineage.ts +77 -0
- package/src/oauth/refresh.ts +169 -82
- package/src/oauth/rotation-grace.ts +216 -0
- package/src/oauth/stores.ts +45 -1
- package/src/oauth/token-grants.ts +4 -1
- package/src/server/auth-failure.ts +145 -0
- package/src/server/jsonrpc.ts +44 -5
- package/dist/chunk-UIILEGAC.js.map +0 -1
- package/dist/chunk-VDD4YRNP.js.map +0 -1
package/src/oauth/stores.ts
CHANGED
|
@@ -58,6 +58,38 @@ export interface StoredRefreshToken {
|
|
|
58
58
|
/** The prior token's hash — the rotation lineage. `null` for a root token. */
|
|
59
59
|
rotatedFrom: string | null;
|
|
60
60
|
revokedAt: Date | null;
|
|
61
|
+
/**
|
|
62
|
+
* This token's own plaintext, SEALED under a key derived from the plaintext of
|
|
63
|
+
* the token it was rotated from (`./rotation-grace.ts`), and readable only by a
|
|
64
|
+
* caller presenting that parent.
|
|
65
|
+
*
|
|
66
|
+
* It is what lets a rotation be retried: within the grace window, re-presenting
|
|
67
|
+
* the consumed parent returns THIS successor again instead of destroying the
|
|
68
|
+
* lineage, so a lost response or two concurrent refreshes no longer force the
|
|
69
|
+
* user through the whole authorization flow again.
|
|
70
|
+
*
|
|
71
|
+
* REQUIRED of a store from this version on, even though the type is optional
|
|
72
|
+
* for the root token that has no parent to seal under. `rotate` writes it on
|
|
73
|
+
* every successor and CLEARS it on every parent it consumes.
|
|
74
|
+
*
|
|
75
|
+
* A PRISMA host that raises the version without the column fails loudly, on
|
|
76
|
+
* every rotation, because the delegate rejects the unknown key — a dead token
|
|
77
|
+
* endpoint rather than a degraded one, and the reason to land the migration in
|
|
78
|
+
* the SAME change as the version raise. A hand-written store has no such
|
|
79
|
+
* backstop: drop the field there and the window silently never applies, so
|
|
80
|
+
* implementing this field and its clearing is part of meeting the port, not an
|
|
81
|
+
* optional extra. `harness/backend/src/mcp-oauth-db.ts` is the worked example.
|
|
82
|
+
*
|
|
83
|
+
* Clearing it on consumption is what bounds the exposure, and it is the whole
|
|
84
|
+
* reason the field is safe to store at all: a seal is openable only by the
|
|
85
|
+
* plaintext of the token it was rotated from, so leaving spent seals in place
|
|
86
|
+
* would let anyone holding ONE historical plaintext plus a copy of this table
|
|
87
|
+
* walk the chain forward offline — hop by hop, with no server call and so no
|
|
88
|
+
* replay detection — all the way to the live token. With the parent's seal
|
|
89
|
+
* cleared as it is consumed, at most one hop is ever open, and only while the
|
|
90
|
+
* successor it points at is still the live token.
|
|
91
|
+
*/
|
|
92
|
+
graceSeal?: string | null;
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
/** A token about to be stored (the plaintext never is). */
|
|
@@ -70,7 +102,13 @@ export interface RefreshTokenStore {
|
|
|
70
102
|
hasSuccessor(tokenHash: string): Promise<boolean>;
|
|
71
103
|
/** Every token of one `(userEmail, clientId)` family — the lineage walk's input. */
|
|
72
104
|
listFamily(userEmail: string, clientId: string): Promise<StoredRefreshToken[]>;
|
|
73
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Revoke exactly these hashes (idempotent), CLEARING each row's `graceSeal`.
|
|
107
|
+
*
|
|
108
|
+
* A revoked lineage must leave nothing openable behind it — otherwise the
|
|
109
|
+
* revocation that replay detection exists to perform would still leave the
|
|
110
|
+
* chain readable to anyone holding one of its plaintexts.
|
|
111
|
+
*/
|
|
74
112
|
revokeHashes(tokenHashes: readonly string[], at: Date): Promise<void>;
|
|
75
113
|
/**
|
|
76
114
|
* CLAIM the parent and store the successor, atomically. The whole of OAuth 2.1
|
|
@@ -97,6 +135,12 @@ export interface RefreshTokenStore {
|
|
|
97
135
|
* live parent AND a live child) but it is not sufficient, and it is the easier
|
|
98
136
|
* half to satisfy by accident.
|
|
99
137
|
*/
|
|
138
|
+
/**
|
|
139
|
+
* The claim MUST also clear the parent's own `graceSeal`. It is not tidiness:
|
|
140
|
+
* an uncleared seal is permanently openable by the plaintext it was sealed
|
|
141
|
+
* under, so a chain of them is an offline path from any historical token to
|
|
142
|
+
* the live one. Clearing on consumption keeps at most one hop readable.
|
|
143
|
+
*/
|
|
100
144
|
rotate(successor: NewRefreshToken, parentHash: string, at: Date): Promise<boolean>;
|
|
101
145
|
/**
|
|
102
146
|
* Revoke every LIVE token a user holds for one client; returns how many were
|
|
@@ -32,7 +32,9 @@ import {
|
|
|
32
32
|
* the stored hash, else `invalid_client` (401).
|
|
33
33
|
* - **Bound `redirect_uri`:** it must equal the one the code was minted with
|
|
34
34
|
* (RFC 6749 §4.1.3).
|
|
35
|
-
* - **Refresh rotation:** client-bound, replay-revoking, narrow-only scope
|
|
35
|
+
* - **Refresh rotation:** client-bound, replay-revoking, narrow-only scope —
|
|
36
|
+
* with a grace window in which re-presenting a just-consumed token is a
|
|
37
|
+
* RETRY answered with the same successor, not a replay (`./rotation-grace.ts`).
|
|
36
38
|
*/
|
|
37
39
|
|
|
38
40
|
/** Throttle default: don't rewrite liveness on every grant. */
|
|
@@ -247,6 +249,7 @@ async function handleRefreshToken(
|
|
|
247
249
|
const refreshContext = {
|
|
248
250
|
store: context.stores.refreshTokens,
|
|
249
251
|
ttlMs: context.refreshTokenTtlMs,
|
|
252
|
+
graceMs: context.refreshRotationGraceMs,
|
|
250
253
|
};
|
|
251
254
|
|
|
252
255
|
// Rotation enforces client binding (the token's stored clientId must equal the
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an unauthorized MCP call is TOLD, as opposed to what it is refused with.
|
|
3
|
+
*
|
|
4
|
+
* ## The failure this module exists to remove
|
|
5
|
+
*
|
|
6
|
+
* A resource server has three RFC 6750 challenge codes and a boolean's worth of
|
|
7
|
+
* expressiveness, so every way a bearer can fail arrives at the agent as one
|
|
8
|
+
* opaque refusal. `Authentication required` is what a lapsed connection, a token
|
|
9
|
+
* minted for a different origin, a surface an operator never switched on, and a
|
|
10
|
+
* missing scope all look like — identical, and none of them actionable.
|
|
11
|
+
*
|
|
12
|
+
* The cost is not cosmetic. An agent that cannot tell those apart cannot tell the
|
|
13
|
+
* user anything useful either: every tool call fails, the connector still reports
|
|
14
|
+
* itself connected, and the one thing that would fix the common case — reconnect
|
|
15
|
+
* it — is the one thing nobody is told to do. Reported from a live deployment as
|
|
16
|
+
* "every tool call failing, even the ones that read nothing".
|
|
17
|
+
*
|
|
18
|
+
* ## The shape of the answer
|
|
19
|
+
*
|
|
20
|
+
* Each reason resolves to three things, and they are deliberately separate:
|
|
21
|
+
*
|
|
22
|
+
* - `challenge` — the RFC 6750 code for the `WWW-Authenticate` header. Only
|
|
23
|
+
* ever one of the two the spec defines for this situation, because a host's
|
|
24
|
+
* OAuth machinery keys off it;
|
|
25
|
+
* - `action` — what would actually fix it, for a client that automates;
|
|
26
|
+
* - `message` — one sentence an agent can relay to a person. English, like
|
|
27
|
+
* everything else a developer or a model reads here; a host that wants its
|
|
28
|
+
* own wording supplies it.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here narrows `unverified`. Signature, issuer and audience stay
|
|
31
|
+
* collapsed into one answer on purpose — see `../oauth/access-token.ts` for why
|
|
32
|
+
* expiry is the single documented exception.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Why a call was refused, across both the transport and the token verifier. */
|
|
36
|
+
export type McpAuthFailureReason =
|
|
37
|
+
/** No `Authorization` header at all — the client has not connected yet. */
|
|
38
|
+
| "no_token"
|
|
39
|
+
/** The token was fine until its `exp` passed. The common one, and recoverable. */
|
|
40
|
+
| "expired"
|
|
41
|
+
/** Signature, issuer or audience did not hold. Deliberately not narrowed. */
|
|
42
|
+
| "unverified"
|
|
43
|
+
/** Verified, but carrying no usable identity. */
|
|
44
|
+
| "incomplete"
|
|
45
|
+
/** The operator has not provisioned signing material, so nothing can verify. */
|
|
46
|
+
| "not_provisioned"
|
|
47
|
+
/** The whole MCP surface is switched off for this deployment. */
|
|
48
|
+
| "surface_disabled"
|
|
49
|
+
/** A valid token that lacks the scope this particular call needs. */
|
|
50
|
+
| "insufficient_scope";
|
|
51
|
+
|
|
52
|
+
/** What a client should do about it. */
|
|
53
|
+
export type McpAuthRecovery =
|
|
54
|
+
/** Exchange the refresh token for a new access token, then retry. */
|
|
55
|
+
| "refresh"
|
|
56
|
+
/** Re-run the authorization flow — a human has to approve it again. */
|
|
57
|
+
| "reconnect"
|
|
58
|
+
/** Nothing the client can do; the deployment has to change. */
|
|
59
|
+
| "contact_operator";
|
|
60
|
+
|
|
61
|
+
/** The resolved answer for one refusal. */
|
|
62
|
+
export interface McpAuthFailure {
|
|
63
|
+
reason: McpAuthFailureReason;
|
|
64
|
+
/** The RFC 6750 code for the `WWW-Authenticate` challenge. */
|
|
65
|
+
challenge: "invalid_token" | "insufficient_scope";
|
|
66
|
+
action: McpAuthRecovery;
|
|
67
|
+
/** One sentence, written to be relayed to a person by an agent. */
|
|
68
|
+
message: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const FAILURES: Record<McpAuthFailureReason, Omit<McpAuthFailure, "reason">> = {
|
|
72
|
+
no_token: {
|
|
73
|
+
challenge: "invalid_token",
|
|
74
|
+
action: "reconnect",
|
|
75
|
+
message:
|
|
76
|
+
"This request carried no access token. Ask the user to connect this MCP server in their assistant's connector settings, then retry.",
|
|
77
|
+
},
|
|
78
|
+
expired: {
|
|
79
|
+
challenge: "invalid_token",
|
|
80
|
+
action: "refresh",
|
|
81
|
+
message:
|
|
82
|
+
"The access token has expired. A client that holds a refresh token should renew it and retry; if renewal also fails, ask the user to reconnect this MCP server in their assistant's connector settings.",
|
|
83
|
+
},
|
|
84
|
+
unverified: {
|
|
85
|
+
challenge: "invalid_token",
|
|
86
|
+
action: "reconnect",
|
|
87
|
+
message:
|
|
88
|
+
"The access token could not be verified for this server. Ask the user to reconnect this MCP server in their assistant's connector settings — a token issued for a different deployment will never verify here.",
|
|
89
|
+
},
|
|
90
|
+
incomplete: {
|
|
91
|
+
challenge: "invalid_token",
|
|
92
|
+
action: "reconnect",
|
|
93
|
+
message:
|
|
94
|
+
"The access token verified but carries no usable identity. Ask the user to reconnect this MCP server in their assistant's connector settings.",
|
|
95
|
+
},
|
|
96
|
+
not_provisioned: {
|
|
97
|
+
challenge: "invalid_token",
|
|
98
|
+
action: "contact_operator",
|
|
99
|
+
message:
|
|
100
|
+
"This server has no signing key provisioned, so no access token can be verified. Reconnecting will not help; the deployment's operator has to configure it.",
|
|
101
|
+
},
|
|
102
|
+
surface_disabled: {
|
|
103
|
+
challenge: "invalid_token",
|
|
104
|
+
action: "contact_operator",
|
|
105
|
+
message:
|
|
106
|
+
"The MCP surface is switched off on this deployment. Reconnecting will not help; the deployment's operator has to enable it.",
|
|
107
|
+
},
|
|
108
|
+
insufficient_scope: {
|
|
109
|
+
challenge: "insufficient_scope",
|
|
110
|
+
action: "reconnect",
|
|
111
|
+
message:
|
|
112
|
+
"The access token does not grant the scope this tool needs. Ask the user to reconnect this MCP server and approve the wider scope.",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Resolve a reason to its challenge code, recovery and human-relayable message. */
|
|
117
|
+
export function describeAuthFailure(reason: McpAuthFailureReason): McpAuthFailure {
|
|
118
|
+
return { reason, ...FAILURES[reason] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The machine-readable half, carried in the JSON-RPC error's `data` member.
|
|
123
|
+
*
|
|
124
|
+
* A model reads `message`; a host that automates its connection lifecycle reads
|
|
125
|
+
* this. Both travel together so neither has to be inferred from the other.
|
|
126
|
+
*/
|
|
127
|
+
export interface McpAuthFailureData {
|
|
128
|
+
reason: McpAuthFailureReason;
|
|
129
|
+
action: McpAuthRecovery;
|
|
130
|
+
/**
|
|
131
|
+
* Whether presenting a NEW token could succeed. `false` means the deployment
|
|
132
|
+
* itself is the problem, so a client that retries forever is wasting its time
|
|
133
|
+
* and the user's — and should say so rather than loop.
|
|
134
|
+
*/
|
|
135
|
+
recoverable: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build the `data` payload for a refusal. */
|
|
139
|
+
export function authFailureData(failure: McpAuthFailure): McpAuthFailureData {
|
|
140
|
+
return {
|
|
141
|
+
reason: failure.reason,
|
|
142
|
+
action: failure.action,
|
|
143
|
+
recoverable: failure.action !== "contact_operator",
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/server/jsonrpc.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { RequestAuth } from "../types";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
authFailureData,
|
|
5
|
+
describeAuthFailure,
|
|
6
|
+
type McpAuthFailureReason,
|
|
7
|
+
} from "./auth-failure";
|
|
3
8
|
import type { ToolRegistry } from "./registry";
|
|
4
9
|
|
|
5
10
|
/**
|
|
@@ -52,7 +57,12 @@ export interface JsonRpcResponse {
|
|
|
52
57
|
jsonrpc: "2.0";
|
|
53
58
|
id: string | number | null;
|
|
54
59
|
result?: unknown;
|
|
55
|
-
|
|
60
|
+
/**
|
|
61
|
+
* `data` is the JSON-RPC 2.0 optional member, and it is what carries the
|
|
62
|
+
* machine-readable half of a refusal ({@link McpAuthFailureData}) while
|
|
63
|
+
* `message` carries the half a model relays to a person.
|
|
64
|
+
*/
|
|
65
|
+
error?: { code: number; message: string; data?: unknown };
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
/** What a client is told it connected to, in `initialize`'s `serverInfo`. */
|
|
@@ -91,8 +101,17 @@ function ok(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse {
|
|
|
91
101
|
return { jsonrpc: "2.0", id: id ?? null, result };
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
function fail(
|
|
95
|
-
|
|
104
|
+
function fail(
|
|
105
|
+
id: JsonRpcRequest["id"],
|
|
106
|
+
code: number,
|
|
107
|
+
message: string,
|
|
108
|
+
data?: unknown,
|
|
109
|
+
): JsonRpcResponse {
|
|
110
|
+
return {
|
|
111
|
+
jsonrpc: "2.0",
|
|
112
|
+
id: id ?? null,
|
|
113
|
+
error: { code, message, ...(data === undefined ? {} : { data }) },
|
|
114
|
+
};
|
|
96
115
|
}
|
|
97
116
|
|
|
98
117
|
/** A parsed body is a usable request only if it's an object carrying a string `method`. */
|
|
@@ -104,8 +123,22 @@ async function handleToolsCall(
|
|
|
104
123
|
request: JsonRpcRequest,
|
|
105
124
|
registry: ToolRegistry,
|
|
106
125
|
auth: RequestAuth | null,
|
|
126
|
+
failure?: McpAuthFailureReason,
|
|
107
127
|
): Promise<JsonRpcResponse> {
|
|
108
|
-
if (!auth)
|
|
128
|
+
if (!auth) {
|
|
129
|
+
// It used to be the bare string "Authentication required", which is true of
|
|
130
|
+
// every refusal and useful for none of them: an agent reading it cannot tell
|
|
131
|
+
// a lapsed connection from a misconfigured server, so it cannot tell the user
|
|
132
|
+
// to reconnect. The host resolves WHY and passes it in; `no_token` is the
|
|
133
|
+
// honest default when it says nothing.
|
|
134
|
+
const described = describeAuthFailure(failure ?? "no_token");
|
|
135
|
+
return fail(
|
|
136
|
+
request.id,
|
|
137
|
+
UNAUTHORIZED_CODE,
|
|
138
|
+
described.message,
|
|
139
|
+
authFailureData(described),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
109
142
|
const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
|
|
110
143
|
if (!params.name) return fail(request.id, INVALID_PARAMS_CODE, "Missing tool name");
|
|
111
144
|
const result = await registry.callTool(params.name, params.arguments ?? {}, auth);
|
|
@@ -135,12 +168,18 @@ function handleInitialize(
|
|
|
135
168
|
* `tools/call` then returns {@link UNAUTHORIZED_CODE}, which the host surfaces as
|
|
136
169
|
* HTTP 401. Discovery (`initialize`, `ping`, `tools/list`) stays open, so a client
|
|
137
170
|
* can read the surface before it has a token.
|
|
171
|
+
*
|
|
172
|
+
* `failure` is WHY `auth` is null, which only the host's verifier knows. It is
|
|
173
|
+
* optional so an existing caller keeps compiling, and passing it is what turns a
|
|
174
|
+
* refusal an agent can only report into one it can act on — see
|
|
175
|
+
* `./auth-failure.ts`.
|
|
138
176
|
*/
|
|
139
177
|
export async function handleMcpJsonRpc(
|
|
140
178
|
request: JsonRpcRequest,
|
|
141
179
|
registry: ToolRegistry,
|
|
142
180
|
auth: RequestAuth | null,
|
|
143
181
|
options: McpJsonRpcOptions,
|
|
182
|
+
failure?: McpAuthFailureReason,
|
|
144
183
|
): Promise<JsonRpcResponse | null> {
|
|
145
184
|
// A host casts the parsed body to JsonRpcRequest without validating it, so a
|
|
146
185
|
// malformed payload can arrive here: a `null` body/batch element, a non-object,
|
|
@@ -157,7 +196,7 @@ export async function handleMcpJsonRpc(
|
|
|
157
196
|
case "tools/list":
|
|
158
197
|
return ok(request.id, { tools: registry.listTools(auth ?? undefined) });
|
|
159
198
|
case "tools/call":
|
|
160
|
-
return handleToolsCall(request, registry, auth);
|
|
199
|
+
return handleToolsCall(request, registry, auth, failure);
|
|
161
200
|
default:
|
|
162
201
|
// JSON-RPC notifications (`notifications/*`) expect no reply — silently
|
|
163
202
|
// ignore any we don't explicitly handle, rather than returning an error.
|