@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.
Files changed (53) hide show
  1. package/ADOPTING.md +41 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-VDD4YRNP.js → chunk-EANHJLDH.js} +13 -4
  4. package/dist/chunk-EANHJLDH.js.map +1 -0
  5. package/dist/chunk-F3LMK6OL.js +25 -0
  6. package/dist/chunk-F3LMK6OL.js.map +1 -0
  7. package/dist/{chunk-UIILEGAC.js → chunk-WCZC4TPX.js} +193 -48
  8. package/dist/chunk-WCZC4TPX.js.map +1 -0
  9. package/dist/{create-api-mcp-oauth-CsC0jlH7.d.ts → create-api-mcp-oauth-BEvYLRBV.d.ts} +96 -4
  10. package/dist/e2e/index.d.ts +109 -0
  11. package/dist/e2e/index.js +27 -0
  12. package/dist/e2e/index.js.map +1 -0
  13. package/dist/e2e/steps/journey.steps.d.ts +2 -0
  14. package/dist/e2e/steps/journey.steps.js +79 -0
  15. package/dist/e2e/steps/journey.steps.js.map +1 -0
  16. package/dist/{guide-KQNcXlMG.d.ts → guide-CrzdsdNf.d.ts} +1 -1
  17. package/dist/hono/index.d.ts +1 -1
  18. package/dist/hono/index.js +1 -1
  19. package/dist/index.d.ts +102 -4
  20. package/dist/index.js +71 -6
  21. package/dist/index.js.map +1 -1
  22. package/dist/{locales-eKE_OJw4.d.ts → locales-Cv0Pecvu.d.ts} +1 -1
  23. package/dist/manifest/index.d.ts +29 -7
  24. package/dist/manifest/index.js +2 -1
  25. package/dist/manifest/index.js.map +1 -1
  26. package/dist/manifest/server.d.ts +1 -1
  27. package/dist/manifest/server.js +2 -2
  28. package/dist/oauth/index.d.ts +19 -4
  29. package/dist/oauth/index.js +4 -2
  30. package/dist/react/index.d.ts +3 -3
  31. package/features/ai-connect.feature +46 -0
  32. package/package.json +25 -8
  33. package/prisma/mcp.prisma +10 -0
  34. package/prisma/migrations/20260910120000_add_refresh_grace_seal/migration.sql +37 -0
  35. package/src/e2e/globs.ts +70 -0
  36. package/src/e2e/index.ts +16 -0
  37. package/src/e2e/steps/journey.steps.ts +136 -0
  38. package/src/e2e/world.ts +84 -0
  39. package/src/index.ts +11 -0
  40. package/src/manifest/index.ts +24 -7
  41. package/src/oauth/access-token.ts +72 -10
  42. package/src/oauth/context.ts +20 -0
  43. package/src/oauth/index.ts +2 -0
  44. package/src/oauth/prisma-stores.ts +16 -5
  45. package/src/oauth/refresh-lineage.ts +77 -0
  46. package/src/oauth/refresh.ts +169 -82
  47. package/src/oauth/rotation-grace.ts +216 -0
  48. package/src/oauth/stores.ts +45 -1
  49. package/src/oauth/token-grants.ts +4 -1
  50. package/src/server/auth-failure.ts +145 -0
  51. package/src/server/jsonrpc.ts +44 -5
  52. package/dist/chunk-UIILEGAC.js.map +0 -1
  53. package/dist/chunk-VDD4YRNP.js.map +0 -1
@@ -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
- /** Revoke exactly these hashes (idempotent). */
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
+ }
@@ -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
- error?: { code: number; message: string };
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(id: JsonRpcRequest["id"], code: number, message: string): JsonRpcResponse {
95
- return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
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) return fail(request.id, UNAUTHORIZED_CODE, "Authentication required");
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.