@softure-ai/mcp-access 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +231 -11
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +2 -2
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +48 -2
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +88 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +19 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/jobs/index.d.ts +2 -0
  14. package/dist/jobs/index.d.ts.map +1 -1
  15. package/dist/jobs/index.js +5 -2
  16. package/dist/jobs/index.js.map +1 -1
  17. package/dist/jobs/operation-state.d.ts +58 -0
  18. package/dist/jobs/operation-state.d.ts.map +1 -0
  19. package/dist/jobs/operation-state.js +68 -0
  20. package/dist/jobs/operation-state.js.map +1 -0
  21. package/dist/jobs/operation-tasks.d.ts +61 -0
  22. package/dist/jobs/operation-tasks.d.ts.map +1 -0
  23. package/dist/jobs/operation-tasks.js +161 -0
  24. package/dist/jobs/operation-tasks.js.map +1 -0
  25. package/dist/jobs/operations.d.ts +68 -0
  26. package/dist/jobs/operations.d.ts.map +1 -0
  27. package/dist/jobs/operations.js +110 -0
  28. package/dist/jobs/operations.js.map +1 -0
  29. package/dist/messages/en.d.ts +55 -0
  30. package/dist/messages/en.d.ts.map +1 -1
  31. package/dist/messages/en.js +55 -0
  32. package/dist/messages/en.js.map +1 -1
  33. package/dist/messages/index.d.ts +54 -0
  34. package/dist/messages/index.d.ts.map +1 -1
  35. package/dist/next/issue-token.d.ts +11 -2
  36. package/dist/next/issue-token.d.ts.map +1 -1
  37. package/dist/next/issue-token.js +56 -7
  38. package/dist/next/issue-token.js.map +1 -1
  39. package/dist/next/token-manager-props.d.ts +3 -2
  40. package/dist/next/token-manager-props.d.ts.map +1 -1
  41. package/dist/next/token-manager-props.js +12 -2
  42. package/dist/next/token-manager-props.js.map +1 -1
  43. package/dist/options.d.ts +47 -0
  44. package/dist/options.d.ts.map +1 -1
  45. package/dist/options.js +53 -0
  46. package/dist/options.js.map +1 -1
  47. package/dist/schema.d.ts +214 -0
  48. package/dist/schema.d.ts.map +1 -1
  49. package/dist/schema.js +21 -3
  50. package/dist/schema.js.map +1 -1
  51. package/dist/server/api-tokens.d.ts +64 -0
  52. package/dist/server/api-tokens.d.ts.map +1 -0
  53. package/dist/server/api-tokens.js +176 -0
  54. package/dist/server/api-tokens.js.map +1 -0
  55. package/dist/server/confirmation.d.ts +69 -0
  56. package/dist/server/confirmation.d.ts.map +1 -0
  57. package/dist/server/confirmation.js +220 -0
  58. package/dist/server/confirmation.js.map +1 -0
  59. package/dist/server/health.d.ts.map +1 -1
  60. package/dist/server/health.js +3 -1
  61. package/dist/server/health.js.map +1 -1
  62. package/dist/server/index.d.ts +4 -2
  63. package/dist/server/index.d.ts.map +1 -1
  64. package/dist/server/index.js +3 -1
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/server/privacy.d.ts +4 -0
  67. package/dist/server/privacy.d.ts.map +1 -1
  68. package/dist/server/privacy.js +5 -1
  69. package/dist/server/privacy.js.map +1 -1
  70. package/dist/server/prune.d.ts +5 -1
  71. package/dist/server/prune.d.ts.map +1 -1
  72. package/dist/server/prune.js +14 -4
  73. package/dist/server/prune.js.map +1 -1
  74. package/dist/server/strict-input.d.ts +3 -1
  75. package/dist/server/strict-input.d.ts.map +1 -1
  76. package/dist/server/strict-input.js +54 -8
  77. package/dist/server/strict-input.js.map +1 -1
  78. package/dist/server/tokens.d.ts +46 -11
  79. package/dist/server/tokens.d.ts.map +1 -1
  80. package/dist/server/tokens.js +46 -24
  81. package/dist/server/tokens.js.map +1 -1
  82. package/dist/server/tools.d.ts +8 -0
  83. package/dist/server/tools.d.ts.map +1 -1
  84. package/dist/server/tools.js +1 -0
  85. package/dist/server/tools.js.map +1 -1
  86. package/dist/tokens/index.d.ts +6 -0
  87. package/dist/tokens/index.d.ts.map +1 -0
  88. package/dist/tokens/index.js +8 -0
  89. package/dist/tokens/index.js.map +1 -0
  90. package/dist/ui/index.d.ts +1 -1
  91. package/dist/ui/index.d.ts.map +1 -1
  92. package/dist/ui/index.js.map +1 -1
  93. package/dist/ui/token-manager.d.ts +18 -2
  94. package/dist/ui/token-manager.d.ts.map +1 -1
  95. package/dist/ui/token-manager.js +28 -3
  96. package/dist/ui/token-manager.js.map +1 -1
  97. package/migrations/0004_create_used_confirmations.sql +13 -0
  98. package/migrations/0005_add_api_tokens.sql +15 -0
  99. package/migrations/0006_create_operation_tasks.sql +18 -0
  100. package/module.json +9 -3
  101. package/package.json +7 -2
  102. package/src/cli/run.ts +2 -2
  103. package/src/contract.ts +60 -2
  104. package/src/index.ts +31 -3
  105. package/src/jobs/index.ts +41 -2
  106. package/src/jobs/operation-state.ts +119 -0
  107. package/src/jobs/operation-tasks.ts +229 -0
  108. package/src/jobs/operations.ts +213 -0
  109. package/src/messages/en.ts +56 -0
  110. package/src/next/issue-token.ts +64 -9
  111. package/src/next/token-manager-props.ts +15 -4
  112. package/src/options.ts +62 -0
  113. package/src/schema.ts +23 -3
  114. package/src/server/api-tokens.ts +224 -0
  115. package/src/server/confirmation.ts +316 -0
  116. package/src/server/health.ts +3 -1
  117. package/src/server/index.ts +32 -2
  118. package/src/server/privacy.ts +9 -1
  119. package/src/server/prune.ts +17 -4
  120. package/src/server/strict-input.ts +62 -13
  121. package/src/server/tokens.ts +73 -27
  122. package/src/server/tools.ts +9 -0
  123. package/src/tokens/index.ts +22 -0
  124. package/src/ui/index.ts +1 -0
  125. package/src/ui/token-manager.tsx +140 -3
