@softure-ai/mcp-access 0.0.0-stage → 0.1.6

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 (122) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +177 -2
  4. package/dist/client-setup.d.ts +32 -0
  5. package/dist/client-setup.d.ts.map +1 -0
  6. package/dist/client-setup.js +57 -0
  7. package/dist/client-setup.js.map +1 -0
  8. package/dist/contract.d.ts +80 -0
  9. package/dist/contract.d.ts.map +1 -0
  10. package/dist/contract.js +2 -0
  11. package/dist/contract.js.map +1 -0
  12. package/dist/index.d.ts +119 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +50 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/messages/en.d.ts +77 -0
  17. package/dist/messages/en.d.ts.map +1 -0
  18. package/dist/messages/en.js +77 -0
  19. package/dist/messages/en.js.map +1 -0
  20. package/dist/messages/index.d.ts +161 -0
  21. package/dist/messages/index.d.ts.map +1 -0
  22. package/dist/messages/index.js +14 -0
  23. package/dist/messages/index.js.map +1 -0
  24. package/dist/messages/pl.d.ts +3 -0
  25. package/dist/messages/pl.d.ts.map +1 -0
  26. package/dist/messages/pl.js +77 -0
  27. package/dist/messages/pl.js.map +1 -0
  28. package/dist/next/actions.d.ts +6 -0
  29. package/dist/next/actions.d.ts.map +1 -0
  30. package/dist/next/actions.js +88 -0
  31. package/dist/next/actions.js.map +1 -0
  32. package/dist/next/context.d.ts +4 -0
  33. package/dist/next/context.d.ts.map +1 -0
  34. package/dist/next/context.js +13 -0
  35. package/dist/next/context.js.map +1 -0
  36. package/dist/next/format.d.ts +4 -0
  37. package/dist/next/format.d.ts.map +1 -0
  38. package/dist/next/format.js +7 -0
  39. package/dist/next/format.js.map +1 -0
  40. package/dist/next/index.d.ts +4 -0
  41. package/dist/next/index.d.ts.map +1 -0
  42. package/dist/next/index.js +6 -0
  43. package/dist/next/index.js.map +1 -0
  44. package/dist/next/pages.d.ts +2 -0
  45. package/dist/next/pages.d.ts.map +1 -0
  46. package/dist/next/pages.js +61 -0
  47. package/dist/next/pages.js.map +1 -0
  48. package/dist/next/route.d.ts +3 -0
  49. package/dist/next/route.d.ts.map +1 -0
  50. package/dist/next/route.js +24 -0
  51. package/dist/next/route.js.map +1 -0
  52. package/dist/options.d.ts +43 -0
  53. package/dist/options.d.ts.map +1 -0
  54. package/dist/options.js +47 -0
  55. package/dist/options.js.map +1 -0
  56. package/dist/schema.d.ts +145 -0
  57. package/dist/schema.d.ts.map +1 -0
  58. package/dist/schema.js +18 -0
  59. package/dist/schema.js.map +1 -0
  60. package/dist/server/endpoint.d.ts +26 -0
  61. package/dist/server/endpoint.d.ts.map +1 -0
  62. package/dist/server/endpoint.js +107 -0
  63. package/dist/server/endpoint.js.map +1 -0
  64. package/dist/server/health.d.ts +3 -0
  65. package/dist/server/health.d.ts.map +1 -0
  66. package/dist/server/health.js +11 -0
  67. package/dist/server/health.js.map +1 -0
  68. package/dist/server/index.d.ts +6 -0
  69. package/dist/server/index.d.ts.map +1 -0
  70. package/dist/server/index.js +8 -0
  71. package/dist/server/index.js.map +1 -0
  72. package/dist/server/options.d.ts +20 -0
  73. package/dist/server/options.d.ts.map +1 -0
  74. package/dist/server/options.js +40 -0
  75. package/dist/server/options.js.map +1 -0
  76. package/dist/server/privacy.d.ts +15 -0
  77. package/dist/server/privacy.d.ts.map +1 -0
  78. package/dist/server/privacy.js +36 -0
  79. package/dist/server/privacy.js.map +1 -0
  80. package/dist/server/tokens.d.ts +66 -0
  81. package/dist/server/tokens.d.ts.map +1 -0
  82. package/dist/server/tokens.js +138 -0
  83. package/dist/server/tokens.js.map +1 -0
  84. package/dist/token-status.d.ts +9 -0
  85. package/dist/token-status.d.ts.map +1 -0
  86. package/dist/token-status.js +14 -0
  87. package/dist/token-status.js.map +1 -0
  88. package/dist/ui/index.d.ts +2 -0
  89. package/dist/ui/index.d.ts.map +1 -0
  90. package/dist/ui/index.js +4 -0
  91. package/dist/ui/index.js.map +1 -0
  92. package/dist/ui/token-manager.d.ts +43 -0
  93. package/dist/ui/token-manager.d.ts.map +1 -0
  94. package/dist/ui/token-manager.js +50 -0
  95. package/dist/ui/token-manager.js.map +1 -0
  96. package/migrations/0001_create_access_tokens.sql +24 -0
  97. package/module.json +15 -0
  98. package/package.json +69 -4
  99. package/src/client-setup.ts +71 -0
  100. package/src/contract.ts +80 -0
  101. package/src/index.ts +81 -0
  102. package/src/messages/en.ts +76 -0
  103. package/src/messages/index.ts +18 -0
  104. package/src/messages/pl.ts +78 -0
  105. package/src/next/actions.ts +91 -0
  106. package/src/next/context.ts +14 -0
  107. package/src/next/format.ts +10 -0
  108. package/src/next/index.ts +5 -0
  109. package/src/next/next-modules.d.ts +16 -0
  110. package/src/next/pages.tsx +80 -0
  111. package/src/next/route.ts +24 -0
  112. package/src/options.ts +58 -0
  113. package/src/schema.ts +19 -0
  114. package/src/server/endpoint.ts +141 -0
  115. package/src/server/health.ts +12 -0
  116. package/src/server/index.ts +36 -0
  117. package/src/server/options.ts +54 -0
  118. package/src/server/privacy.ts +49 -0
  119. package/src/server/tokens.ts +188 -0
  120. package/src/token-status.ts +27 -0
  121. package/src/ui/index.ts +11 -0
  122. package/src/ui/token-manager.tsx +254 -0
