@corbits/mcp 0.3.1 → 0.4.1

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 (124) hide show
  1. package/README.md +162 -28
  2. package/dist/auth/callback.d.ts +14 -0
  3. package/dist/auth/callback.js +79 -0
  4. package/dist/auth/challenge.d.ts +9 -0
  5. package/dist/auth/challenge.js +52 -0
  6. package/dist/auth/contracts.d.ts +18 -0
  7. package/dist/auth/contracts.js +19 -0
  8. package/dist/auth/http.d.ts +2 -0
  9. package/dist/auth/http.js +2 -0
  10. package/dist/auth/index.d.ts +6 -0
  11. package/dist/auth/index.js +6 -0
  12. package/dist/auth/login.d.ts +62 -0
  13. package/dist/auth/login.js +175 -0
  14. package/dist/auth/oauth-http.d.ts +6 -0
  15. package/dist/auth/oauth-http.js +32 -0
  16. package/dist/auth/resource-server.d.ts +25 -0
  17. package/dist/auth/resource-server.js +94 -0
  18. package/dist/auth/url-policy.d.ts +10 -0
  19. package/dist/auth/url-policy.js +35 -0
  20. package/dist/client/resource-budget.d.ts +1 -0
  21. package/dist/client/resource-budget.js +1 -0
  22. package/dist/client/transport.d.ts +10 -0
  23. package/dist/client/transport.js +1 -0
  24. package/dist/client.d.ts +208 -26
  25. package/dist/client.js +1128 -90
  26. package/dist/extensions/apps.d.ts +58 -0
  27. package/dist/extensions/apps.js +55 -0
  28. package/dist/extensions/elicitation.d.ts +18 -0
  29. package/dist/extensions/elicitation.js +55 -0
  30. package/dist/extensions/index.d.ts +5 -0
  31. package/dist/extensions/index.js +5 -0
  32. package/dist/extensions/maturity.d.ts +44 -0
  33. package/dist/extensions/maturity.js +24 -0
  34. package/dist/extensions/skills/contracts.d.ts +53 -0
  35. package/dist/extensions/skills/contracts.js +1 -0
  36. package/dist/extensions/skills/index.d.ts +52 -0
  37. package/dist/extensions/skills/index.js +402 -0
  38. package/dist/extensions/tasks/contracts.d.ts +39 -0
  39. package/dist/extensions/tasks/contracts.js +1 -0
  40. package/dist/extensions/tasks/file-store.d.ts +12 -0
  41. package/dist/extensions/tasks/file-store.js +152 -0
  42. package/dist/extensions/tasks/index.d.ts +82 -0
  43. package/dist/extensions/tasks/index.js +221 -0
  44. package/dist/extensions/tasks/record.d.ts +3 -0
  45. package/dist/extensions/tasks/record.js +37 -0
  46. package/dist/hub/discover.d.ts +5 -1
  47. package/dist/hub/discover.js +36 -13
  48. package/dist/index.d.ts +3 -2
  49. package/dist/index.js +4 -2
  50. package/dist/interchange/client/capabilities.d.ts +4 -0
  51. package/dist/interchange/client/capabilities.js +53 -0
  52. package/dist/interchange/client/catalog.d.ts +14 -0
  53. package/dist/interchange/client/catalog.js +52 -0
  54. package/dist/interchange/client/contracts.d.ts +47 -0
  55. package/dist/interchange/client/contracts.js +1 -0
  56. package/dist/interchange/client/projection.d.ts +6 -0
  57. package/dist/interchange/client/projection.js +92 -0
  58. package/dist/interchange/client/skills.d.ts +16 -0
  59. package/dist/interchange/client/skills.js +87 -0
  60. package/dist/interchange/client.d.ts +8 -0
  61. package/dist/interchange/client.js +6 -0
  62. package/dist/interchange/server/contracts.d.ts +65 -0
  63. package/dist/interchange/server/contracts.js +1 -0
  64. package/dist/interchange/server.d.ts +5 -0
  65. package/dist/interchange/server.js +172 -0
  66. package/dist/naming.d.ts +2 -0
  67. package/dist/naming.js +7 -0
  68. package/dist/oauth-discovery.d.ts +107 -1
  69. package/dist/oauth-discovery.js +292 -56
  70. package/dist/protocol/content.d.ts +35 -0
  71. package/dist/protocol/content.js +64 -0
  72. package/dist/protocol/envelopes.d.ts +14 -0
  73. package/dist/protocol/envelopes.js +22 -0
  74. package/dist/protocol/errors.d.ts +26 -0
  75. package/dist/protocol/errors.js +31 -0
  76. package/dist/protocol/extensions.d.ts +34 -0
  77. package/dist/protocol/extensions.js +86 -0
  78. package/dist/protocol/headers.d.ts +12 -0
  79. package/dist/protocol/headers.js +128 -0
  80. package/dist/protocol/index.d.ts +11 -0
  81. package/dist/protocol/index.js +11 -0
  82. package/dist/protocol/json-schema.d.ts +8 -0
  83. package/dist/protocol/json-schema.js +152 -0
  84. package/dist/protocol/legacy.d.ts +12 -0
  85. package/dist/protocol/legacy.js +18 -0
  86. package/dist/protocol/methods.d.ts +116 -0
  87. package/dist/protocol/methods.js +78 -0
  88. package/dist/protocol/resource-budget.d.ts +9 -0
  89. package/dist/protocol/resource-budget.js +206 -0
  90. package/dist/protocol/schemas.d.ts +115 -0
  91. package/dist/protocol/schemas.js +553 -0
  92. package/dist/protocol/types.d.ts +295 -0
  93. package/dist/protocol/types.js +3 -0
  94. package/dist/protocol/ui.d.ts +3 -0
  95. package/dist/protocol/ui.js +18 -0
  96. package/dist/server/contracts.d.ts +67 -0
  97. package/dist/server/contracts.js +1 -0
  98. package/dist/server/errors.d.ts +6 -0
  99. package/dist/server/errors.js +21 -0
  100. package/dist/server/extensions.d.ts +7 -0
  101. package/dist/server/extensions.js +61 -0
  102. package/dist/server/fixtures.d.ts +2 -0
  103. package/dist/server/fixtures.js +34 -0
  104. package/dist/server/legacy.d.ts +9 -0
  105. package/dist/server/legacy.js +435 -0
  106. package/dist/server/tools.d.ts +17 -0
  107. package/dist/server/tools.js +93 -0
  108. package/dist/server.d.ts +14 -0
  109. package/dist/server.js +406 -0
  110. package/dist/sidecar-bundle.d.ts +7 -7
  111. package/dist/sidecar-bundle.js +36 -29
  112. package/dist/test-server.js +12 -3
  113. package/dist/tool.d.ts +8 -0
  114. package/dist/tool.js +35 -19
  115. package/dist/url.js +8 -3
  116. package/docs/conformance.md +135 -0
  117. package/docs/integration-status.md +100 -0
  118. package/docs/legacy-server.md +58 -0
  119. package/docs/migration.md +143 -0
  120. package/docs/public-api.md +105 -0
  121. package/examples/README.md +28 -0
  122. package/examples/standalone-client.ts +14 -0
  123. package/examples/standalone-server.ts +37 -0
  124. package/package.json +44 -9
