@remit/mcp-server 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/app-entry.d.ts +10 -0
  2. package/dist/app-entry.js +9 -0
  3. package/dist/app.d.ts +11 -0
  4. package/dist/app.js +261 -0
  5. package/dist/auth/authorization-server.d.ts +6835 -0
  6. package/dist/auth/authorization-server.js +592 -0
  7. package/dist/auth/database.d.ts +7 -0
  8. package/dist/auth/database.js +33 -0
  9. package/dist/auth/node-auth.d.ts +3 -0
  10. package/dist/auth/node-auth.js +17 -0
  11. package/dist/auth/remit-password.d.ts +8 -0
  12. package/dist/auth/remit-password.js +18 -0
  13. package/dist/auth/scopes.d.ts +6 -0
  14. package/dist/auth/scopes.js +13 -0
  15. package/dist/auth/token-verifier.d.ts +11 -0
  16. package/dist/auth/token-verifier.js +1 -0
  17. package/dist/cli/device-login.d.ts +16 -0
  18. package/dist/cli/device-login.js +111 -0
  19. package/dist/cli/mcp-login.d.ts +1 -0
  20. package/dist/cli/mcp-login.js +21 -0
  21. package/dist/config.d.ts +9 -0
  22. package/dist/config.js +41 -0
  23. package/dist/deps.d.ts +33 -0
  24. package/dist/deps.js +14 -0
  25. package/dist/filter-tools.d.ts +3 -0
  26. package/dist/filter-tools.js +155 -0
  27. package/dist/folder-tools.d.ts +3 -0
  28. package/dist/folder-tools.js +125 -0
  29. package/dist/main.d.ts +1 -0
  30. package/dist/main.js +53 -0
  31. package/dist/pages/assets.d.ts +8 -0
  32. package/dist/pages/assets.js +14 -0
  33. package/dist/pages/geist-cyrillic-ext-wght-normal.woff2 +0 -0
  34. package/dist/pages/geist-cyrillic-wght-normal.woff2 +0 -0
  35. package/dist/pages/geist-latin-ext-wght-normal.woff2 +0 -0
  36. package/dist/pages/geist-latin-wght-normal.woff2 +0 -0
  37. package/dist/pages/geist-vietnamese-wght-normal.woff2 +0 -0
  38. package/dist/pages/hanken-grotesk-cyrillic-ext-wght-normal.woff2 +0 -0
  39. package/dist/pages/hanken-grotesk-latin-ext-wght-normal.woff2 +0 -0
  40. package/dist/pages/hanken-grotesk-latin-wght-normal.woff2 +0 -0
  41. package/dist/pages/hanken-grotesk-vietnamese-wght-normal.woff2 +0 -0
  42. package/dist/pages/inter-cyrillic-ext-wght-normal.woff2 +0 -0
  43. package/dist/pages/inter-cyrillic-wght-normal.woff2 +0 -0
  44. package/dist/pages/inter-greek-ext-wght-normal.woff2 +0 -0
  45. package/dist/pages/inter-greek-wght-normal.woff2 +0 -0
  46. package/dist/pages/inter-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/pages/inter-latin-wght-normal.woff2 +0 -0
  48. package/dist/pages/inter-vietnamese-wght-normal.woff2 +0 -0
  49. package/dist/pages/load-assets.d.ts +2 -0
  50. package/dist/pages/load-assets.js +23 -0
  51. package/dist/pages/pages.css +1 -0
  52. package/dist/pages/render.d.ts +6 -0
  53. package/dist/pages/render.js +34 -0
  54. package/dist/pages/views.d.ts +11 -0
  55. package/dist/pages/views.js +20 -0
  56. package/dist/read-tools.d.ts +4 -0
  57. package/dist/read-tools.js +205 -0
  58. package/dist/server.d.ts +7 -0
  59. package/dist/server.js +32 -0
  60. package/dist/tool-result.d.ts +5 -0
  61. package/dist/tool-result.js +16 -0
  62. package/package.json +66 -5
  63. package/README.md +0 -3
