toolroll 0.9.42 → 0.9.44

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 (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/chat-actions.js +1 -1
  5. package/dist/chat-approval.d.ts +45 -0
  6. package/dist/chat-approval.js +81 -0
  7. package/dist/chat-channel.js +5 -0
  8. package/dist/chat-decide.d.ts +18 -6
  9. package/dist/chat-decide.js +54 -20
  10. package/dist/chat-flow.js +2 -2
  11. package/dist/cli-http.d.ts +64 -0
  12. package/dist/cli-http.js +143 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +8 -1
  15. package/dist/contracts/cli.d.ts +13 -0
  16. package/dist/contracts/cli.js +7 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/gateway-tools.d.ts +82 -0
  20. package/dist/contracts/gateway-tools.js +33 -0
  21. package/dist/contracts/lead-tools.d.ts +11 -0
  22. package/dist/contracts/lead-tools.js +2 -0
  23. package/dist/desktop-update.d.ts +3 -1
  24. package/dist/desktop-update.js +7 -6
  25. package/dist/flow-engine.d.ts +2 -0
  26. package/dist/flow-engine.js +10 -2
  27. package/dist/flows-cli.d.ts +28 -0
  28. package/dist/flows-cli.js +43 -1
  29. package/dist/guides.js +2 -0
  30. package/dist/mate-contract.js +1 -1
  31. package/dist/mate-tools.js +43 -1
  32. package/dist/mcp-core.d.ts +110 -0
  33. package/dist/mcp-core.js +311 -0
  34. package/dist/mcp-http.d.ts +26 -0
  35. package/dist/mcp-http.js +151 -0
  36. package/dist/mcp-person.d.ts +31 -0
  37. package/dist/mcp-person.js +96 -0
  38. package/dist/mcp.d.ts +1 -8
  39. package/dist/mcp.js +21 -322
  40. package/dist/operate-remote-arguments.d.ts +36 -0
  41. package/dist/operate-remote-arguments.js +185 -0
  42. package/dist/operate-remote.d.ts +86 -0
  43. package/dist/operate-remote.js +125 -0
  44. package/dist/operate.d.ts +21 -1
  45. package/dist/operate.js +229 -15
  46. package/dist/process-recovery-eligibility.d.ts +1 -1
  47. package/dist/remote-command.d.ts +45 -0
  48. package/dist/remote-command.js +124 -0
  49. package/dist/remote-exec.d.ts +18 -0
  50. package/dist/remote-exec.js +241 -0
  51. package/dist/remote-run.d.ts +16 -0
  52. package/dist/remote-run.js +13 -0
  53. package/dist/scope.d.ts +4 -2
  54. package/dist/scope.js +10 -0
  55. package/dist/serve.d.ts +7 -0
  56. package/dist/serve.js +75 -2
  57. package/dist/store.d.ts +23 -2
  58. package/dist/store.js +76 -9
  59. package/dist/surface.d.ts +10 -3
  60. package/dist/surface.js +71 -3
  61. package/dist/team-cli.d.ts +19 -0
  62. package/dist/team-cli.js +20 -3
  63. package/dist/teammate-work.js +1 -1
  64. package/dist/telegram-flow.d.ts +41 -1
  65. package/dist/telegram-flow.js +96 -12
  66. package/dist/telegram.js +27 -3
  67. package/dist/toolroll-update.js +4 -1
  68. package/package.json +1 -1
package/dist/mcp.js CHANGED
@@ -1,12 +1,9 @@
1
- import { dirname, join } from 'node:path';
2
1
  import { statSync } from 'node:fs';
3
- import { repositoryContextRead } from './repository-context.js';
4
- import { ASSIGNMENT_TOOLS, assignmentForCoordinator } from "./assignment-adapters.js";
5
- import { parseContract, toModelSchema } from "./contracts/contract.js";
6
- import { GATEWAY_TOOL_INPUTS, GATEWAY_TOOL_OUTPUTS } from "./contracts/gateway-tools.js";
7
- import { reportToolOutput } from "./contracts/lead-tools.js";
8
2
  /**
9
- * `toolroll mcp` — the MCP stdio server (MCP gateway spec v6).
3
+ * `toolroll mcp` — the MCP stdio adapter (MCP gateway spec v6). What a
4
+ * call means lives in the transport-free core (mcp-core.ts); this file owns
5
+ * the stdio transport, era pinning and the connection's lifecycle.
6
+ *
10
7
  *
11
8
  * Zero-dep JSON-RPC 2.0 over stdio, newline-delimited; stdout carries
12
9
  * protocol bytes ONLY (logs go to stderr); clean EOF is clean shutdown.
@@ -31,207 +28,9 @@ import { reportToolOutput } from "./contracts/lead-tools.js";
31
28
  * refusals are successful `tools/call` results with `isError: true`.
32
29
  */
33
30
  import { PACKAGE_VERSION } from "./version.js";
34
- import { authenticateCoordinator, fileCoordinatorProposal, statusFor, listTasksFor, taskDetailFor, } from "./coordinator.js";
35
- import { ISO_STAMP_RULE, decisionOver, decisionsOver, labelRepos, queueOver, recapOver } from "./mate-tools.js";
36
- import { proposeAsCoordinator } from "./coordinator-proposals.js";
37
- export const MODERN = "2026-07-28";
38
- export const LEGACY = "2025-11-25";
39
- /** The modern revision namespaces its per-request metadata. */
40
- export const META_VERSION = "io.modelcontextprotocol/protocolVersion";
41
- export const META_SERVER = "io.modelcontextprotocol/serverInfo";
42
- export const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
43
- /** The revision's own error code for an unsupported protocol version. */
44
- export const UNSUPPORTED_VERSION = -32022;
45
- const MAX_REQUEST = 256 * 1024;
46
- const MAX_DEPTH = 32;
47
- function depthOf(value) {
48
- // The byte cap bounds the work list. Neither nesting nor array width
49
- // becomes a JavaScript call stack or a spread argument list.
50
- const pending = [{ value, depth: 0 }];
51
- let maximum = 0;
52
- while (pending.length > 0) {
53
- const current = pending.pop();
54
- if (current.depth > MAX_DEPTH)
55
- return current.depth;
56
- maximum = Math.max(maximum, current.depth);
57
- if (current.value !== null && typeof current.value === "object") {
58
- const children = Array.isArray(current.value) ? current.value : Object.values(current.value);
59
- for (const child of children)
60
- pending.push({ value: child, depth: current.depth + 1 });
61
- }
62
- }
63
- return maximum;
64
- }
65
- const CONTRACT_GUIDE = [
66
- "Toolroll MCP contract.",
67
- "You hold a coordinator credential: you may read inside your repo allowlist, file proposals, and record assignment ownership and receipt checks. Ownership and receipt checks grant no execution or approval authority.",
68
- "A filed proposal is an ordinary unapproved task: it is quarantined from planning, claiming, and running until the operator signs its scope in a password ceremony that shows them who asked. Modes never auto-admit coordinator filings.",
69
- "file_proposal requires an idempotency_key (8-64 printable chars, unique per request): replaying the same key+request returns the original task; the same key with a different request refuses. deliverable 'report' files a scout task: a read-only investigation whose only output is a report on the task page, never a branch.",
70
- "You are rate-limited per hour and capped on outstanding unapproved filings. Refusals say the limit and the road in words.",
71
- "recap, list_decisions, get_decision, and queue are the plane's own read queries (shared with the operator's mate). get_decision shows each option's consequence, never the builder's recommendation.",
72
- "propose_next, propose_reserve, propose_hold, propose_unhold, propose_scope, propose_cancel, and propose_answer write a PROPOSAL row and nothing else: an approver confirms it on the console or the CLI, and a stale one refuses there. Proposals share your hourly filing rate and hold at most 20 pending per credential; they expire after seven days.",
73
- "Approve, steer, answer, pick, mint, configure, merge: those verbs do not exist on this surface, by construction.",
74
- "Claim one assignment, then follow list_assignment_updates with a saved cursor; inspect get_assignment when a current result or decision needs attention. claim_assignment records its lead; acknowledge_assignment records that lead's check of an exact ready receipt. Neither operation grants authority, accepts proof, answers decisions, approves work, publishes, or deploys.",
75
- ].join("\n");
76
- /** An assignment tool: the adapter reads its call with the same schema `toolroll assignment` does. */
77
- const assignmentHandler = (operation) => (ctx, args) => {
78
- const result = assignmentForCoordinator(ctx.store, ctx.token, operation, args, ctx.now, ctx.evidenceRoot);
79
- return result.ok ? { ok: true, body: result.body } : { ok: false, message: `${result.reason}: ${result.message}` };
80
- };
81
- const assignmentTool = (name) => {
82
- const spec = ASSIGNMENT_TOOLS.find(one => one.name === name);
83
- return { description: spec.description, handle: assignmentHandler(spec.operation) };
84
- };
85
- /** A propose_* tool: it writes one proposal row through the coordinator door. */
86
- const proposeTool = (kind, description) => ({
87
- description,
88
- handle: (ctx, args) => {
89
- const outcome = proposeAsCoordinator(ctx.store, ctx.token, kind, args, ctx.now, { readDecisions: ctx.readDecisions });
90
- if (!outcome.ok)
91
- return { ok: false, message: outcome.message };
92
- return { ok: true, body: { proposal: outcome.id, kind: outcome.kind, awaiting: outcome.awaiting } };
93
- },
94
- });
95
- const HANDLERS = {
96
- get_project_context: {
97
- description: 'Read bounded source excerpts or advisory static import impact in an admitted project. Falls back to text search without an index.',
98
- handle: (ctx, args) => {
99
- const repo = args.repo;
100
- if (!ctx.who.repos.includes(repo) || ctx.enrolled !== null && !ctx.enrolled.includes(repo))
101
- return { ok: false, message: 'That project is outside your access.' };
102
- return { ok: true, body: repositoryContextRead({ repo, query: args.query, mode: args.mode === 'impact' ? 'impact' : 'search', audience: 'lead',
103
- ...(ctx.evidenceRoot === undefined ? {} : { cacheRoot: join(dirname(ctx.evidenceRoot), 'repository-context') }) }) };
104
- },
105
- },
106
- get_assignment: assignmentTool("get_assignment"),
107
- list_assignment_updates: assignmentTool("list_assignment_updates"),
108
- claim_assignment: assignmentTool("claim_assignment"),
109
- acknowledge_assignment: assignmentTool("acknowledge_assignment"),
110
- get_assignment_brief: assignmentTool("get_assignment_brief"),
111
- get_assignment_inbox: assignmentTool("get_assignment_inbox"),
112
- acknowledge_assignment_delivery: assignmentTool("acknowledge_assignment_delivery"),
113
- status: {
114
- description: "The plane's liveness facts over your repo allowlist: what waits on the operator, what runs, what finished in the last 24h.",
115
- handle: ctx => ({ ok: true, body: statusFor(ctx.store, ctx.who, ctx.now) }),
116
- },
117
- list_tasks: {
118
- description: "Tasks in your repo allowlist. Filter by state and repo; cursor-paginated, stable order.",
119
- handle: (ctx, args) => {
120
- const filter = {};
121
- if (args.state !== undefined)
122
- filter.state = args.state;
123
- if (args.repo !== undefined)
124
- filter.repo = args.repo;
125
- if (args.cursor !== undefined)
126
- filter.cursor = args.cursor;
127
- if (args.limit !== undefined)
128
- filter.limit = args.limit;
129
- return { ok: true, body: listTasksFor(ctx.store, ctx.who, filter, ctx.now) };
130
- },
131
- },
132
- get_task: {
133
- description: "One task's deliverable (branch or report), scope standing, filer provenance, attempt ledger, and — for a finished scout — the report's title, summary, and follow-ups. A ref outside your allowlist answers not-found.",
134
- handle: (ctx, args) => {
135
- const detail = taskDetailFor(ctx.store, ctx.who, args.ref, ctx.evidenceRoot, ctx.now);
136
- if (detail === null)
137
- return { ok: false, message: `not-found: no task \`${args.ref}\` in your repositories` };
138
- return { ok: true, body: detail };
139
- },
140
- },
141
- list_repos: {
142
- description: "The repositories your credential may see and file into, with their operating-mode standing.",
143
- handle: ctx => {
144
- // FAIL CLOSED when the project registry cannot be read (round-2
145
- // finding 4): answering the full allowlist would claim enrollment
146
- // nobody proved.
147
- if (ctx.enrolled === null) {
148
- return { ok: false, message: "the project registry could not be read — the enrolled set is unknown, so nothing lists" };
149
- }
150
- return {
151
- ok: true,
152
- body: {
153
- repos: ctx.who.repos
154
- .filter(repo => ctx.enrolled !== null && ctx.enrolled.includes(repo))
155
- .map(repo => {
156
- const mode = ctx.store.activeMode(repo, ctx.now);
157
- return { repo, mode: mode === null ? "no operating mode — every act is a ceremony" : `mode ${mode.name} signed until ${mode.absoluteExpiry}` };
158
- }),
159
- },
160
- };
161
- },
162
- },
163
- recap: {
164
- description: "How things stand per repository in your allowlist, counts and ids: what waits on the operator (decisions, incidents, scopes awaiting approval), what runs, what is queued, finished, failed. Pass `since` (an ISO timestamp) to count only decisions, incidents, and attempts newer than it, to the hour; queued work and scopes awaiting approval always count.",
165
- handle: (ctx, args) => {
166
- const since = args.since;
167
- if (since !== undefined && (!ISO_STAMP_RULE.test(since) || Number.isNaN(Date.parse(since)))) {
168
- return { ok: false, message: "since is an ISO timestamp like 2026-09-02T12:00:00Z" };
169
- }
170
- return { ok: true, body: labelRepos(recapOver(ctx.store, ctx.who.repos, ctx.now, since ?? null), index => ctx.who.repos[index] ?? "") };
171
- },
172
- },
173
- list_decisions: {
174
- description: "Open decisions in your allowlist: id, task, question, options (id, label, reversible), age in hours. Never consequences or recommendations; the operator answers them.",
175
- handle: ctx => ({ ok: true, body: labelRepos(decisionsOver(ctx.store, ctx.who.repos, ctx.now), index => ctx.who.repos[index] ?? "") }),
176
- },
177
- queue: {
178
- description: "One repository's queue by column — the shared column, then each worker's reserved column — each in dispatch order.",
179
- handle: (ctx, args) => {
180
- const repo = args.repo;
181
- if (!ctx.who.repos.includes(repo))
182
- return { ok: false, message: "not-found: that repository is not in your allowlist" };
183
- // The installation-wide revision stays home (slice-2 review, finding
184
- // 12): a coordinator cannot move queues, and the counter would tell it
185
- // about repos it may not see.
186
- const { queueRevision: _revision, ...columns } = queueOver(ctx.store, repo, ctx.now);
187
- return { ok: true, body: { repo, ...columns } };
188
- },
189
- },
190
- get_decision: {
191
- description: "One open decision in your allowlist in full: question, options with id, label, reversible, and consequence. Never the builder's recommendation.",
192
- handle: (ctx, args) => {
193
- const found = decisionOver(ctx.store, ctx.who.repos, args.decision, ctx.now);
194
- if (found === null)
195
- return { ok: false, message: "not-found: no such decision in your repositories" };
196
- ctx.readDecisions.add(args.decision);
197
- return { ok: true, body: labelRepos(found, index => ctx.who.repos[index] ?? "") };
198
- },
199
- },
200
- propose_next: proposeTool("next", "Propose moving a queued task to the front of its column. An approver confirms; a queue that moved meanwhile refuses."),
201
- propose_reserve: proposeTool("reserve", "Propose reserving a queued task for one worker, or releasing it to the shared queue with worker null."),
202
- propose_hold: proposeTool("hold", "Propose holding a task's next attempt, with a reason. A running attempt is never interrupted."),
203
- propose_unhold: proposeTool("unhold", "Propose lifting the operator's own hold on a task."),
204
- propose_scope: proposeTool("scope", "Propose rewriting a task's scope. An approver confirms the rewrite, then approves it with a password — a scope you wrote never seals under a mode."),
205
- propose_cancel: proposeTool("cancel", "Propose cancelling a task, with a reason. The approver arms and confirms it on the task itself."),
206
- propose_answer: proposeTool("answer", "Propose an answer to an open decision you read with get_decision, with a rationale. The approver confirms where every consequence and the builder's recommendation are shown; an irreversible option needs their explicit confirmation."),
207
- get_contract: {
208
- description: "This surface's contract: what a coordinator may do, the proposal lifecycle, and the admission promise.",
209
- handle: () => ({ ok: true, body: { contract: CONTRACT_GUIDE } }),
210
- },
211
- file_proposal: {
212
- description: "File a task proposal into one of your repositories. It stays quarantined until the operator signs its scope.",
213
- handle: (ctx, args) => {
214
- // The token, not the pre-verified identity: filing re-authenticates
215
- // INSIDE its own transaction (the session's `who` is a courtesy).
216
- const outcome = fileCoordinatorProposal(ctx.store, ctx.token, { repo: args.repo, title: args.title, ...(args.intent === undefined ? {} : { intent: args.intent }), ...(args.deliverable === undefined ? {} : { deliverable: args.deliverable }), idempotencyKey: args.idempotency_key }, ctx.now);
217
- if (!outcome.ok)
218
- return { ok: false, message: outcome.message };
219
- return {
220
- ok: true,
221
- body: {
222
- ref: outcome.id,
223
- replayed: outcome.replayed,
224
- admission: "quarantined until the operator signs its scope — the ceremony shows them your name",
225
- },
226
- };
227
- },
228
- },
229
- };
230
- /** The gateway's tools in tools/list order, each inputSchema derived from its contract. */
231
- const TOOLS = Object.keys(GATEWAY_TOOL_INPUTS).map((name) => {
232
- const handler = HANDLERS[name];
233
- return { name, description: handler.description, inputSchema: toModelSchema(GATEWAY_TOOL_INPUTS[name]), handle: (ctx, args) => handler.handle(ctx, args) };
234
- });
31
+ import { authenticateCoordinator } from "./coordinator.js";
32
+ import { LEGACY, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
33
+ export { LEGACY, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, UNSUPPORTED_VERSION } from "./mcp-core.js";
235
34
  function fileIdentity(path) {
236
35
  if (path === null)
237
36
  return null;
@@ -286,16 +85,8 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
286
85
  const respond = (id, era, result, cacheable) => {
287
86
  if (id !== null && cancelled.has(JSON.stringify(id)))
288
87
  return; // suppressed entirely
289
- const complete = era === "modern"
290
- ? { ...result, resultType: "complete", ...(cacheable ? { ttlMs: 0, cacheScope: "private" } : {}) }
291
- : result;
292
- io.write(JSON.stringify({ jsonrpc: "2.0", id, result: complete }));
88
+ io.write(JSON.stringify({ jsonrpc: "2.0", id, result: shapeResult(era, result, cacheable) }));
293
89
  };
294
- const toolsPayload = () => ({
295
- // No outputSchema: MCP output schemas describe structuredContent, and
296
- // these tools return text content only (round-2 finding 1).
297
- tools: TOOLS.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
298
- });
299
90
  const callTool = (id, era, params) => {
300
91
  // A restore or rollback puts a different file at the database's path: this server's connection still reads and
301
92
  // writes the one it replaced, so it answers nothing more and exits (the agent starts it again on the new one).
@@ -304,112 +95,23 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
304
95
  io.exit(0);
305
96
  return;
306
97
  }
307
- // ONE snapshot per call (review finding 3): the version check, the
308
- // credential re-read, and every data read share a transaction, so a
309
- // concurrent migration cannot slip between them; filing's own
310
- // BEGIN IMMEDIATE reenters this one. A moved schema refuses in words
311
- // and the server exits clean.
312
- store.transact(() => {
313
- if (!store.schemaCurrent()) {
314
- error(id, -32000, "the database schema moved underneath this server — restart it");
315
- io.exit(0);
316
- return;
317
- }
318
- const name = typeof params["name"] === "string" ? params["name"] : "";
319
- const tool = TOOLS.find(one => one.name === name);
320
- if (tool === undefined) {
321
- error(id, -32602, `no tool \`${name}\``);
322
- return;
323
- }
324
- // tools/list visibility is presentation — authorization happens HERE,
325
- // on every call, against the live credential row.
326
- const session = authenticateCoordinator(store, token);
327
- if (!session.ok) {
328
- error(id, -32000, "this credential no longer stands — the server exits");
329
- io.exit(0);
330
- return;
331
- }
332
- // Only an ABSENT `arguments` defaults to empty (round-3 finding 2): an
333
- // explicit null is not the optional-object shape MCP defines.
334
- const rawArgs = params["arguments"] === undefined ? {} : params["arguments"];
335
- if (typeof rawArgs !== "object" || rawArgs === null || Array.isArray(rawArgs)) {
336
- // The root of `arguments` is an object BEFORE any schema applies
337
- // (round-2 finding 2): 42 or false never passes an empty schema.
338
- error(id, -32602, "arguments must be an object");
339
- return;
340
- }
341
- // The call is read by the schema tools/list advertised (review finding
342
- // 2): unknown fields, bad types, bad enums, and bound violations are
343
- // protocol errors (InvalidParams) naming each path, never tool refusals.
344
- const read = parseContract(GATEWAY_TOOL_INPUTS[tool.name], rawArgs);
345
- if (!read.ok) {
346
- error(id, -32602, read.issues.map(one => one.line).join("; "));
347
- return;
348
- }
349
- const answered = tool.handle({ store, who: session.who, token, enrolled, now: clock(), readDecisions, ...(evidenceRoot === undefined ? {} : { evidenceRoot }) }, read.value);
350
- if (!answered.ok) {
351
- respond(id, era, { content: [{ type: "text", text: answered.message }], isError: true }, false);
352
- return;
353
- }
354
- // Checked against its output schema before delivery; a disagreement is reported, never refused.
355
- reportToolOutput("gateway", tool.name, GATEWAY_TOOL_OUTPUTS[tool.name], answered.body);
356
- respond(id, era, { content: [{ type: "text", text: JSON.stringify(answered.body) }] }, false);
357
- });
98
+ // A moved schema or a credential that no longer stands refuses in words and the server exits clean.
99
+ const outcome = callCoordinatorTool({ store, token, enrolled, clock, readDecisions, ...(evidenceRoot === undefined ? {} : { evidenceRoot }) }, params);
100
+ if (outcome.kind === "result")
101
+ return respond(id, era, outcome.result, false);
102
+ error(id, outcome.code, outcome.message);
103
+ if (outcome.fatal)
104
+ io.exit(0);
358
105
  };
359
106
  io.onLine(line => {
360
107
  if (line.trim() === "")
361
108
  return;
362
- if (Buffer.byteLength(line, "utf8") > MAX_REQUEST) {
363
- error(null, -32600, "request over 256 KiB");
364
- return;
365
- }
366
- let message;
367
- try {
368
- message = JSON.parse(line);
369
- }
370
- catch {
371
- error(null, -32700, "parse error");
372
- return;
373
- }
374
- if (Array.isArray(message)) {
375
- // Batching left MCP in 2025-06-18 — neither pinned era accepts it.
376
- error(null, -32600, "JSON-RPC batching is not part of MCP — send one request per line");
377
- return;
378
- }
379
- if (message === null || typeof message !== "object" || depthOf(message) > MAX_DEPTH) {
380
- error(null, -32600, "invalid request");
381
- return;
382
- }
383
- const request = message;
384
- if (request["jsonrpc"] !== "2.0") {
385
- error(null, -32600, 'jsonrpc must be "2.0"');
386
- return;
387
- }
388
- // A notification is the message that carries NO id — the method name
389
- // never decides (round-3 finding 3): notifications/initialized WITH an
390
- // id is a malformed message, not a quiet notification. MCP RequestId is
391
- // a string or an INTEGER (round-2 finding 2): floats, null, and object
392
- // shapes refuse wherever an id appears.
393
- const hasId = "id" in request;
394
- const rawId = request["id"];
395
- if (hasId && (rawId === null || (typeof rawId !== "string" && !(typeof rawId === "number" && Number.isInteger(rawId))))) {
396
- error(null, -32600, "id must be a string or an integer");
397
- return;
398
- }
399
- const method = typeof request["method"] === "string" ? request["method"] : "";
400
- if (method.startsWith("notifications/")) {
401
- if (hasId) {
402
- error(rawId, -32600, "a notification carries no id");
403
- return;
404
- }
405
- }
406
- else if (!hasId) {
407
- error(null, -32600, "a request carries an id — a string or an integer");
109
+ const read = readMessage(line);
110
+ if (!read.ok) {
111
+ error(read.id, read.code, read.message);
408
112
  return;
409
113
  }
410
- const id = (rawId ?? null);
411
- const params = (request["params"] ?? {});
412
- const meta = (params["_meta"] ?? {});
114
+ const { id, method, params, meta } = read;
413
115
  // Every method — lifecycle included — refuses on a moved schema
414
116
  // (review finding 3): a server that answers discover from one world
415
117
  // and tools from another is lying to somebody.
@@ -453,9 +155,6 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
453
155
  }));
454
156
  return;
455
157
  }
456
- // ClientCapabilities is an OBJECT shape (round-3 finding 5): an array
457
- // is not a capabilities declaration.
458
- const capabilitiesShape = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
459
158
  if (method === "initialize") {
460
159
  if (pinnedEra === "modern") {
461
160
  error(id, -32600, "this connection speaks the modern era — initialize belongs to the handshake era");
@@ -489,7 +188,7 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
489
188
  supportedVersions: [MODERN, LEGACY],
490
189
  capabilities: { tools: {} },
491
190
  _meta: { [META_SERVER]: { name: "toolroll", version: PACKAGE_VERSION } },
492
- ...toolsPayload(),
191
+ ...toolsPayload(TOOLS),
493
192
  }, true);
494
193
  return;
495
194
  }
