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.
- package/CHANGELOG.md +18 -0
- package/dist/action-ledger.d.ts +5 -2
- package/dist/action-ledger.js +13 -3
- package/dist/cli-http.d.ts +64 -0
- package/dist/cli-http.js +143 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +7 -0
- package/dist/contracts/cli.d.ts +13 -0
- package/dist/contracts/cli.js +7 -0
- package/dist/contracts/gateway-tools.d.ts +82 -0
- package/dist/contracts/gateway-tools.js +33 -0
- package/dist/desktop-update.d.ts +3 -1
- package/dist/desktop-update.js +7 -6
- package/dist/mcp-core.d.ts +110 -0
- package/dist/mcp-core.js +311 -0
- package/dist/mcp-http.d.ts +26 -0
- package/dist/mcp-http.js +151 -0
- package/dist/mcp-person.d.ts +31 -0
- package/dist/mcp-person.js +96 -0
- package/dist/mcp.d.ts +1 -8
- package/dist/mcp.js +21 -322
- package/dist/operate-remote-arguments.d.ts +36 -0
- package/dist/operate-remote-arguments.js +185 -0
- package/dist/operate-remote.d.ts +86 -0
- package/dist/operate-remote.js +125 -0
- package/dist/operate.d.ts +21 -1
- package/dist/operate.js +176 -12
- package/dist/remote-command.d.ts +45 -0
- package/dist/remote-command.js +124 -0
- package/dist/remote-exec.d.ts +18 -0
- package/dist/remote-exec.js +241 -0
- package/dist/remote-run.d.ts +16 -0
- package/dist/remote-run.js +13 -0
- package/dist/scope.js +10 -0
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +24 -0
- package/dist/store.d.ts +15 -1
- package/dist/store.js +34 -7
- package/dist/surface.d.ts +10 -3
- package/dist/surface.js +67 -2
- package/dist/team-cli.d.ts +19 -0
- package/dist/team-cli.js +20 -3
- package/dist/toolroll-update.js +4 -1
- 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
|
|
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
|
|
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
|
|
35
|
-
import {
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
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 {};
|