@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
package/src/index.ts CHANGED
@@ -34,6 +34,16 @@ export const MCP_RATE_LIMIT_BUCKETS = {
34
34
  "mcp-oauth": { limit: 60, windowMinutes: 15, key: "ip" },
35
35
  } as const satisfies Readonly<Record<string, RateLimitBucketInput>>;
36
36
 
37
+ /**
38
+ * The rate limit buckets of `requireApiToken`, to spread into `security({ buckets })` when the app sets
39
+ * `apiTokens`: `api` counts requests per client address before the token is looked up (guessing),
40
+ * `api-token` counts requests per verified token (one script or Shortcut cannot flood a route).
41
+ */
42
+ export const API_TOKEN_RATE_LIMIT_BUCKETS = {
43
+ api: { limit: 300, windowMinutes: 15, key: "ip" },
44
+ "api-token": { limit: 120, windowMinutes: 15, key: "subject" },
45
+ } as const satisfies Readonly<Record<string, RateLimitBucketInput>>;
46
+
37
47
  /**
38
48
  * Enables MCP access in `softure.config.ts` (after `security` and `auth`):
39
49
  * `mcpAccess({ serverName: "acme", tools: [{ name: "list_orders", access: "read", description: { en: "Lists your orders." } }] })`.
@@ -41,11 +51,17 @@ export const MCP_RATE_LIMIT_BUCKETS = {
41
51
  export const mcpAccess = defineModule({
42
52
  manifest: {
43
53
  id: MODULE_ID,
44
- version: "0.2.9",
54
+ version: "0.3.0",
45
55
  dependsOn: { security: "^0.2.0", auth: "^0.2.0", jobs: "^0.1.1?", ops: "^0.2.0?" },
46
56
  dbSchema: "mcp",
47
- tables: ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants", "oauth_registration_window"],
48
- env: [],
57
+ tables: ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants", "oauth_registration_window", "used_confirmations", "operation_tasks"],
58
+ env: [
59
+ {
60
+ name: "MCP_CONFIRMATION_SECRET",
61
+ required: false,
62
+ description: "Signs and verifies the confirmation tokens of confirmed tool calls (withConfirmation), at least 32 characters; required only by an app that confirms tool calls. Changing it voids the tokens in flight.",
63
+ },
64
+ ],
49
65
  switches: [WRITES_ENABLED_SWITCH],
50
66
  routes: { ...DEFAULT_MCP_ACCESS_ROUTES },
51
67
  mount: [
@@ -78,8 +94,14 @@ export {
78
94
  type McpClientSetupInput,
79
95
  } from "./client-setup.js";
80
96
  export type {
97
+ AccessTokenKind,
81
98
  AccessTokenStatus,
82
99
  AccessTokenView,
100
+ ApiTokenErrorCode,
101
+ ApiTokenExample,
102
+ ApiTokenIdentity,
103
+ ApiTokenRefusal,
104
+ IssuedApiTokenView,
83
105
  IssuedToken,
84
106
  IssueTokenFormState,
85
107
  McpAccessErrorCode,
@@ -94,12 +116,18 @@ export type {
94
116
  } from "./contract.js";
95
117
  export { getTokenErrorMessage, mcpAccessMessages, type McpAccessMessages } from "./messages/index.js";
96
118
  export {
119
+ API_SCOPE_PATTERN,
120
+ API_TOKEN_PREFIX_PATTERN,
121
+ MAX_API_TOKEN_SCOPES,
97
122
  MAX_PRESENTED_TOKEN_LENGTH,
98
123
  MAX_TOKEN_NAME_LENGTH,
99
124
  SERVER_NAME_PATTERN,
100
125
  TOOL_NAME_PATTERN,
101
126
  GENERATED_AUTHORIZATION_SERVER_KEYS,
102
127
  GENERATED_PROTECTED_RESOURCE_KEYS,
128
+ type ApiScopeDefinition,
129
+ type ApiTokenOptions,
130
+ type ApiTokenOptionsInput,
103
131
  type McpAccessOptions,
104
132
  type McpAccessOptionsInput,
105
133
  type McpAppOriginResolver,
package/src/jobs/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- // The jobs adapter of @softure-ai/mcp-access: its daily cleanup as a `@softure-ai/jobs` handler and schedule (an
2
- // optional peer dependency; only apps that import this entry need it).
1
+ // The jobs adapter of @softure-ai/mcp-access: its daily cleanup as a `@softure-ai/jobs` handler and schedule, and long
2
+ // operations as MCP tools (`startOperation`, `get_operation`, `cancel_operation`) or as MCP Tasks
3
+ // (`registerOperationTasks`). Jobs is an optional peer dependency; only apps that import this entry need it.
3
4
  import { ok } from "@softure-ai/core";
4
5
  import { defineJobHandler, type JobHandlers, type JobSchedulesInput } from "@softure-ai/jobs";
5
6
  import { z } from "zod";
@@ -21,3 +22,41 @@ export const mcpAccessJobHandlers = {
21
22
  export const mcpAccessJobSchedules = {
22
23
  "mcp-access.prune": { every: "1d" },
23
24
  } satisfies JobSchedulesInput;
25
+
26
+ export {
27
+ cancelOperation,
28
+ getOperation,
29
+ getOperationStatus,
30
+ getPollAfterSeconds,
31
+ MAX_OPERATION_DEDUP_KEY_LENGTH,
32
+ OPERATION_CANCEL_TOOL_NAME,
33
+ OPERATION_ORIGIN,
34
+ OPERATION_STATUS_TOOL_NAME,
35
+ OPERATION_STATUSES,
36
+ registerOperationCancelTool,
37
+ registerOperationStatusTool,
38
+ startOperation,
39
+ type GetOperationOptions,
40
+ type OperationAnswer,
41
+ type OperationCancelToolOptions,
42
+ type OperationErrorCode,
43
+ type OperationJob,
44
+ type OperationPollOptions,
45
+ type OperationStatus,
46
+ type OperationStatusToolOptions,
47
+ type ReadOperationResult,
48
+ type StartOperationInput,
49
+ } from "./operations.js";
50
+ export {
51
+ DEFAULT_TASK_TTL_SECONDS,
52
+ getOperationTask,
53
+ isTasksRequest,
54
+ MAX_TASK_TTL_SECONDS,
55
+ MIN_TASK_TTL_SECONDS,
56
+ registerOperationTasks,
57
+ TASKS_EXTENSION,
58
+ type OperationTask,
59
+ type OperationTaskRequest,
60
+ type OperationTasksOptions,
61
+ type OperationTaskStatus,
62
+ } from "./operation-tasks.js";
@@ -0,0 +1,119 @@
1
+ // The state of a long operation (#952) as both answers read it: the polling tools (`get_operation`) and MCP Tasks
2
+ // (#977). An operation is a job of origin `mcp` whose dedup key starts with `mcp:<userId>:`.
3
+ import { JOB_CANCELLED_CODE, type Job, type JobProgress } from "@softure-ai/jobs";
4
+ import { getJob, type JobsContext } from "@softure-ai/jobs/server";
5
+ import type { McpServerIdentity } from "../contract.js";
6
+ import { getMcpAccessMessages } from "../server/options.js";
7
+
8
+ /** The origin of every operation's job: the jobs admin page and run log show where it came from. */
9
+ export const OPERATION_ORIGIN = "mcp";
10
+ export const OPERATION_STATUS_TOOL_NAME = "get_operation";
11
+ export const OPERATION_CANCEL_TOOL_NAME = "cancel_operation";
12
+ /** The longest dedup key an app may pass: the column holds 200 characters and the owner's prefix takes 42. */
13
+ export const MAX_OPERATION_DEDUP_KEY_LENGTH = 150;
14
+
15
+ const DEFAULT_MIN_POLL_SECONDS = 2;
16
+ const DEFAULT_MAX_POLL_SECONDS = 30;
17
+ /** The wait grows with the operation's age: a quarter of the seconds since it started. */
18
+ const POLL_AGE_FACTOR = 0.25;
19
+
20
+ export const OPERATION_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled"] as const;
21
+
22
+ /** An operation's status as the assistant reads it: a retrying job is `running`, since the operation has not ended. */
23
+ export type OperationStatus = (typeof OPERATION_STATUSES)[number];
24
+
25
+ export type OperationErrorCode = "mcp-access.operation_not_found" | "mcp-access.operation_finished";
26
+
27
+ /** How often the assistant is told to ask again: between `minSeconds` (default 2) and `maxSeconds` (default 30). */
28
+ export interface OperationPollOptions {
29
+ readonly minSeconds?: number;
30
+ readonly maxSeconds?: number;
31
+ }
32
+
33
+ /** The job behind an operation, as `readResult` receives it. */
34
+ export interface OperationJob {
35
+ readonly id: string;
36
+ readonly queue: string;
37
+ readonly payload: unknown;
38
+ readonly finishedAt: Date | null;
39
+ }
40
+
41
+ /** The app's result of a succeeded operation (a URL, an id, counts), read from its own tables; never a secret. */
42
+ export type ReadOperationResult = (job: OperationJob) => unknown;
43
+
44
+ /** What `get_operation` answers. `pollAfterSeconds` only while the operation runs, `result` and `errorCode` once ended. */
45
+ export interface OperationAnswer {
46
+ readonly operationId: string;
47
+ readonly status: OperationStatus;
48
+ /** The running attempt's progress (`context.reportProgress`): codes and counts only. */
49
+ readonly progress: JobProgress | null;
50
+ readonly pollAfterSeconds?: number;
51
+ /** True once someone asked a running operation to stop and it has not ended yet. */
52
+ readonly cancelRequested?: boolean;
53
+ readonly result?: unknown;
54
+ /** The failure's code (`lastError`), `jobs.cancelled` for a cancelled operation. */
55
+ readonly errorCode?: string;
56
+ /** What the assistant should do next, in a sentence. */
57
+ readonly message: string;
58
+ }
59
+
60
+ export interface GetOperationOptions {
61
+ readonly readResult?: ReadOperationResult;
62
+ readonly poll?: OperationPollOptions;
63
+ readonly statusToolName?: string;
64
+ }
65
+
66
+ /** The operation's status: a pending job that has used an attempt is waiting for a retry, so it is `running`. */
67
+ export function getOperationStatus(job: Pick<Job, "status" | "attempts">): OperationStatus {
68
+ if (job.status === "pending") return job.attempts === 0 ? "queued" : "running";
69
+ return job.status;
70
+ }
71
+
72
+ /** Seconds until the next status call: a quarter of the operation's age, clamped to the poll bounds. */
73
+ export function getPollAfterSeconds(ageSeconds: number, poll: OperationPollOptions = {}): number {
74
+ const min = getPollMin(poll);
75
+ const max = Math.max(min, poll.maxSeconds ?? DEFAULT_MAX_POLL_SECONDS);
76
+ return Math.min(max, Math.max(min, Math.round(Math.max(0, ageSeconds) * POLL_AGE_FACTOR)));
77
+ }
78
+
79
+ export async function describeOperation(ctx: JobsContext, job: Job, options: GetOperationOptions): Promise<OperationAnswer> {
80
+ const messages = getMcpAccessMessages(ctx.config).operations;
81
+ const status = getOperationStatus(job);
82
+ const statusToolName = options.statusToolName ?? OPERATION_STATUS_TOOL_NAME;
83
+ const base = { operationId: job.id, status, progress: status === "running" ? job.progress : null };
84
+ if (status === "queued" || status === "running") {
85
+ const ageSeconds = (ctx.clock.now().getTime() - job.createdAt.getTime()) / 1000;
86
+ const cancelRequested = job.cancelRequestedAt !== null;
87
+ return {
88
+ ...base,
89
+ pollAfterSeconds: getPollAfterSeconds(ageSeconds, options.poll),
90
+ ...(cancelRequested ? { cancelRequested } : {}),
91
+ message: fillToolName(cancelRequested ? messages.cancelRequested : messages[status], statusToolName),
92
+ };
93
+ }
94
+ if (status === "succeeded") {
95
+ const result = options.readResult === undefined ? undefined : await options.readResult({ id: job.id, queue: job.queue, payload: job.payload, finishedAt: job.finishedAt });
96
+ return { ...base, ...(result === undefined ? {} : { result }), message: messages.succeeded };
97
+ }
98
+ if (status === "cancelled") return { ...base, errorCode: JOB_CANCELLED_CODE, message: messages.cancelled };
99
+ return { ...base, ...(job.lastError === null ? {} : { errorCode: job.lastError }), message: messages.failed };
100
+ }
101
+
102
+ /** The job behind the caller's operation, or null: not a job, not an operation, or another account's. */
103
+ export async function findOwnedJob(ctx: JobsContext, identity: McpServerIdentity, operationId: string): Promise<Job | null> {
104
+ const job = await getJob(ctx, operationId);
105
+ if (job === null || job.origin !== OPERATION_ORIGIN || job.dedupKey?.startsWith(getOwnerPrefix(identity.userId)) !== true) return null;
106
+ return job;
107
+ }
108
+
109
+ export function getOwnerPrefix(userId: string): string {
110
+ return `${OPERATION_ORIGIN}:${userId}:`;
111
+ }
112
+
113
+ export function getPollMin(poll: OperationPollOptions = {}): number {
114
+ return poll.minSeconds ?? DEFAULT_MIN_POLL_SECONDS;
115
+ }
116
+
117
+ export function fillToolName(text: string, toolName: string): string {
118
+ return text.replaceAll("{tool}", toolName);
119
+ }
@@ -0,0 +1,229 @@
1
+ // Long operations as MCP Tasks (#977): the Tasks extension `io.modelcontextprotocol/tasks` of protocol revision
2
+ // 2026-07-28. A client that declares the extension on a tool call gets a task instead of an operation id and follows it
3
+ // through `tasks/get`; every other client keeps the polling tools (`get_operation`, `cancel_operation`) of #952.
4
+ //
5
+ // registerOperationTasks(server, { ctx, identity, readResult: (job) => findRenderUrl(job.id) });
6
+ // server.registerTool("render_video", { inputSchema }, (args, request) =>
7
+ // startOperation(ctx, identity, { queue: "video.render", payload: args, taskRequest: request }));
8
+ //
9
+ // A task id is random (the spec requires ids nobody can guess) and lives in `mcp.operation_tasks` with its owner, the
10
+ // operation's job and an expiry. Every task request checks the owner too, and reads the job's state as it is now.
11
+ import { randomBytes } from "node:crypto";
12
+ import {
13
+ CLIENT_CAPABILITIES_META_KEY,
14
+ MissingRequiredClientCapabilityError,
15
+ ProtocolError,
16
+ ProtocolErrorCode,
17
+ type CallToolResult,
18
+ type McpServer,
19
+ } from "@modelcontextprotocol/server";
20
+ import type { Job } from "@softure-ai/jobs";
21
+ import { requestJobCancel, type JobsContext } from "@softure-ai/jobs/server";
22
+ import { and, eq, sql } from "drizzle-orm";
23
+ import { z } from "zod";
24
+ import type { McpServerIdentity } from "../contract.js";
25
+ import { operationTasks } from "../schema.js";
26
+ import { getMcpAccessMessages } from "../server/options.js";
27
+ import { toolResult } from "../server/tools.js";
28
+ import {
29
+ describeOperation,
30
+ findOwnedJob,
31
+ getOperationStatus,
32
+ getPollAfterSeconds,
33
+ type OperationPollOptions,
34
+ type ReadOperationResult,
35
+ } from "./operation-state.js";
36
+
37
+ /** The extension's identifier, in the client's per-request capabilities and the server's `capabilities.extensions`. */
38
+ export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks";
39
+ export const DEFAULT_TASK_TTL_SECONDS = 86_400;
40
+ export const MIN_TASK_TTL_SECONDS = 60;
41
+ /** Thirty days: a task outlives its operation only so long. */
42
+ export const MAX_TASK_TTL_SECONDS = 2_592_000;
43
+
44
+ const TASK_ID_BYTES = 32;
45
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
+ const TASK_NOT_FOUND = "Failed to retrieve task: Task not found";
47
+ const TASK_EXPIRED = "Failed to retrieve task: Task has expired";
48
+ const CANCEL_NEEDS_WRITE = "Cancelling a task needs a token with write access";
49
+
50
+ /** The tool handler's request context (its second argument): only its per-request `_meta` envelope is read. */
51
+ export interface OperationTaskRequest {
52
+ readonly mcpReq: { readonly envelope?: object };
53
+ }
54
+
55
+ /** A task's status as the extension defines it; operations never ask for input, nor fail at the protocol level. */
56
+ export type OperationTaskStatus = "working" | "completed" | "cancelled";
57
+
58
+ /** A task as `tasks/get` answers it. `result` once completed: the `get_operation` answer, `isError` when it failed. */
59
+ export interface OperationTask {
60
+ readonly taskId: string;
61
+ readonly status: OperationTaskStatus;
62
+ readonly statusMessage?: string;
63
+ /** ISO 8601. */
64
+ readonly createdAt: string;
65
+ /** ISO 8601: the job's last change. */
66
+ readonly lastUpdatedAt: string;
67
+ readonly ttlMs: number;
68
+ /** Only while working: the operation's wait (`pollAfterSeconds`) in milliseconds. */
69
+ readonly pollIntervalMs?: number;
70
+ readonly result?: CallToolResult;
71
+ }
72
+
73
+ export interface OperationTasksOptions {
74
+ /** `{ db, clock, config }` of the request. */
75
+ readonly ctx: JobsContext;
76
+ /** The request's identity: a task is found only for its owner, and only a writer may cancel it. */
77
+ readonly identity: McpServerIdentity;
78
+ readonly readResult?: ReadOperationResult;
79
+ readonly poll?: OperationPollOptions;
80
+ }
81
+
82
+ export interface AnswerOperationTaskInput {
83
+ readonly jobId: string;
84
+ readonly ttlSeconds?: number;
85
+ readonly poll?: OperationPollOptions;
86
+ }
87
+
88
+ type TaskRow = typeof operationTasks.$inferSelect;
89
+
90
+ const taskIdParams = z.looseObject({ taskId: z.string() });
91
+ const taskResult = z.looseObject({});
92
+ const clientCapabilities = z.looseObject({ extensions: z.record(z.string(), z.unknown()).optional() });
93
+
94
+ /** True when the request's client declared the Tasks extension in its per-request capabilities (revision 2026-07-28). */
95
+ export function isTasksRequest(request: OperationTaskRequest): boolean {
96
+ const envelope: Partial<Record<string, unknown>> = { ...request.mcpReq.envelope };
97
+ const capabilities = clientCapabilities.safeParse(envelope[CLIENT_CAPABILITIES_META_KEY]);
98
+ return capabilities.success && capabilities.data.extensions?.[TASKS_EXTENSION] !== undefined;
99
+ }
100
+
101
+ export function isTaskTtlValid(seconds: number): boolean {
102
+ return Number.isInteger(seconds) && seconds >= MIN_TASK_TTL_SECONDS && seconds <= MAX_TASK_TTL_SECONDS;
103
+ }
104
+
105
+ /**
106
+ * The tool's answer as a task (`resultType: "task"`) for the operation's job: stores the task before answering, as the
107
+ * spec requires, and gives the same account the same task id for the same job.
108
+ */
109
+ export async function answerOperationTask(ctx: JobsContext, identity: McpServerIdentity, input: AnswerOperationTaskInput): Promise<CallToolResult> {
110
+ const row = await storeTask(ctx, identity, input);
111
+ const job = await findOwnedJob(ctx, identity, input.jobId);
112
+ if (job === null) throw new Error(`answerOperationTask: job ${input.jobId} is not an operation of this account`);
113
+ const task = await describeTask(ctx, row, job, input.poll === undefined ? {} : { poll: input.poll });
114
+ // On revision 2026-07-28 the SDK passes a tool's own `resultType` through; its `CallToolResult` type does not know
115
+ // the extension's task shape, and the `content` it would add anyway is ignored by clients.
116
+ return { content: [], ...task, resultType: "task" };
117
+ }
118
+
119
+ /** The caller's task with its operation's state now; `null` when unknown, another account's, or its job is gone. */
120
+ export async function getOperationTask(
121
+ ctx: JobsContext,
122
+ identity: McpServerIdentity,
123
+ taskId: string,
124
+ options: Pick<OperationTasksOptions, "readResult" | "poll"> = {},
125
+ ): Promise<OperationTask | "expired" | null> {
126
+ const found = await findTask(ctx, identity, taskId);
127
+ if (found === null || found === "expired") return found;
128
+ return describeTask(ctx, found.row, found.job, options);
129
+ }
130
+
131
+ /**
132
+ * Serves the Tasks extension on the app's server: declares the capability and answers `tasks/get`, `tasks/update` and
133
+ * `tasks/cancel` for the request's account. Register it on every server whose tools pass `taskRequest`.
134
+ */
135
+ export function registerOperationTasks(server: McpServer, options: OperationTasksOptions): void {
136
+ const { ctx, identity } = options;
137
+ const describeOptions = { ...(options.readResult === undefined ? {} : { readResult: options.readResult }), ...(options.poll === undefined ? {} : { poll: options.poll }) };
138
+ server.server.registerCapabilities({ extensions: { [TASKS_EXTENSION]: {} } });
139
+
140
+ server.server.setRequestHandler("tasks/get", { params: taskIdParams, result: taskResult }, async ({ taskId }, request) => {
141
+ requireTasksCapability(request);
142
+ return { ...(await requireTask(ctx, identity, taskId, describeOptions)) };
143
+ });
144
+
145
+ // Operations never ask for input, so there is nothing to update; an owned task is acknowledged, as the spec asks.
146
+ server.server.setRequestHandler("tasks/update", { params: taskIdParams, result: taskResult }, async ({ taskId }, request) => {
147
+ requireTasksCapability(request);
148
+ await requireTask(ctx, identity, taskId, describeOptions);
149
+ return {};
150
+ });
151
+
152
+ server.server.setRequestHandler("tasks/cancel", { params: taskIdParams, result: taskResult }, async ({ taskId }, request) => {
153
+ requireTasksCapability(request);
154
+ const found = await findTask(ctx, identity, taskId);
155
+ if (found === null) throw new ProtocolError(ProtocolErrorCode.InvalidParams, TASK_NOT_FOUND);
156
+ if (found === "expired") throw new ProtocolError(ProtocolErrorCode.InvalidParams, TASK_EXPIRED);
157
+ if (!identity.canWrite) throw new ProtocolError(ProtocolErrorCode.InvalidRequest, CANCEL_NEEDS_WRITE);
158
+ // Cancelling is cooperative: an operation that has already ended is acknowledged all the same.
159
+ await requestJobCancel(ctx, found.job.id);
160
+ return {};
161
+ });
162
+ }
163
+
164
+ async function storeTask(ctx: JobsContext, identity: McpServerIdentity, input: AnswerOperationTaskInput): Promise<TaskRow> {
165
+ const now = ctx.clock.now();
166
+ const expiresAt = new Date(now.getTime() + (input.ttlSeconds ?? DEFAULT_TASK_TTL_SECONDS) * 1000);
167
+ // One task per account and job: joining a running operation answers its task again, kept alive at least as long.
168
+ const [row] = await ctx.db
169
+ .insert(operationTasks)
170
+ .values({ taskId: randomBytes(TASK_ID_BYTES).toString("base64url"), userId: identity.userId, jobId: BigInt(input.jobId), createdAt: now, expiresAt })
171
+ .onConflictDoUpdate({
172
+ target: [operationTasks.userId, operationTasks.jobId],
173
+ set: { expiresAt: sql`greatest(${operationTasks.expiresAt}, excluded.expires_at)` },
174
+ })
175
+ .returning();
176
+ if (row === undefined) throw new Error(`answerOperationTask: no task row for job ${input.jobId}`);
177
+ return row;
178
+ }
179
+
180
+ async function findTask(ctx: JobsContext, identity: McpServerIdentity, taskId: string): Promise<{ readonly row: TaskRow; readonly job: Job } | "expired" | null> {
181
+ if (!UUID.test(identity.userId)) return null;
182
+ const [row] = await ctx.db
183
+ .select()
184
+ .from(operationTasks)
185
+ .where(and(eq(operationTasks.taskId, taskId), eq(operationTasks.userId, identity.userId)))
186
+ .limit(1);
187
+ if (row === undefined) return null;
188
+ if (row.expiresAt.getTime() <= ctx.clock.now().getTime()) return "expired";
189
+ const job = await findOwnedJob(ctx, identity, row.jobId.toString());
190
+ return job === null ? null : { row, job };
191
+ }
192
+
193
+ async function requireTask(
194
+ ctx: JobsContext,
195
+ identity: McpServerIdentity,
196
+ taskId: string,
197
+ options: Pick<OperationTasksOptions, "readResult" | "poll">,
198
+ ): Promise<OperationTask> {
199
+ const task = await getOperationTask(ctx, identity, taskId, options);
200
+ if (task === null) throw new ProtocolError(ProtocolErrorCode.InvalidParams, TASK_NOT_FOUND);
201
+ if (task === "expired") throw new ProtocolError(ProtocolErrorCode.InvalidParams, TASK_EXPIRED);
202
+ return task;
203
+ }
204
+
205
+ /** The spec's rule for `tasks/*`: a client that did not declare the extension on this request gets `-32021`. */
206
+ function requireTasksCapability(request: OperationTaskRequest): void {
207
+ if (!isTasksRequest(request)) throw new MissingRequiredClientCapabilityError({ requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } } });
208
+ }
209
+
210
+ async function describeTask(ctx: JobsContext, row: TaskRow, job: Job, options: Pick<OperationTasksOptions, "readResult" | "poll">): Promise<OperationTask> {
211
+ const messages = getMcpAccessMessages(ctx.config).operations;
212
+ const status = getOperationStatus(job);
213
+ const base = {
214
+ taskId: row.taskId,
215
+ createdAt: row.createdAt.toISOString(),
216
+ lastUpdatedAt: job.updatedAt.toISOString(),
217
+ ttlMs: row.expiresAt.getTime() - row.createdAt.getTime(),
218
+ };
219
+ if (status === "queued" || status === "running") {
220
+ const ageSeconds = (ctx.clock.now().getTime() - job.createdAt.getTime()) / 1000;
221
+ const statusMessage = job.cancelRequestedAt !== null ? messages.taskCancelRequested : status === "queued" ? messages.taskQueued : messages.taskRunning;
222
+ return { ...base, status: "working", statusMessage, pollIntervalMs: getPollAfterSeconds(ageSeconds, options.poll) * 1000 };
223
+ }
224
+ if (status === "cancelled") return { ...base, status: "cancelled", statusMessage: messages.cancelled };
225
+ // A failed job is a tool-level failure, not a JSON-RPC error: the spec makes it `completed` with `isError`.
226
+ const answer = await describeOperation(ctx, job, options);
227
+ const result = status === "failed" ? { ...toolResult(answer), isError: true } : toolResult(answer);
228
+ return { ...base, status: "completed", statusMessage: answer.message, result };
229
+ }