@@ -0,0 +1,402 @@
1
+ import { parseDocument, visit, isAlias } from "yaml";
2
+ import { type } from "arktype";
3
+ import { validateSkill } from "../../protocol/index.js";
4
+ export const SKILL_LIMITS = Object.freeze({
5
+ resources: 512,
6
+ bytes: 16_777_216,
7
+ });
8
+ const frontmatterShape = type({ name: "string", description: "string" });
9
+ const fail = (message) => {
10
+ throw Object.assign(new Error(message), { code: -32602 });
11
+ };
12
+ const encoder = new TextEncoder();
13
+ const canonical = (value) => {
14
+ if (Array.isArray(value))
15
+ return `[${value.map(canonical).join(",")}]`;
16
+ if (value !== null && typeof value === "object")
17
+ return `{${Object.entries(value)
18
+ .sort(([a], [b]) => a.localeCompare(b))
19
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`)
20
+ .join(",")}}`;
21
+ return JSON.stringify(value) ?? fail("Non-JSON frontmatter");
22
+ };
23
+ function skillBody(entry) {
24
+ const resources = entry.resources;
25
+ if (resources === "dynamic")
26
+ return fail("Dynamic skills unsupported");
27
+ return canonical({
28
+ frontmatter: entry.frontmatter,
29
+ resources: [...resources].sort((a, b) => a.uri.localeCompare(b.uri)),
30
+ });
31
+ }
32
+ async function digest(bytes) {
33
+ const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
34
+ return `sha256:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("")}`;
35
+ }
36
+ function safeUri(uri) {
37
+ const parsed = new URL(uri);
38
+ if (parsed.search ||
39
+ parsed.hash ||
40
+ parsed.username ||
41
+ parsed.password ||
42
+ /[\\\s]/u.test(uri))
43
+ fail("Unsafe skill URI");
44
+ const segments = uri.slice(uri.indexOf(":") + 1).split("/");
45
+ for (const segment of segments) {
46
+ const decoded = decodeURIComponent(segment);
47
+ if (decoded === "." ||
48
+ decoded === ".." ||
49
+ /[/\\]/u.test(decoded) ||
50
+ decoded.includes(String.fromCharCode(0)))
51
+ fail("Unsafe skill path");
52
+ }
53
+ }
54
+ function checkFrontmatter(value, uri) {
55
+ const parsed = frontmatterShape(value);
56
+ if (parsed instanceof type.errors)
57
+ return fail("Invalid skill frontmatter");
58
+ const { name, description } = parsed;
59
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) ||
60
+ name.length > 64 ||
61
+ !description.trim() ||
62
+ description.length > 1024)
63
+ fail("Invalid skill name or description");
64
+ if (!uri.endsWith(`/${name}/SKILL.md`))
65
+ fail("Skill name does not match directory");
66
+ const fm = value;
67
+ if (fm.compatibility !== undefined &&
68
+ (typeof fm.compatibility !== "string" ||
69
+ !fm.compatibility.length ||
70
+ fm.compatibility.length > 500))
71
+ fail("Invalid compatibility");
72
+ for (const key of ["license", "allowed-tools"])
73
+ if (fm[key] !== undefined && typeof fm[key] !== "string")
74
+ fail(`Invalid ${key}`);
75
+ if (fm.metadata !== undefined &&
76
+ (fm.metadata === null ||
77
+ typeof fm.metadata !== "object" ||
78
+ Array.isArray(fm.metadata) ||
79
+ Object.values(fm.metadata).some((v) => typeof v !== "string")))
80
+ fail("Invalid metadata");
81
+ return fm;
82
+ }
83
+ export function parseSkillFrontmatter(bytes, uri) {
84
+ if (bytes.byteLength > SKILL_LIMITS.bytes)
85
+ fail("Skill exceeds 16 MiB");
86
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
87
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(text);
88
+ if (!match)
89
+ return fail("Missing YAML frontmatter");
90
+ // Bound parser nesting before conversion, and prohibit aliases entirely.
91
+ const doc = parseDocument(match[1], { schema: "core", uniqueKeys: true });
92
+ if (doc.errors.length)
93
+ fail("Invalid YAML frontmatter");
94
+ let nodes = 0;
95
+ visit(doc, (_key, node, path) => {
96
+ if (isAlias(node) || path.length > 32 || ++nodes > 100_000)
97
+ fail("Frontmatter structure limit or alias");
98
+ });
99
+ const value = doc.toJS({ maxAliasCount: 0 });
100
+ const inspect = (v, depth) => {
101
+ if (depth > 32)
102
+ fail("Frontmatter nesting limit");
103
+ if (v !== null && typeof v === "object")
104
+ for (const child of Object.values(v))
105
+ inspect(child, depth + 1);
106
+ else if (!["string", "number", "boolean"].includes(typeof v) && v !== null)
107
+ fail("Non-JSON frontmatter");
108
+ if (typeof v === "number" && !Number.isFinite(v))
109
+ fail("Non-JSON number");
110
+ };
111
+ inspect(value, 0);
112
+ return checkFrontmatter(value, uri);
113
+ }
114
+ function checkEntry(entry) {
115
+ validateSkill(entry);
116
+ safeUri(entry.uri);
117
+ checkFrontmatter(entry.frontmatter, entry.uri);
118
+ if (entry.resources === "dynamic")
119
+ return fail("Dynamic skills cannot be content-bound and are not supported");
120
+ if (!Array.isArray(entry.resources) ||
121
+ entry.resources.length > SKILL_LIMITS.resources)
122
+ fail("Skill exceeds 512 resources or lacks manifest");
123
+ const root = entry.uri.slice(0, -"SKILL.md".length);
124
+ let total = 0;
125
+ for (const file of entry.resources) {
126
+ safeUri(file.uri);
127
+ if (!file.uri.startsWith(root) || file.uri.endsWith("/"))
128
+ fail("Manifest has duplicate or out-of-directory file");
129
+ total += file.size;
130
+ }
131
+ if (total > SKILL_LIMITS.bytes)
132
+ fail("Skill exceeds 16 MiB");
133
+ }
134
+ function freeze(value) {
135
+ if (value !== null && typeof value === "object") {
136
+ for (const child of Object.values(value))
137
+ freeze(child);
138
+ Object.freeze(value);
139
+ }
140
+ return value;
141
+ }
142
+ export function createSkillLoader(source, host) {
143
+ const heldEntries = new WeakMap();
144
+ const current = new Map();
145
+ const approvals = new Map();
146
+ const cache = new Map();
147
+ let cacheBytes = 0;
148
+ async function hold(origin, entry) {
149
+ if (Object.values(origin).some((v) => !v))
150
+ fail("Host origin identity required");
151
+ const snapshot = structuredClone(entry);
152
+ const originSnapshot = structuredClone(origin);
153
+ checkEntry(snapshot);
154
+ const identity = canonical([
155
+ originSnapshot.serverId,
156
+ originSnapshot.tenantId,
157
+ originSnapshot.principalId,
158
+ originSnapshot.authorizationContextId,
159
+ snapshot.uri,
160
+ ]);
161
+ const canonicalBody = skillBody(snapshot);
162
+ const fingerprint = await digest(encoder.encode(canonicalBody));
163
+ let diverged = true;
164
+ try {
165
+ diverged = skillBody(entry) !== canonicalBody;
166
+ }
167
+ catch {
168
+ diverged = true;
169
+ }
170
+ if (diverged || current.get(identity)?.fingerprint !== fingerprint) {
171
+ approvals.delete(identity);
172
+ current.set(identity, { fingerprint, generation: {} });
173
+ }
174
+ const generation = current.get(identity).generation;
175
+ const held = freeze({
176
+ origin: originSnapshot,
177
+ entry: snapshot,
178
+ identity,
179
+ fingerprint,
180
+ });
181
+ heldEntries.set(held, generation);
182
+ return held;
183
+ }
184
+ function assertCurrent(held) {
185
+ if (!heldEntries.has(held) ||
186
+ current.get(held.identity)?.generation !== heldEntries.get(held))
187
+ fail("Skill entry revoked; refresh and reapprove");
188
+ }
189
+ async function verify(held, uri = held.entry.uri) {
190
+ assertCurrent(held);
191
+ try {
192
+ const files = held.entry.resources;
193
+ if (files === "dynamic")
194
+ return fail("Dynamic skills unsupported");
195
+ const file = files.find((f) => f.uri === uri);
196
+ if (!file)
197
+ return fail("Unlisted skill file");
198
+ const key = canonical([held.identity, uri, file.digest]);
199
+ const bytes = new Uint8Array(cache.get(key) ?? (await source.read(held.origin, uri, file.size)));
200
+ assertCurrent(held);
201
+ if (bytes.byteLength !== file.size)
202
+ return fail("Skill integrity mismatch");
203
+ const actualDigest = await digest(bytes);
204
+ // A source read or digest can finish after refresh/clear. Reject stale
205
+ // work before parsing frontmatter, changing the cache, or exposing bytes.
206
+ assertCurrent(held);
207
+ if (actualDigest !== file.digest) {
208
+ cache.delete(key);
209
+ return fail("Skill integrity mismatch");
210
+ }
211
+ if (uri === held.entry.uri &&
212
+ canonical(parseSkillFrontmatter(bytes, uri)) !==
213
+ canonical(held.entry.frontmatter))
214
+ return fail("Skill frontmatter mismatch");
215
+ if (!cache.has(key)) {
216
+ if (cacheBytes + bytes.length > SKILL_LIMITS.bytes) {
217
+ cache.clear();
218
+ cacheBytes = 0;
219
+ }
220
+ cache.set(key, bytes.slice());
221
+ cacheBytes += bytes.length;
222
+ }
223
+ return { held, uri, bytes, trust: "untrusted-mcp" };
224
+ }
225
+ catch (error) {
226
+ // Every verification failure, including source, digest and YAML errors,
227
+ // may invalidate only the generation that initiated this verification.
228
+ if (current.get(held.identity)?.generation === heldEntries.get(held)) {
229
+ approvals.delete(held.identity);
230
+ current.delete(held.identity);
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+ async function activate(held) {
236
+ assertCurrent(held);
237
+ if (!host)
238
+ return fail("Skill host policy unavailable; verified bytes are not activation");
239
+ let approval = approvals.get(held.identity);
240
+ if (!approval) {
241
+ approval = await host.approve(held);
242
+ if (approval.approved !== true ||
243
+ approval.identity !== held.identity ||
244
+ approval.fingerprint !== held.fingerprint)
245
+ return fail("Skill approval denied or mismatched");
246
+ approval = freeze(structuredClone(approval));
247
+ assertCurrent(held);
248
+ approvals.set(held.identity, approval);
249
+ }
250
+ const content = await verify(held);
251
+ let active = true;
252
+ const assertExecutionApproved = () => {
253
+ assertCurrent(held);
254
+ if (!active || !approvals.get(held.identity)?.execution)
255
+ fail("Explicit per-skill execution approval required");
256
+ };
257
+ const window = await host.openActingWindow(held, approval, {
258
+ assertExecutionApproved,
259
+ });
260
+ try {
261
+ assertCurrent(held);
262
+ await window.admit(content);
263
+ }
264
+ catch (error) {
265
+ await window.removeContext();
266
+ await window.close();
267
+ throw error;
268
+ }
269
+ return {
270
+ held,
271
+ read: (uri) => {
272
+ if (!active)
273
+ return Promise.reject(new Error("Skill lease closed"));
274
+ return verify(held, uri);
275
+ },
276
+ assertExecutionApproved,
277
+ async release() {
278
+ if (active) {
279
+ await window.removeContext();
280
+ await window.close();
281
+ active = false;
282
+ }
283
+ },
284
+ };
285
+ }
286
+ return {
287
+ hold,
288
+ verify,
289
+ activate,
290
+ async get(origin, uri) {
291
+ const entry = await source.get(origin, uri);
292
+ if (entry.uri !== uri)
293
+ return fail("Skill URI mismatch");
294
+ return hold(origin, entry);
295
+ },
296
+ clear() {
297
+ cache.clear();
298
+ cacheBytes = 0;
299
+ approvals.clear();
300
+ current.clear();
301
+ },
302
+ };
303
+ }
304
+ /** In-memory immutable snapshot publisher. Host must authorize all methods using the same identity. */
305
+ export async function createSkillPublisher(publications) {
306
+ const skills = new Map();
307
+ const files = new Map();
308
+ for (const original of publications) {
309
+ const publication = {
310
+ uri: original.uri,
311
+ files: new Map([...original.files].map(([uri, bytes]) => [uri, bytes.slice()])),
312
+ };
313
+ if (skills.has(publication.uri))
314
+ fail("Duplicate skill publication");
315
+ const main = publication.files.get(publication.uri);
316
+ if (!main)
317
+ return fail("Publication omits SKILL.md");
318
+ const resources = await Promise.all([...publication.files].map(async ([uri, bytes]) => ({
319
+ uri,
320
+ digest: await digest(bytes),
321
+ size: bytes.length,
322
+ })));
323
+ const entry = {
324
+ uri: publication.uri,
325
+ frontmatter: parseSkillFrontmatter(main, publication.uri),
326
+ resources,
327
+ };
328
+ checkEntry(entry);
329
+ skills.set(entry.uri, entry);
330
+ for (const [uri, bytes] of publication.files) {
331
+ const previous = files.get(uri);
332
+ if (previous && (await digest(previous)) !== (await digest(bytes)))
333
+ fail("Conflicting nested publication");
334
+ files.set(uri, bytes.slice());
335
+ }
336
+ }
337
+ for (const skill of skills.values()) {
338
+ const root = skill.uri.slice(0, -"SKILL.md".length);
339
+ if (skill.resources !== "dynamic")
340
+ for (const uri of files.keys())
341
+ if (uri.startsWith(root) &&
342
+ !skill.resources.some((file) => file.uri === uri))
343
+ fail("Incomplete enclosing skill manifest");
344
+ }
345
+ return {
346
+ list() {
347
+ return {
348
+ resultType: "complete",
349
+ ttlMs: 0,
350
+ cacheScope: "private",
351
+ skills: structuredClone([...skills.values()]),
352
+ };
353
+ },
354
+ get(uri) {
355
+ const skill = skills.get(uri);
356
+ if (!skill)
357
+ return fail("Unknown skill");
358
+ return {
359
+ resultType: "complete",
360
+ ttlMs: 0,
361
+ cacheScope: "private",
362
+ skill: structuredClone(skill),
363
+ };
364
+ },
365
+ read(uri) {
366
+ const bytes = files.get(uri);
367
+ if (!bytes)
368
+ return fail("Unknown resource");
369
+ return {
370
+ resultType: "complete",
371
+ ttlMs: 0,
372
+ cacheScope: "private",
373
+ contents: [{ uri, blob: Buffer.from(bytes).toString("base64") }],
374
+ };
375
+ },
376
+ directoryRead(uri) {
377
+ safeUri(uri);
378
+ if (uri.endsWith("/"))
379
+ fail("Directory URI must not end in slash");
380
+ const children = new Map();
381
+ for (const file of files.keys())
382
+ if (file.startsWith(`${uri}/`)) {
383
+ const relative = file.slice(uri.length + 1);
384
+ const name = relative.split("/")[0];
385
+ const directory = relative.includes("/");
386
+ children.set(`${directory ? "d" : "f"}:${name}`, {
387
+ uri: `${uri}/${name}`,
388
+ name,
389
+ ...(directory ? { mimeType: "inode/directory" } : {}),
390
+ });
391
+ }
392
+ if (!children.size)
393
+ return fail("Unknown directory");
394
+ return {
395
+ resultType: "complete",
396
+ ttlMs: 0,
397
+ cacheScope: "private",
398
+ resources: [...children.values()],
399
+ };
400
+ },
401
+ };
402
+ }
@@ -0,0 +1,39 @@
1
+ import type { DetailedTask, InputRequests, InputResponses } from "../../protocol/index.js";
2
+ export interface McpTaskIdentity {
3
+ tenantId: string;
4
+ principalId: string;
5
+ authorizationContextId: string;
6
+ }
7
+ export interface McpTaskRecord {
8
+ identity: McpTaskIdentity;
9
+ task: DetailedTask;
10
+ revision: number;
11
+ original: {
12
+ method: "tools/call";
13
+ params: Record<string, unknown>;
14
+ };
15
+ backend: Record<string, string>;
16
+ /** Immutable history prevents key reuse even after requests are answered. */
17
+ issued: InputRequests;
18
+ answered: InputResponses;
19
+ cancelRequested: boolean;
20
+ dispatch: "pending" | "started";
21
+ }
22
+ /** All operations are atomic, linearizable and durable before resolving. */
23
+ export interface McpTaskStore {
24
+ readonly durable: true;
25
+ create(record: McpTaskRecord): Promise<void>;
26
+ get(taskId: string): Promise<McpTaskRecord | undefined>;
27
+ compareAndSwap(taskId: string, revision: number, next: McpTaskRecord): Promise<boolean>;
28
+ list(): Promise<McpTaskRecord[]>;
29
+ expire(now: number): Promise<void>;
30
+ }
31
+ export interface McpTaskExecutor {
32
+ /** Start uses taskId as idempotency/correlation key; never hide ambiguous dispatch. */
33
+ start(record: McpTaskRecord): Promise<void>;
34
+ /** Reconcile backend state, including pending dispatch/input/cancel; do not blindly replay. */
35
+ recover(record: McpTaskRecord): Promise<DetailedTask | undefined>;
36
+ /** Responses already persisted. Host must authorize each approval/input binding. */
37
+ submitInput(record: McpTaskRecord, responses: InputResponses): Promise<void>;
38
+ requestCancel(record: McpTaskRecord): Promise<void>;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import type { McpTaskRecord, McpTaskStore } from "./contracts.js";
2
+ /** Single writer on a local filesystem only. A crash leaves the lock: host must
3
+ * prove the previous writer dead before removing it. Never auto-steal a lock.
4
+ * Directory must be host-owned, not writable by models/tools/other users.
5
+ */
6
+ export declare function createFileTaskStore(options: {
7
+ directory: string;
8
+ /** Shared boundary validation plus host record invariants on restart. */
9
+ validateRecord(value: unknown): McpTaskRecord;
10
+ }): Promise<McpTaskStore & {
11
+ close(): Promise<void>;
12
+ }>;
@@ -0,0 +1,152 @@
1
+ /** Explicit server-only import. Not re-exported by the neutral extensions entry. */
2
+ import { open, mkdir, rename, unlink, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { type } from "arktype";
5
+ const envelope = type({ version: "1", records: "unknown[]" });
6
+ const MAX_STORE_BYTES = 16_777_216;
7
+ /** Single writer on a local filesystem only. A crash leaves the lock: host must
8
+ * prove the previous writer dead before removing it. Never auto-steal a lock.
9
+ * Directory must be host-owned, not writable by models/tools/other users.
10
+ */
11
+ export async function createFileTaskStore(options) {
12
+ await mkdir(options.directory, { recursive: true, mode: 0o700 });
13
+ const directoryStat = await stat(options.directory);
14
+ if ((directoryStat.mode & 0o077) !== 0)
15
+ throw new Error("Task directory must be private (0700)");
16
+ const lockPath = join(options.directory, "writer.lock");
17
+ const lock = await open(lockPath, "wx", 0o600);
18
+ const dataPath = join(options.directory, "tasks.json");
19
+ let records = new Map();
20
+ let closed = false;
21
+ let closing;
22
+ let queue = Promise.resolve();
23
+ const serialized = (run) => {
24
+ if (closing)
25
+ return Promise.reject(new Error("Task store closed"));
26
+ const result = queue.then(() => {
27
+ if (closed)
28
+ throw new Error("Task store closed");
29
+ return run();
30
+ });
31
+ queue = result.catch(() => { });
32
+ return result;
33
+ };
34
+ try {
35
+ await lock.writeFile(JSON.stringify({ pid: process.pid }));
36
+ await lock.sync();
37
+ let file;
38
+ try {
39
+ file = await open(dataPath, "r");
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
43
+ throw error;
44
+ }
45
+ if (file)
46
+ try {
47
+ if ((await file.stat()).size > MAX_STORE_BYTES)
48
+ throw new Error("Task store exceeds 16 MiB");
49
+ const parsed = envelope(JSON.parse(await file.readFile("utf8")));
50
+ if (parsed instanceof type.errors)
51
+ throw new Error("Invalid task store envelope");
52
+ for (const value of parsed.records) {
53
+ const record = options.validateRecord(value);
54
+ if (records.has(record.task.taskId))
55
+ throw new Error("Duplicate persisted task");
56
+ records.set(record.task.taskId, structuredClone(record));
57
+ }
58
+ }
59
+ finally {
60
+ await file.close();
61
+ }
62
+ }
63
+ catch (error) {
64
+ await lock.close();
65
+ await unlink(lockPath);
66
+ throw error;
67
+ }
68
+ async function persist(next) {
69
+ const bytes = JSON.stringify({ version: 1, records: [...next.values()] });
70
+ if (Buffer.byteLength(bytes) > MAX_STORE_BYTES)
71
+ throw new Error("Task store exceeds 16 MiB");
72
+ const tempPath = join(options.directory, `tasks-${crypto.randomUUID()}.tmp`);
73
+ const temp = await open(tempPath, "wx", 0o600);
74
+ try {
75
+ await temp.writeFile(bytes);
76
+ await temp.sync();
77
+ }
78
+ finally {
79
+ await temp.close();
80
+ }
81
+ try {
82
+ await rename(tempPath, dataPath);
83
+ const directory = await open(options.directory, "r");
84
+ try {
85
+ await directory.sync();
86
+ }
87
+ finally {
88
+ await directory.close();
89
+ }
90
+ records = next;
91
+ }
92
+ catch (error) {
93
+ // An ambiguous durability failure poisons this writer; restart reconciles disk.
94
+ closed = true;
95
+ throw error;
96
+ }
97
+ }
98
+ return {
99
+ durable: true,
100
+ create(record) {
101
+ return serialized(async () => {
102
+ if (records.has(record.task.taskId) || record.revision !== 0)
103
+ throw new Error("Task exists or invalid initial revision");
104
+ const checked = options.validateRecord(structuredClone(record));
105
+ const next = new Map(records);
106
+ next.set(record.task.taskId, checked);
107
+ await persist(next);
108
+ });
109
+ },
110
+ get(taskId) {
111
+ return serialized(async () => structuredClone(records.get(taskId)));
112
+ },
113
+ compareAndSwap(taskId, revision, record) {
114
+ return serialized(async () => {
115
+ const current = records.get(taskId);
116
+ if (!current || current.revision !== revision)
117
+ return false;
118
+ if (record.task.taskId !== taskId ||
119
+ record.revision !== revision + 1 ||
120
+ JSON.stringify(record.identity) !== JSON.stringify(current.identity))
121
+ throw new Error("Invalid task CAS identity/revision");
122
+ const checked = options.validateRecord(structuredClone(record));
123
+ const next = new Map(records);
124
+ next.set(taskId, checked);
125
+ await persist(next);
126
+ return true;
127
+ });
128
+ },
129
+ list() {
130
+ return serialized(async () => structuredClone([...records.values()]));
131
+ },
132
+ expire(now) {
133
+ return serialized(async () => {
134
+ const next = new Map([...records].filter(([, r]) => r.task.ttlMs === null ||
135
+ Date.parse(r.task.createdAt) + r.task.ttlMs > now));
136
+ if (next.size !== records.size)
137
+ await persist(next);
138
+ });
139
+ },
140
+ close() {
141
+ // Publish the close promise synchronously: reject new operations while
142
+ // draining accepted ones, and never unlink a successor's lock on retry.
143
+ closing ??= (async () => {
144
+ await queue;
145
+ closed = true;
146
+ await lock.close();
147
+ await unlink(lockPath);
148
+ })();
149
+ return closing;
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,82 @@
1
+ import type { DetailedTask, InputRequests, InputResponses } from "../../protocol/index.js";
2
+ import type { McpTaskExecutor, McpTaskIdentity, McpTaskRecord, McpTaskStore } from "./contracts.js";
3
+ export type * from "./contracts.js";
4
+ export declare function createTaskRuntime(options: {
5
+ store: McpTaskStore;
6
+ executor: McpTaskExecutor;
7
+ /** Shared protocol validator supplied by adapter, not a second wire validator. */
8
+ validateTask?(task: DetailedTask): void;
9
+ authorizeInput(identity: McpTaskIdentity, record: McpTaskRecord, responses: InputResponses): Promise<void>;
10
+ now?: () => number;
11
+ }): {
12
+ create(identity: McpTaskIdentity, params: Record<string, unknown>, ttlMs?: number | null): Promise<{
13
+ taskId: string;
14
+ status: "cancelled" | "working";
15
+ createdAt: string;
16
+ lastUpdatedAt: string;
17
+ ttlMs: number | null;
18
+ pollIntervalMs?: number;
19
+ statusMessage?: string;
20
+ resultType: "task";
21
+ }>;
22
+ get(identity: McpTaskIdentity, taskId: string): Promise<{
23
+ resultType: "complete";
24
+ taskId: string;
25
+ status: "cancelled" | "working";
26
+ createdAt: string;
27
+ lastUpdatedAt: string;
28
+ ttlMs: number | null;
29
+ pollIntervalMs?: number;
30
+ statusMessage?: string;
31
+ } | {
32
+ resultType: "complete";
33
+ taskId: string;
34
+ status: "input_required";
35
+ createdAt: string;
36
+ lastUpdatedAt: string;
37
+ ttlMs: number | null;
38
+ pollIntervalMs?: number;
39
+ statusMessage?: string;
40
+ inputRequests: InputRequests;
41
+ } | {
42
+ resultType: "complete";
43
+ taskId: string;
44
+ status: "completed";
45
+ createdAt: string;
46
+ lastUpdatedAt: string;
47
+ ttlMs: number | null;
48
+ pollIntervalMs?: number;
49
+ statusMessage?: string;
50
+ result: Record<string, unknown>;
51
+ } | {
52
+ resultType: "complete";
53
+ taskId: string;
54
+ status: "failed";
55
+ createdAt: string;
56
+ lastUpdatedAt: string;
57
+ ttlMs: number | null;
58
+ pollIntervalMs?: number;
59
+ statusMessage?: string;
60
+ error: import("../../protocol/types.js").McpRpcError;
61
+ }>;
62
+ transition: (identity: McpTaskIdentity, taskId: string, task: DetailedTask, backend?: Record<string, string>) => Promise<McpTaskRecord>;
63
+ submitInput(identity: McpTaskIdentity, taskId: string, responses: InputResponses): Promise<{
64
+ resultType: "complete";
65
+ }>;
66
+ cancel(identity: McpTaskIdentity, taskId: string): Promise<{
67
+ resultType: "complete";
68
+ }>;
69
+ recover(): Promise<void>;
70
+ };
71
+ export declare function pollTask(options: {
72
+ taskId: string;
73
+ get(taskId: string): Promise<DetailedTask>;
74
+ update(taskId: string, responses: InputResponses): Promise<void>;
75
+ persist(taskId: string): Promise<void>;
76
+ input?(requests: InputRequests): Promise<InputResponses>;
77
+ observe?(task: DetailedTask): void;
78
+ signal?: AbortSignal;
79
+ maxPolls?: number;
80
+ sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
81
+ }): Promise<DetailedTask>;
82
+ export { validateTaskRecord } from "./record.js";