@12-apps/mcp 1.19.0 → 2.0.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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `@12-apps/mcp/oauth` — the OAuth 2.1 authorization server behind an MCP surface
3
+ * (12-23): register / authorize / token, the JWKS, and the two `.well-known`
4
+ * discovery documents, plus the primitives under them (stateless codes, ES256
5
+ * access tokens, PKCE, hashed rotating refresh tokens with replay revocation).
6
+ *
7
+ * Its own subpath because this half is Node-only — `jose`, `node:crypto` — and the
8
+ * package root is also imported by browsers through `@12-apps/mcp/react`. A barrel
9
+ * is evaluated whole by Node's ESM loader, so mixing the two would drag key
10
+ * material handling into a bundle that has no business with it.
11
+ */
12
+ export {
13
+ createApiMcpOauth,
14
+ type ApiMcpOauth,
15
+ type McpOauthHandlers,
16
+ type McpOauthRoute,
17
+ } from "./create-api-mcp-oauth";
18
+ export {
19
+ DEFAULT_OAUTH_PATHS,
20
+ resolveMcpOauthConfig,
21
+ type McpConnectionRecording,
22
+ type McpOauthConfig,
23
+ type McpOauthContext,
24
+ type McpOauthPaths,
25
+ type McpOauthSession,
26
+ } from "./context";
27
+ export {
28
+ DEFAULT_MCP_RESOURCE_PATH,
29
+ MCP_SUPPORTED_SCOPES,
30
+ issuer,
31
+ originFromRequest,
32
+ resolveTrustedOrigin,
33
+ resourceAudience,
34
+ trustedOriginsFromEnv,
35
+ type McpScope,
36
+ } from "./config";
37
+ export {
38
+ ACCESS_TOKEN_TTL_SECONDS,
39
+ AccessTokenError,
40
+ signAccessToken,
41
+ verifyAccessToken,
42
+ type AccessTokenErrorCode,
43
+ type SignAccessTokenInput,
44
+ type VerifiedAccessToken,
45
+ type VerifyAccessTokenOptions,
46
+ } from "./access-token";
47
+ export {
48
+ AUTHORIZATION_CODE_AUDIENCE,
49
+ AUTHORIZATION_CODE_TTL_SECONDS,
50
+ AuthorizationCodeError,
51
+ mintCode,
52
+ verifyCode,
53
+ type AuthorizationCodeErrorCode,
54
+ type MintCodeInput,
55
+ type VerifiedAuthorizationCode,
56
+ type VerifyCodeOptions,
57
+ } from "./authorization-code";
58
+ export {
59
+ SUPPORTED_CHALLENGE_METHOD,
60
+ UnsupportedChallengeMethodError,
61
+ computeChallenge,
62
+ verifyChallenge,
63
+ type CodeChallengeMethod,
64
+ } from "./pkce";
65
+ export {
66
+ DEFAULT_SIGNING_KEY_ENV,
67
+ DEFAULT_SIGNING_KEY_ID_ENV,
68
+ SIGNING_ALG,
69
+ loadSigningKeyFromEnv,
70
+ signingKeyProvider,
71
+ type McpSigningKey,
72
+ type McpSigningKeyProvider,
73
+ type PublicSigningJwk,
74
+ } from "./keys";
75
+ export {
76
+ DEFAULT_PROVIDER_ROOTS,
77
+ hashSecret,
78
+ matchesRedirectUri,
79
+ providerFromRedirectUris,
80
+ registerClient,
81
+ type ProviderAttributionRule,
82
+ type RegisterClientInput,
83
+ type RegisteredClient,
84
+ } from "./clients";
85
+ export {
86
+ REFRESH_TOKEN_TTL_MS,
87
+ RefreshTokenError,
88
+ getRefreshTokenIdentity,
89
+ hashToken,
90
+ issueRefreshToken,
91
+ rotateRefreshToken,
92
+ type IssuedRefreshToken,
93
+ type RefreshTokenContext,
94
+ type RefreshTokenErrorCode,
95
+ type RefreshTokenIdentity,
96
+ } from "./refresh";
97
+ export {
98
+ inProcessCodeReplayStore,
99
+ type CodeReplayStore,
100
+ } from "./code-replay";
101
+ export {
102
+ createPrismaMcpStores,
103
+ type McpOauthPrisma,
104
+ type McpOauthPrismaProvider,
105
+ } from "./prisma-stores";
106
+ export type {
107
+ McpConnectionStore,
108
+ McpOauthStores,
109
+ NewOAuthClient,
110
+ NewRefreshToken,
111
+ OAuthClientStore,
112
+ RefreshTokenStore,
113
+ StoredMcpConnection,
114
+ StoredOAuthClient,
115
+ StoredRefreshToken,
116
+ TokenEndpointAuthMethod,
117
+ } from "./stores";
@@ -0,0 +1,107 @@
1
+ import { exportJWK, importPKCS8, type CryptoKey, type JWK } from "jose";
2
+
3
+ /**
4
+ * Signing-key / JWK loading for the OAuth authorization server (12-23, ported
5
+ * from future-pay's `lib/mcp/oauth/keys.ts`).
6
+ *
7
+ * ES256 (P-256) from PEM material, the published public JWK (with `kid` for
8
+ * rotation), and a safe-by-default absence signal (`null`) when no key is
9
+ * configured — callers then refuse to issue tokens and serve the JWKS as 503
10
+ * rather than falling back to a weaker mode while the surface is mounted.
11
+ *
12
+ * WHERE the PEM comes from is the host's business: `loadSigningKeyFromEnv` keeps
13
+ * future-pay's env-var wiring, and any other provider (a secrets manager, a KMS
14
+ * export) satisfies the same `McpSigningKeyProvider` shape.
15
+ */
16
+
17
+ /** JWS algorithm for the signing key pair (asymmetric, self-validated via JWKS). */
18
+ export const SIGNING_ALG = "ES256";
19
+
20
+ /** A public JWK safe to publish at the JWKS endpoint (never carries `d`). */
21
+ export interface PublicSigningJwk extends JWK {
22
+ kid: string;
23
+ kty: "EC";
24
+ crv: "P-256";
25
+ alg: typeof SIGNING_ALG;
26
+ use: "sig";
27
+ }
28
+
29
+ /** The loaded signing material: the private key for signing + its public JWK. */
30
+ export interface McpSigningKey {
31
+ privateKey: CryptoKey;
32
+ publicJwk: PublicSigningJwk;
33
+ kid: string;
34
+ }
35
+
36
+ /**
37
+ * How the surface obtains signing material. Returning `null` means "not
38
+ * provisioned": the AS then mints nothing and the JWKS answers 503.
39
+ */
40
+ export type McpSigningKeyProvider = () => Promise<McpSigningKey | null>;
41
+
42
+ async function parseSigningKey(pem: string, kid: string): Promise<McpSigningKey> {
43
+ // `extractable: true` is required so `exportJWK` can derive the public JWK;
44
+ // jose imports keys as non-extractable by default, which blocks the export.
45
+ const privateKey = await importPKCS8(pem, SIGNING_ALG, { extractable: true });
46
+ const jwk = await exportJWK(privateKey);
47
+ // Strip the private component; publish only the public half.
48
+ const { d: _private, ...publicHalf } = jwk;
49
+ void _private;
50
+
51
+ const publicJwk: PublicSigningJwk = {
52
+ ...publicHalf,
53
+ kty: "EC",
54
+ crv: "P-256",
55
+ alg: SIGNING_ALG,
56
+ use: "sig",
57
+ kid,
58
+ };
59
+
60
+ return { privateKey, publicJwk, kid };
61
+ }
62
+
63
+ /**
64
+ * Build a provider over a PKCS#8 PEM + `kid` pair, with a per-process cache.
65
+ *
66
+ * Parsing PKCS#8 and exporting the JWK is pure for a given (pem, kid), so the
67
+ * promise is cached keyed on the material itself. A rotated key (different pem or
68
+ * kid) produces a different cache key and re-parses — the cache never masks a
69
+ * rotation.
70
+ *
71
+ * Rotation is BY `kid`: each key is published in the JWKS and selected by the
72
+ * `kid` header on issued JWTs, so publishing old + new during an overlap window
73
+ * lets both verify.
74
+ */
75
+ export function signingKeyProvider(
76
+ read: () => { pem: string | undefined; kid: string | undefined },
77
+ ): McpSigningKeyProvider {
78
+ let cache: { key: string; promise: Promise<McpSigningKey> } | null = null;
79
+ return async () => {
80
+ const { pem, kid } = read();
81
+ if (!pem || !kid) return null;
82
+ const cacheKey = `${kid} ${pem}`;
83
+ if (cache?.key === cacheKey) return cache.promise;
84
+ const promise = parseSigningKey(pem, kid);
85
+ cache = { key: cacheKey, promise };
86
+ return promise;
87
+ };
88
+ }
89
+
90
+ /** Env var carrying the ES256 private key as a PKCS#8 PEM (future-pay's name). */
91
+ export const DEFAULT_SIGNING_KEY_ENV = "MCP_OAUTH_SIGNING_KEY";
92
+ /** Env var carrying the key id (`kid`) used to select the key during rotation. */
93
+ export const DEFAULT_SIGNING_KEY_ID_ENV = "MCP_OAUTH_SIGNING_KEY_ID";
94
+
95
+ /**
96
+ * The env-backed provider — future-pay's wiring, kept identical, with the
97
+ * variable names as arguments so the package states no host's vocabulary.
98
+ */
99
+ export function loadSigningKeyFromEnv(
100
+ keyEnv: string = DEFAULT_SIGNING_KEY_ENV,
101
+ kidEnv: string = DEFAULT_SIGNING_KEY_ID_ENV,
102
+ ): McpSigningKeyProvider {
103
+ return signingKeyProvider(() => ({
104
+ pem: typeof process === "undefined" ? undefined : process.env?.[keyEnv],
105
+ kid: typeof process === "undefined" ? undefined : process.env?.[kidEnv],
106
+ }));
107
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * PKCE (RFC 7636) S256 challenge helpers for the OAuth authorization server
3
+ * (12-23, ported verbatim from future-pay's `lib/mcp/oauth/pkce.ts`).
4
+ *
5
+ * OAuth 2.1 mandates the `S256` code-challenge method and forbids `plain`, so
6
+ * this module computes `BASE64URL(SHA-256(code_verifier))` and compares it to
7
+ * the stored `code_challenge` in constant time. The authorization endpoint
8
+ * binds a `code_challenge` into the stateless authorization code; the token
9
+ * endpoint calls {@link verifyChallenge} with the presented `code_verifier` to
10
+ * prove the redeeming client is the one that started the flow.
11
+ *
12
+ * `plain` is refused (throws {@link UnsupportedChallengeMethodError}) rather
13
+ * than silently accepted: `plain` offers no protection against an intercepted
14
+ * authorization code, which is the exact threat PKCE exists to close.
15
+ */
16
+
17
+ /** The only PKCE method this server accepts (OAuth 2.1 requires S256). */
18
+ export const SUPPORTED_CHALLENGE_METHOD = "S256";
19
+
20
+ /**
21
+ * PKCE code-challenge methods, including the rejected legacy `plain`.
22
+ *
23
+ * @public exported because it is a parameter type of the exported
24
+ * {@link verifyChallenge}.
25
+ */
26
+ export type CodeChallengeMethod = "S256" | "plain";
27
+
28
+ /** Thrown when a caller supplies a challenge method other than `S256`. */
29
+ export class UnsupportedChallengeMethodError extends Error {
30
+ readonly method: string;
31
+
32
+ constructor(method: string) {
33
+ super(
34
+ `unsupported code_challenge_method '${method}' — only ${SUPPORTED_CHALLENGE_METHOD} is allowed`,
35
+ );
36
+ this.name = "UnsupportedChallengeMethodError";
37
+ this.method = method;
38
+ }
39
+ }
40
+
41
+ /** Encode raw bytes as unpadded base64url (RFC 7636 challenge encoding). */
42
+ function base64UrlEncode(bytes: Uint8Array): string {
43
+ // Buffer.toString("base64url") emits the URL-safe alphabet with no padding.
44
+ return Buffer.from(bytes).toString("base64url");
45
+ }
46
+
47
+ /**
48
+ * Compute the RFC 7636 S256 challenge for a `code_verifier`:
49
+ * `BASE64URL(SHA-256(ASCII(verifier)))`.
50
+ */
51
+ export async function computeChallenge(verifier: string): Promise<string> {
52
+ const data = new TextEncoder().encode(verifier);
53
+ const digest = await crypto.subtle.digest("SHA-256", data);
54
+ return base64UrlEncode(new Uint8Array(digest));
55
+ }
56
+
57
+ /**
58
+ * Constant-time string comparison over the base64url challenge bytes.
59
+ *
60
+ * Returns `false` immediately on a length mismatch (lengths are not secret);
61
+ * for equal-length inputs every byte is compared so the timing does not reveal
62
+ * how many leading characters matched.
63
+ */
64
+ function constantTimeEquals(a: string, b: string): boolean {
65
+ if (a.length !== b.length) return false;
66
+ let mismatch = 0;
67
+ for (let i = 0; i < a.length; i += 1) {
68
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
69
+ }
70
+ return mismatch === 0;
71
+ }
72
+
73
+ /**
74
+ * Verify a presented `code_verifier` against a stored `code_challenge`.
75
+ *
76
+ * Recomputes the S256 challenge from `verifier` and constant-time-compares it
77
+ * to `storedChallenge`. Returns `true` on a match, `false` on a mismatch (or an
78
+ * empty stored challenge). Any method other than `S256` throws
79
+ * {@link UnsupportedChallengeMethodError} — `plain` is never accepted.
80
+ */
81
+ export async function verifyChallenge(
82
+ verifier: string,
83
+ storedChallenge: string,
84
+ method: CodeChallengeMethod | string = SUPPORTED_CHALLENGE_METHOD,
85
+ ): Promise<boolean> {
86
+ if (method !== SUPPORTED_CHALLENGE_METHOD) {
87
+ throw new UnsupportedChallengeMethodError(method);
88
+ }
89
+ if (!storedChallenge) return false;
90
+
91
+ const computed = await computeChallenge(verifier);
92
+ return constantTimeEquals(computed, storedChallenge);
93
+ }
@@ -0,0 +1,306 @@
1
+ import type {
2
+ McpConnectionStore,
3
+ McpOauthStores,
4
+ NewOAuthClient,
5
+ NewRefreshToken,
6
+ OAuthClientStore,
7
+ RefreshTokenStore,
8
+ StoredMcpConnection,
9
+ StoredOAuthClient,
10
+ StoredRefreshToken,
11
+ } from "./stores";
12
+
13
+ /**
14
+ * The ports of `./stores.ts`, filled by Prisma (12-23).
15
+ *
16
+ * The package owns the three models (`prisma/mcp.prisma`), so their delegate
17
+ * shapes are known and this adapter can be exact. A host with Prisma therefore
18
+ * writes ONE line —
19
+ *
20
+ * stores: createPrismaMcpStores(async () => getPrismaClient() as unknown as McpOauthPrisma)
21
+ *
22
+ * — and no host code at all beyond it. The client is duck-typed (only the
23
+ * delegates used, only the arguments used) so this file never imports a project's
24
+ * generated client, and a non-Prisma host fills the ports directly instead.
25
+ */
26
+
27
+ /** A `where` on the composite unique of `mcp_connections`. */
28
+ interface ConnectionKey {
29
+ userId_oauthClientId: { userId: string; oauthClientId: string };
30
+ }
31
+
32
+ /** The minimal Prisma surface the AS needs. Every field is one the surface writes. */
33
+ export interface McpOauthPrisma {
34
+ oAuthClient: {
35
+ create(args: { data: NewOAuthClient }): Promise<StoredOAuthClient>;
36
+ findUnique(args: { where: { clientId: string } }): Promise<StoredOAuthClient | null>;
37
+ };
38
+ oAuthRefreshToken: {
39
+ create(args: { data: NewRefreshToken }): Promise<unknown>;
40
+ findUnique(args: { where: { tokenHash: string } }): Promise<StoredRefreshToken | null>;
41
+ findFirst(args: { where: { rotatedFrom: string } }): Promise<{ tokenHash: string } | null>;
42
+ findMany(args: {
43
+ where: { userEmail: string; clientId: string };
44
+ }): Promise<StoredRefreshToken[]>;
45
+ // No single-row `update`: the rotation used to revoke its parent with one and
46
+ // that was the bug (unconditional, so two concurrent rotations both won). Every
47
+ // revoke here is now an `updateMany` with a predicate that says WHICH rows may
48
+ // move, which is also why this delegate list stays honest about what is written.
49
+ updateMany(args: {
50
+ where:
51
+ | { tokenHash: { in: string[] } }
52
+ | { userEmail: string; clientId: string; revokedAt: null };
53
+ data: { revokedAt: Date };
54
+ }): Promise<{ count: number }>;
55
+ };
56
+ mcpConnection: {
57
+ findUnique(args: {
58
+ where: ConnectionKey;
59
+ select: { lastActiveAt: true };
60
+ }): Promise<{ lastActiveAt: Date } | null>;
61
+ findFirst(args: {
62
+ where: { userId: string; revokedAt: null; host: null };
63
+ orderBy: { lastActiveAt: "desc" };
64
+ select: { id: true };
65
+ }): Promise<{ id: string } | null>;
66
+ findMany(args: {
67
+ where: { userId: string; revokedAt: null; host?: string | null };
68
+ orderBy?: { lastActiveAt: "desc" };
69
+ select: Record<string, true>;
70
+ }): Promise<Record<string, unknown>[]>;
71
+ upsert(args: {
72
+ where: ConnectionKey;
73
+ create: Record<string, unknown>;
74
+ update: Record<string, unknown>;
75
+ }): Promise<unknown>;
76
+ update(args: { where: { id: string }; data: Record<string, unknown> }): Promise<unknown>;
77
+ updateMany(args: {
78
+ where: { id: { in: string[] } } | { userId: string; revokedAt: null; host: string };
79
+ data: Record<string, unknown>;
80
+ }): Promise<{ count: number }>;
81
+ };
82
+ /**
83
+ * Prisma's INTERACTIVE transaction, used for the rotation's claim + write. The
84
+ * callback form (not the array form) is required: the successor may only be
85
+ * created once the conditional revoke has reported that it, and not a concurrent
86
+ * sibling, claimed the parent — see `RefreshTokenStore.rotate`.
87
+ */
88
+ $transaction<T>(fn: (tx: McpOauthTx) => Promise<T>): Promise<T>;
89
+ }
90
+
91
+ /**
92
+ * The delegate subset used INSIDE the rotation transaction. Not exported: it is
93
+ * reachable structurally through `McpOauthPrisma.$transaction`, so no host ever
94
+ * needs to name it, and exporting a type nobody imports is what knip flags.
95
+ */
96
+ interface McpOauthTx {
97
+ oAuthRefreshToken: {
98
+ create(args: { data: NewRefreshToken }): Promise<unknown>;
99
+ updateMany(args: {
100
+ where: { tokenHash: string; revokedAt: null };
101
+ data: { revokedAt: Date };
102
+ }): Promise<{ count: number }>;
103
+ };
104
+ }
105
+
106
+ /** A lazily-resolved client, so a host's singleton is awaited per call. */
107
+ export type McpOauthPrismaProvider = () => Promise<McpOauthPrisma>;
108
+
109
+ function clientStore(getPrisma: McpOauthPrismaProvider): OAuthClientStore {
110
+ return {
111
+ async create(client: NewOAuthClient) {
112
+ const prisma = await getPrisma();
113
+ return prisma.oAuthClient.create({ data: client });
114
+ },
115
+ async findByClientId(clientId: string) {
116
+ const prisma = await getPrisma();
117
+ return prisma.oAuthClient.findUnique({ where: { clientId } });
118
+ },
119
+ };
120
+ }
121
+
122
+ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore {
123
+ return {
124
+ async create(token) {
125
+ const prisma = await getPrisma();
126
+ await prisma.oAuthRefreshToken.create({ data: token });
127
+ },
128
+ async findByHash(tokenHash) {
129
+ const prisma = await getPrisma();
130
+ return prisma.oAuthRefreshToken.findUnique({ where: { tokenHash } });
131
+ },
132
+ async hasSuccessor(tokenHash) {
133
+ const prisma = await getPrisma();
134
+ const successor = await prisma.oAuthRefreshToken.findFirst({
135
+ where: { rotatedFrom: tokenHash },
136
+ });
137
+ return successor !== null;
138
+ },
139
+ async listFamily(userEmail, clientId) {
140
+ const prisma = await getPrisma();
141
+ return prisma.oAuthRefreshToken.findMany({ where: { userEmail, clientId } });
142
+ },
143
+ async revokeHashes(tokenHashes, at) {
144
+ if (tokenHashes.length === 0) return;
145
+ const prisma = await getPrisma();
146
+ await prisma.oAuthRefreshToken.updateMany({
147
+ where: { tokenHash: { in: [...tokenHashes] } },
148
+ data: { revokedAt: at },
149
+ });
150
+ },
151
+ async rotate(successor, parentHash, at) {
152
+ const prisma = await getPrisma();
153
+ return prisma.$transaction(async (tx) => {
154
+ // CLAIM-ONCE. The `revokedAt: null` predicate is what makes this safe under
155
+ // concurrency, and it is load-bearing rather than defensive: on Postgres's
156
+ // default READ COMMITTED, a second transaction's `updateMany` blocks on the
157
+ // row lock, then re-evaluates this WHERE against the COMMITTED row — which
158
+ // now has a `revokedAt` — and reports 0 rows. So exactly one caller can ever
159
+ // see count 1, and it is the only one that goes on to create a successor.
160
+ // An unconditional `update` would let both through: two live successors of
161
+ // one parent, and replay detection silently defeated (it waits for a third
162
+ // use of the parent that now never comes).
163
+ const { count } = await tx.oAuthRefreshToken.updateMany({
164
+ where: { tokenHash: parentHash, revokedAt: null },
165
+ data: { revokedAt: at },
166
+ });
167
+ // Lost the claim: write NOTHING. The zero-row update commits as the no-op
168
+ // it is, so there is nothing to roll back.
169
+ if (count !== 1) return false;
170
+ // Same transaction as the claim, so a crash cannot leave a live parent AND
171
+ // a live child either.
172
+ await tx.oAuthRefreshToken.create({ data: successor });
173
+ return true;
174
+ });
175
+ },
176
+ async revokeLiveForClient(userEmail, clientId) {
177
+ const prisma = await getPrisma();
178
+ const { count } = await prisma.oAuthRefreshToken.updateMany({
179
+ where: { userEmail, clientId, revokedAt: null },
180
+ data: { revokedAt: new Date() },
181
+ });
182
+ return count;
183
+ },
184
+ };
185
+ }
186
+
187
+ /** The connection columns the account surface reads. */
188
+ const CONNECTION_SELECT = {
189
+ oauthClientId: true,
190
+ clientName: true,
191
+ host: true,
192
+ connectedAt: true,
193
+ lastActiveAt: true,
194
+ } as const;
195
+
196
+ function connectionStore(getPrisma: McpOauthPrismaProvider): McpConnectionStore {
197
+ return {
198
+ async lastActiveAt(userId, oauthClientId) {
199
+ const prisma = await getPrisma();
200
+ const row = await prisma.mcpConnection.findUnique({
201
+ where: { userId_oauthClientId: { userId, oauthClientId } },
202
+ select: { lastActiveAt: true },
203
+ });
204
+ return row?.lastActiveAt ?? null;
205
+ },
206
+ async recordActivity({ userId, oauthClientId, clientName, host, at }) {
207
+ const prisma = await getPrisma();
208
+ await prisma.mcpConnection.upsert({
209
+ where: { userId_oauthClientId: { userId, oauthClientId } },
210
+ create: { userId, oauthClientId, clientName, host, connectedAt: at, lastActiveAt: at },
211
+ // Never blank a known host on refresh — keep the existing attribution
212
+ // when this grant cannot derive one.
213
+ update: {
214
+ clientName,
215
+ lastActiveAt: at,
216
+ revokedAt: null,
217
+ ...(host ? { host } : {}),
218
+ },
219
+ });
220
+ },
221
+ async listActive(userId) {
222
+ const prisma = await getPrisma();
223
+ const rows = await prisma.mcpConnection.findMany({
224
+ where: { userId, revokedAt: null },
225
+ orderBy: { lastActiveAt: "desc" },
226
+ select: { ...CONNECTION_SELECT },
227
+ });
228
+ return rows as unknown as StoredMcpConnection[];
229
+ },
230
+ revokeByHost: (userId, host) => revokeByHost(getPrisma, userId, host),
231
+ announce: (userId, host) => announce(getPrisma, userId, host),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Disconnect one provider's connections — and ONLY that provider's.
237
+ *
238
+ * Every read and write is scoped by `userId`: a connection is per-user (an MCP
239
+ * bearer is not tenant-scoped), so the user id IS the isolation here, and the
240
+ * `id` list passed to the update comes from a query that already applied it.
241
+ */
242
+ async function revokeByHost(
243
+ getPrisma: McpOauthPrismaProvider,
244
+ userId: string,
245
+ host: string,
246
+ ): Promise<string[]> {
247
+ const prisma = await getPrisma();
248
+ const attributed = await prisma.mcpConnection.findMany({
249
+ where: { userId, revokedAt: null, host },
250
+ select: { id: true, oauthClientId: true },
251
+ });
252
+ // A legacy `host = null` row is claimed only when the provider has no row of
253
+ // its own: pre-attribution connections must stay disconnectable, but a provider
254
+ // that DID attribute can never revoke another assistant's row.
255
+ const targets =
256
+ attributed.length > 0
257
+ ? attributed
258
+ : await prisma.mcpConnection.findMany({
259
+ where: { userId, revokedAt: null, host: null },
260
+ select: { id: true, oauthClientId: true },
261
+ });
262
+ if (targets.length === 0) return [];
263
+ await prisma.mcpConnection.updateMany({
264
+ where: { id: { in: targets.map((row) => String(row.id)) } },
265
+ data: { revokedAt: new Date() },
266
+ });
267
+ return targets.map((row) => String(row.oauthClientId));
268
+ }
269
+
270
+ /** A provider's self-report: refresh its own row, or claim the unattributed one. */
271
+ async function announce(
272
+ getPrisma: McpOauthPrismaProvider,
273
+ userId: string,
274
+ host: string,
275
+ ): Promise<number> {
276
+ const prisma = await getPrisma();
277
+ const now = new Date();
278
+ const refreshed = await prisma.mcpConnection.updateMany({
279
+ where: { userId, revokedAt: null, host },
280
+ data: { lastActiveAt: now, revokedAt: null },
281
+ });
282
+ if (refreshed.count > 0) return refreshed.count;
283
+
284
+ // No row for this provider yet — attribute the just-connected one. Scoped by
285
+ // user, so a self-report can never reach another account's connection.
286
+ const candidate = await prisma.mcpConnection.findFirst({
287
+ where: { userId, revokedAt: null, host: null },
288
+ orderBy: { lastActiveAt: "desc" },
289
+ select: { id: true },
290
+ });
291
+ if (!candidate) return 0;
292
+ await prisma.mcpConnection.update({
293
+ where: { id: candidate.id },
294
+ data: { host, lastActiveAt: now, revokedAt: null },
295
+ });
296
+ return 1;
297
+ }
298
+
299
+ /** Every port, over one lazily-resolved Prisma client. */
300
+ export function createPrismaMcpStores(getPrisma: McpOauthPrismaProvider): McpOauthStores {
301
+ return {
302
+ clients: clientStore(getPrisma),
303
+ refreshTokens: refreshTokenStore(getPrisma),
304
+ connections: connectionStore(getPrisma),
305
+ };
306
+ }