@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
@@ -0,0 +1,213 @@
1
+ // Long operations as MCP tools (#952), on top of `@softure-ai/jobs`. A tool whose work takes minutes cannot hold the
2
+ // call open (JSON mode drops mid-call notifications, proxies cut long responses, assistants time out), so it starts a
3
+ // job and answers an operation id at once; the assistant then asks `get_operation` until the operation ends, and may
4
+ // stop it with `cancel_operation`.
5
+ //
6
+ // server.registerTool("render_video", { inputSchema }, (args) => startOperation(ctx, identity, { queue: "video.render", payload: args }));
7
+ // registerOperationStatusTool(server, { ctx, identity, readResult: (job) => findRenderUrl(job.id) });
8
+ // registerOperationCancelTool(server, { ctx, identity });
9
+ //
10
+ // Ownership rides on the job itself: its origin is `mcp` and its dedup key `mcp:<userId>:<key>`, which the jobs table
11
+ // keeps after the job ends. Any other job, or another account's, is "not found".
12
+ import { randomUUID } from "node:crypto";
13
+ import type { CallToolResult, McpServer } from "@modelcontextprotocol/server";
14
+ import { err, ok, type Err, type Ok } from "@softure-ai/core";
15
+ import { enqueueJob, requestJobCancel, type JobsContext } from "@softure-ai/jobs/server";
16
+ import { z } from "zod";
17
+ import type { McpServerIdentity } from "../contract.js";
18
+ import type { McpAccessMessages } from "../messages/index.js";
19
+ import { getMcpAccessMessages } from "../server/options.js";
20
+ import { toolResult, withToolErrors, type ToolErrorHints } from "../server/tools.js";
21
+ import { answerOperationTask, isTasksRequest, isTaskTtlValid, MAX_TASK_TTL_SECONDS, type OperationTaskRequest } from "./operation-tasks.js";
22
+ import {
23
+ describeOperation,
24
+ fillToolName,
25
+ findOwnedJob,
26
+ getOwnerPrefix,
27
+ getPollMin,
28
+ MAX_OPERATION_DEDUP_KEY_LENGTH,
29
+ OPERATION_ORIGIN,
30
+ OPERATION_STATUS_TOOL_NAME,
31
+ OPERATION_CANCEL_TOOL_NAME,
32
+ type GetOperationOptions,
33
+ type OperationAnswer,
34
+ type OperationErrorCode,
35
+ type OperationPollOptions,
36
+ type ReadOperationResult,
37
+ } from "./operation-state.js";
38
+
39
+ export {
40
+ getOperationStatus,
41
+ getPollAfterSeconds,
42
+ MAX_OPERATION_DEDUP_KEY_LENGTH,
43
+ OPERATION_CANCEL_TOOL_NAME,
44
+ OPERATION_ORIGIN,
45
+ OPERATION_STATUS_TOOL_NAME,
46
+ OPERATION_STATUSES,
47
+ type GetOperationOptions,
48
+ type OperationAnswer,
49
+ type OperationErrorCode,
50
+ type OperationJob,
51
+ type OperationPollOptions,
52
+ type OperationStatus,
53
+ type ReadOperationResult,
54
+ } from "./operation-state.js";
55
+
56
+ export interface StartOperationInput {
57
+ /** A queue defined in `jobs({ queues })`. */
58
+ readonly queue: string;
59
+ /** The handler's payload, as `enqueueJob` takes it: ids, not personal data. */
60
+ readonly payload: unknown;
61
+ /**
62
+ * While an operation of this account and queue with the same key runs, starting again joins it (the answer carries
63
+ * its id and status). At most 150 characters. Default: a new operation every time.
64
+ */
65
+ readonly dedupKey?: string;
66
+ /** The status tool the answer tells the assistant to call. Default `get_operation`. */
67
+ readonly statusToolName?: string;
68
+ readonly poll?: OperationPollOptions;
69
+ /**
70
+ * The tool handler's request context (its second argument). When the client declared the MCP Tasks extension on
71
+ * this request, the answer is a task (`resultType: "task"`) instead of an operation id; any other client gets the
72
+ * operation id as before. Pair it with `registerOperationTasks` on the same server, which serves `tasks/get`.
73
+ */
74
+ readonly taskRequest?: OperationTaskRequest;
75
+ /** How long a task can be read after it was created, in seconds. Default 86 400 (a day). */
76
+ readonly taskTtlSeconds?: number;
77
+ }
78
+
79
+ interface OperationToolOptions {
80
+ /** `{ db, clock, config }` of the request. */
81
+ readonly ctx: JobsContext;
82
+ /** The request's identity: operations are scoped to `identity.userId`. */
83
+ readonly identity: McpServerIdentity;
84
+ /** Default: the module dictionary's description. */
85
+ readonly description?: string;
86
+ }
87
+
88
+ export interface OperationStatusToolOptions extends OperationToolOptions {
89
+ /** Default `get_operation`. */
90
+ readonly name?: string;
91
+ readonly readResult?: ReadOperationResult;
92
+ readonly poll?: OperationPollOptions;
93
+ }
94
+
95
+ export interface OperationCancelToolOptions extends OperationToolOptions {
96
+ /** Default `cancel_operation`. */
97
+ readonly name?: string;
98
+ /** The status tool the answer points to. Default `get_operation`. */
99
+ readonly statusToolName?: string;
100
+ readonly poll?: OperationPollOptions;
101
+ }
102
+
103
+ const operationInput = z.object({ operationId: z.string().min(1).max(100) });
104
+
105
+ /**
106
+ * For a tool handler: enqueues the operation's job and answers its id with the fixed sentence (ask the status tool,
107
+ * do not start again). Throws on an unknown queue or a dedup key over 150 characters (a bug in the caller); database
108
+ * failures propagate, so call it inside `withToolErrors` or let the SDK answer the error.
109
+ */
110
+ export async function startOperation(ctx: JobsContext, identity: McpServerIdentity, input: StartOperationInput): Promise<CallToolResult> {
111
+ if (input.dedupKey !== undefined && (input.dedupKey.length === 0 || input.dedupKey.length > MAX_OPERATION_DEDUP_KEY_LENGTH)) {
112
+ throw new Error(`startOperation: the dedup key of queue ${input.queue} must have 1 to ${String(MAX_OPERATION_DEDUP_KEY_LENGTH)} characters`);
113
+ }
114
+ if (input.taskTtlSeconds !== undefined && !isTaskTtlValid(input.taskTtlSeconds)) {
115
+ throw new Error(`startOperation: the task lifetime of queue ${input.queue} must be whole seconds from 60 to ${String(MAX_TASK_TTL_SECONDS)}`);
116
+ }
117
+ const enqueued = await enqueueJob(ctx, {
118
+ queue: input.queue,
119
+ payload: input.payload,
120
+ origin: OPERATION_ORIGIN,
121
+ dedupKey: getOwnerPrefix(identity.userId) + (input.dedupKey ?? randomUUID()),
122
+ });
123
+ const options = { ...(input.poll === undefined ? {} : { poll: input.poll }), statusToolName: input.statusToolName ?? OPERATION_STATUS_TOOL_NAME };
124
+ if (input.taskRequest !== undefined && isTasksRequest(input.taskRequest)) {
125
+ return answerOperationTask(ctx, identity, { jobId: enqueued.id, ...(input.taskTtlSeconds === undefined ? {} : { ttlSeconds: input.taskTtlSeconds }), ...(input.poll === undefined ? {} : { poll: input.poll }) });
126
+ }
127
+ if (enqueued.isDuplicate) {
128
+ const joined = await getOperation(ctx, identity, enqueued.id, options);
129
+ if (joined.ok) return toolResult(joined.value);
130
+ }
131
+ const messages = getMcpAccessMessages(ctx.config).operations;
132
+ const answer: OperationAnswer = {
133
+ operationId: enqueued.id,
134
+ status: "queued",
135
+ progress: null,
136
+ pollAfterSeconds: getPollMin(input.poll),
137
+ message: fillToolName(messages.started, options.statusToolName),
138
+ };
139
+ return toolResult(answer);
140
+ }
141
+
142
+ /** The caller's operation as `get_operation` answers it; `mcp-access.operation_not_found` for any other id. */
143
+ export async function getOperation(
144
+ ctx: JobsContext,
145
+ identity: McpServerIdentity,
146
+ operationId: string,
147
+ options: GetOperationOptions = {},
148
+ ): Promise<Ok<OperationAnswer> | Err<"mcp-access.operation_not_found">> {
149
+ const job = await findOwnedJob(ctx, identity, operationId);
150
+ if (job === null) return err("mcp-access.operation_not_found");
151
+ return ok(await describeOperation(ctx, job, options));
152
+ }
153
+
154
+ /**
155
+ * Cancels the caller's operation: a queued one ends at once, a running one is asked to stop (its handler's `signal`
156
+ * aborts on the next heartbeat). `mcp-access.operation_finished` once it has ended.
157
+ */
158
+ export async function cancelOperation(
159
+ ctx: JobsContext,
160
+ identity: McpServerIdentity,
161
+ operationId: string,
162
+ options: Omit<GetOperationOptions, "readResult"> = {},
163
+ ): Promise<Ok<OperationAnswer> | Err<OperationErrorCode>> {
164
+ const owned = await findOwnedJob(ctx, identity, operationId);
165
+ if (owned === null) return err("mcp-access.operation_not_found");
166
+ const cancelled = await requestJobCancel(ctx, owned.id);
167
+ if (!cancelled.ok) return err(cancelled.error === "jobs.not_found" ? "mcp-access.operation_not_found" : "mcp-access.operation_finished");
168
+ return ok(await describeOperation(ctx, cancelled.value, options));
169
+ }
170
+
171
+ /** Registers the read-only status tool (`get_operation`) for the request's account. */
172
+ export function registerOperationStatusTool(server: McpServer, options: OperationStatusToolOptions): void {
173
+ const name = options.name ?? OPERATION_STATUS_TOOL_NAME;
174
+ const messages = getMcpAccessMessages(options.ctx.config).operations;
175
+ const getOptions: GetOperationOptions = {
176
+ statusToolName: name,
177
+ ...(options.readResult === undefined ? {} : { readResult: options.readResult }),
178
+ ...(options.poll === undefined ? {} : { poll: options.poll }),
179
+ };
180
+ server.registerTool(
181
+ name,
182
+ {
183
+ description: options.description ?? messages.statusToolDescription,
184
+ inputSchema: operationInput,
185
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
186
+ },
187
+ ({ operationId }) =>
188
+ withToolErrors(() => getOperation(options.ctx, options.identity, operationId, getOptions), { hints: getOperationHints(messages), label: name }),
189
+ );
190
+ }
191
+
192
+ /** Registers the cancel tool (`cancel_operation`), a write: nothing is registered without `identity.canWrite`. */
193
+ export function registerOperationCancelTool(server: McpServer, options: OperationCancelToolOptions): void {
194
+ if (!options.identity.canWrite) return;
195
+ const name = options.name ?? OPERATION_CANCEL_TOOL_NAME;
196
+ const messages = getMcpAccessMessages(options.ctx.config).operations;
197
+ const cancelOptions = { statusToolName: options.statusToolName ?? OPERATION_STATUS_TOOL_NAME, ...(options.poll === undefined ? {} : { poll: options.poll }) };
198
+ server.registerTool(
199
+ name,
200
+ {
201
+ description: options.description ?? messages.cancelToolDescription,
202
+ inputSchema: operationInput,
203
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
204
+ },
205
+ ({ operationId }) =>
206
+ withToolErrors(() => cancelOperation(options.ctx, options.identity, operationId, cancelOptions), { hints: getOperationHints(messages), label: name }),
207
+ );
208
+ }
209
+
210
+
211
+ function getOperationHints(messages: McpAccessMessages["operations"]): ToolErrorHints {
212
+ return { "mcp-access.operation_not_found": messages.notFound, "mcp-access.operation_finished": messages.finished };
213
+ }
@@ -20,6 +20,28 @@ export const en = {
20
20
  pending: "Creating…",
21
21
  limit: "You can hold up to {max} active tokens.",
22
22
  },