@@ -523,7 +222,7 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
523
222
  return;
524
223
  }
525
224
  if (method === "tools/list") {
526
- respond(id, era, toolsPayload(), true);
225
+ respond(id, era, toolsPayload(TOOLS), true);
527
226
  return;
528
227
  }
529
228
  if (method === "tools/call") {
@@ -0,0 +1,36 @@
1
+ /** The audited remote argument vocabulary. Unlisted flags and extra positionals never dispatch.
2
+ * Keep scalar inputs explicit: adding a resource argument requires classifying it here first.
3
+ * Indices below are relative to the command row (after `task review`, `flows trigger add`, etc.). */
4
+ import type { Store } from './store.js';
5
+ type Kind = 'value' | 'task' | 'run' | 'review' | 'flow' | 'trigger' | 'trigger-json' | 'steps' | 'refused' | 'step-up';
6
+ type Argument = {
7
+ name: string;
8
+ kind: Kind;
9
+ optional: boolean;
10
+ rest: boolean;
11
+ };
12
+ export type RemoteArguments = {
13
+ positionals: readonly Argument[];
14
+ flags: ReadonlyMap<string, Kind>;
15
+ };
16
+ /** All remotely allowed rows, including those with no resource references. Credentials and file-output
17
+ * flags are refused by the outer boundary. `source` on memory is provenance text; on revise it is a scope
18
+ * digest. Neither dereferences a conversation, task or proposal. Creation IDs are never looked up. */
19
+ export declare const REMOTE_ARGUMENTS: ReadonlyMap<string, RemoteArguments>;
20
+ export declare const REMOTE_NOT_FOUND: {
21
+ readonly ok: false;
22
+ readonly reason: 'not-found';
23
+ readonly message: 'Not found.';
24
+ };
25
+ type Result = {
26
+ ok: true;
27
+ repo: string | null;
28
+ taskId: string | null;
29
+ } | {
30
+ ok: false;
31
+ reason: string;
32
+ message: string;
33
+ };
34
+ /** Read-only authorization of every supplied reference, before the handler can disclose or mutate it. */
35
+ export declare function remoteArgumentsOf(store: Store, allows: (repo: string | null) => boolean, invocation: string, positional: readonly string[], flags: ReadonlyMap<string, string | true>, named: string | null, files: Readonly<Record<string, string>>): Result;
36
+ export {};
@@ -0,0 +1,185 @@
1
+ const args = (positionals = '', values = '', references = {}) => ({
2
+ positionals: positionals.split(' ').filter(Boolean).map(word => {
3
+ const [name = '', type = 'value'] = word.split(':');
4
+ return { name: name.replace(/[?*]$/, ''), kind: type, optional: name.endsWith('?'), rest: name.endsWith('*') };
5
+ }),
6
+ flags: new Map([...['json', 'help', 'repo', ...values.split(' ').filter(Boolean)].map(name => [name, 'value']), ...Object.entries(references)]),
7
+ });
8
+ /** All remotely allowed rows, including those with no resource references. Credentials and file-output
9
+ * flags are refused by the outer boundary. `source` on memory is provenance text; on revise it is a scope
10
+ * digest. Neither dereferences a conversation, task or proposal. Creation IDs are never looked up. */
11
+ export const REMOTE_ARGUMENTS = new Map([
12
+ ...['status', 'ready', 'grants', 'gaps', 'sync', 'runner list', 'coordinator list', 'incident list',
13
+ 'routine list', 'intake show', 'intake run', 'template list', 'webhook status', 'webhook test', 'review show',
14
+ 'chat-approval show', 'chat-approval off'].map(row => [row, args()]),
15
+ ['integrations', args('', 'saved')],
16
+ ['outbox list', args('', 'all')],
17
+ ['task add', args('title*', 'key report checks', { id: 'refused', replaces: 'task' })],
18
+ ['task ask', args('id:task', 'person why')],
19
+ ['task checks', args('task:task', 'level')],
20
+ ['task add-tests', args('task:task')],
21
+ ['task list', args('', 'state view limit cursor')],
22
+ ['task show', args('id:task')],
23
+ ['task wait', args('id:task', 'timeout')],
24
+ ['task complete', args('task:task', 'digest', { run: 'run', 'pull-request': 'refused' })],
25
+ ['task revise', args('task:task', 'feedback source key', { run: 'run' })],
26
+ ['task state', args('id:task state', 'key reason', { 'replaced-by': 'task' })],
27
+ ['task block', args('id:task', 'key', { on: 'task' })],
28
+ ['task unblock', args('id:task', 'key', { on: 'task' })],
29
+ ['task next', args('id:task', 'key undo')],
30
+ ['task steer', args('id:task', 'note')],
31
+ ['task assign', args('id:task', 'key runner anyone')],
32
+ ['task scope', args('id:task', 'key goal not touches budget-usd risk acceptance candidate compare model provider race race-count race-per-usd race-total-usd repair-model')],
33
+ ['task plan', args('id:task', 'key provider model')],
34
+ ['task hold', args('id:task', 'key reason until')],
35
+ ['task unhold', args('id:task', 'key')],
36
+ ['task require', args('id:task', 'key cap require')],
37
+ ['task requeue', args('id:task')],
38
+ ['task review', args('id:review', 'brief all', { run: 'run' })],
39
+ ['task repair', args('run:run', '', { yes: 'step-up' })],
40
+ ['task route', args('id:task', 'risk phase provider model clear-phase digest size risky')],
41
+ ['task reopen', args('id:task')],
42
+ ['task stop', args('id:task', '', { run: 'run' })],
43
+ ['task resume', args('id:task', '', { run: 'run' })],
44
+ ['check-progress', args('run:run')],
45
+ ['cap list', args()], ['cap add', args('name', 'kind probe expires')],
46
+ // These objects are installation-scoped at the outer boundary; only an all-project principal reaches them.
47
+ ['incident resolve', args('incident')],
48
+ ['routine show', args('id')],
49
+ ['routine add', args('name', 'goal schedule acceptance ceiling budget-usd not touches require')],
50
+ ...['refresh', 'pause', 'resume', 'run-now'].map(action => [`routine ${action}`, args('name')]),
51
+ ['config show', args('phase?')], ['verify show', args()],
52
+ ['intake preview', args()], ['intake pr-comments', args('', 'limit')],
53
+ ['template show', args('name')],
54
+ ['contest show', args('id')], ['contest exclude', args('contest ordinal')],
55
+ ...['search', 'impact'].map(action => [`knowledge ${action}`, args('query', 'base')]),
56
+ ['knowledge refresh', args('', 'base')],
57
+ // Decision reads and supersession are already project-bound in project-memory.ts. Proposal IDs are
58
+ // accepted only by memory apply (step-up), never by the listing commands below.
59
+ ...['search', 'decisions', 'show', 'decide', 'retire', 'review', 'status'].map(action => [`memory ${action}`,
60
+ args(['search', 'decide'].includes(action) ? 'query*' : ['show', 'retire'].includes(action) ? 'query' : '', 'why reason supersedes source all decision sessions no-local')]),
61
+ ['flows list', args()],
62
+ ['flows show', args('flow:flow')], ['flows export', args('flow:flow')],
63
+ ['flows create', args('', 'yes name template', { steps: 'steps' })],
64
+ ['flows edit', args('flow:flow', 'yes name', { steps: 'steps' })],
65
+ ['flows trigger add', args('flow:flow trigger:trigger-json', 'yes')],
66
+ ...['pause', 'resume', 'remove'].map(action => [`flows trigger ${action}`, args('flow:flow trigger:trigger')]),
67
+ ['flows card add', args('flow:flow', 'title description zone')],
68
+ ['flows archive', args('flow:flow', 'yes')],
69
+ ['models status', args('', 'provider')], ['models list', args('', 'provider')],
70
+ ]);
71
+ export const REMOTE_NOT_FOUND = { ok: false, reason: 'not-found', message: 'Not found.' };
72
+ const positiveId = (value) => /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(Number(value));
73
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
74
+ /** Read-only authorization of every supplied reference, before the handler can disclose or mutate it. */
75
+ export function remoteArgumentsOf(store, allows, invocation, positional, flags, named, files) {
76
+ const audit = REMOTE_ARGUMENTS.get(invocation);
77
+ if (audit === undefined)
78
+ return REMOTE_NOT_FOUND;
79
+ const values = positional.slice(invocation.split(' ').length - 1);
80
+ const resolved = [];
81
+ const admit = (repo) => typeof repo === 'string' && allows(repo) && (named === null || named === repo);
82
+ const input = (value) => {
83
+ const raw = value.trim().startsWith('{') || value.trim().startsWith('[') ? value
84
+ : Object.prototype.hasOwnProperty.call(files, value) ? files[value] : undefined;
85
+ if (raw === undefined)
86
+ return undefined;
87
+ try {
88
+ return JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ };
94
+ const check = (kind, value) => {
95
+ if (kind === 'value')
96
+ return true;
97
+ if (kind === 'review')
98
+ return check(flags.has('brief') ? 'task' : 'run', value);
99
+ if (kind === 'task' || kind === 'run') {
100
+ if (kind === 'task' && (!value || value.length > 64 || /[\x00-\x1f\x7f]/.test(value)))
101
+ return false;
102
+ if (kind === 'run' && !positiveId(value))
103
+ return false;
104
+ const run = kind === 'run' ? store.getRun(Number(value)) : null;
105
+ const ref = kind === 'run' ? run === null ? null : store.refById(run.taskRef) : store.lookupRef(value);
106
+ if (ref === null || !admit(ref.repo) || store.lookupRef(ref.externalId)?.id !== ref.id || store.getTask(ref.externalId) === null)
107
+ return false;
108
+ resolved.push({ repo: ref.repo, taskId: ref.externalId, taskRef: ref.id, kind });
109
+ return true;
110
+ }
111
+ if (kind === 'flow' || kind === 'trigger') {
112
+ if (!positiveId(value) || value.length > 10)
113
+ return false;
114
+ const trigger = kind === 'trigger' ? store.getFlowTrigger(Number(value)) : null;
115
+ const flow = kind === 'flow' ? store.getFlow(Number(value)) : trigger === null ? null : store.getFlow(trigger.flow);
116
+ if (flow === null || flow.state !== 'active' || !admit(flow.repo) || trigger?.state === 'removed')
117
+ return false;
118
+ resolved.push({ repo: flow.repo, taskId: null, ...(kind === 'flow' ? { flow: flow.id } : { parentFlow: trigger.flow }), kind });
119
+ return true;
120
+ }
121
+ if (kind === 'trigger-json') {
122
+ const config = input(value);
123
+ // Invalid/unavailable input cannot reach a resource lookup; the handler reports its existing file/JSON error.
124
+ if (!object(config))
125
+ return true;
126
+ return config['kind'] !== 'flow' || (typeof config['flow'] === 'number' && check('flow', String(config['flow'])));
127
+ }
128
+ if (kind === 'steps') {
129
+ const config = input(value), steps = object(config) ? config['steps'] : config;
130
+ if (!Array.isArray(steps))
131
+ return true;
132
+ // Build steps can name another project. Check the token ceiling as well as the owner's account grants.
133
+ return steps.every(step => !object(step) || step['repo'] === undefined ||
134
+ typeof step['repo'] === 'string' && store.knownRepos().includes(step['repo']) && allows(step['repo']));
135
+ }
136
+ return false;
137
+ };
138
+ for (const [name, value] of flags) {
139
+ const kind = audit.flags.get(name);
140
+ if (kind === 'step-up')
141
+ return { ok: false, reason: 'step-up', message: 'approve in the console or chat' };
142
+ if (kind === 'refused')
143
+ return { ok: false, reason: 'not-remote', message: 'That option is not available remotely.' };
144
+ if (kind === undefined)
145
+ return { ok: false, reason: 'usage', message: 'That option is not available for this remote command.' };
146
+ // Resolve positionals first below so the primary ledger target is stable regardless of flag order.
147
+ if (kind !== 'value' && typeof value !== 'string')
148
+ return REMOTE_NOT_FOUND;
149
+ }
150
+ for (let i = 0; i < audit.positionals.length; i++) {
151
+ const arg = audit.positionals[i];
152
+ const value = values[i];
153
+ if (value === undefined) {
154
+ if (arg.optional)
155
+ continue;
156
+ return REMOTE_NOT_FOUND;
157
+ }
158
+ if (!check(arg.kind, value))
159
+ return REMOTE_NOT_FOUND;
160
+ }
161
+ if (values.length > audit.positionals.length && !audit.positionals.at(-1)?.rest)
162
+ return REMOTE_NOT_FOUND;
163
+ for (const [name, value] of flags)
164
+ if (!check(audit.flags.get(name), String(value)))
165
+ return REMOTE_NOT_FOUND;
166
+ const task = resolved.find(ref => ref.kind === 'task');
167
+ for (const ref of resolved) {
168
+ if (ref.kind === 'run' && task !== undefined && ref.taskRef !== task.taskRef) {
169
+ // Review/revise accept a saved result from the same revision family. Stop/resume name an exact attempt.
170
+ const runFamily = store.taskFamilyOf(ref.taskId, [ref.repo], false);
171
+ const taskFamily = store.taskFamilyOf(task.taskId, [task.repo], false);
172
+ if (!['task review', 'task revise'].includes(invocation) || ref.repo !== task.repo || runFamily === null || taskFamily === null ||
173
+ runFamily.root.id !== taskFamily.root.id)
174
+ return REMOTE_NOT_FOUND;
175
+ }
176
+ if (ref.parentFlow !== undefined && ref.parentFlow !== resolved.find(one => one.kind === 'flow')?.flow)
177
+ return REMOTE_NOT_FOUND;
178
+ }
179
+ if (invocation === 'task repair') {
180
+ const draft = store.repairChainFor(Number(values[0]))?.draftTask;
181
+ if (draft != null && (!check('task', draft) || resolved.at(-1)?.repo !== resolved[0]?.repo))
182
+ return REMOTE_NOT_FOUND;
183
+ }
184
+ return { ok: true, repo: resolved[0]?.repo ?? named, taskId: resolved[0]?.taskId ?? null };
185
+ }