@@ -1,14 +1,27 @@
1
- // The module's scheduled cleanup: expired access tokens and dead OAuth records of every account, in
2
- // one call for a daily job (`softure-mcp prune`, a deploy `maintain` hook) instead of inline on requests.
1
+ // The module's scheduled cleanup: expired access tokens, dead OAuth records of every account, used
2
+ // confirmation tokens and MCP tasks past their expiry, in one call for a daily job (`softure-mcp prune`, a deploy `maintain` hook) instead of inline on requests.
3
+ import { lte } from "drizzle-orm";
4
+ import { operationTasks } from "../schema.js";
5
+ import { pruneUsedConfirmations } from "./confirmation.js";
3
6
  import { pruneOAuthRecords, type PrunedOAuthRecords } from "./oauth.js";
4
7
  import { pruneAccessTokens, type McpAccessContext } from "./tokens.js";
5
8
 
6
9
  export interface PrunedMcpAccessRecords extends PrunedOAuthRecords {
7
10
  readonly accessTokens: number;
11
+ readonly usedConfirmations: number;
12
+ readonly operationTasks: number;
8
13
  }
9
14
 
10
- /** Runs `pruneAccessTokens`, then `pruneOAuthRecords`, and counts what each removed. */
15
+ /** Runs `pruneAccessTokens`, `pruneOAuthRecords`, `pruneUsedConfirmations`, then `pruneOperationTasks`, and counts what each removed. */
11
16
  export async function pruneMcpAccess(ctx: McpAccessContext): Promise<PrunedMcpAccessRecords> {
12
17
  const accessTokens = await pruneAccessTokens(ctx);
13
- return { accessTokens, ...(await pruneOAuthRecords(ctx)) };
18
+ const oauth = await pruneOAuthRecords(ctx);
19
+ const usedConfirmations = await pruneUsedConfirmations(ctx);
20
+ return { accessTokens, ...oauth, usedConfirmations, operationTasks: await pruneOperationTasks(ctx) };
21
+ }
22
+
23
+ /** Deletes the MCP tasks past their expiry (#977); the operations' jobs stay with `@softure-ai/jobs`. */
24
+ export async function pruneOperationTasks(ctx: McpAccessContext): Promise<number> {
25
+ const removed = await ctx.db.delete(operationTasks).where(lte(operationTasks.expiresAt, ctx.clock.now())).returning({ taskId: operationTasks.taskId });
26
+ return removed.length;
14
27
  }
@@ -3,8 +3,8 @@
3
3
  // `withStrictToolInput` rewrites every input schema once, at registration: each object refuses a field
4
4
  // it does not know and names the ones it accepts, a field sent under its old name is refused with the
5
5
  // stable code `input.field_renamed`, and boolean and number fields also take their exact text (a client
6
- // on an older tool list sends a field it does not know as text). The SDK validates before the handler
7
- // runs, so a refused call saves nothing.
6
+ // on an older tool list sends a field it does not know as text). A tool registered without an input schema
7
+ // gets the strict empty object. The SDK validates before the handler runs, so a refused call saves nothing.
8
8
  //
9
9
  // const server = withStrictToolInput(new McpServer({ name: "acme", version }), { renamedFields: { notes: "note" } });
10
10
  import type { McpServer } from "@modelcontextprotocol/server";
@@ -16,9 +16,12 @@ import { en } from "../messages/en.js";
16
16
  /** The texts of a refused field: `toolInput` of the module dictionary (`getMcpAccessMessages(config).toolInput`). */
17
17
  export type StrictToolInputMessages = typeof en.toolInput;
18
18
 