23
+ apiScopes: {
24
+ title: "What a token for other apps can do",
25
+ lead: "Tokens for other apps, such as a phone Shortcut, an automation tool or a script, call this app's HTTP routes. Each one gets only the access you tick.",
26
+ read: "Reads",
27
+ write: "Changes data",
28
+ writeUnavailable: "Changes data (turned off in this app)",
29
+ },
30
+ apiIssue: {
31
+ title: "New token for other apps",
32
+ name: "Name",
33
+ nameHint: "Where you will use it, e.g. iPhone Shortcut or Make.",
34
+ scopes: "Access",
35
+ submit: "Create token",
36
+ pending: "Creating…",
37
+ },
38
+ apiIssued: {
39
+ title: "Token created: {name}",
40
+ once: "Copy it now. It is shown only once; after you leave this page, only its name remains.",
41
+ scopes: "Access: {scopes}.",
42
+ tokenTitle: "Token",
43
+ headerTitle: "Authorization header",
44
+ },
23
45
  issued: {
24
46
  title: "Token created: {name}",
25
47
  once: "Copy it now. It is shown only once; after you leave this page, only its name remains.",
@@ -42,6 +64,8 @@ export const en = {
42
64
  },
43
65
  list: {
44
66
  title: "Your tokens",
67
+ kindMcp: "AI assistant",
68
+ kindApi: "Other apps",
45
69
  empty: "You have no tokens yet.",
46
70
  readOnly: "Read only",
47
71
  readWrite: "Read and change",
@@ -91,10 +115,39 @@ export const en = {
91
115
  noFields: "none",
92
116
  fieldRenamed: "input.field_renamed: the field \"{field}\" is now \"{renamed}\". Send it as \"{renamed}\".",
93
117
  },
118
+ confirmation: {
119
+ ask: "Nothing was done yet. Show the person this estimate and ask whether to go ahead. Only after they agree in the chat, call the tool again with the same arguments and \"confirmation\" set to the value above, before {expiresAt}.",
120
+ fieldDescription: "Leave it out on the first call. Set it only to the confirmation the first call answered, after the person agreed to its estimate.",
121
+ invalid: "input.confirmation_invalid: this is not a confirmation this tool issued. Call the tool again without \"confirmation\" for a new estimate.",
122
+ otherTool: "input.confirmation_other_tool: the confirmation was issued for another tool. Call this tool without \"confirmation\" for its own estimate.",
123
+ otherAccount: "input.confirmation_other_account: the confirmation was issued to another account or access token. Call the tool again without \"confirmation\" for a new estimate.",
124
+ expired: "input.confirmation_expired: the confirmation has expired. Call the tool again without \"confirmation\" for a new estimate and ask the person again.",
125
+ argumentsChanged: "input.confirmation_arguments_changed: the arguments differ from the ones the person agreed to. Call the tool again without \"confirmation\" for an estimate of these arguments and ask the person again.",
126
+ estimateChanged: "input.confirmation_estimate_changed: the estimate has changed since the person agreed. Call the tool again without \"confirmation\" for the new estimate and ask the person again.",
127
+ used: "input.confirmation_used: the confirmation was used already; nothing was done again. Call the tool again without \"confirmation\" if the person wants it done once more.",
128
+ },
94
129
  serverInfoTool: {
95
130
  description:
96
131
  "Tells which build of this server answers: its name and version (the version changes whenever a tool changes), the release, the commit, when it was built and started, and the changelog when the app publishes one. Use it to check that a change is live.",
97
132
  },
133
+ operations: {
134
+ statusToolDescription:
135
+ "Tells how a long operation started by another tool is going: queued, running (with its progress), succeeded (with its result), failed (with an error code) or cancelled. Call it again after pollAfterSeconds while the operation runs.",
136
+ cancelToolDescription: "Stops a long operation started by another tool. A queued one ends at once; a running one stops at its next safe point.",
137
+ started: "The operation has started and runs in the background. Call {tool} with this operationId after pollAfterSeconds to follow it. Do not call the starting tool again.",
138
+ queued: "The operation is waiting to start. Call {tool} again after pollAfterSeconds.",
139
+ running: "The operation is running. Call {tool} again after pollAfterSeconds.",
140
+ cancelRequested: "The operation was asked to stop and will end shortly. Call {tool} again after pollAfterSeconds.",
141
+ succeeded: "The operation has finished.",
142
+ failed: "The operation failed. Tell the user; start it again only if they ask.",
143
+ cancelled: "The operation was cancelled.",
144
+ notFound: "No operation with this id belongs to this account.",
145
+ finished: "The operation has already ended, so it cannot be cancelled.",
146
+ /** The `statusMessage` of an MCP task (#977) while its operation has not ended. */
147
+ taskQueued: "The operation is waiting to start.",
148
+ taskRunning: "The operation is running.",
149
+ taskCancelRequested: "The operation was asked to stop and will end shortly.",
150
+ },
98
151
  errors: {
99
152
  "mcp-access": {
100
153
  name_required: "Give the token a name, so you can tell it from the others.",
@@ -104,6 +157,9 @@ export const en = {
104
157
  grant_not_found: "This app is no longer connected.",
105
158
  issue_refused: "Your account cannot create tokens right now.",
106
159
  write_refused: "Your account cannot create tokens with write access. Create a read-only token instead.",
160
+ api_disabled: "This app does not offer tokens for other apps.",
161
+ scope_required: "Tick at least one kind of access.",
162
+ scope_unknown: "This app does not offer that access. Reload the page and try again.",
107
163
  },
108
164
  auth: {
109
165
  unauthenticated: "Your session has ended. Log in again.",
@@ -16,10 +16,11 @@ import { getSoftureConfig } from "@softure-ai/core/next";
16
16
  import { revalidatePath } from "next/cache";
17
17
  import { z } from "zod";
18
18
  import { getMcpClientSetup } from "../client-setup.js";
19
- import type { IssueTokenFormState, McpIssueRefusal, McpIssueRefusalCode } from "../contract.js";
19
+ import type { AccessTokenKind, IssueTokenFormState, McpIssueRefusal, McpIssueRefusalCode } from "../contract.js";
20
20
  import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
21
- import { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "../server/options.js";
22
- import { issueAccessToken } from "../server/tokens.js";
21
+ import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "../server/options.js";
22
+ import { getApiTokenOptions, issueApiToken } from "../server/api-tokens.js";
23
+ import { issueAccessToken, type McpAccessContext } from "../server/tokens.js";
23
24
  import { getMcpAccessContext, getRequestOrigins } from "./context.js";
24
25
  import { formatDate } from "./format.js";
25
26
 
@@ -38,7 +39,24 @@ const issueInput = z.object({
38
39
  /** What the gate learns about the token about to be issued. */
39
40
  export interface IssueGateInput {
40
41
  readonly userId: string;
42
+ /** For an API token: whether any asked scope writes. */
41
43
  readonly canWrite: boolean;
44
+ /** `mcp` for an assistant, `api` for the app's HTTP routes (the form's `kind` field). */
45
+ readonly kind: AccessTokenKind;
46
+ /** The scopes asked for an API token, as the form sent them; empty for an MCP token. */
47
+ readonly scopes: readonly string[];
48
+ }
49
+
50
+ /** The form fields of the API scopes: a ticked checkbox `scope:<name>` sends "on". */
51
+ const SCOPE_FIELD_PREFIX = "scope:";
52
+
53
+ /** The scope names the form ticked, at most as many as the app could declare. */
54
+ function readScopes(formData: FormData): string[] {
55
+ const names: string[] = [];
56
+ for (const key of formData.keys()) {
57
+ if (key.startsWith(SCOPE_FIELD_PREFIX) && names.length < 64) names.push(key.slice(SCOPE_FIELD_PREFIX.length, SCOPE_FIELD_PREFIX.length + 200));
58
+ }
59
+ return [...new Set(names)];
42
60
  }
43
61
 
44
62
  /**
@@ -54,7 +72,11 @@ export interface IssueTokenOptions {
54
72
  readonly beforeIssue?: BeforeIssueGate;
55
73
  }
56
74
 
57
- /** Issues a token for the signed-in user, after the gate, and returns its plaintext with the setup snippets, once. */
75
+ /**
76
+ * Issues a token for the signed-in user, after the gate, and returns its plaintext once: an MCP token with
77
+ * the setup snippets (`ok`), or, when the form sends `kind=api`, an API token with the ticked `scope:<name>`
78
+ * fields and the app's examples (`api-ok`).
79
+ */
58
80
  export async function issueToken(_previous: IssueTokenFormState, formData: FormData, options: IssueTokenOptions = {}): Promise<IssueTokenFormState> {
59
81
  const config = getSoftureConfig();
60
82
  const user = await getCurrentUser();
@@ -62,19 +84,46 @@ export async function issueToken(_previous: IssueTokenFormState, formData: FormD
62
84
 
63
85
  // Every field has a `catch`, so parsing cannot fail.
64
86
  const input = issueInput.parse({ name: formData.get("name"), canWrite: formData.get("canWrite") });
65
- const canWrite = input.canWrite !== null;
87
+ const kind: AccessTokenKind = formData.get("kind") === "api" ? "api" : "mcp";
88
+ const scopes = kind === "api" ? readScopes(formData) : [];
66
89
  try {
90
+ const ctx = await getMcpAccessContext(config);
91
+ const canWrite = kind === "api" ? isAnyWriteScope(ctx, scopes) : input.canWrite !== null;
67
92
  if (options.beforeIssue !== undefined) {
68
- const gate = await options.beforeIssue({ userId: user.id, canWrite });
93
+ const gate = await options.beforeIssue({ userId: user.id, canWrite, kind, scopes });
69
94
  if (!gate.ok) return getRefusalState(gate.error, "message" in gate ? gate.message : undefined);
70
95
  }
71
96
  // Resolved before issuing: a failure afterwards would leave a token nobody received.
72
- const endpointUrl = getMcpEndpointUrl(config, await getRequestOrigins(config, getMcpAccessRoutes(config).page));
73
- const result = await issueAccessToken(await getMcpAccessContext(config), { userId: user.id, name: input.name, canWrite });
97
+ const origins = await getRequestOrigins(config, getMcpAccessRoutes(config).page);
98
+ const messages = getMcpAccessMessages(config);
99
+ if (kind === "api") {
100
+ const result = await issueApiToken(ctx, { userId: user.id, name: input.name, scopes });
101
+ if (!result.ok) return { status: "error", error: result.error };
102
+ revalidatePath(getMcpAccessRoutes(config).page);
103
+ const issued = result.value;
104
+ const examples = getApiTokenOptions(ctx)?.examples ?? [];
105
+ return {
106
+ status: "api-ok",
107
+ issued: {
108
+ id: issued.id,
109
+ name: issued.name,
110
+ scopes: issued.scopes,
111
+ expiresText: formatMessage(messages.issued.expires, { date: formatDate(config, issued.expiresAt) }),
112
+ expiresAt: issued.expiresAt.toISOString(),
113
+ token: issued.token,
114
+ authorizationHeader: `Bearer ${issued.token}`,
115
+ examples: examples.map((example) => ({
116
+ title: getLocalizedText(example.title, config.locale),
117
+ text: formatMessage(getLocalizedText(example.text, config.locale), { token: issued.token, appOrigin: origins.appOrigin }),
118
+ })),
119
+ },
120
+ };
121
+ }
122
+ const endpointUrl = getMcpEndpointUrl(config, origins);
123
+ const result = await issueAccessToken(ctx, { userId: user.id, name: input.name, canWrite });
74
124
  if (!result.ok) return { status: "error", error: result.error };
75
125
  revalidatePath(getMcpAccessRoutes(config).page);
76
126
  const issued = result.value;
77
- const messages = getMcpAccessMessages(config);
78
127
  const accessOptions = getMcpAccessOptions(config);
79
128
  return {
80
129
  status: "ok",
@@ -101,6 +150,12 @@ export async function issueToken(_previous: IssueTokenFormState, formData: FormD
101
150
  }
102
151
  }
103
152
 
153
+ /** Whether any of the asked scopes is one the app declares as a write scope. */
154
+ function isAnyWriteScope(ctx: McpAccessContext, scopes: readonly string[]): boolean {
155
+ const declared = getApiTokenOptions(ctx)?.scopes ?? [];
156
+ return declared.some((scope) => scope.access === "write" && scopes.includes(scope.name));
157
+ }
158
+
104
159
  /** A gate's refusal with the app's own text, which the token page shows instead of the code's copy. */
105
160
  export function refuseIssue(code: McpIssueRefusalCode, message: string): McpIssueRefusal {
106
161
  return { ok: false, error: code, message };
@@ -10,12 +10,12 @@ import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions } from "../
10
10
  import { listOAuthGrants } from "../server/oauth.js";
11
11
  import { listAccessTokens } from "../server/tokens.js";
12
12
  import { getAccessTokenStatus } from "../token-status.js";
13
- import type { TokenManagerGrant, TokenManagerProps, TokenManagerRow, TokenManagerTool } from "../ui/token-manager.js";
13
+ import type { TokenManagerApiScope, TokenManagerGrant, TokenManagerProps, TokenManagerRow, TokenManagerTool } from "../ui/token-manager.js";
14
14
  import { getMcpAccessContext } from "./context.js";
15
15
  import { formatDate, formatDateTime } from "./format.js";
16
16
 
17
17
  /** Every prop of `TokenManager` but the actions and the styling. */
18
- export type TokenManagerData = Pick<TokenManagerProps, "tools" | "tokens" | "grants" | "allowWrites" | "maxTokens" | "messages" | "locale">;
18
+ export type TokenManagerData = Pick<TokenManagerProps, "tools" | "tokens" | "grants" | "apiScopes" | "allowWrites" | "maxTokens" | "messages" | "locale">;
19
19
 
20
20
  export interface TokenManagerPropsInput {
21
21
  /** The signed-in user's id, from the app's session check: only this user's tokens and apps are listed. */
@@ -44,10 +44,13 @@ function toRow(token: AccessTokenView, now: Date, config: SoftureConfig, options
44
44
  const isExpired = status.kind === "expired";
45
45
  const expiry = formatMessage(isExpired ? messages.list.expiredOn : messages.list.expiresOn, { date: formatDate(config, token.expiresAt) });
46
46
  const lastUse = token.lastUsedAt === null ? messages.list.neverUsed : formatMessage(messages.list.lastUsed, { date: formatDateTime(config, token.lastUsedAt) });
47
+ const mcpScopeText = token.canWrite && options.allowWrites ? messages.list.readWrite : messages.list.readOnly;
47
48
  return {
48
49
  id: token.id,
49
50
  name: token.name,
50
- scopeText: token.canWrite && options.allowWrites ? messages.list.readWrite : messages.list.readOnly,
51
+ scopeText: token.kind === "api" ? token.scopes.join(", ") : mcpScopeText,
52
+ // Named only while the app offers both kinds; an MCP-only page stays as it was.
53
+ ...(options.apiTokens === undefined ? {} : { kindText: token.kind === "api" ? messages.list.kindApi : messages.list.kindMcp }),
51
54
  statusText: describeStatus(status, messages),
52
55
  isExpired,
53
56
  details: [formatMessage(messages.list.createdOn, { date: formatDate(config, token.createdAt) }), expiry, lastUse],
@@ -69,8 +72,15 @@ function toTool(tool: McpAccessOptions["tools"][number], config: SoftureConfig,
69
72
  return { name: tool.name, description: getLocalizedText(tool.description, config.locale), accessText };
70
73
  }
71
74
 
75
+ function toApiScope(scope: NonNullable<McpAccessOptions["apiTokens"]>["scopes"][number], config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerApiScope {
76
+ const isOffered = scope.access === "read" || options.allowWrites;
77
+ const accessText = scope.access === "read" ? messages.apiScopes.read : isOffered ? messages.apiScopes.write : messages.apiScopes.writeUnavailable;
78
+ return { name: scope.name, description: getLocalizedText(scope.description, config.locale), accessText, isOffered };
79
+ }
80
+
72
81
  /**
73
- * The tools, the user's tokens and (while OAuth is on) connected apps, and the page settings, ready to
82
+ * The tools, the user's tokens of both kinds, (while OAuth is on) connected apps, (with `apiTokens`) the
83
+ * app's API scopes, and the page settings, ready to
74
84
  * spread into `TokenManager` next to the actions. Reads the database; it checks no session itself.
75
85
  */
76
86
  export async function getTokenManagerProps({ userId, config = getSoftureConfig(), now }: TokenManagerPropsInput): Promise<TokenManagerData> {
@@ -84,6 +94,7 @@ export async function getTokenManagerProps({ userId, config = getSoftureConfig()
84
94
  tools: options.tools.map((tool) => toTool(tool, config, options, messages)),
85
95
  tokens: tokens.map((token) => toRow(token, at, config, options, messages)),
86
96
  ...(grants === null ? {} : { grants: grants.map((grant) => toGrant(grant, config, options, messages)) }),
97
+ ...(options.apiTokens === undefined ? {} : { apiScopes: options.apiTokens.scopes.map((scope) => toApiScope(scope, config, options, messages)) }),
87
98
  allowWrites: options.allowWrites,
88
99
  maxTokens: options.maxTokensPerUser,
89
100
  messages,
package/src/options.ts CHANGED
@@ -122,6 +122,60 @@ const oauthSchema = z.strictObject({
122
122
  .prefault({}),
123
123
  });
124
124
 
125
+ /** An app scope of an API token: `recipes:write`, `orders`, `files:read` (segments of a-z, 0-9, `_` and `-`). */
126
+ export const API_SCOPE_PATTERN = /^[a-z][a-z0-9_-]{0,31}(?::[a-z][a-z0-9_-]{0,31}){0,3}$/;
127
+
128
+ /** The prefix of an API token: lowercase letters and digits ending in `_`, e.g. `sftapi_` or `acme_`. */
129
+ export const API_TOKEN_PREFIX_PATTERN = /^[a-z][a-z0-9]{1,14}_$/;
130
+
131
+ /** The module's own secrets start with this (`sftmcp_`, the OAuth `sftmc*_` prefixes): an API prefix may not. */
132
+ const RESERVED_PREFIX_START = "sftmc";
133
+
134
+ /** The most scopes one API token holds (`access_tokens.scopes`). */
135
+ export const MAX_API_TOKEN_SCOPES = 32;
136
+
137
+ const apiScopeSchema = z.strictObject({
138
+ /** The scope a route asks for in `requireApiToken`, e.g. `recipes:write`. `mcp:` scopes belong to the MCP endpoint. */
139
+ name: z
140
+ .string()
141
+ .regex(API_SCOPE_PATTERN, "must be lowercase segments joined by ':', e.g. recipes:write")
142
+ .refine((name) => name !== "mcp" && !name.startsWith("mcp:"), "mcp scopes belong to the MCP endpoint"),
143
+ /** `write` scopes are granted only with `allowWrites` and work only while the writes switch is on. */
144
+ access: z.enum(["read", "write"]),
145
+ /** What the scope lets a token do, for the owner choosing scopes on the token page. */
146
+ description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
147
+ });
148
+
149
+ const apiExampleSchema = z.strictObject({
150
+ /** The snippet's title, e.g. "iPhone Shortcut" or "curl". */
151
+ title: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
152
+ /** The snippet; `{token}` and `{appOrigin}` are filled in when the token is revealed. */
153
+ text: z
154
+ .record(z.string().refine(isLocale, "must be a BCP 47 language tag"), z.string().trim().min(1).max(4000))
155
+ .refine((text) => text.en !== undefined, "needs at least an en text"),
156
+ });
157
+
158
+ const apiTokensSchema = z
159
+ .strictObject({
160
+ /** Marks the app's API tokens for people and secret scanners. MCP tokens keep `sftmcp_`. */
161
+ prefix: z
162
+ .string()
163
+ .regex(API_TOKEN_PREFIX_PATTERN, "must be 2 to 15 lowercase letters or digits followed by _, e.g. acme_")
164
+ .refine((prefix) => !prefix.startsWith(RESERVED_PREFIX_START), `must not start with ${RESERVED_PREFIX_START}, the module's own prefixes`)
165
+ .default("sftapi_"),
166
+ /** The scopes the app's HTTP routes ask for; the token page offers them. */
167
+ scopes: z.array(apiScopeSchema).min(1, "needs at least one scope").max(MAX_API_TOKEN_SCOPES),
168
+ /** Ready-made recipes shown once with a new token (a phone Shortcut, a curl call). */
169
+ examples: z.array(apiExampleSchema).max(8).default([]),
170
+ })
171
+ .superRefine((options, context) => {
172
+ const names = new Set<string>();
173
+ options.scopes.forEach((scope, index) => {
174
+ if (names.has(scope.name)) context.addIssue({ code: "custom", path: ["scopes", index, "name"], message: `"${scope.name}" is listed twice` });
175
+ names.add(scope.name);
176
+ });
177
+ });
178
+
125
179
  /** A pattern for tokens an app issued before adopting the module: anchored, and stateless. */
126
180
  const legacyTokenPatternSchema = z
127
181
  .instanceof(RegExp, { message: "must be a RegExp" })
@@ -170,6 +224,11 @@ export const mcpAccessOptionsSchema = z
170
224
  resourceOrigins: z.array(originSchema).max(16, "takes at most 16 origins").default([]),
171
225
  /** OAuth 2.1 for MCP clients that connect without a pasted token (claude.ai, ChatGPT). */
172
226
  oauth: oauthSchema.prefault({}),
227
+ /**
228
+ * Personal tokens for the app's plain HTTP routes (a phone Shortcut, Make, a script), checked with
229
+ * `requireApiToken` from `@softure-ai/mcp-access/tokens`. Unset, the token page issues MCP tokens only.
230
+ */
231
+ apiTokens: apiTokensSchema.optional(),
173
232
  })
174
233
  .superRefine((options, context) => {
175
234
  const names = new Set<string>();
@@ -187,3 +246,6 @@ export type McpToolDefinitionInput = z.input<typeof toolSchema>;
187
246
  export type McpToolDefinition = z.output<typeof toolSchema>;
188
247
  export type McpToolAccess = McpToolDefinition["access"];
189
248
  export type McpOAuthOptions = z.output<typeof oauthSchema>;
249
+ export type ApiTokenOptions = z.output<typeof apiTokensSchema>;
250
+ export type ApiTokenOptionsInput = z.input<typeof apiTokensSchema>;
251
+ export type ApiScopeDefinition = z.output<typeof apiScopeSchema>;
package/src/schema.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // Drizzle view of the module's tables (migrations/0001_create_access_tokens.sql,
2
- // 0002_create_oauth_grants.sql and 0003_create_oauth_registration_window.sql). The migrations are the
3
- // source of truth; this file only types the queries.
2
+ // 0002_create_oauth_grants.sql, 0003_create_oauth_registration_window.sql, 0004_create_used_confirmations.sql,
3
+ // 0005_add_api_tokens.sql and 0006_create_operation_tasks.sql). The migrations are the source of truth; this file only types the queries.
4
4
  import { users } from "@softure-ai/auth";
5
- import { boolean, jsonb, pgSchema, smallint, text, timestamp, uuid, type AnyPgColumn } from "drizzle-orm/pg-core";
5
+ import { bigint, boolean, jsonb, pgSchema, smallint, text, timestamp, uuid, type AnyPgColumn } from "drizzle-orm/pg-core";
6
6
 
7
7
  export const mcpSchema = pgSchema("mcp");
8
8
 
@@ -61,6 +61,9 @@ export const accessTokens = mcpSchema.table("access_tokens", {
61
61
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
62
62
  lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
63
63
  grantId: uuid("grant_id").references((): AnyPgColumn => oauthGrants.id, { onDelete: "cascade" }),
64
+ /** `mcp` opens the MCP endpoint; `api` passes `requireApiToken` with its `scopes` (migration 0005). */
65
+ kind: text("kind").$type<"mcp" | "api">().notNull().default("mcp"),
66
+ scopes: text("scopes").array().$type<string[]>().notNull().default([]),
64
67
  });
65
68
 
66
69
  export const oauthRegistrationWindow = mcpSchema.table("oauth_registration_window", {
@@ -69,5 +72,22 @@ export const oauthRegistrationWindow = mcpSchema.table("oauth_registration_windo
69
72
  openUntil: timestamp("open_until", { withTimezone: true }).notNull(),
70
73
  });
71
74
 
75
+ export const usedConfirmations = mcpSchema.table("used_confirmations", {
76
+ tokenHash: text("token_hash").primaryKey(),
77
+ usedAt: timestamp("used_at", { withTimezone: true }).notNull(),
78
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
79
+ });
80
+
81
+ /** MCP Tasks over long operations (#977): a random task id per account and job, until `expiresAt`. */
82
+ export const operationTasks = mcpSchema.table("operation_tasks", {
83
+ taskId: text("task_id").primaryKey(),
84
+ userId: uuid("user_id")
85
+ .notNull()
86
+ .references(() => users.id, { onDelete: "cascade" }),
87
+ jobId: bigint("job_id", { mode: "bigint" }).notNull(),
88
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
89
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
90
+ });
91
+
72
92
  export type OAuthClientRow = typeof oauthClients.$inferSelect;
73
93
  export type OAuthTokenEndpointAuthMethod = OAuthClientRow["tokenEndpointAuthMethod"];