@@ -0,0 +1,80 @@
1
+ // The token page, ready to mount with one line:
2
+ // `export { McpAccessPage as default } from "@softure-ai/mcp-access/next"`.
3
+ // A server component for the signed-in user; without a session it redirects to the login page and
4
+ // back. Everything the client part shows is prepared here, so dates are formatted once, in the
5
+ // app's locale and time zone, and never differ between the server render and hydration.
6
+ import { requireUser } from "@softure-ai/auth/next";
7
+ import { formatMessage, type SoftureConfig } from "@softure-ai/core";
8
+ import { getSoftureConfig } from "@softure-ai/core/next";
9
+ import { Card } from "@softure-ai/ui";
10
+ import type { AccessTokenStatus, AccessTokenView } from "../contract.js";
11
+ import type { McpAccessMessages } from "../messages/index.js";
12
+ import type { McpAccessOptions } from "../options.js";
13
+ import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes } from "../server/options.js";
14
+ import { listAccessTokens } from "../server/tokens.js";
15
+ import { getAccessTokenStatus } from "../token-status.js";
16
+ import { TokenManager, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
17
+ import { issueTokenAction, revokeTokenAction } from "./actions.js";
18
+ import { getMcpAccessContext } from "./context.js";
19
+ import { formatDate, formatDateTime } from "./format.js";
20
+
21
+ const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-3xl sft:px-4 sft:py-4";
22
+
23
+ function describeStatus(status: AccessTokenStatus, messages: McpAccessMessages): string {
24
+ switch (status.kind) {
25
+ case "active":
26
+ return messages.list.active;
27
+ case "expired":
28
+ return messages.list.expired;
29
+ case "expiring":
30
+ if (status.daysLeft === 0) return messages.list.expiresToday;
31
+ if (status.daysLeft === 1) return messages.list.expiresTomorrow;
32
+ return formatMessage(messages.list.expiresInDays, { days: status.daysLeft });
33
+ }
34
+ }
35
+
36
+ function toRow(token: AccessTokenView, now: Date, config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerRow {
37
+ const status = getAccessTokenStatus(token.expiresAt, now, { timezone: config.timezone, warningDays: options.expiryWarningDays });
38
+ const isExpired = status.kind === "expired";
39
+ const expiry = formatMessage(isExpired ? messages.list.expiredOn : messages.list.expiresOn, { date: formatDate(config, token.expiresAt) });
40
+ const lastUse = token.lastUsedAt === null ? messages.list.neverUsed : formatMessage(messages.list.lastUsed, { date: formatDateTime(config, token.lastUsedAt) });
41
+ return {
42
+ id: token.id,
43
+ name: token.name,
44
+ scopeText: token.canWrite && options.allowWrites ? messages.list.readWrite : messages.list.readOnly,
45
+ statusText: describeStatus(status, messages),
46
+ isExpired,
47
+ details: [formatMessage(messages.list.createdOn, { date: formatDate(config, token.createdAt) }), expiry, lastUse],
48
+ };
49
+ }
50
+
51
+ function toTool(tool: McpAccessOptions["tools"][number], config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerTool {
52
+ const accessText = tool.access === "read" ? messages.tools.read : options.allowWrites ? messages.tools.write : messages.tools.writeUnavailable;
53
+ return { name: tool.name, description: getLocalizedText(tool.description, config.locale), accessText };
54
+ }
55
+
56
+ export async function McpAccessPage() {
57
+ const config = getSoftureConfig();
58
+ const user = await requireUser({ next: getMcpAccessRoutes(config).page });
59
+ const options = getMcpAccessOptions(config);
60
+ const messages = getMcpAccessMessages(config);
61
+ const ctx = await getMcpAccessContext(config);
62
+ const now = ctx.clock.now();
63
+ const tokens = await listAccessTokens(ctx, user.id);
64
+ return (
65
+ <main className={LAYOUT_CLASS}>
66
+ <Card title={messages.page.title} subtitle={messages.page.lead}>
67
+ <TokenManager
68
+ tools={options.tools.map((tool) => toTool(tool, config, options, messages))}
69
+ tokens={tokens.map((token) => toRow(token, now, config, options, messages))}
70
+ allowWrites={options.allowWrites}
71
+ maxTokens={options.maxTokensPerUser}
72
+ issueAction={issueTokenAction}
73
+ revokeAction={revokeTokenAction}
74
+ messages={messages}
75
+ locale={config.locale}
76
+ />
77
+ </Card>
78
+ </main>
79
+ );
80
+ }
@@ -0,0 +1,24 @@
1
+ // `POST /api/mcp`, built around the app's MCP server factory and mounted with one line:
2
+ // `app/api/mcp/route.ts → export const POST = createMcpRoute({ createServer });`
3
+ // The factory lives in the app, next to its tools; the module brings the rate limit, the token
4
+ // check and the identity the factory receives. GET and DELETE are not exported, so Next answers
5
+ // them with 405: the endpoint is stateless and keeps no sessions.
6
+ import { errorLogLabel } from "@softure-ai/core";
7
+ import { createMcpEndpoint, type McpEndpointOptions } from "../server/endpoint.js";
8
+ import { getMcpAccessContext } from "./context.js";
9
+
10
+ const NO_STORE = { "cache-control": "no-store" };
11
+
12
+ export function createMcpRoute(options: McpEndpointOptions): (request: Request) => Promise<Response> {
13
+ const endpoint = createMcpEndpoint(options);
14
+ return async (request) => {
15
+ let ctx;
16
+ try {
17
+ ctx = await getMcpAccessContext();
18
+ } catch (error) {
19
+ console.error(`@softure-ai/mcp-access: opening the database failed: ${errorLogLabel(error)}`);
20
+ return Response.json({ error: "temporarily_unavailable" }, { status: 503, headers: NO_STORE });
21
+ }
22
+ return endpoint(ctx, request);
23
+ };
24
+ }
package/src/options.ts ADDED
@@ -0,0 +1,58 @@
1
+ // The options an app passes to `mcpAccess({ ... })` in softure.config.ts, parsed at startup.
2
+ import { LOCALES } from "@softure-ai/core";
3
+ import { z } from "zod";
4
+
5
+ /** The name an MCP client lists the server under: no spaces, so a shell command needs no quotes. */
6
+ export const SERVER_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
7
+
8
+ /** The MCP rule for tool names: 1 to 128 of A-Z, a-z, 0-9, `_`, `-` and `.`. */
9
+ export const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
10
+
11
+ /** The longest token name the table takes (`access_tokens.name`). */
12
+ export const MAX_TOKEN_NAME_LENGTH = 60;
13
+
14
+ /** Copy per locale; a locale without its own text falls back to `en`. */
15
+ const localizedTextSchema = z.partialRecord(z.enum(LOCALES), z.string().trim().min(1).max(500));
16
+
17
+ const toolSchema = z.strictObject({
18
+ /** The tool's name as the app's MCP server registers it. */
19
+ name: z.string().regex(TOOL_NAME_PATTERN, "must be 1 to 128 of A-Z, a-z, 0-9, _, - and ."),
20
+ /**
21
+ * `write` tools change data: the app registers them only for a token that can write. They stay
22
+ * listed while `allowWrites` is off, marked as unavailable.
23
+ */
24
+ access: z.enum(["read", "write"]),
25
+ /** What the tool does, for the owner deciding which token to issue. */
26
+ description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
27
+ });
28
+
29
+ export const mcpAccessOptionsSchema = z
30
+ .strictObject({
31
+ /** The name MCP clients list the server under, e.g. "acme". Used in the setup instructions. */
32
+ serverName: z.string().regex(SERVER_NAME_PATTERN, "must be lowercase letters, digits and inner dashes, at most 64 characters"),
33
+ /** The app's tool catalog, shown on the token page. The MCP server itself comes from the route. */
34
+ tools: z.array(toolSchema).default([]),
35
+ /** Lets write tokens be issued and used. Off, every token reads only, write tokens included. */
36
+ allowWrites: z.boolean().default(false),
37
+ /** How long a token works. A choice: longer than a session, never forever. */
38
+ tokenLifetimeDays: z.int().min(1).max(365).default(90),
39
+ /** How many unexpired tokens one account may hold. */
40
+ maxTokensPerUser: z.int().min(1).max(100).default(20),
41
+ /** From how many calendar days before its expiry the page warns about a token. */
42
+ expiryWarningDays: z.int().min(0).max(60).default(14),
43
+ })
44
+ .superRefine((options, context) => {
45
+ const names = new Set<string>();
46
+ options.tools.forEach((tool, index) => {
47
+ if (names.has(tool.name)) {
48
+ context.addIssue({ code: "custom", path: ["tools", index, "name"], message: `"${tool.name}" is listed twice` });
49
+ }
50
+ names.add(tool.name);
51
+ });
52
+ });
53
+
54
+ export type McpAccessOptionsInput = z.input<typeof mcpAccessOptionsSchema>;
55
+ export type McpAccessOptions = z.output<typeof mcpAccessOptionsSchema>;
56
+ export type McpToolDefinitionInput = z.input<typeof toolSchema>;
57
+ export type McpToolDefinition = z.output<typeof toolSchema>;
58
+ export type McpToolAccess = McpToolDefinition["access"];
package/src/schema.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Drizzle view of the module's table (migrations/0001_create_access_tokens.sql). The migration is
2
+ // the source of truth; this file only types the queries.
3
+ import { users } from "@softure-ai/auth";
4
+ import { boolean, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
5
+
6
+ export const mcpSchema = pgSchema("mcp");
7
+
8
+ export const accessTokens = mcpSchema.table("access_tokens", {
9
+ id: uuid("id").primaryKey().defaultRandom(),
10
+ userId: uuid("user_id")
11
+ .notNull()
12
+ .references(() => users.id, { onDelete: "cascade" }),
13
+ name: text("name").notNull(),
14
+ tokenHash: text("token_hash").notNull().unique("access_tokens_token_hash_key"),
15
+ canWrite: boolean("can_write").notNull(),
16
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
17
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
18
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
19
+ });
@@ -0,0 +1,141 @@
1
+ // The MCP endpoint (FIRE_TRACKER `src/app/api/mcp/route.ts`, generalised). It takes a Web
2
+ // `Request` and returns a `Response`, so it runs under any fetch-shaped host; the Next adapter only
3
+ // supplies the context. Order: identify the client, count the request, verify the Bearer token,
4
+ // then hand the request to a fresh server from the app's factory. Each refusal happens before the
5
+ // next step costs anything.
6
+ import {
7
+ bearerAuthChallengeResponse,
8
+ createMcpHandler,
9
+ OAuthError,
10
+ OAuthErrorCode,
11
+ type AuthInfo,
12
+ type McpServer,
13
+ type Server,
14
+ } from "@modelcontextprotocol/server";
15
+ import { errorLogLabel, getModule } from "@softure-ai/core";
16
+ import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
17
+ import type { McpServerIdentity } from "../contract.js";
18
+ import { verifyAccessToken, type McpAccessContext } from "./tokens.js";
19
+
20
+ /** Every valid token reads. */
21
+ export const MCP_READ_SCOPE = "mcp:read";
22
+ /** A token that may write: issued for writes, and the app allows them. */
23
+ export const MCP_WRITE_SCOPE = "mcp:write";
24
+
25
+ /** The security bucket the endpoint counts in; `MCP_RATE_LIMIT_BUCKETS` holds its default. */
26
+ export const MCP_RATE_LIMIT_BUCKET = "mcp";
27
+
28
+ /** Larger bodies get 413 from the SDK before anything is parsed. No tool call needs more. */
29
+ export const MAX_MCP_REQUEST_BYTES = 1024 * 1024;
30
+
31
+ /** `AuthInfo.extra` key that carries the owner's user id to the factory. */
32
+ const USER_ID_CLAIM = "userId";
33
+ const BEARER_PREFIX = /^Bearer[ \t]+/i;
34
+ const NO_STORE = { "cache-control": "no-store" };
35
+
36
+ /** The app's MCP server for one request. It registers write tools only when `canWrite` is true. */
37
+ export type McpServerFactory = (identity: McpServerIdentity) => McpServer | Server | Promise<McpServer | Server>;
38
+
39
+ export interface McpEndpointOptions {
40
+ readonly createServer: McpServerFactory;
41
+ }
42
+
43
+ /** Serves one request to the MCP endpoint. */
44
+ export type McpEndpoint = (ctx: McpAccessContext, request: Request) => Promise<Response>;
45
+
46
+ /**
47
+ * The endpoint around the app's factory. The factory runs once per request with that request's
48
+ * identity, so one account's server never answers another's request. Answers are plain JSON
49
+ * (`responseMode: "json"`): a stream would add proxy buffering and timeouts for no tool that needs
50
+ * it, and mid-call notifications are dropped.
51
+ */
52
+ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndpoint {
53
+ const handler = createMcpHandler(
54
+ (requestContext) => createServer(readIdentity(requestContext.authInfo)),
55
+ {
56
+ responseMode: "json",
57
+ maxRequestBodySize: MAX_MCP_REQUEST_BYTES,
58
+ onerror: (error) => console.error(`@softure-ai/mcp-access: the MCP handler reported: ${errorLogLabel(error)}`),
59
+ },
60
+ );
61
+
62
+ return async (ctx, request) => {
63
+ assertRateLimitBucket(ctx);
64
+ const client = identifyClient(ctx, request.headers);
65
+ if (!client.ok) return Response.json({ error: "client_unidentified" }, { status: 400, headers: NO_STORE });
66
+
67
+ let limit;
68
+ try {
69
+ // Counted before verification, so a flood of guesses is stopped before any lookup.
70
+ limit = await consumeRateLimit(ctx, { bucket: MCP_RATE_LIMIT_BUCKET, key: client.value });
71
+ } catch (error) {
72
+ // Fails closed and says nothing: the route is public and this runs before authentication.
73
+ console.error(`@softure-ai/mcp-access: counting a request failed: ${errorLogLabel(error)}`);
74
+ return Response.json({ error: "temporarily_unavailable" }, { status: 503, headers: NO_STORE });
75
+ }
76
+ if (!limit.ok) {
77
+ return Response.json(
78
+ { error: "too_many_requests" },
79
+ { status: 429, headers: { ...NO_STORE, "retry-after": String(limit.retryAfterSeconds) } },
80
+ );
81
+ }
82
+
83
+ const authInfo = await authenticate(ctx, request.headers.get("authorization"));
84
+ if (authInfo instanceof Response) return authInfo;
85
+ return handler.fetch(request, { authInfo });
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The verified identity as the SDK's `AuthInfo`, or the challenge response. Unknown, revoked,
91
+ * expired and malformed tokens get the same `401 invalid_token`. Expiry is decided by the query on
92
+ * the module's clock, not by the SDK's own check on the wall clock.
93
+ */
94
+ async function authenticate(ctx: McpAccessContext, header: string | null): Promise<AuthInfo | Response> {
95
+ const challenge = { requiredScopes: [MCP_READ_SCOPE] };
96
+ if (header === null || !BEARER_PREFIX.test(header)) {
97
+ return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "Missing Bearer token"), challenge);
98
+ }
99
+ const token = header.replace(BEARER_PREFIX, "").trim();
100
+
101
+ let verified;
102
+ try {
103
+ verified = await verifyAccessToken(ctx, token);
104
+ } catch (error) {
105
+ console.error(`@softure-ai/mcp-access: verifying a token failed: ${errorLogLabel(error)}`);
106
+ return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.ServerError, "Internal Server Error"), challenge);
107
+ }
108
+ if (verified === null) {
109
+ return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "Invalid access token"), challenge);
110
+ }
111
+ return {
112
+ token,
113
+ // The client is the token: it is what gets revoked, and its id may be logged.
114
+ clientId: verified.tokenId,
115
+ scopes: verified.canWrite ? [MCP_READ_SCOPE, MCP_WRITE_SCOPE] : [MCP_READ_SCOPE],
116
+ // AuthInfo counts seconds; Date counts milliseconds.
117
+ expiresAt: Math.floor(verified.expiresAt.getTime() / 1000),
118
+ extra: { [USER_ID_CLAIM]: verified.userId },
119
+ };
120
+ }
121
+
122
+ /** The factory's input from the request's `AuthInfo`; throws when the two sides disagree. */
123
+ function readIdentity(authInfo: AuthInfo | undefined): McpServerIdentity {
124
+ const userId = authInfo?.extra?.[USER_ID_CLAIM];
125
+ if (authInfo === undefined || typeof userId !== "string" || userId === "") {
126
+ // Unreachable through the endpoint, which always passes a verified identity. A server built
127
+ // without one would run queries with no account scope, so this refuses instead.
128
+ throw new Error("@softure-ai/mcp-access: an MCP request reached the factory without a verified identity");
129
+ }
130
+ return { userId, canWrite: authInfo.scopes.includes(MCP_WRITE_SCOPE), tokenId: authInfo.clientId };
131
+ }
132
+
133
+ /** A missing bucket is a setup bug: thrown, so it reaches the log by name instead of a quiet 503. */
134
+ function assertRateLimitBucket(ctx: McpAccessContext): void {
135
+ const options = getModule(ctx.config, "security")?.options as { buckets?: Record<string, unknown> } | undefined;
136
+ if (options?.buckets?.[MCP_RATE_LIMIT_BUCKET] === undefined) {
137
+ throw new Error(
138
+ `@softure-ai/mcp-access: the security module has no "${MCP_RATE_LIMIT_BUCKET}" bucket; spread MCP_RATE_LIMIT_BUCKETS into security({ buckets })`,
139
+ );
140
+ }
141
+ }
@@ -0,0 +1,12 @@
1
+ // The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the tokens table exists
2
+ // and answers, i.e. `softure migrate` ran. It reads no rows.
3
+ import { ok, type HealthCheck } from "@softure-ai/core";
4
+ import type { Queryable } from "@softure-ai/db";
5
+ import { sql } from "drizzle-orm";
6
+
7
+ export const checkAccessTokensTable: HealthCheck = async (context) => {
8
+ // Core types `db` as unknown; the health route passes the @softure-ai/db handle.
9
+ const db = context.db as Queryable;
10
+ await db.execute(sql`select 1 from mcp.access_tokens limit 0`);
11
+ return ok();
12
+ };
@@ -0,0 +1,36 @@
1
+ // Server-only API of @softure-ai/mcp-access. Every function receives the module context
2
+ // (`{ db, clock, config }`) and never reads request scope; the endpoint takes a Web `Request`.
3
+ export {
4
+ createMcpEndpoint,
5
+ MAX_MCP_REQUEST_BYTES,
6
+ MCP_RATE_LIMIT_BUCKET,
7
+ MCP_READ_SCOPE,
8
+ MCP_WRITE_SCOPE,
9
+ type McpEndpoint,
10
+ type McpEndpointOptions,
11
+ type McpServerFactory,
12
+ } from "./endpoint.js";
13
+ export { checkAccessTokensTable } from "./health.js";
14
+ export { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl, type McpAccessRoutes } from "./options.js";
15
+ export {
16
+ deleteMcpAccessUserData,
17
+ exportMcpAccessUserData,
18
+ mcpAccessPrivacyContributor,
19
+ type McpAccessUserData,
20
+ } from "./privacy.js";
21
+ export {
22
+ ACCESS_TOKEN_PREFIX,
23
+ hashAccessToken,
24
+ isAccessTokenShape,
25
+ issueAccessToken,
26
+ listAccessTokens,
27
+ pruneAccessTokens,
28
+ revokeAccessToken,
29
+ verifyAccessToken,
30
+ type IssueAccessTokenInput,
31
+ type IssueAccessTokenResult,
32
+ type IssuedAccessToken,
33
+ type McpAccessContext,
34
+ type RevokeAccessTokenInput,
35
+ type VerifiedAccessToken,
36
+ } from "./tokens.js";
@@ -0,0 +1,54 @@
1
+ // The mcp-access options of the running app, read from the configuration in the module context.
2
+ import { getModule, type Locale, type SoftureConfig } from "@softure-ai/core";
3
+ import type { McpAccessMessages } from "../messages/index.js";
4
+ import type { McpAccessOptions } from "../options.js";
5
+
6
+ const MODULE_ID = "mcp-access";
7
+
8
+ /** The enabled module. Throws when the app did not enable it: calling its functions then is a bug. */
9
+ export function getMcpAccessModule(config: SoftureConfig) {
10
+ const module = getModule(config, MODULE_ID);
11
+ if (module === undefined) {
12
+ throw new Error("@softure-ai/mcp-access: the module is not enabled; add mcpAccess({ ... }) to modules in softure.config.ts");
13
+ }
14
+ return module;
15
+ }
16
+
17
+ export function getMcpAccessOptions(config: SoftureConfig): McpAccessOptions {
18
+ // The module factory parsed these options with mcpAccessOptionsSchema.
19
+ return getMcpAccessModule(config).options as McpAccessOptions;
20
+ }
21
+
22
+ export interface McpAccessRoutes {
23
+ /** The token page. */
24
+ readonly page: string;
25
+ /** `POST` MCP endpoint. */
26
+ readonly endpoint: string;
27
+ }
28
+
29
+ export function getMcpAccessRoutes(config: SoftureConfig): McpAccessRoutes {
30
+ const routes = getMcpAccessModule(config).routes;
31
+ const read = (name: keyof McpAccessRoutes): string => {
32
+ const path = routes[name];
33
+ // The manifest declares every route, so a missing one means a broken module definition.
34
+ if (path === undefined) throw new Error(`@softure-ai/mcp-access: route "${name}" is missing from the module manifest`);
35
+ return path;
36
+ };
37
+ return { page: read("page"), endpoint: read("endpoint") };
38
+ }
39
+
40
+ /** The absolute URL MCP clients call: the app's origin and the endpoint route. */
41
+ export function getMcpEndpointUrl(config: SoftureConfig): string {
42
+ return new URL(getMcpAccessRoutes(config).endpoint, config.appOrigin).toString();
43
+ }
44
+
45
+ /** The module's copy in the app's locale, with the app's overrides applied. */
46
+ export function getMcpAccessMessages(config: SoftureConfig): McpAccessMessages {
47
+ // The module factory merged the dictionaries; their shape is the module's own.
48
+ return getMcpAccessModule(config).messages[config.locale] as McpAccessMessages;
49
+ }
50
+
51
+ /** The text in `locale`, else in `en`. */
52
+ export function getLocalizedText(text: Partial<Record<Locale, string>>, locale: Locale): string {
53
+ return text[locale] ?? text.en ?? "";
54
+ }
@@ -0,0 +1,49 @@
1
+ // The mcp-access part of a GDPR export and deletion (`@softure-ai/privacy`): the user's access
2
+ // tokens, by name and dates. Never a token hash. Deletion removes the tokens by name before auth
3
+ // deletes the account, so it does not rest on the foreign key's cascade alone.
4
+ import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
5
+ import type { Queryable } from "@softure-ai/db";
6
+ import { asc, eq } from "drizzle-orm";
7
+ import { accessTokens } from "../schema.js";
8
+
9
+ /** What mcp-access holds about one user, as it appears in their export. */
10
+ export interface McpAccessUserData {
11
+ readonly accessTokens: readonly {
12
+ readonly name: string;
13
+ readonly canWrite: boolean;
14
+ readonly createdAt: Date;
15
+ readonly expiresAt: Date;
16
+ readonly lastUsedAt: Date | null;
17
+ }[];
18
+ }
19
+
20
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+
22
+ export async function exportMcpAccessUserData(context: ModuleContext, userId: string): Promise<Ok<McpAccessUserData>> {
23
+ if (!UUID.test(userId)) return ok({ accessTokens: [] });
24
+ // Core types `db` as unknown; privacy passes the @softure-ai/db handle or its transaction.
25
+ const db = context.db as Queryable;
26
+ const rows = await db
27
+ .select({
28
+ name: accessTokens.name,
29
+ canWrite: accessTokens.canWrite,
30
+ createdAt: accessTokens.createdAt,
31
+ expiresAt: accessTokens.expiresAt,
32
+ lastUsedAt: accessTokens.lastUsedAt,
33
+ })
34
+ .from(accessTokens)
35
+ .where(eq(accessTokens.userId, userId))
36
+ .orderBy(asc(accessTokens.createdAt));
37
+ return ok({ accessTokens: rows });
38
+ }
39
+
40
+ export async function deleteMcpAccessUserData(context: ModuleContext, userId: string): Promise<Ok<undefined>> {
41
+ if (!UUID.test(userId)) return ok();
42
+ await (context.db as Queryable).delete(accessTokens).where(eq(accessTokens.userId, userId));
43
+ return ok();
44
+ }
45
+
46
+ export const mcpAccessPrivacyContributor: PrivacyContributor = {
47
+ exportUserData: exportMcpAccessUserData,
48
+ deleteUserData: deleteMcpAccessUserData,
49
+ };
@@ -0,0 +1,188 @@
1
+ // Access tokens (FIRE_TRACKER `src/db/access-tokens.ts`, generalised). A token is `sftmcp_` and 32
2
+ // random bytes; the database holds only its sha256, so a leaked row cannot be replayed. The
3
+ // plaintext leaves this file once, in the result of `issueAccessToken`.
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { err, errorLogLabel, ok, type Err, type ModuleContext, type Ok } from "@softure-ai/core";
6
+ import type { Queryable } from "@softure-ai/db";
7
+ import { and, count, desc, eq, gt, isNull, lt, lte, or, sql } from "drizzle-orm";
8
+ import type { AccessTokenView } from "../contract.js";
9
+ import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
10
+ import { accessTokens } from "../schema.js";
11
+ import { getMcpAccessOptions } from "./options.js";
12
+
13
+ export type McpAccessContext = ModuleContext<Queryable>;
14
+
15
+ /** Marks a token as this module's, for people and for secret scanners. */
16
+ export const ACCESS_TOKEN_PREFIX = "sftmcp_";
17
+
18
+ const TOKEN_BYTES = 32;
19
+ /** The prefix and base64url of 32 bytes. */
20
+ const TOKEN_SHAPE = /^sftmcp_[A-Za-z0-9_-]{43}$/;
21
+ const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+ /** `last_used_at` answers "is this token still in use?"; a minute is precise enough for that. */
24
+ const LAST_USED_RESOLUTION_MS = 60_000;
25
+
26
+ export function hashAccessToken(token: string): string {
27
+ return createHash("sha256").update(token, "utf8").digest("hex");
28
+ }
29
+
30
+ /** Whether a presented value could be a token at all; anything else is not looked up. */
31
+ export function isAccessTokenShape(token: string): boolean {
32
+ return TOKEN_SHAPE.test(token);
33
+ }
34
+
35
+ function createAccessTokenSecret(): { readonly token: string; readonly tokenHash: string } {
36
+ const token = `${ACCESS_TOKEN_PREFIX}${randomBytes(TOKEN_BYTES).toString("base64url")}`;
37
+ return { token, tokenHash: hashAccessToken(token) };
38
+ }
39
+
40
+ export interface IssueAccessTokenInput {
41
+ /** The auth user id of the owner, from the session. */
42
+ readonly userId: string;
43
+ readonly name: string;
44
+ /** Asked for write access; granted only while the app allows writes. */
45
+ readonly canWrite: boolean;
46
+ }
47
+
48
+ export interface IssuedAccessToken {
49
+ readonly id: string;
50
+ /** The plaintext: shown to the owner once, stored nowhere. */
51
+ readonly token: string;
52
+ readonly name: string;
53
+ readonly canWrite: boolean;
54
+ readonly expiresAt: Date;
55
+ }
56
+
57
+ export type IssueAccessTokenResult =
58
+ | Ok<IssuedAccessToken>
59
+ | Err<"mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached">;
60
+
61
+ /**
62
+ * Issues a token for its owner. The per-account limit counts unexpired tokens, in one transaction
63
+ * that holds a per-user advisory lock, so two parallel requests cannot both take the last place.
64
+ * The owner's expired tokens are deleted first: they would otherwise count against the limit.
65
+ * Database failures propagate.
66
+ */
67
+ export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccessTokenInput): Promise<IssueAccessTokenResult> {
68
+ const name = input.name.trim();
69
+ if (name === "") return err("mcp-access.name_required");
70
+ if (name.length > MAX_TOKEN_NAME_LENGTH) return err("mcp-access.name_too_long");
71
+
72
+ const options = getMcpAccessOptions(ctx.config);
73
+ const canWrite = input.canWrite && options.allowWrites;
74
+ const now = ctx.clock.now();
75
+ const expiresAt = new Date(now.getTime() + options.tokenLifetimeDays * DAY_MS);
76
+
77
+ return ctx.db.transaction(async (tx): Promise<IssueAccessTokenResult> => {
78
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`mcp-access:${input.userId}`}, 0))`);
79
+ await tx.delete(accessTokens).where(and(eq(accessTokens.userId, input.userId), lte(accessTokens.expiresAt, now)));
80
+ const [held] = await tx.select({ total: count() }).from(accessTokens).where(eq(accessTokens.userId, input.userId));
81
+ if ((held?.total ?? 0) >= options.maxTokensPerUser) return err("mcp-access.token_limit_reached");
82
+
83
+ const { token, tokenHash } = createAccessTokenSecret();
84
+ const [row] = await tx
85
+ .insert(accessTokens)
86
+ .values({ userId: input.userId, name, tokenHash, canWrite, createdAt: now, expiresAt })
87
+ .returning();
88
+ if (row === undefined) throw new Error("@softure-ai/mcp-access: inserting a token returned no row");
89
+ return ok({ id: row.id, token, name, canWrite, expiresAt });
90
+ });
91
+ }
92
+
93
+ /** The owner's tokens, newest first, expired ones included (the list marks them). */
94
+ export async function listAccessTokens(ctx: McpAccessContext, userId: string): Promise<AccessTokenView[]> {
95
+ return ctx.db
96
+ .select({
97
+ id: accessTokens.id,
98
+ name: accessTokens.name,
99
+ canWrite: accessTokens.canWrite,
100
+ createdAt: accessTokens.createdAt,
101
+ expiresAt: accessTokens.expiresAt,
102
+ lastUsedAt: accessTokens.lastUsedAt,
103
+ })
104
+ .from(accessTokens)
105
+ .where(eq(accessTokens.userId, userId))
106
+ .orderBy(desc(accessTokens.createdAt), desc(accessTokens.id));
107
+ }
108
+
109
+ export interface RevokeAccessTokenInput {
110
+ /** The owner, from the session. */
111
+ readonly userId: string;
112
+ /** From the form: any value, including another account's token id. */
113
+ readonly tokenId: string;
114
+ }
115
+
116
+ /**
117
+ * Deletes a token at once; the next request with it gets 401. The owner is part of the `WHERE`,
118
+ * so another account's id matches no row instead of relying on a comparison here.
119
+ */
120
+ export async function revokeAccessToken(
121
+ ctx: McpAccessContext,
122
+ input: RevokeAccessTokenInput,
123
+ ): Promise<Ok<undefined> | Err<"mcp-access.token_not_found">> {
124
+ if (!UUID_SHAPE.test(input.tokenId)) return err("mcp-access.token_not_found");
125
+ const removed = await ctx.db
126
+ .delete(accessTokens)
127
+ .where(and(eq(accessTokens.userId, input.userId), eq(accessTokens.id, input.tokenId)))
128
+ .returning();
129
+ return removed.length > 0 ? ok() : err("mcp-access.token_not_found");
130
+ }
131
+
132
+ /** The identity behind a valid token. */
133
+ export interface VerifiedAccessToken {
134
+ readonly tokenId: string;
135
+ readonly userId: string;
136
+ /** Effective write access: the token was issued for writes and the app allows them now. */
137
+ readonly canWrite: boolean;
138
+ readonly expiresAt: Date;
139
+ }
140
+
141
+ /**
142
+ * The identity behind a token, or null for anything unknown, revoked or expired: the caller cannot
143
+ * tell which, so a guess learns nothing. The expiry sits in the query, so no code path can forget
144
+ * it. Records the use (at most once a minute per token); a failed record is logged and does not
145
+ * fail the request. Database failures of the lookup propagate.
146
+ */
147
+ export async function verifyAccessToken(ctx: McpAccessContext, token: string): Promise<VerifiedAccessToken | null> {
148
+ if (!isAccessTokenShape(token)) return null;
149
+ const now = ctx.clock.now();
150
+ const [row] = await ctx.db
151
+ .select({ id: accessTokens.id, userId: accessTokens.userId, canWrite: accessTokens.canWrite, expiresAt: accessTokens.expiresAt })
152
+ .from(accessTokens)
153
+ .where(and(eq(accessTokens.tokenHash, hashAccessToken(token)), gt(accessTokens.expiresAt, now)));
154
+ if (row === undefined) return null;
155
+
156
+ await recordUse(ctx, row.id, now);
157
+ return {
158
+ tokenId: row.id,
159
+ userId: row.userId,
160
+ canWrite: row.canWrite && getMcpAccessOptions(ctx.config).allowWrites,
161
+ expiresAt: row.expiresAt,
162
+ };
163
+ }
164
+
165
+ async function recordUse(ctx: McpAccessContext, tokenId: string, now: Date): Promise<void> {
166
+ const staleBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS);
167
+ try {
168
+ await ctx.db
169
+ .update(accessTokens)
170
+ .set({ lastUsedAt: now })
171
+ .where(and(eq(accessTokens.id, tokenId), or(isNull(accessTokens.lastUsedAt), lt(accessTokens.lastUsedAt, staleBefore))));
172
+ } catch (error) {
173
+ // The token id is not a secret; the token is never logged.
174
+ console.error(`@softure-ai/mcp-access: recording the use of token ${tokenId} failed: ${errorLogLabel(error)}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Deletes every expired token, of every account. Issuing already removes the issuer's own; an app
180
+ * may call this from a scheduled job so abandoned accounts do not keep dead rows.
181
+ */
182
+ export async function pruneAccessTokens(ctx: McpAccessContext): Promise<number> {
183
+ const removed = await ctx.db
184
+ .delete(accessTokens)
185
+ .where(lte(accessTokens.expiresAt, ctx.clock.now()))
186
+ .returning();
187
+ return removed.length;
188
+ }