@softure-ai/mcp-access 0.2.9 → 0.4.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.
- package/CHANGELOG.md +66 -0
- package/README.md +241 -11
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +2 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +48 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +88 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- package/dist/index.js.map +1 -1
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +5 -2
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/operation-state.d.ts +58 -0
- package/dist/jobs/operation-state.d.ts.map +1 -0
- package/dist/jobs/operation-state.js +68 -0
- package/dist/jobs/operation-state.js.map +1 -0
- package/dist/jobs/operation-tasks.d.ts +68 -0
- package/dist/jobs/operation-tasks.d.ts.map +1 -0
- package/dist/jobs/operation-tasks.js +163 -0
- package/dist/jobs/operation-tasks.js.map +1 -0
- package/dist/jobs/operations.d.ts +89 -0
- package/dist/jobs/operations.d.ts.map +1 -0
- package/dist/jobs/operations.js +131 -0
- package/dist/jobs/operations.js.map +1 -0
- package/dist/messages/en.d.ts +55 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +55 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +54 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/next/issue-token.d.ts +11 -2
- package/dist/next/issue-token.d.ts.map +1 -1
- package/dist/next/issue-token.js +56 -7
- package/dist/next/issue-token.js.map +1 -1
- package/dist/next/token-manager-props.d.ts +3 -2
- package/dist/next/token-manager-props.d.ts.map +1 -1
- package/dist/next/token-manager-props.js +12 -2
- package/dist/next/token-manager-props.js.map +1 -1
- package/dist/options.d.ts +47 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +53 -0
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +214 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +21 -3
- package/dist/schema.js.map +1 -1
- package/dist/server/api-tokens.d.ts +64 -0
- package/dist/server/api-tokens.d.ts.map +1 -0
- package/dist/server/api-tokens.js +176 -0
- package/dist/server/api-tokens.js.map +1 -0
- package/dist/server/confirmation.d.ts +69 -0
- package/dist/server/confirmation.d.ts.map +1 -0
- package/dist/server/confirmation.js +220 -0
- package/dist/server/confirmation.js.map +1 -0
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +3 -1
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/privacy.d.ts +4 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +5 -1
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/prune.d.ts +5 -1
- package/dist/server/prune.d.ts.map +1 -1
- package/dist/server/prune.js +14 -4
- package/dist/server/prune.js.map +1 -1
- package/dist/server/strict-input.d.ts +3 -1
- package/dist/server/strict-input.d.ts.map +1 -1
- package/dist/server/strict-input.js +54 -8
- package/dist/server/strict-input.js.map +1 -1
- package/dist/server/tokens.d.ts +46 -11
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +46 -24
- package/dist/server/tokens.js.map +1 -1
- package/dist/server/tools.d.ts +8 -0
- package/dist/server/tools.d.ts.map +1 -1
- package/dist/server/tools.js +1 -0
- package/dist/server/tools.js.map +1 -1
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +8 -0
- package/dist/tokens/index.js.map +1 -0
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/token-manager.d.ts +18 -2
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +28 -3
- package/dist/ui/token-manager.js.map +1 -1
- package/migrations/0004_create_used_confirmations.sql +13 -0
- package/migrations/0005_add_api_tokens.sql +15 -0
- package/migrations/0006_create_operation_tasks.sql +18 -0
- package/module.json +9 -3
- package/package.json +7 -2
- package/src/cli/run.ts +2 -2
- package/src/contract.ts +60 -2
- package/src/index.ts +31 -3
- package/src/jobs/index.ts +43 -2
- package/src/jobs/operation-state.ts +119 -0
- package/src/jobs/operation-tasks.ts +234 -0
- package/src/jobs/operations.ts +258 -0
- package/src/messages/en.ts +56 -0
- package/src/next/issue-token.ts +64 -9
- package/src/next/token-manager-props.ts +15 -4
- package/src/options.ts +62 -0
- package/src/schema.ts +23 -3
- package/src/server/api-tokens.ts +224 -0
- package/src/server/confirmation.ts +316 -0
- package/src/server/health.ts +3 -1
- package/src/server/index.ts +32 -2
- package/src/server/privacy.ts +9 -1
- package/src/server/prune.ts +17 -4
- package/src/server/strict-input.ts +62 -13
- package/src/server/tokens.ts +73 -27
- package/src/server/tools.ts +9 -0
- package/src/tokens/index.ts +22 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/token-manager.tsx +140 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,72 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
|
|
|
4
4
|
production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
|
|
5
5
|
"Definition of done"). Versions before the first one below are described in their GitHub Releases (`mcp-access@x.y.z`).
|
|
6
6
|
|
|
7
|
+
## 0.4.0
|
|
8
|
+
|
|
9
|
+
- Operation tools with a context getter (#990), in `@softure-ai/mcp-access/jobs`: `registerOperationStatusTool`,
|
|
10
|
+
`registerOperationCancelTool` and `registerOperationTasks` take `getContext: () => Promise<JobsContext>` instead of
|
|
11
|
+
`ctx`, resolved inside each call (never at registration, so listing the tools opens no database), with an optional
|
|
12
|
+
`config` for the tool descriptions (default: the English dictionary). The error sentences now come from the config
|
|
13
|
+
of the call. `getOperationErrorHints(config)` exports the hints for `mcp-access.operation_not_found` and
|
|
14
|
+
`mcp-access.operation_finished`, for an app that wraps `getOperation` or `cancelOperation` itself. `{ ctx }` works
|
|
15
|
+
as before. Migrate: an app that registered its own `get_operation` / `cancel_operation` around a per-call context
|
|
16
|
+
can drop them and pass `getContext` (README "Long operations").
|
|
17
|
+
|
|
18
|
+
## 0.3.0
|
|
19
|
+
|
|
20
|
+
- Long operations as MCP Tasks (#977), in `@softure-ai/mcp-access/jobs`: `startOperation` takes `taskRequest` (the
|
|
21
|
+
tool handler's request context) and `taskTtlSeconds`; when the client declared the Tasks extension
|
|
22
|
+
(`io.modelcontextprotocol/tasks`, revision 2026-07-28) on that request, the tool answers a task instead of an
|
|
23
|
+
operation id. `registerOperationTasks(server, { ctx, identity, readResult?, poll? })` declares the extension and
|
|
24
|
+
serves `tasks/get` (working, completed with the `get_operation` answer, `isError` for a failed operation, cancelled),
|
|
25
|
+
`tasks/update` and `tasks/cancel` (write access only), scoped to the account. Task ids are random and live in the
|
|
26
|
+
new table `mcp.operation_tasks` (migration `0006`), pruned past their expiry by `pruneMcpAccess` (which now also
|
|
27
|
+
counts `operationTasks`, and `softure-mcp prune` prints it) and deleted with the account. Every other client keeps the
|
|
28
|
+
polling tools. Migrate: run `softure migrate`; to offer tasks, pass the handler's second argument as `taskRequest`
|
|
29
|
+
and call `registerOperationTasks` on the same server (README "As MCP Tasks").
|
|
30
|
+
- Long operations as tools (#952), in `@softure-ai/mcp-access/jobs` on top of `@softure-ai/jobs` (with the progress
|
|
31
|
+
and running-job cancel of jobs #951): `startOperation(ctx, identity, { queue, payload, dedupKey? })` enqueues and
|
|
32
|
+
answers `{ operationId, status: "queued", pollAfterSeconds, message }` at once; `registerOperationStatusTool`
|
|
33
|
+
(`get_operation`) answers the status, the handler's progress, a growing `pollAfterSeconds`, the app's `result`
|
|
34
|
+
(`readResult`) and the `errorCode`; `registerOperationCancelTool` (`cancel_operation`, write access only) cancels a
|
|
35
|
+
queued operation and asks a running one to stop. Operations are scoped to the caller's account; no migration.
|
|
36
|
+
Migrate: an app with its own "start returns an id" and "status" tools can replace them with these (README "Long
|
|
37
|
+
operations").
|
|
38
|
+
- Personal tokens for the app's plain HTTP routes (#961): the option `apiTokens` (`prefix`, default `sftapi_`;
|
|
39
|
+
`scopes` with `read`/`write` access and a description; `examples` for the one-time reveal) turns on a second token
|
|
40
|
+
kind. `requireApiToken(ctx, request, { scopes })` checks a route's request: client address counted (bucket `api`),
|
|
41
|
+
Bearer token verified, token counted (bucket `api-token`), scopes and the write switch checked; refusals are values
|
|
42
|
+
that `apiTokenErrorResponse` answers (400/401/403/429/503). The new entry `@softure-ai/mcp-access/tokens` exports
|
|
43
|
+
it with `issueApiToken`, `verifyApiToken`, list, revoke and prune, and loads no MCP SDK. An API token never opens
|
|
44
|
+
the MCP endpoint and an MCP token never passes the check. The token page lists the app's scopes, adds the form
|
|
45
|
+
"New token for other apps" (`kind=api`, `scope:<name>` fields), shows the token once with the app's examples
|
|
46
|
+
(form state `api-ok`, `IssuedApiTokenView`) and names each token's kind. `AccessTokenView` gains `kind` and
|
|
47
|
+
`scopes`; the gate input `kind` and `scopes`; the privacy export `kind` and `scopes`. Both kinds share
|
|
48
|
+
`maxTokensPerUser`. Migration `0005_add_api_tokens.sql` adds `kind` and `scopes` (existing rows stay MCP tokens).
|
|
49
|
+
Migrate: nothing for an app without `apiTokens`, apart from running the migration; an app that compares
|
|
50
|
+
`listAccessTokens` or the privacy export field by field gains the two fields. To use it, add `apiTokens`, spread
|
|
51
|
+
`API_TOKEN_RATE_LIMIT_BUCKETS` into `security({ buckets })` and call `requireApiToken` in the routes.
|
|
52
|
+
- Confirmed tool calls (#953): `withConfirmation(handler, { ctx, identity, tool, estimate, ttlSeconds?,
|
|
53
|
+
runWithoutConfirmationWhen?, secret? })` (`/server`) makes a priced or irreversible tool estimate first and answer
|
|
54
|
+
`{ needsConfirmation: true, estimate, confirmation, expiresAt, message }`; the second call with the same arguments
|
|
55
|
+
and the token does the work. The token is an HMAC bound to the tool, the canonical arguments, the account and access
|
|
56
|
+
token, the estimate's total and the expiry, single use (stored by hash in the new `mcp.used_confirmations`), and a
|
|
57
|
+
mismatch is refused as `input.confirmation_invalid`, `_other_tool`, `_other_account`, `_expired`,
|
|
58
|
+
`_arguments_changed`, `_estimate_changed` or `_used`. `confirmationInput` is the schema field. `ToolCallRecord`
|
|
59
|
+
gains `confirmed: boolean | null` (`null` for other tools), and `pruneMcpAccess` counts `usedConfirmations`.
|
|
60
|
+
Migrate: run `softure migrate` (file 4). To confirm a tool, set `MCP_CONFIRMATION_SECRET` (32+ characters), add
|
|
61
|
+
`confirmation: confirmationInput` to its input schema and wrap its callback; drop an app-level `confirm: true`
|
|
62
|
+
boolean the assistant could set itself. A test that builds a `ToolCallRecord` by hand adds `confirmed: null`.
|
|
63
|
+
- Strict tool input lists a required nullable scalar as required (#919): a `z.number().nullable()` or
|
|
64
|
+
`z.boolean().nullable()` field that the parse requires is in `required` of the listed schema again, and a scalar
|
|
65
|
+
field's `default` (or `prefault`) is listed. The parse rules are unchanged. Migrate: an app that copied the inner
|
|
66
|
+
schema's `optin` onto its own text step, or put a `default` inside it, can drop that wrapper.
|
|
67
|
+
- A tool registered without an input schema on a strict server gets the strict empty object (#919): `tools/call`
|
|
68
|
+
with any argument is refused with the unknown-fields text, and the handler is still called with the context only.
|
|
69
|
+
The listing gains `additionalProperties: false`, so the measured tool list version changes once.
|
|
70
|
+
- `renamedFields` also takes a rule `(field) => newName | undefined` (#919), e.g. for a rename by pattern across many
|
|
71
|
+
fields. `RenamedFields` is exported from `/server`.
|
|
72
|
+
|
|
7
73
|
## 0.2.9
|
|
8
74
|
|
|
9
75
|
- A runtime kill switch for writes (#904): the manifest switch `mcp-access.writes_enabled` (`WRITES_ENABLED_SWITCH`)
|
package/README.md
CHANGED
|
@@ -42,6 +42,9 @@ limit check made race-free.
|
|
|
42
42
|
- one grant per account and client, listed on the token page as "connected apps"; revoking it
|
|
43
43
|
deletes its access tokens at once (`grant_id`, `ON DELETE CASCADE`). OAuth access tokens do not
|
|
44
44
|
count against `maxTokensPerUser` and are not listed with the hand-issued ones.
|
|
45
|
+
- **Tokens for the app's own HTTP routes** (`apiTokens`, off by default): personal tokens with the app's prefix and
|
|
46
|
+
scopes (`recipes:write`) for a phone Shortcut, an automation tool or a script, checked in a route handler with
|
|
47
|
+
`requireApiToken` from `@softure-ai/mcp-access/tokens`, an entry that loads no MCP SDK (§4 "Tokens for HTTP routes").
|
|
45
48
|
- Tokens an app issued before adopting the module keep working through `legacyTokenPattern`.
|
|
46
49
|
- Server functions for scripts and other hosts, and a health check for `GET /api/health`.
|
|
47
50
|
- For the app's own server: tool result helpers with a safe error boundary and server actions as write tools
|
|
@@ -49,7 +52,9 @@ limit check made race-free.
|
|
|
49
52
|
(`createMcpServerFactory`, `measureToolList`, `/server`), strict tool input that refuses unknown and renamed
|
|
50
53
|
fields (`withStrictToolInput`, `/server`), an opt-in `get_server_info` tool that tells which build answers
|
|
51
54
|
(`serverInfoTool`, `/server`), a stdio entry for local assistants (`/stdio`), a catalog parity check (`/testing`), a gate before a
|
|
52
|
-
token is issued (`issueToken`, `/next`)
|
|
55
|
+
token is issued (`issueToken`, `/next`), long operations as tools over `@softure-ai/jobs` (`startOperation`,
|
|
56
|
+
`get_operation`, `cancel_operation`, `/jobs`) or as MCP Tasks for clients that declare them (`registerOperationTasks`)
|
|
57
|
+
and the `softure-mcp prune` command for a daily cleanup (§4, §5).
|
|
53
58
|
|
|
54
59
|
## 2. Installation
|
|
55
60
|
|
|
@@ -193,7 +198,7 @@ input schema registered on the server from then on, or pass `strictToolInput: tr
|
|
|
193
198
|
|
|
194
199
|
```ts
|
|
195
200
|
const server = withStrictToolInput(new McpServer({ name: "acme", version }), {
|
|
196
|
-
renamedFields: { notes: "note" }, // old name → new name
|
|
201
|
+
renamedFields: { notes: "note" }, // old name → new name, or a rule: (field) => newName | undefined
|
|
197
202
|
coerceTextScalars: true, // default
|
|
198
203
|
messages: getMcpAccessMessages(config).toolInput, // default: the English dictionary
|
|
199
204
|
});
|
|
@@ -206,10 +211,16 @@ const server = withStrictToolInput(new McpServer({ name: "acme", version }), {
|
|
|
206
211
|
`.catchall(x)`) keeps it; refinements and the object's own error messages stay.
|
|
207
212
|
- **Renamed fields:** a field sent under an old name where the object accepts the new one is refused with the
|
|
208
213
|
stable code `input.field_renamed` and the new name, so a session on an older tool list learns it in one call.
|
|
214
|
+
`renamedFields` is a map or a rule, e.g. `(field) => field.includes("Pln") ? field.replace("Pln", "Home") :
|
|
215
|
+
undefined` for a rename across many fields; a name the rule leaves alone is an unknown field.
|
|
209
216
|
- **Text scalars:** a client on an older tool list sends a field it does not know as text, so a boolean field also
|
|
210
217
|
takes the exact text `"true"`/`"false"` and a number field a JSON number written as text, converted before the
|
|
211
218
|
field's own rules (`"-1"` still fails `min(0)`). Any other text is refused. The listed JSON Schema keeps `boolean`
|
|
212
|
-
and `number` and gains `additionalProperties: false`.
|
|
219
|
+
and `number`, the fields' `required` and the `default` of a scalar field, and gains `additionalProperties: false`.
|
|
220
|
+
A required `z.number().nullable()` stays required. (zod lists no `default` on an object or array that holds a
|
|
221
|
+
scalar field, since its input is transformed.)
|
|
222
|
+
- **No input schema:** a tool registered without one gets the strict empty object, so `tools/call` with any argument
|
|
223
|
+
is refused; its handler is still called with the request context only.
|
|
213
224
|
- The SDK validates before the handler runs, so a refused call saves nothing. A raw shape is wrapped in
|
|
214
225
|
`z.object` first; a schema that is not zod passes unchanged. `toStrictToolInput(schema, options?)` gives the
|
|
215
226
|
strict schema on its own.
|
|
@@ -296,6 +307,138 @@ export async function createServer({ userId, canWrite, tokenId }: McpServerIdent
|
|
|
296
307
|
}
|
|
297
308
|
```
|
|
298
309
|
|
|
310
|
+
### Long operations: start, status, cancel
|
|
311
|
+
|
|
312
|
+
A tool whose work takes minutes (a render, an import) cannot hold the call open: the endpoint answers in JSON mode
|
|
313
|
+
and drops mid-call notifications, a proxy may cut a long response (Cloudflare at 100 seconds), and assistants time out
|
|
314
|
+
tool calls. `@softure-ai/mcp-access/jobs` turns such work into an operation on top of `@softure-ai/jobs`: the tool
|
|
315
|
+
starts a job and answers an id at once, the assistant asks `get_operation` until it ends, and may stop it with
|
|
316
|
+
`cancel_operation`.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { registerOperationCancelTool, registerOperationStatusTool, startOperation } from "@softure-ai/mcp-access/jobs";
|
|
320
|
+
|
|
321
|
+
export async function createServer(identity: McpServerIdentity) {
|
|
322
|
+
server.registerTool("render_video", { inputSchema: z.object({ videoId: z.string() }) }, (args) =>
|
|
323
|
+
withToolErrors(() => startOperation(ctx, identity, { queue: "video.render", payload: args, dedupKey: `video:${args.videoId}` })),
|
|
324
|
+
);
|
|
325
|
+
registerOperationStatusTool(server, { ctx, identity, readResult: (job) => findRenderUrl(db, job.id) });
|
|
326
|
+
registerOperationCancelTool(server, { ctx, identity }); // registers nothing without identity.canWrite
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
- `startOperation(ctx, identity, { queue, payload, dedupKey?, statusToolName?, poll? })` enqueues the job and answers
|
|
331
|
+
`{ operationId, status: "queued", progress: null, pollAfterSeconds, message }`; the message tells the assistant to
|
|
332
|
+
call the status tool and not to start again. With a `dedupKey` (at most 150 characters), starting again while the
|
|
333
|
+
account's operation of that queue runs joins it: the answer carries its id and status.
|
|
334
|
+
- `get_operation` (`registerOperationStatusTool`, read only) answers `{ operationId, status, progress,
|
|
335
|
+
pollAfterSeconds?, cancelRequested?, result?, errorCode?, message }`. `status` is `queued`, `running` (also while a
|
|
336
|
+
failed attempt waits for its retry), `succeeded`, `failed` or `cancelled`; `progress` is what the handler reports
|
|
337
|
+
with `context.reportProgress` (codes and counts only); `pollAfterSeconds` is there while the operation runs and
|
|
338
|
+
grows with its age (a quarter of its seconds, between `poll.minSeconds` 2 and `poll.maxSeconds` 30); `result` is
|
|
339
|
+
what `readResult(job)` reads from the app's tables once it succeeded (jobs stores no result); `errorCode` is the
|
|
340
|
+
failure's code (`jobs.cancelled` for a cancelled one).
|
|
341
|
+
- `cancel_operation` (`registerOperationCancelTool`, a write) ends a queued operation at once and asks a running one
|
|
342
|
+
to stop (`requestJobCancel`: the handler's `signal` aborts on its next heartbeat); the answer says
|
|
343
|
+
`cancelRequested: true` until it has ended. An ended one answers "already ended" as a tool error.
|
|
344
|
+
- Scoped to the account: an operation's job has origin `mcp` and the dedup key `mcp:<userId>:<key>` (a random key
|
|
345
|
+
without `dedupKey`). Another account's operation, a job that is not an operation and a malformed id all answer
|
|
346
|
+
"No operation with this id belongs to this account." No table of its own: an operation lives as long as its job
|
|
347
|
+
(`retention` of jobs).
|
|
348
|
+
- `getOperation` and `cancelOperation` are the same reads as functions; the texts are under `operations` in the
|
|
349
|
+
module dictionary, and `name`/`description` rename a tool. An app that wraps them in tools of its own answers with
|
|
350
|
+
the module's sentences through `withToolErrors(work, { hints: getOperationErrorHints(ctx.config) })`.
|
|
351
|
+
- A server factory that resolves its context per call passes `getContext` instead of `ctx` (to
|
|
352
|
+
`registerOperationStatusTool`, `registerOperationCancelTool` and `registerOperationTasks`): it is called inside each
|
|
353
|
+
call, once, never at registration, so listing the tools opens no database. The error sentences come from the config
|
|
354
|
+
the call resolves; the tool description from `config` when given, else the built-in English dictionary.
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
registerOperationStatusTool(server, { getContext: () => getJobsContext(), config, identity, readResult });
|
|
358
|
+
registerOperationCancelTool(server, { getContext: () => getJobsContext(), config, identity });
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
#### As MCP Tasks
|
|
362
|
+
|
|
363
|
+
A client that speaks the Tasks extension (`io.modelcontextprotocol/tasks`, protocol revision 2026-07-28) can follow an
|
|
364
|
+
operation as a task instead of calling `get_operation`. It is opt-in per tool and needs two things on the same server:
|
|
365
|
+
pass the tool handler's request context as `taskRequest`, and register the task handlers.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { registerOperationTasks, startOperation } from "@softure-ai/mcp-access/jobs";
|
|
369
|
+
|
|
370
|
+
server.registerTool("render_video", { inputSchema: z.object({ videoId: z.string() }) }, (args, request) =>
|
|
371
|
+
withToolErrors(() => startOperation(ctx, identity, { queue: "video.render", payload: args, taskRequest: request })),
|
|
372
|
+
);
|
|
373
|
+
registerOperationStatusTool(server, { ctx, identity, readResult }); // still there for every other client
|
|
374
|
+
registerOperationTasks(server, { ctx, identity, readResult });
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
- Only a request whose client declared the extension in its per-request capabilities gets a task (`resultType:
|
|
378
|
+
"task"`, `status: "working"`, `ttlMs`, `pollIntervalMs`). A client that did not, including every 2025-era client,
|
|
379
|
+
gets the operation id exactly as above, so the polling tools stay the default.
|
|
380
|
+
- `registerOperationTasks(server, { ctx, identity, readResult?, poll? })` declares the extension in `server/discover`
|
|
381
|
+
and answers `tasks/get`, `tasks/update` and `tasks/cancel` for the request's account:
|
|
382
|
+
- `tasks/get`: `working` while the operation is queued or running (`statusMessage` says which, or that a stop was
|
|
383
|
+
asked; `pollIntervalMs` is `pollAfterSeconds` in milliseconds); `completed` once it ended with a `result` that is
|
|
384
|
+
the `get_operation` answer as a tool result (with the app's `readResult`), marked `isError: true` when the
|
|
385
|
+
operation failed (the spec keeps the `failed` status for protocol errors); `cancelled` when it was cancelled.
|
|
386
|
+
- `tasks/cancel`: like `cancel_operation`, a write: a read-only token gets `-32600`. An operation that has ended is
|
|
387
|
+
acknowledged all the same (cancelling is cooperative in the spec).
|
|
388
|
+
- `tasks/update`: operations never ask for input, so an owned task is acknowledged.
|
|
389
|
+
- An unknown task, another account's, or one whose job is gone is `-32602` "Task not found"; an expired one `-32602`
|
|
390
|
+
"Task has expired"; a request without the declared extension `-32021`.
|
|
391
|
+
- A task id is random (32 bytes, base64url) and stored in `mcp.operation_tasks` (§5) with its owner, its job and an
|
|
392
|
+
expiry: `taskTtlSeconds` on `startOperation`, a minute to thirty days, default a day. The same account starting the
|
|
393
|
+
same running operation again (same `dedupKey`) gets the same task. Progress counts stay with `get_operation`.
|
|
394
|
+
|
|
395
|
+
### Confirmed calls
|
|
396
|
+
|
|
397
|
+
A tool that spends money (a paid provider call) or cannot be undone follows the `--commit` rule over MCP:
|
|
398
|
+
`withConfirmation` (`/server`) makes the first call estimate only, the person agrees in the chat, and only a second
|
|
399
|
+
call with the same input does the work. The assistant cannot agree on its own: the token comes only from an estimate,
|
|
400
|
+
is single use and bound to that exact input.
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import { confirmationInput, withConfirmation } from "@softure-ai/mcp-access/server";
|
|
404
|
+
|
|
405
|
+
server.registerTool(
|
|
406
|
+
"render_film",
|
|
407
|
+
{ inputSchema: { scene: z.string(), seconds: z.number(), confirmation: confirmationInput } },
|
|
408
|
+
withConfirmation((args) => renderFilm(ctx, userId, args), {
|
|
409
|
+
ctx, // the module context, as for the app's queries
|
|
410
|
+
identity, // the factory's McpServerIdentity: the token is bound to its userId and tokenId
|
|
411
|
+
tool: "render_film",
|
|
412
|
+
estimate: (args) => estimateFilm(ctx, userId, args), // { total: 12, currency: "credits", ... }
|
|
413
|
+
runWithoutConfirmationWhen: (estimate) => estimate.total === 0, // everything was cached
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
- **First call** (no `confirmation`): runs `estimate(args)` only and answers
|
|
419
|
+
`{ needsConfirmation: true, estimate, confirmation, expiresAt, message }`; `message` is the fixed sentence
|
|
420
|
+
`confirmation.ask` of the dictionary, which tells the assistant to show the estimate and get the person's yes.
|
|
421
|
+
`estimate` returns an object with a finite numeric `total`; every other field reaches the assistant as it is.
|
|
422
|
+
When `runWithoutConfirmationWhen(estimate)` is true the handler runs at once instead.
|
|
423
|
+
- **The token** is an HMAC-SHA256 signed with `MCP_CONFIRMATION_SECRET` (§6, or the `secret` option) over the tool
|
|
424
|
+
name, a digest of the canonical JSON of the arguments without `confirmation` (`toCanonicalJson`: keys sorted,
|
|
425
|
+
`undefined` left out), a digest of the account and the access token, the estimate's total and the expiry
|
|
426
|
+
(`ttlSeconds`, default 900).
|
|
427
|
+
- **Second call** (same arguments and `confirmation`): the estimate runs again, then the checks, each refused with its
|
|
428
|
+
own code and the text of `confirmation.*` in the dictionary (an app's `hints` replace them):
|
|
429
|
+
`input.confirmation_invalid` (not signed by this secret, malformed), `input.confirmation_other_tool`,
|
|
430
|
+
`input.confirmation_other_account` (another account or access token), `input.confirmation_expired`,
|
|
431
|
+
`input.confirmation_arguments_changed`, `input.confirmation_estimate_changed` (the total changed after the yes)
|
|
432
|
+
and `input.confirmation_used`. Only when all pass is the token stored by its hash and the handler run with the
|
|
433
|
+
arguments without `confirmation`; two parallel calls with one token run once. A refused call never uses up a valid
|
|
434
|
+
token; a handler that fails after the check needs a new estimate.
|
|
435
|
+
- **The record**: `onToolCall` records gain `confirmed`: `true` for a call that ran on a valid confirmation, `false`
|
|
436
|
+
for an estimate, a refusal or a call run through `runWithoutConfirmationWhen`, `null` for tools without
|
|
437
|
+
confirmation. `argumentNames` leave `confirmation` out.
|
|
438
|
+
- `confirmationInput` is the field for the input schema (an optional string), so a strict server lists and accepts
|
|
439
|
+
it. Without `MCP_CONFIRMATION_SECRET` the call fails as a bug, with one log line naming the variable: it never runs
|
|
440
|
+
without asking.
|
|
441
|
+
|
|
299
442
|
### Stdio
|
|
300
443
|
|
|
301
444
|
A local assistant on the machine that holds the database can run the same factory over stdio, without a token:
|
|
@@ -491,6 +634,67 @@ An app that composes its own routes from `/server` builds the context with
|
|
|
491
634
|
resolves origins with `resolveMcpOrigins(config, request)` and passes them to the URL builders,
|
|
492
635
|
whose last argument they are; `OAuthClientRow` is exported from the package root.
|
|
493
636
|
|
|
637
|
+
### Tokens for HTTP routes
|
|
638
|
+
|
|
639
|
+
A person who calls the app from outside the browser (an iPhone Shortcut, Make or Zapier, a script) gets a personal
|
|
640
|
+
token for the app's plain HTTP routes. Declare the scopes the routes ask for, and the buckets:
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
// softure.config.ts
|
|
644
|
+
security({ buckets: { ...AUTH_RATE_LIMIT_BUCKETS, ...MCP_RATE_LIMIT_BUCKETS, ...API_TOKEN_RATE_LIMIT_BUCKETS } }),
|
|
645
|
+
mcpAccess({
|
|
646
|
+
serverName: "acme",
|
|
647
|
+
allowWrites: true,
|
|
648
|
+
apiTokens: {
|
|
649
|
+
prefix: "acme_", // default "sftapi_"; never one of the module's own sftmc* prefixes
|
|
650
|
+
scopes: [
|
|
651
|
+
{ name: "recipes:read", access: "read", description: { en: "Reads your recipes." } },
|
|
652
|
+
{ name: "recipes:write", access: "write", description: { en: "Adds and changes recipes." } },
|
|
653
|
+
],
|
|
654
|
+
// Shown once with a new token; {token} and {appOrigin} are filled in.
|
|
655
|
+
examples: [{ title: { en: "curl" }, text: { en: "curl -H 'Authorization: Bearer {token}' {appOrigin}/api/recipes" } }],
|
|
656
|
+
},
|
|
657
|
+
}),
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
// app/api/recipes/route.ts
|
|
662
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
663
|
+
import { apiTokenErrorResponse, createMcpAccessContext, requireApiToken } from "@softure-ai/mcp-access/tokens";
|
|
664
|
+
|
|
665
|
+
export async function POST(request: Request): Promise<Response> {
|
|
666
|
+
const ctx = await createMcpAccessContext(getSoftureConfig());
|
|
667
|
+
const caller = await requireApiToken(ctx, request, { scopes: ["recipes:write"] });
|
|
668
|
+
if (!caller.ok) return apiTokenErrorResponse(caller);
|
|
669
|
+
// caller.value: { userId, tokenId, scopes }; scope every query to userId.
|
|
670
|
+
return Response.json(await addRecipe(caller.value.userId, await request.json()));
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
- **The two kinds stay apart.** An API token (`kind = 'api'`, the app's prefix) never opens `POST /api/mcp`, and an
|
|
675
|
+
MCP token (`sftmcp_`, `mcp:read`/`mcp:write`) never passes `requireApiToken`. Both share the table, the hashing,
|
|
676
|
+
`tokenLifetimeDays`, `maxTokensPerUser` (one count for both), the revoke action, privacy and prune.
|
|
677
|
+
- **Order of the check:** the client address is counted in `api` before any lookup, the `Authorization: Bearer`
|
|
678
|
+
token is verified (expiry in the query, last use recorded), the token is counted in `api-token`, then the scopes.
|
|
679
|
+
- **Refusals are values** for `apiTokenErrorResponse`: `400 client_unidentified`; `401 api_token_missing` and
|
|
680
|
+
`api_token_invalid` (unknown, revoked, expired, an MCP token) with `WWW-Authenticate: Bearer`; `403
|
|
681
|
+
api_scope_missing` with `missingScopes` and `error="insufficient_scope"`; `403 api_writes_disabled` when the token
|
|
682
|
+
holds the write scope but writing is off; `429 rate_limited` with `Retry-After`; `503 api_unavailable` (logged)
|
|
683
|
+
when the count or the lookup fails. A scope the app does not declare, or missing buckets, throws: a setup bug.
|
|
684
|
+
- **Writes:** a `write` scope is granted at issue only with `allowWrites`, and counts on a request only while
|
|
685
|
+
writing is enabled (`allowWrites` and the `mcp-access.writes_enabled` switch, §7).
|
|
686
|
+
- **The token page** lists the app's scopes and offers a second form, "New token for other apps", that posts to the
|
|
687
|
+
same issue action with `kind=api` and a `scope:<name>` checkbox per offered scope. The new token is shown once
|
|
688
|
+
with its header and the app's `examples`; the action answers `{ status: "api-ok", issued }`
|
|
689
|
+
(`IssuedApiTokenView`), so an app reading `status === "ok"` for MCP tokens is unchanged. The list names each
|
|
690
|
+
token's kind ("AI assistant", "Other apps") and an API token's scopes. A `beforeIssue` gate gets `kind` and
|
|
691
|
+
`scopes`, with `canWrite` true when a write scope is asked.
|
|
692
|
+
- **Without MCP:** an app that has no MCP server enables `mcpAccess` with `apiTokens`, mounts only the token page
|
|
693
|
+
and its routes, and imports `@softure-ai/mcp-access/tokens` (which loads no `@modelcontextprotocol/*` module). The
|
|
694
|
+
npm peer list still names the SDK.
|
|
695
|
+
- Server functions: `issueApiToken`, `verifyApiToken`, `requireApiToken`, `apiTokenErrorResponse`,
|
|
696
|
+
`isApiTokenShape`, plus `listAccessTokens`, `revokeAccessToken`, `pruneAccessTokens` (`/tokens`, also in `/server`).
|
|
697
|
+
|
|
494
698
|
### Closed registration
|
|
495
699
|
|
|
496
700
|
Registration is public by default: claude.ai and ChatGPT register a client before anyone signs in, and a client
|
|
@@ -546,9 +750,25 @@ expired (with their access tokens) and clients registered over a day ago that ho
|
|
|
546
750
|
(`id = 1`) with `opened_at` and `open_until` (`open_until > opened_at`), the window of "Closed registration" (§4). It
|
|
547
751
|
holds no personal data.
|
|
548
752
|
|
|
753
|
+
`migrations/0004_create_used_confirmations.sql` creates `mcp.used_confirmations`: the sha256 of each confirmation
|
|
754
|
+
token that has been used (`token_hash`, the primary key), `used_at` and the token's `expires_at`, which makes a
|
|
755
|
+
confirmation single use ("Confirmed calls", §4). It holds no personal data; `pruneUsedConfirmations(ctx)` deletes the
|
|
756
|
+
rows past `expires_at`.
|
|
757
|
+
|
|
758
|
+
`migrations/0005_add_api_tokens.sql` adds `access_tokens.kind` (`mcp` or `api`, default `mcp`) and
|
|
759
|
+
`access_tokens.scopes` (`text[]`, default empty), with one check: an `mcp` row has no scopes, an `api` row has 1 to
|
|
760
|
+
32 scopes and no grant. Existing rows become MCP tokens through the defaults. An API token's `token_hash` is the
|
|
761
|
+
sha256 of its random part after the prefix (the prefix is checked by shape before any lookup). Rollback: in the migration header.
|
|
762
|
+
|
|
763
|
+
`migrations/0006_create_operation_tasks.sql` creates `mcp.operation_tasks` for long operations as MCP Tasks (§4):
|
|
764
|
+
`task_id` (random, 43 base64url characters, the primary key), `user_id` (`REFERENCES auth.users ON DELETE CASCADE`),
|
|
765
|
+
`job_id` (the operation's job in `@softure-ai/jobs`, no foreign key since jobs is optional), `created_at` and
|
|
766
|
+
`expires_at`, unique per account and job. `pruneOperationTasks(ctx)` (`/server`) deletes the rows past `expires_at`;
|
|
767
|
+
the jobs themselves follow the `retention` of jobs.
|
|
768
|
+
|
|
549
769
|
### Scheduled pruning
|
|
550
770
|
|
|
551
|
-
`pruneMcpAccess(ctx)` (`/server`) runs
|
|
771
|
+
`pruneMcpAccess(ctx)` (`/server`) runs the prunes of tokens, OAuth records, used confirmations and operation tasks and counts what each removed. The `softure-mcp prune` command
|
|
552
772
|
runs it with the app's config (loaded like `softure migrate`, or from an app script through `runMcpAccessCli` from
|
|
553
773
|
`/cli`) and prints one line. Run it daily, e.g. as a `maintain` hook of `@softure-ai/deploy`:
|
|
554
774
|
|
|
@@ -593,7 +813,11 @@ migrations after the baseline.
|
|
|
593
813
|
|
|
594
814
|
## 6. Environment variables
|
|
595
815
|
|
|
596
|
-
|
|
816
|
+
The endpoint URL comes from `appOrigin` in `softure.config.ts`.
|
|
817
|
+
|
|
818
|
+
| Variable | Required | Meaning |
|
|
819
|
+
| --- | --- | --- |
|
|
820
|
+
| `MCP_CONFIRMATION_SECRET` | only for confirmed calls | Signs and verifies the tokens of `withConfirmation` (§4), at least 32 characters, read on every call. Changing it voids the tokens in flight (the person is asked again). |
|
|
597
821
|
|
|
598
822
|
## 7. Switches
|
|
599
823
|
|
|
@@ -663,11 +887,12 @@ The MCP server factory passed to `createMcpRoute` (and to `serveMcpStdio`) and t
|
|
|
663
887
|
The rows hold no personal data beyond the owner's id and the token names. The module contributes
|
|
664
888
|
to `@softure-ai/privacy` (`privacy` flags on):
|
|
665
889
|
|
|
666
|
-
- **Export** (`exportMcpAccessUserData`): `accessTokens`, each hand-issued token's name,
|
|
667
|
-
flag and created, expiry and last-use dates, and `connectedApps`, each grant's client name, write
|
|
890
|
+
- **Export** (`exportMcpAccessUserData`): `accessTokens`, each hand-issued token's name, kind
|
|
891
|
+
(`mcp` or `api`), scopes, write flag and created, expiry and last-use dates, and `connectedApps`, each grant's client name, write
|
|
668
892
|
flag, created and last-use dates. Never a hash.
|
|
669
|
-
- **Deletion** (`deleteMcpAccessUserData`): the user's authorization codes, grants and
|
|
670
|
-
before auth deletes the account (the foreign keys' cascade would remove them too).
|
|
893
|
+
- **Deletion** (`deleteMcpAccessUserData`): the user's authorization codes, grants, tokens and MCP tasks,
|
|
894
|
+
before auth deletes the account (the foreign keys' cascade would remove them too). Tasks hold a job id and dates
|
|
895
|
+
only, so the export leaves them out. Registered
|
|
671
896
|
clients hold no personal data and are pruned when unused.
|
|
672
897
|
|
|
673
898
|
## 12. Limitations / known gaps
|
|
@@ -677,5 +902,10 @@ to `@softure-ai/privacy` (`privacy` flags on):
|
|
|
677
902
|
app that wants `jwks_uri` adds it through `oauth.metadata` and serves the empty set itself).
|
|
678
903
|
- Only `S256` PKCE and the `authorization_code` and `refresh_token` grants.
|
|
679
904
|
- Write access is per token, not per tool; finer scopes would be a new option.
|
|
680
|
-
- The rate limit is per client address, not per token.
|
|
681
|
-
-
|
|
905
|
+
- The MCP endpoint's rate limit is per client address, not per token (API tokens are counted per token as well).
|
|
906
|
+
- One API token kind per app (one prefix); the `kind` column takes more kinds without a migration of the rows.
|
|
907
|
+
- `responseMode: "json"` drops mid-call notifications; a streaming tool would need a different mode. Long work is an
|
|
908
|
+
operation instead (start, `get_operation`, `cancel_operation`).
|
|
909
|
+
- MCP Tasks follow the extension of revision 2026-07-28 only; the 2025-11-25 core Tasks (task-augmented requests,
|
|
910
|
+
`tasks/result`, `tasks/list`) are not served, and those clients use the polling tools. Tasks carry no input
|
|
911
|
+
requests and no `notifications/tasks`.
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface RunMcpAccessCliOptions {
|
|
|
18
18
|
export declare const EXIT_OK = 0;
|
|
19
19
|
export declare const EXIT_FAILED = 1;
|
|
20
20
|
export declare const EXIT_USAGE = 2;
|
|
21
|
-
export declare const MCP_ACCESS_USAGE = "Usage:\n softure-mcp prune deletes expired access tokens, authorization codes, grants and
|
|
21
|
+
export declare const MCP_ACCESS_USAGE = "Usage:\n softure-mcp prune deletes expired access tokens, authorization codes, grants, abandoned clients and used confirmations";
|
|
22
22
|
/** Runs one command and answers its exit code. */
|
|
23
23
|
export declare function runMcpAccessCli(options: RunMcpAccessCliOptions): Promise<number>;
|
|
24
24
|
//# sourceMappingURL=run.d.ts.map
|
package/dist/cli/run.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAQA,OAAO,EAA8B,KAAK,KAAK,EAAE,KAAK,aAAa,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC1H,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAG3E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,sBAAsB;IACrC,8CAA8C;IAC9C,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IACvB,oFAAoF;IACpF,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,qBAAqB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;CACvF;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,eAAO,MAAM,gBAAgB,
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAQA,OAAO,EAA8B,KAAK,KAAK,EAAE,KAAK,aAAa,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC1H,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAG3E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,sBAAsB;IACrC,8CAA8C;IAC9C,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IACvB,oFAAoF;IACpF,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,qBAAqB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;CACvF;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,eAAO,MAAM,gBAAgB,wIAC+F,CAAC;AAO7H,kDAAkD;AAClD,wBAAsB,eAAe,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CAatF"}
|
package/dist/cli/run.js
CHANGED
|
@@ -13,7 +13,7 @@ export const EXIT_OK = 0;
|
|
|
13
13
|
export const EXIT_FAILED = 1;
|
|
14
14
|
export const EXIT_USAGE = 2;
|
|
15
15
|
export const MCP_ACCESS_USAGE = `Usage:
|
|
16
|
-
softure-mcp prune deletes expired access tokens, authorization codes, grants and
|
|
16
|
+
softure-mcp prune deletes expired access tokens, authorization codes, grants, abandoned clients and used confirmations`;
|
|
17
17
|
const consoleOutput = {
|
|
18
18
|
log: (line) => console.log(line),
|
|
19
19
|
error: (line) => console.error(line),
|
|
@@ -43,7 +43,7 @@ async function prune(options, output) {
|
|
|
43
43
|
try {
|
|
44
44
|
opened = await (options.openDatabase ?? ((settings) => openCommandDatabase(settings, { max: 1 })))(database);
|
|
45
45
|
const removed = await pruneMcpAccess({ db: opened.handle.db, clock: options.clock ?? systemClock, config: options.config });
|
|
46
|
-
output.log(`softure-mcp prune: removed ${String(removed.accessTokens)} access tokens, ${String(removed.codes)} authorization codes, ${String(removed.grants)} grants, ${String(removed.clients)} clients`);
|
|
46
|
+
output.log(`softure-mcp prune: removed ${String(removed.accessTokens)} access tokens, ${String(removed.codes)} authorization codes, ${String(removed.grants)} grants, ${String(removed.clients)} clients, ${String(removed.usedConfirmations)} used confirmations, ${String(removed.operationTasks)} operation tasks`);
|
|
47
47
|
return EXIT_OK;
|
|
48
48
|
}
|
|
49
49
|
catch (error) {
|
package/dist/cli/run.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAAA,wGAAwG;AACxG,mGAAmG;AACnG,2FAA2F;AAC3F,EAAE;AACF,sBAAsB;AACtB,kEAAkE;AAClE,4CAA4C;AAC5C,uFAAuF;AACvF,OAAO,EAAE,aAAa,EAAE,WAAW,EAA8D,MAAM,kBAAkB,CAAC;AAC1H,OAAO,EAAE,mBAAmB,EAAwB,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAmBpD,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,CAAC;AACzB,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAC;AAC7B,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,CAAC;AAE5B,MAAM,CAAC,MAAM,gBAAgB,GAAG;
|
|
1
|
+
{"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"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"}
|
package/dist/contract.d.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { CoreErrorCode, Err } from "@softure-ai/core";
|
|
2
|
-
export type McpAccessErrorCode = "mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached" | "mcp-access.token_not_found" | "mcp-access.grant_not_found";
|
|
2
|
+
export type McpAccessErrorCode = "mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached" | "mcp-access.token_not_found" | "mcp-access.grant_not_found" | "mcp-access.api_disabled" | "mcp-access.scope_required" | "mcp-access.scope_unknown";
|
|
3
|
+
/** Which door a token opens: the MCP endpoint, or the app's HTTP routes through `requireApiToken`. */
|
|
4
|
+
export type AccessTokenKind = "mcp" | "api";
|
|
5
|
+
/** Why `requireApiToken` refused a request; `apiTokenErrorResponse` turns each into its HTTP answer. */
|
|
6
|
+
export type ApiTokenErrorCode = "mcp-access.api_token_missing" | "mcp-access.api_token_invalid" | "mcp-access.api_scope_missing" | "mcp-access.api_writes_disabled" | "mcp-access.api_unavailable" | "security.client_unidentified" | "security.rate_limited";
|
|
7
|
+
/** A refusal of `requireApiToken`. */
|
|
8
|
+
export type ApiTokenRefusal = Err<Exclude<ApiTokenErrorCode, "security.rate_limited" | "mcp-access.api_scope_missing" | "mcp-access.api_writes_disabled">> | (Err<"mcp-access.api_scope_missing" | "mcp-access.api_writes_disabled"> & {
|
|
9
|
+
readonly missingScopes: readonly string[];
|
|
10
|
+
}) | (Err<"security.rate_limited"> & {
|
|
11
|
+
readonly retryAfterSeconds: number;
|
|
12
|
+
});
|
|
13
|
+
/** Who called a route with a valid API token. */
|
|
14
|
+
export interface ApiTokenIdentity {
|
|
15
|
+
/** The auth user id of the token's owner: scope every query of the route to it. */
|
|
16
|
+
readonly userId: string;
|
|
17
|
+
/** The token's id, for the route's own logs. Never the token. */
|
|
18
|
+
readonly tokenId: string;
|
|
19
|
+
/** The token's effective scopes: write scopes only while the app allows writes. */
|
|
20
|
+
readonly scopes: readonly string[];
|
|
21
|
+
}
|
|
3
22
|
/** What an app's `beforeIssue` gate may answer instead of letting a token be issued. */
|
|
4
23
|
export type McpIssueRefusalCode = "mcp-access.issue_refused" | "mcp-access.write_refused";
|
|
5
24
|
/**
|
|
@@ -24,6 +43,9 @@ export interface McpServerIdentity {
|
|
|
24
43
|
export interface AccessTokenView {
|
|
25
44
|
readonly id: string;
|
|
26
45
|
readonly name: string;
|
|
46
|
+
readonly kind: AccessTokenKind;
|
|
47
|
+
/** The app scopes of an API token; empty for an MCP token. */
|
|
48
|
+
readonly scopes: readonly string[];
|
|
27
49
|
/** Issued with write access (it still writes only while the app allows writes). */
|
|
28
50
|
readonly canWrite: boolean;
|
|
29
51
|
readonly createdAt: Date;
|
|
@@ -59,7 +81,7 @@ export interface McpClientSetup {
|
|
|
59
81
|
/** The `Authorization` header value, for clients with a header field. */
|
|
60
82
|
readonly authorizationHeader: string;
|
|
61
83
|
}
|
|
62
|
-
/**
|
|
84
|
+
/** An MCP token right after it was issued: the only moment its plaintext exists outside the client. */
|
|
63
85
|
export interface IssuedToken {
|
|
64
86
|
readonly id: string;
|
|
65
87
|
readonly name: string;
|
|
@@ -70,6 +92,25 @@ export interface IssuedToken {
|
|
|
70
92
|
readonly expiresAt: string;
|
|
71
93
|
readonly setup: McpClientSetup;
|
|
72
94
|
}
|
|
95
|
+
/** A snippet the app gave for API tokens (`apiTokens.examples`), filled in with the new token. */
|
|
96
|
+
export interface ApiTokenExample {
|
|
97
|
+
readonly title: string;
|
|
98
|
+
readonly text: string;
|
|
99
|
+
}
|
|
100
|
+
/** An API token right after it was issued, for the one-time reveal. */
|
|
101
|
+
export interface IssuedApiTokenView {
|
|
102
|
+
readonly id: string;
|
|
103
|
+
readonly name: string;
|
|
104
|
+
/** The scopes granted (write scopes only with `allowWrites`). */
|
|
105
|
+
readonly scopes: readonly string[];
|
|
106
|
+
readonly expiresText: string;
|
|
107
|
+
readonly expiresAt: string;
|
|
108
|
+
/** The plaintext, shown once. */
|
|
109
|
+
readonly token: string;
|
|
110
|
+
/** `Bearer <token>`. */
|
|
111
|
+
readonly authorizationHeader: string;
|
|
112
|
+
readonly examples: readonly ApiTokenExample[];
|
|
113
|
+
}
|
|
73
114
|
/** What the issue action returns to its form (`useActionState`). */
|
|
74
115
|
export type IssueTokenFormState = {
|
|
75
116
|
readonly status: "idle";
|
|
@@ -77,6 +118,11 @@ export type IssueTokenFormState = {
|
|
|
77
118
|
readonly status: "ok";
|
|
78
119
|
readonly issued: IssuedToken;
|
|
79
120
|
}
|
|
121
|
+
/** An API token was issued (`kind=api` in the form): its one-time reveal. */
|
|
122
|
+
| {
|
|
123
|
+
readonly status: "api-ok";
|
|
124
|
+
readonly issued: IssuedApiTokenView;
|
|
125
|
+
}
|
|
80
126
|
/** `message`: the app's own refusal text from the `beforeIssue` gate, shown instead of the code's copy. */
|
|
81
127
|
| {
|
|
82
128
|
readonly status: "error";
|
package/dist/contract.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAE3D,MAAM,MAAM,kBAAkB,GAC1B,0BAA0B,GAC1B,0BAA0B,GAC1B,gCAAgC,GAChC,4BAA4B,GAC5B,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"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"}
|