@12-apps/mcp 1.18.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 +248 -0
- package/README.md +39 -12
- package/package.json +28 -9
- package/prisma/mcp.prisma +108 -0
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/scripts/sync-mcp-schema.mjs +61 -0
- package/src/auth/authorization-server-metadata.ts +27 -8
- package/src/coverage-gate/index.ts +243 -0
- package/src/coverage-gate/route-methods.ts +86 -0
- package/src/generate/index.ts +197 -0
- package/src/hono/index.ts +43 -0
- package/src/index.ts +1 -0
- package/src/oauth/access-token.ts +186 -0
- package/src/oauth/authorization-code.ts +215 -0
- package/src/oauth/authorize.ts +260 -0
- package/src/oauth/clients.ts +158 -0
- package/src/oauth/code-replay.ts +51 -0
- package/src/oauth/config.ts +142 -0
- package/src/oauth/context.ts +253 -0
- package/src/oauth/create-api-mcp-oauth.ts +205 -0
- package/src/oauth/index.ts +117 -0
- package/src/oauth/keys.ts +107 -0
- package/src/oauth/pkce.ts +93 -0
- package/src/oauth/prisma-stores.ts +306 -0
- package/src/oauth/refresh.ts +286 -0
- package/src/oauth/register.ts +282 -0
- package/src/oauth/stores.ts +157 -0
- package/src/oauth/token-grants.ts +326 -0
- package/src/oauth/token-response.ts +154 -0
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
21
|
-
"@12-apps/ui": "^1.18.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.19.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;
|