@@ -0,0 +1,592 @@
1
+ import { DEVICE_CODE_GRANT_TYPE, getOAuthProviderApi, oauthDeviceAuthorization, oauthProvider, oauthProviderAuthServerMetadata, } from "@better-auth/oauth-provider";
2
+ import { ACCOUNT_DISABLED, CLIENT_IP_HEADER, SIGN_IN_DISABLED, } from "@remit/auth-service/constants";
3
+ import { betterAuth, } from "better-auth";
4
+ import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, isAPIError, sessionMiddleware, } from "better-auth/api";
5
+ import { setSessionCookie } from "better-auth/cookies";
6
+ import { jwt } from "better-auth/plugins";
7
+ import { z } from "zod";
8
+ import { MCP_SCOPES, OFFLINE_ACCESS_SCOPE } from "./scopes.js";
9
+ export const AUTH_BASE_PATH = "/auth";
10
+ export const LOGIN_PATH = "/login";
11
+ export const CONSENT_PATH = "/consent";
12
+ export const DEVICE_PATH = "/device";
13
+ export const CONNECTED_APPS_PATH = "/apps";
14
+ export const MCP_PATH = "/mcp";
15
+ export const SIGN_IN_PATH = "/mcp/sign-in";
16
+ export const CONNECTED_APPS_API_PATH = "/mcp/connected-apps";
17
+ export const REVOKE_CLIENT_API_PATH = "/mcp/revoke-client";
18
+ export const JWKS_PATH = "/jwks";
19
+ export const OAUTH2_PATH_PREFIX = "/oauth2/";
20
+ export const DEVICE_CODE_PATH = "/device/code";
21
+ export const DEVICE_APPROVE_PATH = "/device/approve";
22
+ export const DEVICE_DENY_PATH = "/device/deny";
23
+ export const ALLOWED_REDIRECT_URIS = [
24
+ "https://claude.ai/api/mcp/auth_callback",
25
+ "https://claude.com/api/mcp/auth_callback",
26
+ ];
27
+ export const DEVICE_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
28
+ export const BROWSER_TOKEN_TTL_SECONDS = 60 * 60;
29
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
30
+ export const isAllowedRedirectUri = (uri) => {
31
+ if (ALLOWED_REDIRECT_URIS.includes(uri))
32
+ return true;
33
+ if (!URL.canParse(uri))
34
+ return false;
35
+ const url = new URL(uri);
36
+ return (url.protocol === "http:" &&
37
+ LOOPBACK_HOSTS.has(url.hostname) &&
38
+ url.username === "" &&
39
+ url.password === "" &&
40
+ url.hash === "");
41
+ };
42
+ export const mcpResource = (origin) => `${origin}${MCP_PATH}`;
43
+ const modelNames = {
44
+ oauthClient: { modelName: "mcp_oauth_client" },
45
+ oauthResource: { modelName: "mcp_oauth_resource" },
46
+ oauthClientResource: { modelName: "mcp_oauth_client_resource" },
47
+ oauthRefreshToken: { modelName: "mcp_oauth_refresh_token" },
48
+ oauthAccessToken: { modelName: "mcp_oauth_access_token" },
49
+ oauthConsent: { modelName: "mcp_oauth_consent" },
50
+ oauthClientAssertion: { modelName: "mcp_oauth_client_assertion" },
51
+ };
52
+ const buildOAuthOptions = (origin) => {
53
+ const resource = mcpResource(origin);
54
+ return {
55
+ schema: modelNames,
56
+ scopes: [...MCP_SCOPES, OFFLINE_ACCESS_SCOPE],
57
+ grantTypes: ["authorization_code", "refresh_token"],
58
+ // A refreshable token stays short; the device flow, which a static
59
+ // header carries and nothing refreshes, gets the long default. Every
60
+ // browser authorization is made refreshable by the authorize hook.
61
+ accessTokenExpiresIn: DEVICE_TOKEN_TTL_SECONDS,
62
+ scopeExpirations: {
63
+ [OFFLINE_ACCESS_SCOPE]: `${BROWSER_TOKEN_TTL_SECONDS}s`,
64
+ },
65
+ loginPage: `${origin}${LOGIN_PATH}`,
66
+ consentPage: `${origin}${CONSENT_PATH}`,
67
+ allowDynamicClientRegistration: true,
68
+ allowUnauthenticatedClientRegistration: true,
69
+ clientRegistrationDefaultApplicationType: "infer",
70
+ resources: [{ identifier: resource, name: "remit mail" }],
71
+ clientRegistrationDefaultResources: [resource],
72
+ };
73
+ };
74
+ const redirectUrisOf = (body) => {
75
+ if (typeof body !== "object" || body === null)
76
+ return [];
77
+ const uris = body.redirect_uris;
78
+ return Array.isArray(uris) ? uris : [];
79
+ };
80
+ const grantTypesOf = (body) => {
81
+ if (typeof body !== "object" || body === null)
82
+ return [];
83
+ const grantTypes = body.grant_types;
84
+ return Array.isArray(grantTypes) ? grantTypes : [];
85
+ };
86
+ const isDeviceOnlyClient = (body) => {
87
+ const grantTypes = grantTypesOf(body);
88
+ return (grantTypes.includes(DEVICE_CODE_GRANT_TYPE) &&
89
+ !grantTypes.includes("authorization_code"));
90
+ };
91
+ const withOfflineAccess = (query) => {
92
+ if (typeof query.scope !== "string")
93
+ return query;
94
+ const scopes = query.scope.split(" ").filter((scope) => scope.length > 0);
95
+ if (scopes.includes(OFFLINE_ACCESS_SCOPE))
96
+ return query;
97
+ return { ...query, scope: [...scopes, OFFLINE_ACCESS_SCOPE].join(" ") };
98
+ };
99
+ const userCodeOf = (body) => {
100
+ if (typeof body !== "object" || body === null)
101
+ return undefined;
102
+ const userCode = body.userCode;
103
+ return typeof userCode === "string" ? userCode : undefined;
104
+ };
105
+ const deviceCodeOf = async (ctx, userCode) => {
106
+ const find = (value) => ctx.context.adapter.findOne({
107
+ model: "deviceCode",
108
+ where: [{ field: "userCode", value }],
109
+ });
110
+ return ((await find(userCode)) ??
111
+ (await find(userCode.replace(/[^a-zA-Z0-9]/g, "").toUpperCase())));
112
+ };
113
+ const hasResource = (fields) => typeof fields === "object" &&
114
+ fields !== null &&
115
+ fields.resource !== undefined;
116
+ const grantOf = (userId, clientId) => [
117
+ { field: "userId", value: userId },
118
+ { field: "clientId", value: clientId },
119
+ ];
120
+ const refuseDisabled = async (identity, userId, headers) => {
121
+ if (!(await identity.isDisabled(userId)))
122
+ return;
123
+ await identity.audit({
124
+ actor: userId,
125
+ target: userId,
126
+ action: "SignInRefusedDisabled",
127
+ outcome: "RefusedDisabled",
128
+ clientIp: headers?.get(CLIENT_IP_HEADER) ?? "unknown",
129
+ });
130
+ throw new APIError("FORBIDDEN", { ...ACCOUNT_DISABLED });
131
+ };
132
+ const signInEndpoint = (signIn, identity) => createAuthEndpoint(SIGN_IN_PATH, {
133
+ method: "POST",
134
+ body: z.object({
135
+ email: z.string(),
136
+ password: z.string(),
137
+ oauth_query: z.string(),
138
+ }),
139
+ }, async (ctx) => {
140
+ const result = await signIn(ctx.body.email, ctx.body.password);
141
+ if (!result.ok) {
142
+ throw new APIError("UNAUTHORIZED", {
143
+ message: "Invalid email or password",
144
+ });
145
+ }
146
+ await refuseDisabled(identity, result.userId, ctx.request?.headers);
147
+ const user = await ctx.context.internalAdapter.findUserById(result.userId);
148
+ if (!user) {
149
+ throw new APIError("UNAUTHORIZED", {
150
+ message: "Invalid email or password",
151
+ });
152
+ }
153
+ const session = await ctx.context.internalAdapter.createSession(user.id);
154
+ await setSessionCookie(ctx, { session, user });
155
+ return ctx.json({ redirect: false });
156
+ });
157
+ const isAlwaysServed = (path) => path === JWKS_PATH || path.startsWith("/.well-known/");
158
+ const BROWSER_PATHS = new Set([
159
+ "/oauth2/authorize",
160
+ "/oauth2/consent",
161
+ "/oauth2/public-client",
162
+ DEVICE_APPROVE_PATH,
163
+ DEVICE_DENY_PATH,
164
+ CONNECTED_APPS_API_PATH,
165
+ REVOKE_CLIENT_API_PATH,
166
+ ]);
167
+ const subjectSession = (subjectOf, identity) => ({
168
+ matcher: (ctx) => BROWSER_PATHS.has(ctx.path ?? ""),
169
+ handler: createAuthMiddleware(async (ctx) => {
170
+ const subject = await subjectOf(ctx.headers ?? new Headers());
171
+ if (!subject) {
172
+ throw new APIError("UNAUTHORIZED", {
173
+ message: "The host named no signed-in user",
174
+ });
175
+ }
176
+ await refuseDisabled(identity, subject.id, ctx.headers);
177
+ const { adapter, internalAdapter } = ctx.context;
178
+ const now = new Date();
179
+ const user = (await internalAdapter.findUserById(subject.id)) ??
180
+ (await adapter.create({
181
+ model: "user",
182
+ data: {
183
+ id: subject.id,
184
+ email: `${subject.id}@subject.invalid`,
185
+ name: subject.id,
186
+ emailVerified: true,
187
+ createdAt: now,
188
+ updatedAt: now,
189
+ },
190
+ forceAllowId: true,
191
+ }));
192
+ const live = (await internalAdapter.listSessions(user.id)).find((session) => session.expiresAt > now);
193
+ const session = live ?? (await internalAdapter.createSession(user.id));
194
+ ctx.context.session = { session, user };
195
+ }),
196
+ });
197
+ const remitMcpBridge = (identity, resource, oauthOptions, signInEnabled) => ({
198
+ id: "remit-mcp",
199
+ endpoints: {
200
+ ...("signIn" in identity.browser
201
+ ? { remitMcpSignIn: signInEndpoint(identity.browser.signIn, identity) }
202
+ : {}),
203
+ remitMcpVerifyAccessToken: createAuthEndpoint("/mcp/verify-access-token", {
204
+ method: "POST",
205
+ body: z.object({ token: z.string() }),
206
+ metadata: { SERVER_ONLY: true },
207
+ }, async (ctx) => {
208
+ if (!signInEnabled) {
209
+ throw new APIError("UNAUTHORIZED", { error: "invalid_token" });
210
+ }
211
+ const payload = await getOAuthProviderApi(ctx, oauthOptions).requireActiveAccessToken(ctx.body.token);
212
+ const clientId = payload.azp ?? payload.client_id;
213
+ if (typeof payload.sub !== "string" || typeof clientId !== "string") {
214
+ throw new APIError("UNAUTHORIZED", { error: "invalid_token" });
215
+ }
216
+ const consent = await ctx.context.adapter.findOne({
217
+ model: "oauthConsent",
218
+ where: grantOf(payload.sub, clientId),
219
+ });
220
+ if (!consent) {
221
+ throw new APIError("UNAUTHORIZED", {
222
+ error: "invalid_token",
223
+ error_description: "The user disconnected this client",
224
+ });
225
+ }
226
+ if (await identity.isDisabled(payload.sub)) {
227
+ throw new APIError("UNAUTHORIZED", {
228
+ error: "invalid_token",
229
+ error_description: "The account is disabled",
230
+ });
231
+ }
232
+ return ctx.json(payload);
233
+ }),
234
+ remitMcpConnectedApps: createAuthEndpoint(CONNECTED_APPS_API_PATH, { method: "GET", use: [sessionMiddleware] }, async (ctx) => {
235
+ const consents = await ctx.context.adapter.findMany({
236
+ model: "oauthConsent",
237
+ where: [{ field: "userId", value: ctx.context.session.user.id }],
238
+ });
239
+ const apps = new Map();
240
+ for (const consent of consents) {
241
+ const known = apps.get(consent.clientId);
242
+ if (known) {
243
+ known.scopes = [...new Set([...known.scopes, ...consent.scopes])];
244
+ continue;
245
+ }
246
+ const client = await ctx.context.adapter.findOne({
247
+ model: "oauthClient",
248
+ where: [{ field: "clientId", value: consent.clientId }],
249
+ });
250
+ apps.set(consent.clientId, {
251
+ clientId: consent.clientId,
252
+ clientName: client?.name ?? consent.clientId,
253
+ scopes: consent.scopes,
254
+ connectedAt: consent.createdAt,
255
+ });
256
+ }
257
+ return ctx.json({ apps: [...apps.values()] });
258
+ }),
259
+ remitMcpRevokeClient: createAuthEndpoint(REVOKE_CLIENT_API_PATH, {
260
+ method: "POST",
261
+ use: [sessionMiddleware],
262
+ body: z.object({ client_id: z.string().min(1) }),
263
+ }, async (ctx) => {
264
+ const grant = grantOf(ctx.context.session.user.id, ctx.body.client_id);
265
+ const revoked = new Date();
266
+ await ctx.context.adapter.deleteMany({
267
+ model: "oauthConsent",
268
+ where: grant,
269
+ });
270
+ await ctx.context.adapter.updateMany({
271
+ model: "oauthRefreshToken",
272
+ where: grant,
273
+ update: { revoked },
274
+ });
275
+ await ctx.context.adapter.updateMany({
276
+ model: "oauthAccessToken",
277
+ where: grant,
278
+ update: { revoked },
279
+ });
280
+ return ctx.json({ revoked: true });
281
+ }),
282
+ },
283
+ hooks: {
284
+ before: [
285
+ ...("subjectOf" in identity.browser
286
+ ? [subjectSession(identity.browser.subjectOf, identity)]
287
+ : []),
288
+ {
289
+ matcher: (ctx) => ctx.path === "/oauth2/authorize",
290
+ handler: createAuthMiddleware(async (ctx) => {
291
+ const query = withOfflineAccess(ctx.query ?? {});
292
+ return {
293
+ context: {
294
+ query: hasResource(query) ? query : { ...query, resource },
295
+ },
296
+ };
297
+ }),
298
+ },
299
+ {
300
+ matcher: (ctx) => ctx.path === DEVICE_APPROVE_PATH,
301
+ handler: createAuthMiddleware(async (ctx) => {
302
+ const session = await getSessionFromCtx(ctx);
303
+ const userCode = userCodeOf(ctx.body);
304
+ if (!session || !userCode)
305
+ return;
306
+ const deviceCode = await deviceCodeOf(ctx, userCode);
307
+ if (!deviceCode?.oauthClientId)
308
+ return;
309
+ const client = await ctx.context.adapter.findOne({
310
+ model: "oauthClient",
311
+ where: [{ field: "clientId", value: deviceCode.oauthClientId }],
312
+ });
313
+ if (client?.userId && client.userId !== session.user.id) {
314
+ throw new APIError("FORBIDDEN", {
315
+ error: "access_denied",
316
+ error_description: "This device belongs to another account",
317
+ });
318
+ }
319
+ await ctx.context.adapter.update({
320
+ model: "oauthClient",
321
+ where: [{ field: "clientId", value: deviceCode.oauthClientId }],
322
+ update: { userId: session.user.id },
323
+ });
324
+ }),
325
+ },
326
+ {
327
+ matcher: (ctx) => ctx.path === "/oauth2/token" && !hasResource(ctx.body),
328
+ handler: createAuthMiddleware(async (ctx) => ({
329
+ context: { body: { ...ctx.body, resource } },
330
+ })),
331
+ },
332
+ {
333
+ matcher: (ctx) => ctx.path === DEVICE_CODE_PATH && !hasResource(ctx.body),
334
+ handler: createAuthMiddleware(async (ctx) => ({
335
+ context: { body: { ...ctx.body, resource } },
336
+ })),
337
+ },
338
+ {
339
+ matcher: (ctx) => ctx.path === "/oauth2/register",
340
+ handler: createAuthMiddleware(async (ctx) => {
341
+ const uris = redirectUrisOf(ctx.body);
342
+ const refused = uris.find((uri) => typeof uri !== "string" || !isAllowedRedirectUri(uri));
343
+ const missing = uris.length === 0 && !isDeviceOnlyClient(ctx.body);
344
+ if (missing || refused !== undefined) {
345
+ throw new APIError("BAD_REQUEST", {
346
+ error: "invalid_redirect_uri",
347
+ error_description: `redirect_uris must be ${ALLOWED_REDIRECT_URIS.join(" or ")}, or a loopback address`,
348
+ });
349
+ }
350
+ }),
351
+ },
352
+ ],
353
+ after: [
354
+ {
355
+ matcher: (ctx) => ctx.path === DEVICE_APPROVE_PATH,
356
+ handler: createAuthMiddleware(async (ctx) => {
357
+ if (isAPIError(ctx.context.returned))
358
+ return;
359
+ const session = await getSessionFromCtx(ctx);
360
+ const userCode = userCodeOf(ctx.body);
361
+ if (!session || !userCode)
362
+ return;
363
+ const deviceCode = await deviceCodeOf(ctx, userCode);
364
+ if (!deviceCode?.oauthClientId)
365
+ return;
366
+ const grant = grantOf(session.user.id, deviceCode.oauthClientId);
367
+ const existing = await ctx.context.adapter.findOne({
368
+ model: "oauthConsent",
369
+ where: grant,
370
+ });
371
+ if (existing)
372
+ return;
373
+ const now = new Date();
374
+ await ctx.context.adapter.create({
375
+ model: "oauthConsent",
376
+ data: {
377
+ clientId: deviceCode.oauthClientId,
378
+ userId: session.user.id,
379
+ scopes: (deviceCode.scope ?? "")
380
+ .split(" ")
381
+ .filter((scope) => scope.length > 0),
382
+ resources: deviceCode.resources ?? [resource],
383
+ createdAt: now,
384
+ updatedAt: now,
385
+ },
386
+ });
387
+ }),
388
+ },
389
+ ],
390
+ },
391
+ });
392
+ /**
393
+ * The kill switch on the connector's own better-auth instance: with
394
+ * `SIGN_IN_ENABLED` off every path but the JWKS and the server metadata is
395
+ * refused, so no one signs in, consents or exchanges a token.
396
+ */
397
+ const signInGate = (signInEnabled) => createAuthMiddleware(async (ctx) => {
398
+ if (signInEnabled || isAlwaysServed(ctx.path))
399
+ return;
400
+ throw new APIError("FORBIDDEN", { ...SIGN_IN_DISABLED });
401
+ });
402
+ export const buildMcpAuthOptions = (config, database, identity) => {
403
+ const oauthOptions = buildOAuthOptions(config.origin);
404
+ return {
405
+ secret: config.secret,
406
+ baseURL: config.origin,
407
+ basePath: AUTH_BASE_PATH,
408
+ trustedOrigins: [config.origin],
409
+ database,
410
+ disabledPaths: ["/token"],
411
+ hooks: { before: signInGate(config.signInEnabled) },
412
+ advanced: {
413
+ cookiePrefix: "remit-mcp",
414
+ ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
415
+ },
416
+ rateLimit: {
417
+ enabled: true,
418
+ customRules: { [SIGN_IN_PATH]: { window: 60, max: 5 } },
419
+ },
420
+ user: { modelName: "auth_user" },
421
+ account: { modelName: "auth_account" },
422
+ session: { modelName: "mcp_session", storeSessionInDatabase: true },
423
+ verification: { modelName: "mcp_verification" },
424
+ plugins: [
425
+ jwt({
426
+ schema: { jwks: { modelName: "mcp_jwks" } },
427
+ disableSettingJwtHeader: true,
428
+ jwt: { issuer: config.origin, audience: mcpResource(config.origin) },
429
+ }),
430
+ oauthProvider(oauthOptions),
431
+ oauthDeviceAuthorization({
432
+ schema: { deviceCode: { modelName: "mcp_device_code" } },
433
+ verificationUri: `${config.origin}${DEVICE_PATH}`,
434
+ }),
435
+ remitMcpBridge(identity, mcpResource(config.origin), oauthOptions, config.signInEnabled),
436
+ ],
437
+ };
438
+ };
439
+ const createBetterAuth = (config, database, identity) => betterAuth(buildMcpAuthOptions(config, database, identity));
440
+ export const createMcpAuth = (config, database, identity) => {
441
+ const auth = createBetterAuth(config, database, identity);
442
+ return { auth, routes: createMcpAuthRoutes(auth, identity) };
443
+ };
444
+ const DEVICE_AUTH_PATHS = new Set([
445
+ DEVICE_CODE_PATH,
446
+ DEVICE_PATH,
447
+ DEVICE_APPROVE_PATH,
448
+ DEVICE_DENY_PATH,
449
+ ]);
450
+ const jsonHeaders = (headers) => {
451
+ const json = new Headers(headers);
452
+ json.set("accept", "application/json");
453
+ json.set("content-type", "application/json");
454
+ return json;
455
+ };
456
+ const postToRouter = (auth, headers, path, body) => auth.handler(new Request(`${auth.options.baseURL}${AUTH_BASE_PATH}${path}`, {
457
+ method: "POST",
458
+ headers: jsonHeaders(headers),
459
+ body: JSON.stringify(body),
460
+ }));
461
+ const refusedWith = (error) => {
462
+ if (isAPIError(error))
463
+ return undefined;
464
+ throw error;
465
+ };
466
+ const refusalResponse = (error) => {
467
+ if (!isAPIError(error))
468
+ throw error;
469
+ return Response.json(error.body ?? {}, { status: error.statusCode });
470
+ };
471
+ const isServedAuthPath = (path) => path.startsWith(OAUTH2_PATH_PREFIX) ||
472
+ DEVICE_AUTH_PATHS.has(path) ||
473
+ path === SIGN_IN_PATH ||
474
+ path === CONNECTED_APPS_API_PATH ||
475
+ path === REVOKE_CLIENT_API_PATH ||
476
+ path === JWKS_PATH;
477
+ const SUBJECT_SERVED_PATHS = new Set([
478
+ ...BROWSER_PATHS,
479
+ ...DEVICE_AUTH_PATHS,
480
+ "/oauth2/register",
481
+ "/oauth2/token",
482
+ "/oauth2/revoke",
483
+ JWKS_PATH,
484
+ ]);
485
+ const browserRoutesOf = (auth, browser) => "signIn" in browser
486
+ ? {
487
+ kind: "signIn",
488
+ // Through the router rather than auth.api: only the router applies
489
+ // the sign-in rate limit.
490
+ signIn: (headers, body) => postToRouter(auth, headers, SIGN_IN_PATH, body),
491
+ }
492
+ : { kind: "host" };
493
+ const subjectIdOf = async (auth, browser, headers) => {
494
+ if ("subjectOf" in browser)
495
+ return (await browser.subjectOf(headers))?.id;
496
+ return (await auth.api.getSession({ headers }))?.user.id;
497
+ };
498
+ const createMcpAuthRoutes = (auth, identity) => ({
499
+ browser: browserRoutesOf(auth, identity.browser),
500
+ handle: async (request) => {
501
+ const { pathname } = new URL(request.url);
502
+ if (!pathname.startsWith(AUTH_BASE_PATH)) {
503
+ return new Response("Not found", { status: 404 });
504
+ }
505
+ const path = pathname.slice(AUTH_BASE_PATH.length);
506
+ const served = "subjectOf" in identity.browser
507
+ ? SUBJECT_SERVED_PATHS.has(path)
508
+ : isServedAuthPath(path);
509
+ if (!served)
510
+ return new Response("Not found", { status: 404 });
511
+ return auth.handler(request);
512
+ },
513
+ serverMetadata: oauthProviderAuthServerMetadata(auth),
514
+ sessionOf: async (headers) => {
515
+ const userId = await subjectIdOf(auth, identity.browser, headers);
516
+ if (userId === undefined)
517
+ return "absent";
518
+ return (await identity.isDisabled(userId)) ? "disabled" : "active";
519
+ },
520
+ // Through the router rather than auth.api: the authorization it resumes
521
+ // needs a request.
522
+ consent: (headers, body) => postToRouter(auth, headers, "/oauth2/consent", body),
523
+ clientName: async (headers, clientId) => {
524
+ const client = await auth.api
525
+ .getOAuthClientPublic({ query: { client_id: clientId }, headers })
526
+ .catch(refusedWith);
527
+ if (!client)
528
+ return undefined;
529
+ return client.client_name ?? clientId;
530
+ },
531
+ claimDeviceCode: async (headers, userCode) => {
532
+ const response = await auth.api.deviceVerify({
533
+ query: { user_code: userCode },
534
+ headers,
535
+ asResponse: true,
536
+ });
537
+ await response.body?.cancel();
538
+ return response.ok;
539
+ },
540
+ decideDeviceCode: (headers, userCode, approve) => (approve
541
+ ? auth.api.deviceApprove({
542
+ body: { userCode },
543
+ headers: jsonHeaders(headers),
544
+ asResponse: true,
545
+ })
546
+ : auth.api.deviceDeny({
547
+ body: { userCode },
548
+ headers: jsonHeaders(headers),
549
+ asResponse: true,
550
+ })).catch(refusalResponse),
551
+ connectedApps: (headers) => auth.api
552
+ .remitMcpConnectedApps({ headers, asResponse: true })
553
+ .catch(refusalResponse),
554
+ revokeClient: (headers, clientId) => auth.api
555
+ .remitMcpRevokeClient({
556
+ body: { client_id: clientId },
557
+ headers: jsonHeaders(headers),
558
+ asResponse: true,
559
+ })
560
+ .catch(refusalResponse),
561
+ });
562
+ const audienceOf = (aud) => {
563
+ if (typeof aud === "string")
564
+ return [aud];
565
+ if (Array.isArray(aud)) {
566
+ return aud.filter((value) => typeof value === "string");
567
+ }
568
+ return [];
569
+ };
570
+ export const createBetterAuthTokenVerifier = (auth, origin) => ({
571
+ async verify(bearer) {
572
+ const payload = await auth.api
573
+ .remitMcpVerifyAccessToken({ body: { token: bearer } })
574
+ .catch((error) => {
575
+ if (isAPIError(error))
576
+ return undefined;
577
+ throw error;
578
+ });
579
+ if (!payload)
580
+ return { ok: false, reason: "invalid_token" };
581
+ if (!audienceOf(payload.aud).includes(mcpResource(origin))) {
582
+ return { ok: false, reason: "wrong_audience" };
583
+ }
584
+ if (typeof payload.sub !== "string" || payload.sub.length === 0) {
585
+ return { ok: false, reason: "no_subject" };
586
+ }
587
+ const scopes = typeof payload.scope === "string"
588
+ ? payload.scope.split(" ").filter((scope) => scope.length > 0)
589
+ : [];
590
+ return { ok: true, principal: { userId: payload.sub, scopes } };
591
+ },
592
+ });
@@ -0,0 +1,7 @@
1
+ import { type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
2
+ export interface McpAuthDatabase {
3
+ db: BetterSQLite3Database;
4
+ close: () => void;
5
+ }
6
+ export declare const refuseSharedTableWrites: (db: BetterSQLite3Database) => BetterSQLite3Database;
7
+ export declare const openMcpAuthDatabase: (filename: string) => McpAuthDatabase;
@@ -0,0 +1,33 @@
1
+ import { auth_account, auth_user } from "@remit/auth-service/schema";
2
+ import Database from "better-sqlite3";
3
+ import { drizzle, } from "drizzle-orm/better-sqlite3";
4
+ const SHARED_TABLES = new Set([auth_user, auth_account]);
5
+ const WRITES = new Set([
6
+ "insert",
7
+ "update",
8
+ "delete",
9
+ ]);
10
+ export const refuseSharedTableWrites = (db) => new Proxy(db, {
11
+ get(target, property, receiver) {
12
+ const value = Reflect.get(target, property, receiver);
13
+ if (!WRITES.has(property) || typeof value !== "function")
14
+ return value;
15
+ return (table) => {
16
+ if (SHARED_TABLES.has(table)) {
17
+ throw new Error("The MCP server never writes the shared auth_user or auth_account tables");
18
+ }
19
+ return value.call(target, table);
20
+ };
21
+ },
22
+ });
23
+ export const openMcpAuthDatabase = (filename) => {
24
+ const sqlite = new Database(filename);
25
+ sqlite.pragma("journal_mode = WAL");
26
+ sqlite.pragma("busy_timeout = 5000");
27
+ sqlite.pragma("synchronous = NORMAL");
28
+ sqlite.pragma("foreign_keys = ON");
29
+ return {
30
+ db: drizzle(sqlite),
31
+ close: () => sqlite.close(),
32
+ };
33
+ };
@@ -0,0 +1,3 @@
1
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
2
+ import { type McpAuthConfig, type McpAuthServer } from "./authorization-server.js";
3
+ export declare const createSqliteMcpAuth: (config: McpAuthConfig, db: BetterSQLite3Database) => McpAuthServer;
@@ -0,0 +1,17 @@
1
+ import { isUserDisabled, writeAudit } from "@remit/auth-service";
2
+ import { auth_account, auth_user } from "@remit/auth-service/schema";
3
+ import * as mcpTables from "@remit/drizzle-service/schema-sets/mcp";
4
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
5
+ import { createMcpAuth, } from "./authorization-server.js";
6
+ import { refuseSharedTableWrites } from "./database.js";
7
+ import { checkRemitPassword } from "./remit-password.js";
8
+ export const createSqliteMcpAuth = (config, db) => createMcpAuth(config, drizzleAdapter(refuseSharedTableWrites(db), {
9
+ provider: "sqlite",
10
+ schema: { ...mcpTables, auth_user, auth_account },
11
+ }), {
12
+ isDisabled: async (userId) => isUserDisabled(db, userId),
13
+ audit: async (record) => writeAudit(db, record),
14
+ browser: {
15
+ signIn: (email, password) => checkRemitPassword(db, email, password),
16
+ },
17
+ });
@@ -0,0 +1,8 @@
1
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
2
+ export type PasswordCheck = {
3
+ ok: true;
4
+ userId: string;
5
+ } | {
6
+ ok: false;
7
+ };
8
+ export declare const checkRemitPassword: (db: BetterSQLite3Database, email: string, password: string) => Promise<PasswordCheck>;
@@ -0,0 +1,18 @@
1
+ import { auth_account, auth_user } from "@remit/auth-service/schema";
2
+ import { verifyPassword } from "better-auth/crypto";
3
+ import { and, eq } from "drizzle-orm";
4
+ const CREDENTIAL_PROVIDER = "credential";
5
+ export const checkRemitPassword = async (db, email, password) => {
6
+ const [row] = await db
7
+ .select({ id: auth_user.id, hash: auth_account.password })
8
+ .from(auth_user)
9
+ .innerJoin(auth_account, and(eq(auth_account.userId, auth_user.id), eq(auth_account.providerId, CREDENTIAL_PROVIDER)))
10
+ .where(eq(auth_user.email, email.trim().toLowerCase()))
11
+ .limit(1);
12
+ if (!row?.hash)
13
+ return { ok: false };
14
+ if (!(await verifyPassword({ hash: row.hash, password }))) {
15
+ return { ok: false };
16
+ }
17
+ return { ok: true, userId: row.id };
18
+ };