toolroll 0.9.43 → 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 (44) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/cli-http.d.ts +64 -0
  5. package/dist/cli-http.js +143 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.js +7 -0
  8. package/dist/contracts/cli.d.ts +13 -0
  9. package/dist/contracts/cli.js +7 -0
  10. package/dist/contracts/gateway-tools.d.ts +82 -0
  11. package/dist/contracts/gateway-tools.js +33 -0
  12. package/dist/desktop-update.d.ts +3 -1
  13. package/dist/desktop-update.js +7 -6
  14. package/dist/mcp-core.d.ts +110 -0
  15. package/dist/mcp-core.js +311 -0
  16. package/dist/mcp-http.d.ts +26 -0
  17. package/dist/mcp-http.js +151 -0
  18. package/dist/mcp-person.d.ts +31 -0
  19. package/dist/mcp-person.js +96 -0
  20. package/dist/mcp.d.ts +1 -8
  21. package/dist/mcp.js +21 -322
  22. package/dist/operate-remote-arguments.d.ts +36 -0
  23. package/dist/operate-remote-arguments.js +185 -0
  24. package/dist/operate-remote.d.ts +86 -0
  25. package/dist/operate-remote.js +125 -0
  26. package/dist/operate.d.ts +21 -1
  27. package/dist/operate.js +176 -12
  28. package/dist/remote-command.d.ts +45 -0
  29. package/dist/remote-command.js +124 -0
  30. package/dist/remote-exec.d.ts +18 -0
  31. package/dist/remote-exec.js +241 -0
  32. package/dist/remote-run.d.ts +16 -0
  33. package/dist/remote-run.js +13 -0
  34. package/dist/scope.js +10 -0
  35. package/dist/serve.d.ts +7 -0
  36. package/dist/serve.js +24 -0
  37. package/dist/store.d.ts +15 -1
  38. package/dist/store.js +34 -7
  39. package/dist/surface.d.ts +10 -3
  40. package/dist/surface.js +67 -2
  41. package/dist/team-cli.d.ts +19 -0
  42. package/dist/team-cli.js +20 -3
  43. package/dist/toolroll-update.js +4 -1
  44. package/package.json +1 -1
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A person's tools on the HTTP gateway. A person signs in with their own API token (api-tokens.ts) and every tool call
3
+ * is the exact `toolroll` command line they could type, run on the server under their principal through
4
+ * `runOperateAs` with source "mcp": the command's own authorization, project grants and attribution apply, and its
5
+ * `--json` envelope is the answer. Nothing here reads data directly — a coordinator handler would skip the person's
6
+ * project grants — and nothing here approves, answers or changes people or policy: those verbs have no tool.
7
+ *
8
+ * tools/list follows the account's role and the token's scope; dispatch checks again, independent of the list.
9
+ */
10
+ import { parseContract, toModelSchema } from "./contracts/contract.js";
11
+ import { PERSON_TOOL_INPUTS, PERSON_TOOL_OUTPUTS } from "./contracts/gateway-tools.js";
12
+ import { reportToolOutput } from "./contracts/lead-tools.js";
13
+ import { callArguments } from "./mcp-core.js";
14
+ const TOOLS = {
15
+ status: {
16
+ description: "Running work, queued reasons, results to review and plan windows, over the projects you may see.",
17
+ acts: false,
18
+ argv: () => ["status"],
19
+ },
20
+ list_tasks: {
21
+ description: "A page of tasks in the projects you may see. Filter by state and repo; cursor-paginated.",
22
+ acts: false,
23
+ argv: args => [
24
+ "task", "list",
25
+ ...(args.state === undefined ? [] : ["--state", args.state]),
26
+ ...(args.repo === undefined ? [] : ["--repo", args.repo]),
27
+ ...(args.cursor === undefined ? [] : ["--cursor", String(args.cursor)]),
28
+ ...(args.limit === undefined ? [] : ["--limit", String(args.limit)]),
29
+ ],
30
+ },
31
+ task_show: {
32
+ description: "One task in full. A task outside your projects answers unknown-task.",
33
+ acts: false,
34
+ argv: args => ["task", "show", args.ref],
35
+ },
36
+ task_review: {
37
+ description: "A task's compact result packet: saved conclusion, changed files, checks, findings, screenshots, plan and next actions. Pass run for an exact finished attempt; all includes LOW findings.",
38
+ acts: false,
39
+ argv: args => ["task", "review", args.ref, "--brief", ...(args.run === undefined ? [] : ["--run", String(args.run)]), ...(args.all === true ? ["--all"] : [])],
40
+ },
41
+ review_findings: {
42
+ description: "One run's automatic review findings.",
43
+ acts: false,
44
+ argv: args => ["task", "review", String(args.run)],
45
+ },
46
+ file_task: {
47
+ description: "File a task into one of your projects, under your name. It waits for approval as usual; approving stays in the console or chat. Replaying the same idempotency_key returns the first answer.",
48
+ acts: true,
49
+ argv: args => ["task", "add", args.title, "--repo", args.repo, "--key", args.idempotency_key, ...(args.deliverable === "report" ? ["--report"] : [])],
50
+ },
51
+ };
52
+ const NAMES = Object.keys(PERSON_TOOL_INPUTS);
53
+ /** What this person may call: every read, and the acts only on an act token of an account that acts. */
54
+ export function personTools(person) {
55
+ return NAMES.filter(name => mayCall(person, name)).map(name => ({ name, description: TOOLS[name].description, inputSchema: toModelSchema(PERSON_TOOL_INPUTS[name]) }));
56
+ }
57
+ const mayCall = (person, name) => !TOOLS[name].acts || person.principal.scope === "act" && person.role === "approver";
58
+ /** One person tools/call: read by its schema, checked against the person, then run as the command it names. */
59
+ export async function callPersonTool(person, store, runAs, params) {
60
+ const name = typeof params["name"] === "string" ? params["name"] : "";
61
+ if (!NAMES.includes(name))
62
+ return { kind: "error", code: -32602, message: `no tool \`${name}\``, fatal: false };
63
+ const tool = name;
64
+ const rawArgs = callArguments(params);
65
+ if (rawArgs === null)
66
+ return { kind: "error", code: -32602, message: "arguments must be an object", fatal: false };
67
+ const read = parseContract(PERSON_TOOL_INPUTS[tool], rawArgs);
68
+ if (!read.ok)
69
+ return { kind: "error", code: -32602, message: read.issues.map(one => one.line).join("; "), fatal: false };
70
+ if (!mayCall(person, tool)) {
71
+ const why = person.principal.scope === "read" ? "this token reads only — make an act token in the console to file work" : "your account can watch, not act";
72
+ return refused(`${tool}: ${why}`);
73
+ }
74
+ const argv = [...TOOLS[tool].argv(read.value), "--json"];
75
+ let written = "";
76
+ let code;
77
+ try {
78
+ code = await runAs(argv, { principal: person.principal, store, write: chunk => { written += chunk; }, source: "mcp" });
79
+ }
80
+ catch (error) {
81
+ return refused(`the command failed on the server: ${error instanceof Error ? error.message : String(error)}`);
82
+ }
83
+ const text = written.trim();
84
+ let body;
85
+ try {
86
+ body = JSON.parse(text);
87
+ }
88
+ catch {
89
+ return refused(text === "" ? `the command answered nothing (exit ${code})` : text);
90
+ }
91
+ const ok = code === 0 && typeof body === "object" && body !== null && body["ok"] === true;
92
+ if (ok)
93
+ reportToolOutput("gateway", tool, PERSON_TOOL_OUTPUTS[tool], body);
94
+ return { kind: "result", result: ok ? { content: [{ type: "text", text }] } : { content: [{ type: "text", text }], isError: true } };
95
+ }
96
+ const refused = (message) => ({ kind: "result", result: { content: [{ type: "text", text: message }], isError: true } });
package/dist/mcp.d.ts CHANGED
@@ -1,12 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
- export declare const MODERN = "2026-07-28";
3
- export declare const LEGACY = "2025-11-25";
4
- /** The modern revision namespaces its per-request metadata. */
5
- export declare const META_VERSION = "io.modelcontextprotocol/protocolVersion";
6
- export declare const META_SERVER = "io.modelcontextprotocol/serverInfo";
7
- export declare const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
8
- /** The revision's own error code for an unsupported protocol version. */
9
- export declare const UNSUPPORTED_VERSION = -32022;
2
+ export { LEGACY, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, UNSUPPORTED_VERSION } from "./mcp-core.js";
10
3
  export type McpIo = {
11
4
  onLine: (handler: (line: string) => void) => void;
12
5
  onEof: (handler: () => void) => void;
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 {};