@12-apps/mcp 1.19.0 → 1.20.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 ADDED
@@ -0,0 +1,248 @@
1
+ # Adopting @12-apps/mcp
2
+
3
+ A **plug-and-play MCP plugin** (12-23): the tool surface, the authorization server
4
+ that protects it, the models both need, and the two CI gates that keep the
5
+ advertised surface honest. A host repo only *points* at these surfaces; when the
6
+ library updates, every host updates with **no app changes**. Same contract
7
+ `@12-apps/report-builder` and `@12-apps/rbac` established.
8
+
9
+ ## The standardized plugin surfaces
10
+
11
+ | Surface | Export | What the host does |
12
+ |---|---|---|
13
+ | **Core** | `@12-apps/mcp` | `generateTools(openapi)`, `createToolRegistry`, `dispatchTool` (bearer passthrough), `buildManifest`, the surface lock, and both discovery-document builders. |
14
+ | **OAuth AS** | `@12-apps/mcp/oauth` | `createApiMcpOauth({ stores, resolveSession })` → `routes` + named `handlers` + `verifyBearer`. register / authorize / token, the JWKS, and both `.well-known` documents, with PKCE, stateless codes, hashed rotating refresh tokens and replay revocation inside. |
15
+ | **Hono** | `@12-apps/mcp/hono` | `const oauth = mcpOauthRouter({ … }); app.route('/', oauth.router)` — at the ORIGIN ROOT (see rule 2). `hono` is an OPTIONAL peer. |
16
+ | **React** | `@12-apps/mcp/react` | The AI-connect onboarding UI and status board (pt-BR, overridable). |
17
+ | **`mcp:generate` / `mcp:check`** | `@12-apps/mcp/generate` | Your script becomes `mcpGenerateCli({ document, version, source, versionLocation, outputs, check })`. `12-apps/ci`'s `mcp-contract.yml` shells out to your `mcp:check` package script and keeps working unchanged. |
18
+ | **`mcp:coverage`** | `@12-apps/mcp/coverage` | `mcpCoverageCli({ appDir, endpoints, exclusionsPath, actionMapPath })` — the route/action completeness gate. |
19
+ | **Prisma** | `prisma/mcp.prisma` + `prisma/migrations/*` | `pnpm --filter @12-apps/mcp prisma:sync -- <host schema dir>`: the partial is **COPIED** into the host's multi-file schema folder — never symlinked (a symlinked migration is silently skipped by Prisma; a symlinked partial dangles under `turbo prune`). |
20
+
21
+ ## Host wiring rules (the ones that bite)
22
+
23
+ 1. **The host answers WHO; the package answers WHAT THEY MAY HAVE.**
24
+ `resolveSession` reads the host's cookie session and returns
25
+ `{ subject, email }` — or `null`, which redirects the caller into the host's
26
+ sign-in flow with a callback back to the authorize URL. **Identity never comes
27
+ from a query parameter**, so a client cannot name the user it wants a token for.
28
+ 2. **Mount at the origin root.** Two of the six paths are `.well-known` documents
29
+ and a connector reads them from the origin, not from a prefix — the descriptors
30
+ therefore carry absolute paths. `paths` moves any of them, and the RFC 8414
31
+ document is built from the RESOLVED paths, so a document that lies about a path
32
+ (a flow that fails at the first hop) is not expressible.
33
+ 3. **The gate is an operator decision, and 404 is the disabled answer.** With
34
+ `enabled: false` authorize/token/jwks/discovery answer **404** — a probe cannot
35
+ tell a disabled AS from an app that has none — while registration answers
36
+ **403 `access_denied`**, because RFC 7591 has a code for "the endpoint is here,
37
+ registration is closed". future-pay passes
38
+ `enabled: () => process.env.MCP_BEARER_ENABLED === 'true'`, so the surface stays
39
+ OFF until an operator opts in.
40
+ 4. **No signing key, no tokens.** `signingKey` defaults to the env-backed provider
41
+ (`MCP_OAUTH_SIGNING_KEY` + `MCP_OAUTH_SIGNING_KEY_ID`, PKCS#8 PEM, ES256).
42
+ Returning `null` is a *safe-by-default* state, not an error: authorize
43
+ `server_error`s, token issuance refuses, and the JWKS answers **503** rather
44
+ than an empty key set a client would mistake for a usable one. Rotation is by
45
+ `kid` — publish old + new during the overlap window.
46
+ 5. **`trustedOrigins` is REQUIRED behind a reverse proxy.** The server sees only
47
+ its internal bind on `request.url`, so the public origin comes from
48
+ `X-Forwarded-Host` — honoured **only** when it is on this allowlist; anything
49
+ else (spoofed, foreign, absent) resolves to the FIRST entry. With no allowlist a
50
+ forwarded host is never trusted at all, so a proxied deployment fails closed to
51
+ the internal origin rather than to an attacker's. Issuance and verification read
52
+ the same resolver from the same request, which is what stops "minted for A,
53
+ verified against B" from rejecting valid tokens.
54
+ `trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS')` keeps future-pay's wiring.
55
+ 6. **The stores are narrow ports; Prisma fills them in one line.**
56
+ `createPrismaMcpStores(async () => prisma as unknown as McpOauthPrisma)`. A
57
+ non-Prisma host implements `OAuthClientStore` / `RefreshTokenStore` /
58
+ `McpConnectionStore` directly — the shapes are CLOSED and documented in
59
+ `src/oauth/stores.ts`, and the harness fills exactly them with SQL.
60
+ 7. **`rotate` must CLAIM the parent, not just revoke it.** The port's contract is
61
+ "revoke the parent CONDITIONALLY on it still being live, require a count of
62
+ exactly 1, and create the successor in the same transaction — otherwise write
63
+ nothing and return `false`". Atomicity alone (both writes or neither) covers a
64
+ crash and NOT a race: with an unconditional `update`, two concurrent rotations of
65
+ one token both succeed, leaving two live successors and replay detection silently
66
+ defeated, because the replay rule waits for a third use of the parent that now
67
+ never comes. That is OAuth 2.1 §4.3.1 bypassed by WINNING a race instead of
68
+ arriving second, which is the whole attack rotation exists to stop. The Prisma
69
+ adapter does it with `updateMany({ where: { tokenHash, revokedAt: null } })`
70
+ inside an interactive `$transaction`; the harness does the same in raw SQL, on
71
+ purpose, as the worked example of a non-Prisma host meeting the contract.
72
+ 8. **`codeReplay` is REQUIRED, and that is the point.** Single-use codes are only as
73
+ strong as the replay store, and the in-process one remembers redeemed `jti`s IN
74
+ THIS PROCESS: exact on one instance, and on several a code can be replayed
75
+ against a pod that has not seen the `jti`, inside the ≤60s code lifetime. So
76
+ there is no default. Pass a shared atomic store (a short-TTL row with a unique
77
+ constraint, or a distributed cache), or pass the literal `'in-process'` to
78
+ acknowledge the single-instance limit out loud:
79
+
80
+ ```ts
81
+ codeReplay: 'in-process', // one pod, and you have said so
82
+ codeReplay: myRedisSetIfAbsentStore, // more than one pod
83
+ ```
84
+
85
+ Every other default in this config fails CLOSED — no signing key mints nothing
86
+ and answers JWKS 503, `enabled: false` is 404 everywhere, an empty
87
+ `trustedOrigins` never trusts a forwarded host. An in-process default would be
88
+ the only one that fails OPEN, on the very topology a reusable package exists for.
89
+ Scaling out must not be able to weaken the guard by silence.
90
+ 9. **`authorize` has NO consent screen, so it refuses clients nobody approved.**
91
+ Registration is open whenever `enabled` is true (RFC 7591), and a cookie session
92
+ proves who is asking, never that they AGREED. Without a gate the chain is one
93
+ click: an attacker registers a client carrying their own `redirect_uris` and
94
+ their own `scope`, sends a signed-in admin a link to `authorize`, and the
95
+ endpoint mints them a code — and the two guards that look like they would stop
96
+ it, exact redirect-URI matching and the per-client scope ceiling, are both
97
+ checked against the ATTACKER'S OWN registration. So:
98
+
99
+ - pass **`resolveApproval(request, client, scopes)`** — your approval screen or
100
+ policy; returning `false` gives the caller `access_denied`;
101
+ - or list first-party client ids in **`preApprovedClientIds`** if you register
102
+ your own clients and have no screen to show;
103
+ - with neither, every dynamically registered client is refused. That is the
104
+ default, and it is deliberately the inconvenient one.
105
+ 10. **Connections are per USER, not per tenant** — an MCP bearer is
106
+ auth-passthrough. `connections.resolveUserId(email)` maps the token's email to
107
+ the host's user id (future-pay resolves it by email because `session.user.id` is
108
+ the OAuth `sub`); returning `null` records nothing. Recording is best-effort and
109
+ FENCED: a failing directory can never turn a valid grant into a 500, and nothing
110
+ about the attempt is logged, because the only values in hand are an email and a
111
+ client id.
112
+ 11. **Disconnecting means BOTH halves.** `connections.revokeByHost(...)` returns the
113
+ OAuth client ids it revoked, and the caller must then
114
+ `refreshTokens.revokeLiveForClient(email, clientId)` for each — a host holding a
115
+ live refresh token simply rotates its way back in and the card lights green on
116
+ the next grant. Neither half invalidates an outstanding ACCESS token: those are
117
+ self-contained JWTs, so a disconnected host keeps working for at most their
118
+ 15-minute TTL and can then obtain nothing further.
119
+ 12. **These bodies are NOT the `{ data }` envelope.** A 302 with a `Location`, RFC
120
+ 6749 §5.1/§5.2 JSON, an RFC 8414/9728 document — every shape here is fixed by
121
+ specification, and `Cache-Control: no-store` is on every credential-bearing
122
+ response. Wrapping any of it would break every client. That is why the adapters
123
+ are one line and hand the `Response` straight back.
124
+
125
+ ## The config, field by field
126
+
127
+ | Field | Required | Default | Notes |
128
+ |---|---|---|---|
129
+ | `stores` | yes | — | `clients` + `refreshTokens` (+ optional `connections`) |
130
+ | `resolveSession` | yes | — | `{ subject, email }` or `null` → sign-in redirect |
131
+ | `enabled` | no | `true` | `false` ⇒ 404 everywhere, 403 on register |
132
+ | `signingKey` | no | env provider | `null` ⇒ mints nothing, JWKS 503 |
133
+ | `trustedOrigins` | no | `[]` (never trust a forwarded host) | REQUIRED behind a proxy |
134
+ | `scopes` | no | `mcp:read mcp:write` | advertised AND validated against |
135
+ | `resourcePath` | no | `/api/mcp` | the access token's `aud` |
136
+ | `paths` | no | `/api/oauth/*` + `/.well-known/*` | also rewrites the discovery document |
137
+ | `loginPath` / `loginCallbackParam` | no | `/login` / `callbackUrl` | Auth.js's names |
138
+ | `accessTokenTtlSeconds` | no | 900 | 15 minutes |
139
+ | `refreshTokenTtlMs` | no | 30 days | |
140
+ | `codeReplay` | **yes** | — (no default, on purpose) | a shared atomic store, or `'in-process'` to acknowledge one pod — rule 8 |
141
+ | `resolveApproval` | no | refuse unapproved clients | the consent seam — rule 9 |
142
+ | `preApprovedClientIds` | no | `[]` | first-party client ids exempt from the approval gate — rule 9 |
143
+ | `connections` | no | — | `resolveUserId`, `providerRules`, `activityThrottleMs` |
144
+
145
+ ## The endpoints
146
+
147
+ | Method | Path (default) | Answers |
148
+ |---|---|---|
149
+ | GET | `/.well-known/oauth-authorization-server` | RFC 8414 metadata, built from the resolved paths |
150
+ | GET | `/.well-known/oauth-protected-resource` | RFC 9728 metadata (same origin + scope source) |
151
+ | GET | `/.well-known/jwks.json` | the public JWK (503 while unprovisioned), `max-age=300` |
152
+ | GET | `/api/oauth/authorize` | 302 with `code` + `state`; a plain 400 when the client/`redirect_uri` is unregistered — **never** an error redirect to an unvalidated URI |
153
+ | POST | `/api/oauth/token` | `authorization_code` (single-use, PKCE-verified, bound `redirect_uri`) and `refresh_token` (rotated, client-bound, narrow-only scope) |
154
+ | POST | `/api/oauth/register` | 201 RFC 7591 client information; the secret exactly once, hashed at rest |
155
+
156
+ ## Minimal host (Hono)
157
+
158
+ ```ts
159
+ import { mcpOauthRouter } from '@12-apps/mcp/hono';
160
+ import { createPrismaMcpStores, trustedOriginsFromEnv, type McpOauthPrisma } from '@12-apps/mcp/oauth';
161
+
162
+ const oauth = mcpOauthRouter({
163
+ stores: createPrismaMcpStores(async () => (await getPrismaClient()) as unknown as McpOauthPrisma),
164
+ enabled: () => process.env.MCP_BEARER_ENABLED === 'true',
165
+ trustedOrigins: trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS'),
166
+ resolveSession: async (request) => {
167
+ const session = await getRequestSession(request);
168
+ const email = session?.user?.email;
169
+ return email ? { subject: session.user.id || email, email } : null;
170
+ },
171
+ connections: { resolveUserId: async (email) => (await getUserByEmail(email))?.id ?? null },
172
+ });
173
+
174
+ app.route('/', oauth.router);
175
+ // The resource server's half, on the same key and origin resolution:
176
+ const identity = await oauth.verifyBearer(token, request, { requiredScope: 'mcp:write' });
177
+ ```
178
+
179
+ ## Minimal host (the two gates)
180
+
181
+ ```ts
182
+ // scripts/mcp/generate.ts (mcp:generate / mcp:check)
183
+ import { mcpGenerateCli } from '@12-apps/mcp/generate';
184
+ mcpGenerateCli({
185
+ document: () => buildOpenApiDocument(),
186
+ version: MCP_SURFACE_VERSION,
187
+ source: 'my app',
188
+ versionLocation: 'lib/mcp/surface-version.ts',
189
+ outputs: { openapi: …, manifest: …, surfaceLock: … },
190
+ extraArtifacts: [{ path: submissionPath, render: renderSubmission }],
191
+ check: process.argv.includes('--check'),
192
+ });
193
+
194
+ // scripts/mcp/coverage.ts (mcp:coverage)
195
+ import { mcpCoverageCli } from '@12-apps/mcp/coverage';
196
+ mcpCoverageCli({ appDir, webRoot, endpoints, exclusionsPath, actionMapPath });
197
+ ```
198
+
199
+ Two things about the gates worth knowing before you adopt them:
200
+
201
+ - **The scan root is the whole `app` folder, never `app/api`.** A completeness gate
202
+ rooted below the surface it claims to cover does not fail when it misses
203
+ something — it simply never looks, and three OAuth/JWKS discovery routes shipped
204
+ unregistered for exactly as long as that was true.
205
+ - **The filesystem walk is imported from `@12-apps/rbac/coverage`**, not copied.
206
+ Both gates assert completeness over the same two surfaces, and they must not be
207
+ able to disagree about what the surface IS; two copies agree on the day they are
208
+ written and drift silently afterwards, in the direction of not looking.
209
+ - `mcp:coverage` reports staleness for ACTION exclusions only, exactly as the host
210
+ gate did. Route-prefix staleness is a follow-up (it would go red on a host's
211
+ committed exclusions file the moment it adopted the package, so it needs its own
212
+ burn-down).
213
+
214
+ ## Phase B — adopting into a host that ALREADY has these tables (future-pay)
215
+
216
+ **Nothing to baseline.** Every statement in the package migration is guarded
217
+ (`CREATE TABLE IF NOT EXISTS`, `CREATE [UNIQUE] INDEX IF NOT EXISTS`, `ADD COLUMN
218
+ IF NOT EXISTS`, and a conrelid-scoped `DO` block for the CHECK), so applying it to
219
+ a host that already has `oauth_clients` / `oauth_refresh_tokens` /
220
+ `mcp_connections` changes nothing and exits 0 — no `prisma migrate resolve
221
+ --applied` step, and no risk of a green deploy that skipped a schema change.
222
+
223
+ Deliberate deltas to reconcile:
224
+
225
+ - **The FK from `mcp_connections.user_id` to `users` is not in the package
226
+ migration** — host vocabulary. future-pay keeps its `ON DELETE CASCADE`.
227
+ - **`onboarding_states` is not here.** future-pay's migration created it beside
228
+ `mcp_connections`; it belongs to `@12-apps/onboarding` (12-23).
229
+ - The host's `lib/mcp/oauth/**` (~1.5k LOC) and its four route files are replaced
230
+ by the mount plus, where a coverage gate forces the file to exist, a one-line
231
+ `export const GET = mcpOauth.handlers.authorize`.
232
+ - `scripts/mcp/generate.ts`, `scripts/mcp/coverage.ts` and
233
+ `scripts/mcp/surface-lock.ts` collapse into the two CLI calls above.
234
+
235
+ ## What deliberately did NOT move into the package
236
+
237
+ - **The account/connection SCREENS' endpoints** (`GET/DELETE
238
+ /api/account/mcp-connections`) — they mix session resolution, published plugin
239
+ URLs and a logger. The stores they need (`listActive`, `revokeByHost`,
240
+ `revokeLiveForClient`) are all here; the route is a follow-up.
241
+ - **The MCP registry itself** — which endpoints become tools, their annotations
242
+ and redactions, is the host's catalogue. The package generates, dispatches and
243
+ gates it.
244
+ - **`mcp:lint`, `mcp:parity`, `mcp:smoke`, `mcp:test-coverage`** — the remaining
245
+ future-pay MCP scripts. Only the two the reusable CI workflows shell out to moved
246
+ (12-23's scope).
247
+ - **Authorization codes as rows.** They are stateless signed blobs, so there is no
248
+ table and nothing to sweep — only the replay store (rule 8).
package/README.md CHANGED
@@ -35,19 +35,46 @@ across apps.
35
35
  | `buildManifest` / `serializeManifest` | The committed drift artifact `mcp:check` regenerates + diffs (see `12-apps/ci` `mcp-contract.yml`). |
36
36
  | `buildProtectedResourceMetadata` / `bearerChallenge` | OAuth 2.0 Protected Resource Metadata (RFC 9728) + `WWW-Authenticate` for the resource-server mode. |
37
37
 
38
+ ## The authorization server (12-23)
39
+
40
+ The passthrough above needs somebody to MINT the bearer it forwards, and until
41
+ 12-23 every app wrote that itself — ~1.5k LOC of authorize/token/register plus the
42
+ code, PKCE, rotation and replay machinery under it. All of that is the surface's
43
+ contract, so it lives here now:
44
+
45
+ | Entry | Export | Role |
46
+ |---|---|---|
47
+ | `./oauth` | `createApiMcpOauth({ stores, resolveSession })` | OAuth 2.1 authorization server: `register` (RFC 7591) / `authorize` (code + mandatory PKCE S256) / `token` (code + refresh), the JWKS, and BOTH `.well-known` documents. Also the primitives — stateless signed codes, ES256 access tokens, hashed rotating refresh tokens with lineage revocation, the `verifyBearer` resource-server half. |
48
+ | `./hono` | `mcpOauthRouter(config)` | The same surface as a router, mounted at the **origin root** (a connector reads `.well-known` from the origin, never from a prefix). `hono` is an OPTIONAL peer. |
49
+ | `./generate` | `mcpGenerateCli(options)` | `mcp:generate` / `mcp:check` — the committed manifest and its drift gate. |
50
+ | `./coverage` | `mcpCoverageCli(options)` | `mcp:coverage` — every route method and server action either exposed as a tool or excluded with a reason. |
51
+ | `prisma/` | `mcp.prisma` + a migration | `OAuthClient`, `OAuthRefreshToken`, `McpConnection`. Authorization codes are deliberately NOT a table: they are stateless signed blobs. |
52
+
53
+ The gates are library + CLI FACE, so a host's `scripts/mcp/{generate,coverage}.ts`
54
+ becomes an import and one call, and the reusable CI workflows
55
+ (`12-apps/ci`'s `mcp-contract.yml`) keep shelling out to the same package scripts.
56
+
57
+ **[ADOPTING.md](./ADOPTING.md) is the adoption contract** — the config table, the
58
+ ten wiring rules (the operator gate, the trusted-origin allowlist, the
59
+ multi-instance caveat on the replay store) and the Phase B notes.
60
+
61
+ ```ts
62
+ const mcpOauth = mcpOauthRouter({
63
+ stores: createPrismaMcpStores(async () => (await getPrismaClient()) as unknown as McpOauthPrisma),
64
+ resolveSession: async (request) => sessionOf(request), // cookie session ONLY
65
+ enabled: () => process.env.MCP_BEARER_ENABLED === '1',
66
+ trustedOrigins: trustedOriginsFromEnv('MCP_OAUTH_TRUSTED_ORIGINS'),
67
+ });
68
+ app.route('/', mcpOauth.router);
69
+ ```
70
+
38
71
  ## What the app provides (not here)
39
72
 
40
- - The **OpenAPI document** (from its Zod-schema'd routes).
41
- - The **`AuthResolver`** validates the incoming access token (OAuth
42
- resource-server: signature / audience / scope) and returns the bearer to
43
- forward. In future-pay this is the `getRequestSession()` shim over NextAuth
44
- `auth()`.
73
+ - The **OpenAPI document** (from its Zod-schema'd routes) and the registry that
74
+ decides which endpoints become tools.
75
+ - **Who is signed in** the cookie session `authorize` binds a code to, and the
76
+ `AuthResolver` for the resource-server side.
77
+ - **Where the data lives** — the three stores (one line with
78
+ `createPrismaMcpStores`), and the signing material.
45
79
  - Binding `ToolRegistry` to the **MCP transport** (the `@modelcontextprotocol/sdk`
46
80
  HTTP server at `/api/mcp`).
47
-
48
- ## Status
49
-
50
- Scaffold: the generator, dispatcher, registry, manifest, and OAuth
51
- resource-metadata helpers are implemented and dependency-light (no MCP SDK). The
52
- SDK/HTTP transport binding and the app-side `AuthResolver` land in the pilot's
53
- next phase (`apps/web`). See the pilot design notes under `docs/`.
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@12-apps/mcp",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "type": "module",
5
- "description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call to the endpoint carrying the caller's bearer token (permission passthrough). Also ships the reusable AI-connect onboarding UI at @12-apps/mcp/react.",
5
+ "description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call carrying the caller's bearer token (permission passthrough). Also ships the OAuth 2.1 authorization server (./oauth, ./hono: register/authorize/token, JWKS and both .well-known documents), the package-owned Prisma partial + migration for its three tables, the mcp:generate/mcp:check (./generate) and mcp:coverage (./coverage) gates, and the reusable AI-connect onboarding UI (./react).",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
- "./react": "./src/react/index.ts"
8
+ "./react": "./src/react/index.ts",
9
+ "./oauth": "./src/oauth/index.ts",
10
+ "./hono": "./src/hono/index.ts",
11
+ "./coverage": "./src/coverage-gate/index.ts",
12
+ "./generate": "./src/generate/index.ts",
13
+ "./package.json": "./package.json"
9
14
  },
10
15
  "scripts": {
11
16
  "clean": "rm -rf node_modules coverage",
@@ -13,24 +18,32 @@
13
18
  "test:watch": "vitest watch",
14
19
  "lint": "eslint src --max-warnings 0",
15
20
  "check-types": "tsc --noEmit",
16
- "typecheck": "tsc --noEmit"
21
+ "typecheck": "tsc --noEmit",
22
+ "prisma:sync": "node scripts/sync-mcp-schema.mjs",
23
+ "prisma:sync:check": "node scripts/sync-mcp-schema.mjs --check"
17
24
  },
18
25
  "dependencies": {
26
+ "@12-apps/onboarding": "^1.20.0",
27
+ "@12-apps/rbac": "^1.20.0",
28
+ "@12-apps/ui": "^4.0.0",
19
29
  "@mui/icons-material": "^6.5.0",
20
- "@12-apps/onboarding": "^1.19.0",
21
- "@12-apps/ui": "^1.19.0",
30
+ "jose": "^6.1.3",
22
31
  "react": "^19.2.0"
23
32
  },
24
33
  "peerDependencies": {
25
- "react": ">=19.0.0"
34
+ "react": ">=19.0.0",
35
+ "hono": ">=4.0.0"
26
36
  },
27
37
  "devDependencies": {
38
+ "@12-apps/eslint-config": "^1.20.0",
39
+ "@12-apps/typescript-config": "^1.20.0",
28
40
  "@mui/material": "^6.5.0",
29
41
  "@testing-library/react": "^16.1.0",
30
- "@12-apps/typescript-config": "^1.20.0",
31
42
  "@types/node": "^22.15.3",
32
43
  "@types/react": "19.2.2",
33
44
  "eslint": "^9.39.1",
45
+ "eslint-plugin-test-flakiness": "^1.4.0",
46
+ "hono": "^4.6.0",
34
47
  "jsdom": "^25.0.1",
35
48
  "react-dom": "^19.2.0",
36
49
  "typescript": "^5.8.2",
@@ -53,6 +66,7 @@
53
66
  "src",
54
67
  "dist",
55
68
  "prisma",
69
+ "scripts",
56
70
  "*.js",
57
71
  "*.mjs",
58
72
  "*.md",
@@ -64,5 +78,10 @@
64
78
  "!**/*.stories.*",
65
79
  "!**/*.test-story.*",
66
80
  "!**/test-helpers.*"
67
- ]
81
+ ],
82
+ "peerDependenciesMeta": {
83
+ "hono": {
84
+ "optional": true
85
+ }
86
+ }
68
87
  }
@@ -0,0 +1,108 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @12-apps/mcp — CANONICAL Prisma model partial (plug-and-play).
3
+ //
4
+ // The three tables behind an MCP surface's authorization server. A host project
5
+ // does NOT copy these models into its main schema by hand: it uses Prisma's
6
+ // multi-file schema folder and SYNCS this file into it with this package's
7
+ // `prisma:sync` script (a byte-for-byte COPY — never a symlink; see
8
+ // scripts/sync-mcp-schema.mjs for why). The migration ships alongside, in
9
+ // prisma/migrations/, and is copied into the host's migrations folder by the
10
+ // host's plugin-migration sync.
11
+ //
12
+ // Host-agnostic by design (the entity-lifecycle / rbac doctrine): `user_id` is a
13
+ // by-value scalar with NO relation, because this package cannot know the name of
14
+ // the host's user model. The host's own migration may add the FK (future-pay's
15
+ // is ON DELETE CASCADE). Note there is deliberately no `oauth_codes` table:
16
+ // authorization codes are STATELESS signed blobs, so there is nothing to store
17
+ // and nothing to sweep.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ // A registered external OAuth host — a Claude.ai / ChatGPT connector produced by
21
+ // RFC 7591 dynamic client registration, or a static registration an operator
22
+ // created out of band. This is the OAuth *client application*, NOT a
23
+ // multi-tenant customer: namespaced `OAuth*` to avoid that collision, and with
24
+ // no relation to any user (clients are apps, not people).
25
+ //
26
+ // `clientId` is the public, unique identifier; `clientSecretHash` is null for
27
+ // public PKCE clients (which authenticate via `token_endpoint_auth_method =
28
+ // "none"`) and holds a SHA-256 hash for confidential ones — the plaintext secret
29
+ // is returned exactly once at registration and never persisted. `redirectUris`
30
+ // is the EXACT-MATCH allowlist the authorization endpoint validates against for
31
+ // open-redirect prevention. Array columns are Postgres `TEXT[]`.
32
+ model OAuthClient {
33
+ id String @id @default(uuid())
34
+ clientId String @unique @map("client_id")
35
+ clientSecretHash String? @map("client_secret_hash")
36
+ redirectUris String[] @map("redirect_uris")
37
+ clientName String? @map("client_name")
38
+ // "none" (public PKCE) | "client_secret_basic" (confidential). String + CHECK
39
+ // (the house convention), not a Prisma enum; validated at the register route.
40
+ tokenEndpointAuthMethod String @default("none") @map("token_endpoint_auth_method")
41
+ grantTypes String[] @map("grant_types")
42
+ scopes String[]
43
+ createdAt DateTime @default(now()) @map("created_at")
44
+ updatedAt DateTime @updatedAt @map("updated_at")
45
+
46
+ @@map("oauth_clients")
47
+ }
48
+
49
+ // A rotating OAuth refresh token bound to a signed-in user + a registered
50
+ // client. Stored HASHED (`tokenHash` = SHA-256 of the opaque token, never
51
+ // plaintext) and rotated on every use. Identity is the user's EMAIL: the AS binds
52
+ // to email and a host's guards resolve their user row by it, so this is a scalar
53
+ // rather than a FK and the token store stays decoupled from the user table.
54
+ // `userSub` is the original OAuth subject, carried so a rotated successor mints
55
+ // an access token with the SAME stable `sub` as the initial one (RFC 6749 §5.1 /
56
+ // OIDC §2) — without it a refreshed token's `sub` would fall back to the email
57
+ // and diverge, breaking identity correlation after the first refresh.
58
+ // `clientId` is a by-value link to `OAuthClient.clientId`. Rotation lineage:
59
+ // `rotatedFrom` holds the prior token's hash so reuse of a rotated token is
60
+ // detectable and revokes the whole lineage; `revokedAt` is the explicit revoke
61
+ // path.
62
+ model OAuthRefreshToken {
63
+ id String @id @default(uuid())
64
+ tokenHash String @unique @map("token_hash")
65
+ userEmail String @map("user_email")
66
+ userSub String @map("user_sub")
67
+ clientId String @map("client_id")
68
+ scopes String[]
69
+ expiresAt DateTime @map("expires_at")
70
+ rotatedFrom String? @map("rotated_from")
71
+ revokedAt DateTime? @map("revoked_at")
72
+ createdAt DateTime @default(now()) @map("created_at")
73
+
74
+ // `tokenHash` is already indexed by its `@unique` constraint — the
75
+ // lookup-on-presentation path — so no separate `@@index([tokenHash])` is added
76
+ // (it would be redundant). The composite index serves per-user/per-client
77
+ // enumeration and bulk revoke.
78
+ @@index([userEmail, clientId])
79
+ @@map("oauth_refresh_tokens")
80
+ }
81
+
82
+ // A live AI-assistant (MCP) connection a user has authorized. MCP auth is
83
+ // auth-passthrough / per-user (a token is not tenant-scoped), so this is keyed by
84
+ // the host's user id + the registered OAuth client — NOT by tenant. `clientName`
85
+ // snapshots the client's display name for the UI (a registration can change it).
86
+ // `lastActiveAt` is bumped (throttled, best-effort) on each token grant so a
87
+ // config page can show "connected via Claude · active 2 min ago" and an operator
88
+ // panel can tell an active connection from a stalled one. `host` is the AI
89
+ // provider the connection is attributed to (claude / chatgpt / codex), derived
90
+ // from the client's redirect URIs at grant time and confirmable by a self-report
91
+ // tool; null for a pre-attribution connection. `revokedAt` is the explicit
92
+ // disconnect path — and ending the connection is only half of a disconnect, since
93
+ // a host holding a live refresh token would simply rotate its way back in.
94
+ model McpConnection {
95
+ id String @id @default(uuid())
96
+ userId String @map("user_id")
97
+ oauthClientId String @map("oauth_client_id")
98
+ clientName String? @map("client_name")
99
+ host String? @map("host")
100
+ connectedAt DateTime @default(now()) @map("connected_at")
101
+ lastActiveAt DateTime @default(now()) @map("last_active_at")
102
+ revokedAt DateTime? @map("revoked_at")
103
+
104
+ @@unique([userId, oauthClientId])
105
+ @@index([userId])
106
+ @@index([lastActiveAt])
107
+ @@map("mcp_connections")
108
+ }
@@ -0,0 +1,152 @@
1
+ -- @12-apps/mcp (12-23): the three tables behind an MCP surface's OAuth 2.1
2
+ -- authorization server, owned by the package and copied into a host's migrations
3
+ -- folder by its plugin-migration sync.
4
+ --
5
+ -- * oauth_clients — a registered external host (a Claude.ai / ChatGPT
6
+ -- connector) from RFC 7591 dynamic client
7
+ -- registration or a static registration. NOT a
8
+ -- multi-tenant customer table — namespaced to avoid
9
+ -- that collision; no FK to users (clients are apps).
10
+ -- * oauth_refresh_tokens — a rotating refresh token bound to a user email +
11
+ -- client, stored HASHED (SHA-256), rotated on use
12
+ -- with `rotated_from` lineage and a `revoked_at`
13
+ -- revoke path.
14
+ -- * mcp_connections — which AI host a user has connected, and how
15
+ -- recently it was active. Per-USER, because an MCP
16
+ -- bearer is auth-passthrough and not tenant-scoped.
17
+ --
18
+ -- Authorization codes are deliberately absent: they are STATELESS signed blobs,
19
+ -- so there is no table to create and nothing to sweep.
20
+ --
21
+ -- The columns, defaults, indexes and CHECK are future-pay's
22
+ -- `20260713120000_add_oauth_client_refresh`,
23
+ -- `20260715180000_add_onboarding_state_mcp_connection` (the mcp_connections half
24
+ -- — the onboarding half belongs to @12-apps/onboarding) and
25
+ -- `20260720120000_add_mcp_connection_host` verbatim, minus the FK to `users`:
26
+ -- this package cannot know the name of a host's user table, and a host that has
27
+ -- one keeps its own constraint (future-pay's is ON DELETE CASCADE).
28
+ --
29
+ -- EVERY statement is guarded (`IF NOT EXISTS`, and a conrelid-scoped DO block for
30
+ -- the CHECK, which has no IF NOT EXISTS form). That is what makes adoption by a
31
+ -- host that ALREADY has these tables a no-op instead of a failed deploy — and
32
+ -- what lets the PGlite provisioner replay it into an existing schema.
33
+ --
34
+ -- Guarding every STATEMENT is not the same as guarding every COLUMN, though, and
35
+ -- the difference bites exactly the host this file is written for: `CREATE TABLE IF
36
+ -- NOT EXISTS` skips the whole table, columns included, so a host holding an OLDER
37
+ -- shape of one of these tables silently keeps it. Each table below is therefore
38
+ -- followed by a guarded `ADD COLUMN` for every column that reached future-pay in a
39
+ -- LATER migration than its own CREATE. The full audit: `oauth_refresh_tokens
40
+ -- .user_sub` (`20260713150000_add_oauth_refresh_user_sub`) and `mcp_connections
41
+ -- .host` (`20260720120000_add_mcp_connection_host`). `oauth_clients` needs none —
42
+ -- it arrived complete, CHECK and all, and was never altered afterwards. A column
43
+ -- added to this file later needs the same treatment.
44
+
45
+ -- Registered OAuth client (host app). Array columns are Postgres TEXT[]:
46
+ -- redirect_uris is the exact-match allowlist for open-redirect prevention;
47
+ -- grant_types / scopes are the DCR metadata.
48
+ CREATE TABLE IF NOT EXISTS "oauth_clients" (
49
+ "id" TEXT NOT NULL,
50
+ "client_id" TEXT NOT NULL,
51
+ "client_secret_hash" TEXT,
52
+ "redirect_uris" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
53
+ "client_name" TEXT,
54
+ "token_endpoint_auth_method" TEXT NOT NULL DEFAULT 'none',
55
+ "grant_types" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
56
+ "scopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
57
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+ "updated_at" TIMESTAMP(3) NOT NULL,
59
+
60
+ CONSTRAINT "oauth_clients_pkey" PRIMARY KEY ("id")
61
+ );
62
+
63
+ -- Public identifier a token/authorize request presents; unique across clients.
64
+ CREATE UNIQUE INDEX IF NOT EXISTS "oauth_clients_client_id_key"
65
+ ON "oauth_clients"("client_id");
66
+
67
+ -- token_endpoint_auth_method domain guard (String+CHECK house style). Scoped to
68
+ -- conrelid because constraint names are unique only per table.
69
+ DO $$
70
+ BEGIN
71
+ IF NOT EXISTS (
72
+ SELECT 1 FROM pg_constraint
73
+ WHERE conname = 'oauth_clients_token_endpoint_auth_method_valid'
74
+ AND conrelid = 'oauth_clients'::regclass
75
+ ) THEN
76
+ ALTER TABLE "oauth_clients"
77
+ ADD CONSTRAINT "oauth_clients_token_endpoint_auth_method_valid"
78
+ CHECK ("token_endpoint_auth_method" IN ('none', 'client_secret_basic'));
79
+ END IF;
80
+ END $$;
81
+
82
+ -- Rotating refresh token. token_hash is the SHA-256 of the opaque token (never
83
+ -- plaintext); user_email is the bound identity; user_sub is the original OAuth
84
+ -- subject, kept stable across every rotation; client_id is a by-value link to
85
+ -- oauth_clients.client_id; rotated_from carries the prior token's hash for
86
+ -- rotation lineage / replay detection; revoked_at is the explicit revoke path.
87
+ CREATE TABLE IF NOT EXISTS "oauth_refresh_tokens" (
88
+ "id" TEXT NOT NULL,
89
+ "token_hash" TEXT NOT NULL,
90
+ "user_email" TEXT NOT NULL,
91
+ "user_sub" TEXT NOT NULL,
92
+ "client_id" TEXT NOT NULL,
93
+ "scopes" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
94
+ "expires_at" TIMESTAMP(3) NOT NULL,
95
+ "rotated_from" TEXT,
96
+ "revoked_at" TIMESTAMP(3),
97
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
98
+
99
+ CONSTRAINT "oauth_refresh_tokens_pkey" PRIMARY KEY ("id")
100
+ );
101
+
102
+ -- Lookup-on-presentation: hash the incoming token, find its row. Unique so a
103
+ -- duplicate insert is a DB-level error rather than a silent second live token.
104
+ CREATE UNIQUE INDEX IF NOT EXISTS "oauth_refresh_tokens_token_hash_key"
105
+ ON "oauth_refresh_tokens"("token_hash");
106
+
107
+ -- Per-user/per-client enumeration + bulk revoke (the lineage walk's input, and
108
+ -- the disconnect path).
109
+ CREATE INDEX IF NOT EXISTS "oauth_refresh_tokens_user_email_client_id_idx"
110
+ ON "oauth_refresh_tokens"("user_email", "client_id");
111
+
112
+ -- `CREATE TABLE IF NOT EXISTS` skips the WHOLE table, so a host that already holds
113
+ -- `oauth_refresh_tokens` in an OLDER SHAPE gets none of the columns declared above
114
+ -- — statement-level guarding is not the same as column-level guarding. That is
115
+ -- precisely how future-pay's own history ran: `user_sub` arrived in a SECOND
116
+ -- migration (FUT-105, `20260713150000_add_oauth_refresh_user_sub`), so a host
117
+ -- frozen before it would adopt this file, skip the CREATE, never get the column,
118
+ -- and then fail on every refresh the package serves. Mirror future-pay's pair
119
+ -- verbatim — guarded add with a backfill default to satisfy NOT NULL, then drop
120
+ -- the default so the column matches the Prisma schema (`String`, no default).
121
+ -- Both statements are no-ops on a fresh host and on a replay.
122
+ ALTER TABLE "oauth_refresh_tokens"
123
+ ADD COLUMN IF NOT EXISTS "user_sub" TEXT NOT NULL DEFAULT '';
124
+ ALTER TABLE "oauth_refresh_tokens"
125
+ ALTER COLUMN "user_sub" DROP DEFAULT;
126
+
127
+ -- A user's live AI connections. `host` is nullable: a connection can exist before
128
+ -- any provider attribution is derivable (a CLI callback with no public domain).
129
+ CREATE TABLE IF NOT EXISTS "mcp_connections" (
130
+ "id" TEXT NOT NULL,
131
+ "user_id" TEXT NOT NULL,
132
+ "oauth_client_id" TEXT NOT NULL,
133
+ "client_name" TEXT,
134
+ "host" TEXT,
135
+ "connected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
136
+ "last_active_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
137
+ "revoked_at" TIMESTAMP(3),
138
+
139
+ CONSTRAINT "mcp_connections_pkey" PRIMARY KEY ("id")
140
+ );
141
+
142
+ -- One connection row per (user, OAuth client) — the upsert key for liveness.
143
+ CREATE UNIQUE INDEX IF NOT EXISTS "mcp_connections_user_id_oauth_client_id_key"
144
+ ON "mcp_connections"("user_id", "oauth_client_id");
145
+ CREATE INDEX IF NOT EXISTS "mcp_connections_user_id_idx" ON "mcp_connections"("user_id");
146
+ CREATE INDEX IF NOT EXISTS "mcp_connections_last_active_at_idx"
147
+ ON "mcp_connections"("last_active_at");
148
+
149
+ -- A host adopting this migration where `mcp_connections` predates the `host`
150
+ -- column (future-pay added it in a later migration) gets it here; a fresh host
151
+ -- already has it from the CREATE above, so the guard makes both cases a no-op.
152
+ ALTER TABLE "mcp_connections" ADD COLUMN IF NOT EXISTS "host" TEXT;