19
+ /** Old field name → new field name, as a map or a rule (`undefined`: not renamed). */
20
+ export type RenamedFields = Readonly<Record<string, string>> | ((field: string) => string | undefined);
21
+
19
22
  export interface StrictToolInputOptions {
20
23
  /** Old field name → new field name, refused as `input.field_renamed` wherever an object accepts the new name. */
21
- readonly renamedFields?: Readonly<Record<string, string>>;
24
+ readonly renamedFields?: RenamedFields;
22
25
  /** Boolean fields also take `"true"`/`"false"` and number fields a number written as text. Default: `true`. */
23
26
  readonly coerceTextScalars?: boolean;
24
27
  /** Default: the module's English dictionary. */
@@ -32,14 +35,20 @@ const strictServers = new WeakSet<McpServer>();
32
35
 
33
36
  type ToolConfig = Readonly<Record<string, unknown>> & { readonly inputSchema?: unknown };
34
37
  type RegisterTool = (name: string, config: ToolConfig, callback: unknown) => unknown;
38
+ type ContextCallback = (ctx: unknown) => unknown;
35
39
 
36
40
  /** Makes every tool registered on the server from now on strict; wrapping a server twice changes nothing. */
37
41
  export function withStrictToolInput<Server extends McpServer>(server: Server, options: StrictToolInputOptions = {}): Server {
38
42
  if (strictServers.has(server)) return server;
39
43
  // The SDK's overloads only differ in how the schema is typed; at runtime it is one method.
40
44
  const register = server.registerTool.bind(server) as unknown as RegisterTool;
41
- const registerStrict: RegisterTool = (name, config, callback) =>
42
- register(name, config.inputSchema === undefined ? config : { ...config, inputSchema: toStrictToolInput(config.inputSchema, options) }, callback);
45
+ const registerStrict: RegisterTool = (name, config, callback) => {
46
+ if (config.inputSchema !== undefined) return register(name, { ...config, inputSchema: toStrictToolInput(config.inputSchema, options) }, callback);
47
+ // Without a schema the SDK skips validation and calls `callback(ctx)`; with the strict empty object it calls
48
+ // `callback(args, ctx)`, so the callback keeps receiving the context only.
49
+ const contextCallback = callback as ContextCallback;
50
+ return register(name, { ...config, inputSchema: toStrictToolInput(z.object({}), options) }, (_args: unknown, ctx: unknown) => contextCallback(ctx));
51
+ };
43
52
  Object.defineProperty(server, "registerTool", { value: registerStrict, configurable: true, writable: true });
44
53
  strictServers.add(server);
45
54
  return server;
@@ -50,7 +59,7 @@ export function toStrictToolInput(schema: unknown, options: StrictToolInputOptio
50
59
  const zodSchema = isZodSchema(schema) ? schema : isRawShape(schema) ? z.object(schema) : null;
51
60
  if (zodSchema === null) return schema;
52
61
  const rewriter = new StrictRewriter({
53
- renamedFields: options.renamedFields ?? {},
62
+ renameField: toRenameRule(options.renamedFields ?? {}),
54
63
  coerceTextScalars: options.coerceTextScalars ?? true,
55
64
  messages: options.messages ?? en.toolInput,
56
65
  });
@@ -66,7 +75,19 @@ function isRawShape(value: unknown): value is Record<string, core.$ZodType> {
66
75
  return Object.values(value).every(isZodSchema);
67
76
  }
68
77
 
69
- type RewriteSettings = Required<StrictToolInputOptions>;
78
+ function toRenameRule(renamedFields: RenamedFields): (field: string) => string | undefined {
79
+ if (typeof renamedFields === "function") return renamedFields;
80
+ return (field) => (Object.hasOwn(renamedFields, field) ? renamedFields[field] : undefined);
81
+ }
82
+
83
+ interface RewriteSettings {
84
+ readonly renameField: (field: string) => string | undefined;
85
+ readonly coerceTextScalars: boolean;
86
+ readonly messages: StrictToolInputMessages;
87
+ }
88
+
89
+ /** Wrappers that keep the value's kind: a chain of them around a scalar takes the text step around the whole chain. */
90
+ const WRAPPER_TYPES = new Set(["optional", "nullable", "default", "prefault", "nonoptional", "readonly", "catch"]);
70
91
 
71
92
  /** A schema node as the rewriter reads it: its kind and the parts it rewrites. */
72
93
  type AnyDef = core.$ZodTypeDef & Record<string, unknown>;
@@ -98,8 +119,13 @@ class StrictRewriter {
98
119
  case "prefault":
99
120
  case "nonoptional":
100
121
  case "readonly":
101
- case "catch":
122
+ case "catch": {
123
+ // Outside the chain, zod still lists its `default` (it drops one on a transforming node) and the field's
124
+ // optionality is the chain's own; the step passes `undefined` and `null` through to it.
125
+ const reader = this.findTextReader(schema);
126
+ if (reader !== undefined) return withTextStep(reader, schema);
102
127
  return this.cloneWith(schema, { innerType: this.rewrite(def.innerType as core.$ZodType) });
128
+ }
103
129
  case "union":
104
130
  return this.cloneWith(schema, { options: (def.options as core.$ZodType[]).map((option) => this.rewrite(option)) });
105
131
  case "intersection":
@@ -118,14 +144,26 @@ class StrictRewriter {
118
144
  return this.cloneWith(schema, { getter: () => this.rewrite(getter()) });
119
145
  }
120
146
  case "boolean":
121
- return this.settings.coerceTextScalars && def.coerce !== true ? z.preprocess(readBooleanText, schema) : schema;
122
- case "number":
123
- return this.settings.coerceTextScalars && def.coerce !== true ? z.preprocess(readNumberText, schema) : schema;
147
+ case "number": {
148
+ const reader = this.findTextReader(schema);
149
+ return reader === undefined ? schema : withTextStep(reader, schema);
150
+ }
124
151
  default:
125
152
  return schema;
126
153
  }
127
154
  }
128
155
 
156
+ /** The text reader for a boolean or number, seen through its wrapper chain; none when text is not taken. */
157
+ private findTextReader(schema: core.$ZodType): ((value: unknown) => unknown) | undefined {
158
+ if (!this.settings.coerceTextScalars) return undefined;
159
+ let def = schema._zod.def as AnyDef;
160
+ while (WRAPPER_TYPES.has(def.type)) def = (def.innerType as core.$ZodType)._zod.def as AnyDef;
161
+ if (def.coerce === true) return undefined;
162
+ if (def.type === "boolean") return readBooleanText;
163
+ if (def.type === "number") return readNumberText;
164
+ return undefined;
165
+ }
166
+
129
167
  /** Only the input side: `in`, or `out` after a preprocess (whose `in` is the transform). */
130
168
  private rewritePipe(schema: core.$ZodType, def: AnyDef): core.$ZodType {
131
169
  const input = def.in as core.$ZodType;
@@ -162,11 +200,11 @@ class StrictRewriter {
162
200
  /** The text for an object's unknown fields: renamed ones first, then the rest with the fields it accepts. */
163
201
  function describeUnknownFields(issue: core.$ZodRawIssue, accepted: readonly string[], settings: RewriteSettings): string | undefined {
164
202
  if (issue.code !== "unrecognized_keys") return undefined;
165
- const { messages, renamedFields } = settings;
203
+ const { messages, renameField } = settings;
166
204
  const renamed: string[] = [];
167
205
  const unknown: string[] = [];
168
206
  for (const field of issue.keys) {
169
- const newName = Object.hasOwn(renamedFields, field) ? renamedFields[field] : undefined;
207
+ const newName = renameField(field);
170
208
  if (newName !== undefined && accepted.includes(newName)) renamed.push(formatMessage(messages.fieldRenamed, { field, renamed: newName }));
171
209
  else unknown.push(field);
172
210
  }
@@ -180,6 +218,17 @@ function quoteAll(names: readonly string[]): string {
180
218
  return names.map((name) => JSON.stringify(name)).join(", ");
181
219
  }
182
220
 
221
+ /**
222
+ * `schema` behind a text step. zod gives every transform an optional input, so a step would make a required field
223
+ * look optional in the listed JSON Schema inside `nullable` or a union; it takes the optionality of the schema it
224
+ * wraps instead. That changes no parse: the step passes `undefined` on, and the schema refuses it as before.
225
+ */
226
+ function withTextStep(reader: (value: unknown) => unknown, schema: core.$ZodType): core.$ZodType {
227
+ const step = z.preprocess(reader, schema as z.ZodType);
228
+ Object.defineProperty(step._zod, "optin", { value: schema._zod.optin, configurable: true, writable: true });
229
+ return step;
230
+ }
231
+
183
232
  function readBooleanText(value: unknown): unknown {
184
233
  if (value === "true") return true;
185
234
  if (value === "false") return false;
@@ -1,11 +1,11 @@
1
- // Access tokens (an adopting app's token code, generalised). A token is `sftmcp_` and 32 random
2
- // bytes; the database holds only its sha256, so a leaked row cannot be replayed. The plaintext
1
+ // Access tokens (an adopting app's token code, generalised). An MCP token is `sftmcp_` and 32 random
2
+ // bytes, an API token (`api-tokens.ts`) the app's prefix and 32 random bytes; the database holds only its sha256, so a leaked row cannot be replayed. The plaintext
3
3
  // leaves this file once, in the result of `issueAccessToken` (or of an OAuth exchange, `oauth.ts`).
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { err, errorLogLabel, ok, type Err, type ModuleContext, type Ok } from "@softure-ai/core";
6
6
  import type { Queryable } from "@softure-ai/db";
7
7
  import { and, count, desc, eq, gt, isNull, lt, lte, or, sql } from "drizzle-orm";
8
- import type { AccessTokenView } from "../contract.js";
8
+ import type { AccessTokenKind, AccessTokenView } from "../contract.js";
9
9
  import { MAX_PRESENTED_TOKEN_LENGTH, MAX_TOKEN_NAME_LENGTH, type McpAccessOptions } from "../options.js";
10
10
  import { accessTokens, oauthGrants } from "../schema.js";
11
11
  import { getMcpAccessOptions } from "./options.js";
@@ -70,22 +70,46 @@ export type IssueAccessTokenResult =
70
70
  | Err<"mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached">;
71
71
 
72
72
  /**
73
- * Issues a token for its owner. The per-account limit counts unexpired tokens, in one transaction
74
- * that holds a per-user advisory lock, so two parallel requests cannot both take the last place.
75
- * The owner's expired tokens are deleted first: they would otherwise count against the limit.
73
+ * Issues an MCP token for its owner. The per-account limit counts unexpired tokens of both kinds, in one
74
+ * transaction that holds a per-user advisory lock, so two parallel requests cannot both take the last
75
+ * place. The owner's expired tokens are deleted first: they would otherwise count against the limit.
76
76
  * Database failures propagate.
77
77
  */
78
78
  export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccessTokenInput): Promise<IssueAccessTokenResult> {
79
+ const options = getMcpAccessOptions(ctx.config);
80
+ const canWrite = input.canWrite && options.allowWrites;
81
+ const result = await insertToken(ctx, { userId: input.userId, name: input.name, kind: "mcp", canWrite, scopes: [], secret: createSecret(ACCESS_TOKEN_PREFIX) });
82
+ if (!result.ok) return result;
83
+ const { id, token, name, expiresAt } = result.value;
84
+ return ok({ id, token, name, canWrite, expiresAt });
85
+ }
86
+
87
+ /** What `insertToken` stores: the new secret with its hash, write access and scopes are already decided. */
88
+ export interface TokenInsert {
89
+ readonly userId: string;
90
+ readonly name: string;
91
+ readonly kind: AccessTokenKind;
92
+ readonly canWrite: boolean;
93
+ readonly scopes: readonly string[];
94
+ /** The plaintext and the hash the lookup will find it by (`createSecret`, or the API token's own). */
95
+ readonly secret: { readonly secret: string; readonly secretHash: string };
96
+ }
97
+
98
+ export type InsertTokenResult =
99
+ | Ok<{ readonly id: string; readonly token: string; readonly name: string; readonly expiresAt: Date }>
100
+ | Err<"mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached">;
101
+
102
+ /** Stores a new token of either kind under the owner's lock and limit; the plaintext is returned once. */
103
+ export async function insertToken(ctx: McpAccessContext, input: TokenInsert): Promise<InsertTokenResult> {
79
104
  const name = input.name.trim();
80
105
  if (name === "") return err("mcp-access.name_required");
81
106
  if (name.length > MAX_TOKEN_NAME_LENGTH) return err("mcp-access.name_too_long");
82
107
 
83
108
  const options = getMcpAccessOptions(ctx.config);
84
- const canWrite = input.canWrite && options.allowWrites;
85
109
  const now = ctx.clock.now();
86
110
  const expiresAt = new Date(now.getTime() + options.tokenLifetimeDays * DAY_MS);
87
111
 
88
- return ctx.db.transaction(async (tx): Promise<IssueAccessTokenResult> => {
112
+ return ctx.db.transaction(async (tx): Promise<InsertTokenResult> => {
89
113
  await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`mcp-access:${input.userId}`}, 0))`);
90
114
  await tx.delete(accessTokens).where(and(eq(accessTokens.userId, input.userId), lte(accessTokens.expiresAt, now)));
91
115
  // OAuth access tokens belong to their grant and rotate every hour: they do not take places.
@@ -95,25 +119,27 @@ export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccess
95
119
  .where(and(eq(accessTokens.userId, input.userId), isNull(accessTokens.grantId)));
96
120
  if ((held?.total ?? 0) >= options.maxTokensPerUser) return err("mcp-access.token_limit_reached");
97
121
 
98
- const { secret: token, secretHash: tokenHash } = createSecret(ACCESS_TOKEN_PREFIX);
122
+ const { secret: token, secretHash: tokenHash } = input.secret;
99
123
  const [row] = await tx
100
124
  .insert(accessTokens)
101
- .values({ userId: input.userId, name, tokenHash, canWrite, createdAt: now, expiresAt })
125
+ .values({ userId: input.userId, name, tokenHash, canWrite: input.canWrite, createdAt: now, expiresAt, kind: input.kind, scopes: [...input.scopes] })
102
126
  .returning();
103
127
  if (row === undefined) throw new Error("@softure-ai/mcp-access: inserting a token returned no row");
104
- return ok({ id: row.id, token, name, canWrite, expiresAt });
128
+ return ok({ id: row.id, token, name, expiresAt });
105
129
  });
106
130
  }
107
131
 
108
132
  /**
109
- * The owner's hand-issued tokens, newest first, expired ones included (the list marks them).
110
- * OAuth access tokens are left out: the page shows their grants instead.
133
+ * The owner's hand-issued tokens of both kinds, newest first, expired ones included (the list marks
134
+ * them). OAuth access tokens are left out: the page shows their grants instead.
111
135
  */
112
136
  export async function listAccessTokens(ctx: McpAccessContext, userId: string): Promise<AccessTokenView[]> {
113
137
  return ctx.db
114
138
  .select({
115
139
  id: accessTokens.id,
116
140
  name: accessTokens.name,
141
+ kind: accessTokens.kind,
142
+ scopes: accessTokens.scopes,
117
143
  canWrite: accessTokens.canWrite,
118
144
  createdAt: accessTokens.createdAt,
119
145
  expiresAt: accessTokens.expiresAt,
@@ -170,14 +196,40 @@ export async function findTokenClientName(ctx: McpAccessContext, tokenId: string
170
196
  }
171
197
 
172
198
  /**
173
- * The identity behind a token, or null for anything unknown, revoked or expired: the caller cannot
174
- * tell which, so a guess learns nothing. The expiry sits in the query, so no code path can forget
175
- * it. Records the use (at most once a minute per token); a failed record is logged and does not
176
- * fail the request. Database failures of the lookup propagate.
199
+ * The identity behind an MCP token, or null for anything unknown, revoked or expired, and for an API
200
+ * token: the caller cannot tell which, so a guess learns nothing. The expiry sits in the query, so no
201
+ * code path can forget it. Records the use (at most once a minute per token); a failed record is
202
+ * logged and does not fail the request. Database failures of the lookup propagate.
177
203
  */
178
204
  export async function verifyAccessToken(ctx: McpAccessContext, token: string): Promise<VerifiedAccessToken | null> {
179
205
  const options = getMcpAccessOptions(ctx.config);
180
206
  if (!isAcceptedTokenShape(options, token)) return null;
207
+ const row = await findValidToken(ctx, hashAccessToken(token), "mcp");
208
+ if (row === null) return null;
209
+ return {
210
+ tokenId: row.id,
211
+ userId: row.userId,
212
+ canWrite: row.canWrite && options.allowWrites,
213
+ expiresAt: row.expiresAt,
214
+ grantId: row.grantId,
215
+ };
216
+ }
217
+
218
+ /** A stored, unexpired token of one kind, found by the hash of its plaintext. */
219
+ export interface ValidTokenRow {
220
+ readonly id: string;
221
+ readonly userId: string;
222
+ readonly canWrite: boolean;
223
+ readonly expiresAt: Date;
224
+ readonly grantId: string | null;
225
+ readonly scopes: readonly string[];
226
+ }
227
+
228
+ /**
229
+ * The unexpired token of `kind` stored under this hash, or null, after recording its use. The caller
230
+ * checks the shape first, so no lookup runs for a value that cannot be a token.
231
+ */
232
+ export async function findValidToken(ctx: McpAccessContext, tokenHash: string, kind: AccessTokenKind): Promise<ValidTokenRow | null> {
181
233
  const now = ctx.clock.now();
182
234
  const [row] = await ctx.db
183
235
  .select({
@@ -186,19 +238,13 @@ export async function verifyAccessToken(ctx: McpAccessContext, token: string): P
186
238
  canWrite: accessTokens.canWrite,
187
239
  expiresAt: accessTokens.expiresAt,
188
240
  grantId: accessTokens.grantId,
241
+ scopes: accessTokens.scopes,
189
242
  })
190
243
  .from(accessTokens)
191
- .where(and(eq(accessTokens.tokenHash, hashAccessToken(token)), gt(accessTokens.expiresAt, now)));
244
+ .where(and(eq(accessTokens.tokenHash, tokenHash), eq(accessTokens.kind, kind), gt(accessTokens.expiresAt, now)));
192
245
  if (row === undefined) return null;
193
-
194
246
  await recordUse(ctx, row, now);
195
- return {
196
- tokenId: row.id,
197
- userId: row.userId,
198
- canWrite: row.canWrite && options.allowWrites,
199
- expiresAt: row.expiresAt,
200
- grantId: row.grantId,
201
- };
247
+ return row;
202
248
  }
203
249
 
204
250
  /** Records the use on the token and, for an OAuth token, on its grant (the connected apps list). */
@@ -222,7 +268,7 @@ async function recordUse(ctx: McpAccessContext, token: { readonly id: string; re
222
268
  }
223
269
 
224
270
  /**
225
- * Deletes every expired token, of every account, hand-issued and OAuth alike. Issuing already
271
+ * Deletes every expired token, of every account and kind, hand-issued and OAuth alike. Issuing already
226
272
  * removes the issuer's own; an app may call this from a scheduled job so abandoned accounts do not
227
273
  * keep dead rows (with `pruneOAuthRecords` for codes, grants and clients).
228
274
  */
@@ -19,6 +19,8 @@ export interface ToolErrorOptions {
19
19
  readonly caller?: ToolCaller;
20
20
  /** The tool's arguments, for the record: only their names are read, never their values. */
21
21
  readonly args?: Readonly<Record<string, unknown>>;
22
+ /** Whether the call ran on the person's confirmation, for the record (`withConfirmation` sets it). */
23
+ readonly confirmed?: boolean;
22
24
  }
23
25
 
24
26
  /** The client behind a call: the token's id (`McpServerIdentity.tokenId`) and its name (`findTokenClientName`). */
@@ -44,6 +46,12 @@ export interface ToolCallRecord {
44
46
  readonly tokenId: string | null;
45
47
  readonly clientName: string | null;
46
48
  readonly argumentNames: readonly string[];
49
+ /**
50
+ * For a tool wrapped in `withConfirmation`: `true` when the call carried a valid confirmation and did the work,
51
+ * `false` when it answered an estimate, was refused, or ran without asking (`runWithoutConfirmationWhen`). `null`
52
+ * for any other tool.
53
+ */
54
+ readonly confirmed: boolean | null;
47
55
  readonly outcome: ToolCallOutcome;
48
56
  readonly startedAt: Date;
49
57
  readonly durationMs: number;
@@ -96,6 +104,7 @@ async function reportToolCall(options: ToolErrorOptions, outcome: ToolCallOutcom
96
104
  tokenId: options.caller?.tokenId ?? null,
97
105
  clientName: options.caller?.clientName ?? null,
98
106
  argumentNames: Object.keys(options.args ?? {}),
107
+ confirmed: options.confirmed ?? null,
99
108
  outcome,
100
109
  startedAt: new Date(startedAt),
101
110
  durationMs: Date.now() - startedAt,
@@ -0,0 +1,22 @@
1
+ // Personal access tokens for an app's plain HTTP routes, without the MCP server (#961): nothing this
2
+ // entry loads imports `@modelcontextprotocol/*` (tests/api-tokens-961.test.ts walks the graph). A route
3
+ // handler checks a request with `requireApiToken` and answers a refusal with `apiTokenErrorResponse`.
4
+ export {
5
+ API_RATE_LIMIT_BUCKET,
6
+ API_TOKEN_RATE_LIMIT_BUCKET,
7
+ apiTokenErrorResponse,
8
+ getApiTokenOptions,
9
+ isApiTokenShape,
10
+ issueApiToken,
11
+ requireApiToken,
12
+ verifyApiToken,
13
+ type IssueApiTokenInput,
14
+ type IssueApiTokenResult,
15
+ type IssuedApiToken,
16
+ type RequireApiTokenOptions,
17
+ type VerifiedApiToken,
18
+ } from "../server/api-tokens.js";
19
+ export { createMcpAccessContext } from "../server/context.js";
20
+ export { isWritingEnabled } from "../server/writes-switch.js";
21
+ export { hashAccessToken, listAccessTokens, pruneAccessTokens, revokeAccessToken, type McpAccessContext, type RevokeAccessTokenInput } from "../server/tokens.js";
22
+ export type { AccessTokenKind, AccessTokenView, ApiTokenErrorCode, ApiTokenIdentity, ApiTokenRefusal } from "../contract.js";
package/src/ui/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  export { ConsentError, ConsentForm, type ConsentErrorProps, type ConsentFormProps, type ConsentFormSlot } from "./consent-form.js";
4
4
  export {
5
5
  TokenManager,
6
+ type TokenManagerApiScope,
6
7
  type RevokeGrantAction,
7
8
  type TokenManagerGrant,
8
9
  type IssueTokenAction,
@@ -3,12 +3,13 @@
3
3
  import { formatMessage, type Locale } from "@softure-ai/core";
4
4
  import { Button, ButtonLink, Checkbox, type ClassNames, createSlotClassGetter, FormError, TextField } from "@softure-ai/ui";
5
5
  import { useActionState, useEffect, useRef, useState } from "react";
6
- import type { IssuedToken, IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
6
+ import type { IssuedApiTokenView, IssuedToken, IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
7
7
  import { getTokenErrorMessage, type McpAccessMessages } from "../messages/index.js";
8
8
  import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
9
9
 
10
10
  // The token page's client part: the tool catalog, the issue form, the token just issued with its
11
- // setup snippets, the owner's tokens with a revoke button each, and, with OAuth on, the apps
11
+ // setup snippets, (with API scopes) the app's scopes and a second form for tokens for other apps,
12
+ // the owner's tokens of both kinds with a revoke button each, and, with OAuth on, the apps
12
13
  // connected through OAuth with a disconnect button each. Both forms submit to their
13
14
  // server actions through `useActionState`. The plaintext lives only in the issue form's state:
14
15
  // "Done" or leaving the page drops it. Focus follows the one-time token: its heading takes focus
@@ -29,11 +30,23 @@ export interface TokenManagerTool {
29
30
  readonly accessText: string;
30
31
  }
31
32
 
33
+ /** One scope the app declares for API tokens, prepared on the server. */
34
+ export interface TokenManagerApiScope {
35
+ readonly name: string;
36
+ readonly description: string;
37
+ /** "Reads", "Changes data", or that writes are off, already in the app's copy. */
38
+ readonly accessText: string;
39
+ /** Offered on the form: a read scope, or a write scope while the app allows writes. */
40
+ readonly isOffered: boolean;
41
+ }
42
+
32
43
  /** One of the owner's tokens, prepared on the server. */
33
44
  export interface TokenManagerRow {
34
45
  readonly id: string;
35
46
  readonly name: string;
36
- /** Read only, or read and change, already in the app's copy. */
47
+ /** "AI assistant" or "Other apps", while the app offers both kinds. */
48
+ readonly kindText?: string;
49
+ /** Read only, or read and change, already in the app's copy; an API token's scopes. */
37
50
  readonly scopeText: string;
38
51
  /** Active, expiring in n days, or expired. */
39
52
  readonly statusText: string;
@@ -65,6 +78,11 @@ export interface TokenManagerProps {
65
78
  readonly grants?: readonly TokenManagerGrant[];
66
79
  /** Disconnects one app; needed with `grants`. */
67
80
  readonly revokeGrantAction?: RevokeGrantAction;
81
+ /**
82
+ * The app's API scopes (`apiTokens`); with them the page lists them and offers a second form for tokens
83
+ * for other apps, submitted to `issueAction` with `kind=api`. Leave out, and the page offers MCP tokens only.
84
+ */
85
+ readonly apiScopes?: readonly TokenManagerApiScope[];
68
86
  readonly messages: McpAccessMessages;
69
87
  /** Locale of the built-in copy of the ui primitives. */
70
88
  readonly locale?: Locale;
@@ -211,6 +229,103 @@ function IssueForm({
211
229
  );
212
230
  }
213
231
 
232
+ function IssuedApiTokenPanel({
233
+ issued,
234
+ onDone,
235
+ messages,
236
+ slot,
237
+ unstyled,
238
+ }: {
239
+ issued: IssuedApiTokenView;
240
+ onDone: () => void;
241
+ messages: McpAccessMessages;
242
+ slot: Slot;
243
+ unstyled: boolean | undefined;
244
+ }) {
245
+ const snippet = (title: string, value: string) => <Snippet key={title} title={title} value={value} messages={messages} slot={slot} unstyled={unstyled} />;
246
+ const headingRef = useRef<HTMLHeadingElement>(null);
247
+ useEffect(() => {
248
+ headingRef.current?.focus();
249
+ }, [issued.id]);
250
+ return (
251
+ <section className={slot("section")}>
252
+ <h3 ref={headingRef} tabIndex={-1} className={slot("heading")}>{formatMessage(messages.apiIssued.title, { name: issued.name })}</h3>
253
+ <p className={slot("text")}>{messages.apiIssued.once}</p>
254
+ <p className={slot("text")}>
255
+ {issued.expiresText} {formatMessage(messages.apiIssued.scopes, { scopes: issued.scopes.join(", ") })}
256
+ </p>
257
+ {snippet(messages.apiIssued.tokenTitle, issued.token)}
258
+ {snippet(messages.apiIssued.headerTitle, `Authorization: ${issued.authorizationHeader}`)}
259
+ {issued.examples.map((example) => snippet(example.title, example.text))}
260
+ <Button variant="primary" onClick={onDone} unstyled={unstyled}>
261
+ {messages.issued.done}
262
+ </Button>
263
+ </section>
264
+ );
265
+ }
266
+
267
+ function ApiIssueForm({
268
+ action,
269
+ scopes,
270
+ maxTokens,
271
+ messages,
272
+ locale,
273
+ slot,
274
+ unstyled,
275
+ }: {
276
+ action: IssueTokenAction;
277
+ scopes: readonly TokenManagerApiScope[];
278
+ maxTokens: number;
279
+ messages: McpAccessMessages;
280
+ locale: Locale | undefined;
281
+ slot: Slot;
282
+ unstyled: boolean | undefined;
283
+ }) {
284
+ const [state, formAction, isPending] = useActionState(action, { status: "idle" });
285
+ const [dismissedId, setDismissedId] = useState<string | null>(null);
286
+ const issued = state.status === "api-ok" && state.issued.id !== dismissedId ? state.issued : null;
287
+ const formRef = useRef<HTMLFormElement>(null);
288
+ function dismiss(id: string) {
289
+ setDismissedId(id);
290
+ requestAnimationFrame(() => {
291
+ const field = formRef.current?.elements.namedItem("name");
292
+ if (field instanceof HTMLInputElement) field.focus();
293
+ });
294
+ }
295
+ return (
296
+ <>
297
+ <section className={slot("section")}>
298
+ <h2 className={slot("heading")}>{messages.apiIssue.title}</h2>
299
+ <form ref={formRef} action={formAction} className={slot("list")}>
300
+ <input type="hidden" name="kind" value="api" />
301
+ <TextField
302
+ name="name"
303
+ label={messages.apiIssue.name}
304
+ hint={messages.apiIssue.nameHint}
305
+ maxLength={MAX_TOKEN_NAME_LENGTH}
306
+ required
307
+ autoComplete="off"
308
+ locale={locale}
309
+ unstyled={unstyled}
310
+ />
311
+ <p className={slot("heading")}>{messages.apiIssue.scopes}</p>
312
+ {scopes
313
+ .filter((scope) => scope.isOffered)
314
+ .map((scope) => (
315
+ <Checkbox key={scope.name} name={`scope:${scope.name}`} label={scope.name} description={scope.description} unstyled={unstyled} />
316
+ ))}
317
+ <p className={slot("text")}>{formatMessage(messages.issue.limit, { max: maxTokens })}</p>
318
+ <FormError message={state.status === "error" ? (state.message ?? getTokenErrorMessage(messages, state.error)) : undefined} unstyled={unstyled} />
319
+ <Button type="submit" variant="primary" pending={isPending} unstyled={unstyled}>
320
+ {isPending ? messages.apiIssue.pending : messages.apiIssue.submit}
321
+ </Button>
322
+ </form>
323
+ </section>
324
+ {issued === null ? null : <IssuedApiTokenPanel issued={issued} onDone={() => dismiss(issued.id)} messages={messages} slot={slot} unstyled={unstyled} />}
325
+ </>
326
+ );
327
+ }
328
+
214
329
  function TokenItem({
215
330
  row,
216
331
  action,
@@ -237,6 +352,7 @@ function TokenItem({
237
352
  </form>
238
353
  </div>
239
354
  <p className={row.isExpired ? slot("details") : slot("badge")}>
355
+ {row.kindText === undefined ? null : `${row.kindText} · `}
240
356
  {row.statusText} · {row.scopeText}
241
357
  </p>
242
358
  <p className={slot("details")}>{row.details.join(" · ")}</p>
@@ -286,6 +402,7 @@ export function TokenManager({
286
402
  revokeAction,
287
403
  grants,
288
404
  revokeGrantAction,
405
+ apiScopes,
289
406
  messages,
290
407
  locale,
291
408
  classNames,
@@ -313,6 +430,26 @@ export function TokenManager({
313
430
  )}
314
431
  </section>
315
432
  <IssueForm action={issueAction} allowWrites={allowWrites} maxTokens={maxTokens} messages={messages} locale={locale} slot={slot} unstyled={unstyled} />
433
+ {apiScopes === undefined ? null : (
434
+ <>
435
+ <section className={slot("section")}>
436
+ <h2 className={slot("heading")}>{messages.apiScopes.title}</h2>
437
+ <p className={slot("text")}>{messages.apiScopes.lead}</p>
438
+ <ul className={slot("list")}>
439
+ {apiScopes.map((scope) => (
440
+ <li key={scope.name} className={slot("item")}>
441
+ <div className={slot("itemHeader")}>
442
+ <code className={slot("heading")}>{scope.name}</code>
443
+ <span className={slot("badge")}>{scope.accessText}</span>
444
+ </div>
445
+ <p className={slot("text")}>{scope.description}</p>
446
+ </li>
447
+ ))}
448
+ </ul>
449
+ </section>
450
+ <ApiIssueForm action={issueAction} scopes={apiScopes} maxTokens={maxTokens} messages={messages} locale={locale} slot={slot} unstyled={unstyled} />
451
+ </>
452
+ )}
316
453
  <section className={slot("section")}>
317
454
  <h2 className={slot("heading")}>{messages.list.title}</h2>
318
455
  {tokens.length === 0 ? (