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

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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -2
  3. package/dist/client-setup.d.ts +32 -0
  4. package/dist/client-setup.d.ts.map +1 -0
  5. package/dist/client-setup.js +57 -0
  6. package/dist/client-setup.js.map +1 -0
  7. package/dist/contract.d.ts +80 -0
  8. package/dist/contract.d.ts.map +1 -0
  9. package/dist/contract.js +2 -0
  10. package/dist/contract.js.map +1 -0
  11. package/dist/index.d.ts +119 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +50 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/messages/en.d.ts +77 -0
  16. package/dist/messages/en.d.ts.map +1 -0
  17. package/dist/messages/en.js +77 -0
  18. package/dist/messages/en.js.map +1 -0
  19. package/dist/messages/index.d.ts +161 -0
  20. package/dist/messages/index.d.ts.map +1 -0
  21. package/dist/messages/index.js +14 -0
  22. package/dist/messages/index.js.map +1 -0
  23. package/dist/messages/pl.d.ts +3 -0
  24. package/dist/messages/pl.d.ts.map +1 -0
  25. package/dist/messages/pl.js +77 -0
  26. package/dist/messages/pl.js.map +1 -0
  27. package/dist/next/actions.d.ts +6 -0
  28. package/dist/next/actions.d.ts.map +1 -0
  29. package/dist/next/actions.js +88 -0
  30. package/dist/next/actions.js.map +1 -0
  31. package/dist/next/context.d.ts +4 -0
  32. package/dist/next/context.d.ts.map +1 -0
  33. package/dist/next/context.js +13 -0
  34. package/dist/next/context.js.map +1 -0
  35. package/dist/next/format.d.ts +4 -0
  36. package/dist/next/format.d.ts.map +1 -0
  37. package/dist/next/format.js +7 -0
  38. package/dist/next/format.js.map +1 -0
  39. package/dist/next/index.d.ts +4 -0
  40. package/dist/next/index.d.ts.map +1 -0
  41. package/dist/next/index.js +6 -0
  42. package/dist/next/index.js.map +1 -0
  43. package/dist/next/pages.d.ts +2 -0
  44. package/dist/next/pages.d.ts.map +1 -0
  45. package/dist/next/pages.js +61 -0
  46. package/dist/next/pages.js.map +1 -0
  47. package/dist/next/route.d.ts +3 -0
  48. package/dist/next/route.d.ts.map +1 -0
  49. package/dist/next/route.js +24 -0
  50. package/dist/next/route.js.map +1 -0
  51. package/dist/options.d.ts +43 -0
  52. package/dist/options.d.ts.map +1 -0
  53. package/dist/options.js +47 -0
  54. package/dist/options.js.map +1 -0
  55. package/dist/schema.d.ts +145 -0
  56. package/dist/schema.d.ts.map +1 -0
  57. package/dist/schema.js +18 -0
  58. package/dist/schema.js.map +1 -0
  59. package/dist/server/endpoint.d.ts +26 -0
  60. package/dist/server/endpoint.d.ts.map +1 -0
  61. package/dist/server/endpoint.js +107 -0
  62. package/dist/server/endpoint.js.map +1 -0
  63. package/dist/server/health.d.ts +3 -0
  64. package/dist/server/health.d.ts.map +1 -0
  65. package/dist/server/health.js +11 -0
  66. package/dist/server/health.js.map +1 -0
  67. package/dist/server/index.d.ts +6 -0
  68. package/dist/server/index.d.ts.map +1 -0
  69. package/dist/server/index.js +8 -0
  70. package/dist/server/index.js.map +1 -0
  71. package/dist/server/options.d.ts +20 -0
  72. package/dist/server/options.d.ts.map +1 -0
  73. package/dist/server/options.js +40 -0
  74. package/dist/server/options.js.map +1 -0
  75. package/dist/server/privacy.d.ts +15 -0
  76. package/dist/server/privacy.d.ts.map +1 -0
  77. package/dist/server/privacy.js +36 -0
  78. package/dist/server/privacy.js.map +1 -0
  79. package/dist/server/tokens.d.ts +66 -0
  80. package/dist/server/tokens.d.ts.map +1 -0
  81. package/dist/server/tokens.js +138 -0
  82. package/dist/server/tokens.js.map +1 -0
  83. package/dist/token-status.d.ts +9 -0
  84. package/dist/token-status.d.ts.map +1 -0
  85. package/dist/token-status.js +14 -0
  86. package/dist/token-status.js.map +1 -0
  87. package/dist/ui/index.d.ts +2 -0
  88. package/dist/ui/index.d.ts.map +1 -0
  89. package/dist/ui/index.js +4 -0
  90. package/dist/ui/index.js.map +1 -0
  91. package/dist/ui/token-manager.d.ts +43 -0
  92. package/dist/ui/token-manager.d.ts.map +1 -0
  93. package/dist/ui/token-manager.js +50 -0
  94. package/dist/ui/token-manager.js.map +1 -0
  95. package/migrations/0001_create_access_tokens.sql +24 -0
  96. package/module.json +15 -0
  97. package/package.json +65 -4
  98. package/src/client-setup.ts +71 -0
  99. package/src/contract.ts +80 -0
  100. package/src/index.ts +81 -0
  101. package/src/messages/en.ts +76 -0
  102. package/src/messages/index.ts +18 -0
  103. package/src/messages/pl.ts +78 -0
  104. package/src/next/actions.ts +91 -0
  105. package/src/next/context.ts +14 -0
  106. package/src/next/format.ts +10 -0
  107. package/src/next/index.ts +5 -0
  108. package/src/next/next-modules.d.ts +16 -0
  109. package/src/next/pages.tsx +80 -0
  110. package/src/next/route.ts +24 -0
  111. package/src/options.ts +58 -0
  112. package/src/schema.ts +19 -0
  113. package/src/server/endpoint.ts +141 -0
  114. package/src/server/health.ts +12 -0
  115. package/src/server/index.ts +36 -0
  116. package/src/server/options.ts +54 -0
  117. package/src/server/privacy.ts +49 -0
  118. package/src/server/tokens.ts +188 -0
  119. package/src/token-status.ts +27 -0
  120. package/src/ui/index.ts +11 -0
  121. package/src/ui/token-manager.tsx +254 -0
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ // Where a token stands on its expiry, for the owner's list (FIRE_TRACKER
2
+ // `src/lib/access-token-status.ts`, generalised to the app's time zone). Expiry compares instants
3
+ // with the same boundary as verification (valid while `expires_at > now`), so the list never calls
4
+ // a token active that the endpoint already refuses; the distance counts calendar days, as people do.
5
+ import type { AccessTokenStatus } from "./contract.js";
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+
9
+ export interface AccessTokenStatusOptions {
10
+ /** The app's IANA time zone: calendar days are counted there. */
11
+ readonly timezone: string;
12
+ /** From how many days before the expiry the token counts as expiring. */
13
+ readonly warningDays: number;
14
+ }
15
+
16
+ export function getAccessTokenStatus(expiresAt: Date, now: Date, options: AccessTokenStatusOptions): AccessTokenStatus {
17
+ if (expiresAt.getTime() <= now.getTime()) return { kind: "expired" };
18
+ const daysLeft = getDayNumber(expiresAt, options.timezone) - getDayNumber(now, options.timezone);
19
+ return daysLeft <= options.warningDays ? { kind: "expiring", daysLeft } : { kind: "active" };
20
+ }
21
+
22
+ /** The calendar day of an instant in a time zone, as a day count; two of them subtract to days. */
23
+ function getDayNumber(instant: Date, timezone: string): number {
24
+ const format = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" });
25
+ const parts = Object.fromEntries(format.formatToParts(instant).map((part) => [part.type, part.value]));
26
+ return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day)) / DAY_MS;
27
+ }
@@ -0,0 +1,11 @@
1
+ // The token manager, to compose into the app's own page. It takes the server actions from
2
+ // `@softure-ai/mcp-access/next` as props: no `next/*` import here (docs/02-module-standard.md §5).
3
+ export {
4
+ TokenManager,
5
+ type IssueTokenAction,
6
+ type RevokeTokenAction,
7
+ type TokenManagerProps,
8
+ type TokenManagerRow,
9
+ type TokenManagerSlot,
10
+ type TokenManagerTool,
11
+ } from "./token-manager.js";