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,110 @@
1
+ import { type GatewayToolName } from "./contracts/gateway-tools.js";
2
+ import type { Store } from "./store.js";
3
+ import { type VerifiedCoordinator } from "./coordinator.js";
4
+ export declare const MODERN = "2026-07-28";
5
+ export declare const LEGACY = "2025-11-25";
6
+ /** The modern revision namespaces its per-request metadata. */
7
+ export declare const META_VERSION = "io.modelcontextprotocol/protocolVersion";
8
+ export declare const META_SERVER = "io.modelcontextprotocol/serverInfo";
9
+ export declare const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
10
+ /** The revision's own error code for an unsupported protocol version. */
11
+ export declare const UNSUPPORTED_VERSION = -32022;
12
+ export declare const MAX_REQUEST: number;
13
+ export declare const MAX_DEPTH = 32;
14
+ export type Json = null | boolean | number | string | Json[] | {
15
+ [key: string]: Json;
16
+ };
17
+ export declare function depthOf(value: unknown): number;
18
+ /** One handler per tool; its input is GATEWAY_TOOL_INPUTS[name]
19
+ * (src/contracts/gateway-tools.ts) — THE source for parsing and inputSchema.
20
+ * Output is built field-by-field; spreading a database row into a result is
21
+ * forbidden in this module (arch-tested). */
22
+ export type ToolContext = {
23
+ store: Store;
24
+ who: VerifiedCoordinator;
25
+ /** The installation's enrolled repos (repos.json), when the launcher
26
+ * knows them — list_repos answers allowlist ∩ enrolled. null = the
27
+ * launcher could not read the registry; the allowlist alone answers. */
28
+ enrolled: readonly string[] | null;
29
+ /** The raw credential, per-SERVER closure state — never module state:
30
+ * two serveMcp instances in one process must not cross credentials
31
+ * (implementation review, finding 5). Filing re-authenticates with it
32
+ * inside its own transaction. */
33
+ token: string;
34
+ now: Date;
35
+ /** Decisions this connection read with get_decision — propose_answer needs one. */
36
+ readDecisions: Set<number>;
37
+ /** Where evidence lives, when the launcher knows — get_task reads a scout's report from here. */
38
+ evidenceRoot?: string;
39
+ };
40
+ export type Answer = {
41
+ ok: true;
42
+ body: Json;
43
+ } | {
44
+ ok: false;
45
+ message: string;
46
+ };
47
+ type Tool = {
48
+ name: GatewayToolName;
49
+ description: string;
50
+ inputSchema: Json;
51
+ handle: (ctx: ToolContext, args: Record<string, unknown>) => Answer;
52
+ };
53
+ /** The gateway's tools in tools/list order, each inputSchema derived from its contract. */
54
+ export declare const TOOLS: Tool[];
55
+ /** The tools/list payload for a registry. No outputSchema: MCP output schemas describe structuredContent, and these
56
+ * tools return text content only (round-2 finding 1). */
57
+ export declare const toolsPayload: (tools: readonly {
58
+ name: string;
59
+ description: string;
60
+ inputSchema: Json;
61
+ }[]) => {
62
+ tools: Json;
63
+ };
64
+ /** A modern result is schema-complete; a cacheable one (tools/list, discover) carries `ttlMs: 0` and the narrowest scope. */
65
+ export declare function shapeResult(era: "modern" | "legacy", result: Record<string, Json>, cacheable: boolean): Record<string, Json>;
66
+ /** One JSON-RPC message, read and checked before any method applies. */
67
+ export type ReadMessage = {
68
+ ok: true;
69
+ hasId: boolean;
70
+ id: Json;
71
+ method: string;
72
+ params: Record<string, unknown>;
73
+ meta: Record<string, unknown>;
74
+ } | {
75
+ ok: false;
76
+ id: Json;
77
+ code: number;
78
+ message: string;
79
+ };
80
+ /** Read one message's text: size, JSON, no batching, depth, `jsonrpc`, and the id rules. A notification is the message
81
+ * that carries NO id — the method name never decides (round-3 finding 3). MCP RequestId is a string or an INTEGER
82
+ * (round-2 finding 2): floats, null, and object shapes refuse wherever an id appears. */
83
+ export declare function readMessage(text: string): ReadMessage;
84
+ /** ClientCapabilities is an OBJECT shape (round-3 finding 5): an array is not a capabilities declaration. */
85
+ export declare const capabilitiesShape: (value: unknown) => boolean;
86
+ /** What a tools/call came to: a JSON-RPC error (`fatal` = the stdio server must exit after it) or a tool result. */
87
+ export type CallOutcome = {
88
+ kind: "error";
89
+ code: number;
90
+ message: string;
91
+ fatal: boolean;
92
+ } | {
93
+ kind: "result";
94
+ result: Record<string, Json>;
95
+ };
96
+ /** The `arguments` of a call: only an ABSENT value defaults to empty (round-3 finding 2), and the root is an object
97
+ * BEFORE any schema applies (round-2 finding 2): 42 or false never passes an empty schema. */
98
+ export declare function callArguments(params: Record<string, unknown>): Record<string, unknown> | null;
99
+ /** A handler's answer as a tools/call result: a refusal is `isError`, never a protocol error. */
100
+ export declare function answerResult(answered: Answer): Record<string, Json>;
101
+ /**
102
+ * One coordinator tools/call. ONE snapshot per call (review finding 3): the version check, the credential re-read, and
103
+ * every data read share a transaction, so a concurrent migration cannot slip between them; filing's own BEGIN IMMEDIATE
104
+ * reenters this one. tools/list visibility is presentation — authorization happens HERE, on every call, against the
105
+ * live credential row.
106
+ */
107
+ export declare function callCoordinatorTool(call: Omit<ToolContext, "who" | "now"> & {
108
+ clock: () => Date;
109
+ }, params: Record<string, unknown>): CallOutcome;
110
+ export {};
@@ -0,0 +1,311 @@
1
+ /**
2
+ * The MCP gateway's transport-free core (MCP gateway spec v6): protocol
3
+ * constants and limits, message reading, the coordinator tool registry and
4
+ * its per-call authorization. The stdio adapter (mcp.ts) and the HTTP adapter
5
+ * (mcp-http.ts) each own their transport and lifecycle; neither re-implements
6
+ * what a call means.
7
+ *
8
+ * Protocol errors are JSON-RPC errors; tool-level refusals are successful
9
+ * `tools/call` results with `isError: true`.
10
+ */
11
+ import { dirname, join } from 'node:path';
12
+ import { statSync } from 'node:fs';
13
+ import { repositoryContextRead } from './repository-context.js';
14
+ import { ASSIGNMENT_TOOLS, assignmentForCoordinator } from "./assignment-adapters.js";
15
+ import { parseContract, toModelSchema } from "./contracts/contract.js";
16
+ import { GATEWAY_TOOL_INPUTS, GATEWAY_TOOL_OUTPUTS } from "./contracts/gateway-tools.js";
17
+ import { reportToolOutput } from "./contracts/lead-tools.js";
18
+ import { PACKAGE_VERSION } from "./version.js";
19
+ import { authenticateCoordinator, fileCoordinatorProposal, statusFor, listTasksFor, taskDetailFor, } from "./coordinator.js";
20
+ import { ISO_STAMP_RULE, decisionOver, decisionsOver, labelRepos, queueOver, recapOver } from "./mate-tools.js";
21
+ import { proposeAsCoordinator } from "./coordinator-proposals.js";
22
+ export const MODERN = "2026-07-28";
23
+ export const LEGACY = "2025-11-25";
24
+ /** The modern revision namespaces its per-request metadata. */
25
+ export const META_VERSION = "io.modelcontextprotocol/protocolVersion";
26
+ export const META_SERVER = "io.modelcontextprotocol/serverInfo";
27
+ export const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
28
+ /** The revision's own error code for an unsupported protocol version. */
29
+ export const UNSUPPORTED_VERSION = -32022;
30
+ export const MAX_REQUEST = 256 * 1024;
31
+ export const MAX_DEPTH = 32;
32
+ export function depthOf(value) {
33
+ // The byte cap bounds the work list. Neither nesting nor array width
34
+ // becomes a JavaScript call stack or a spread argument list.
35
+ const pending = [{ value, depth: 0 }];
36
+ let maximum = 0;
37
+ while (pending.length > 0) {
38
+ const current = pending.pop();
39
+ if (current.depth > MAX_DEPTH)
40
+ return current.depth;
41
+ maximum = Math.max(maximum, current.depth);
42
+ if (current.value !== null && typeof current.value === "object") {
43
+ const children = Array.isArray(current.value) ? current.value : Object.values(current.value);
44
+ for (const child of children)
45
+ pending.push({ value: child, depth: current.depth + 1 });
46
+ }
47
+ }
48
+ return maximum;
49
+ }
50
+ const CONTRACT_GUIDE = [
51
+ "Toolroll MCP contract.",
52
+ "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.",
53
+ "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.",
54
+ "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.",
55
+ "You are rate-limited per hour and capped on outstanding unapproved filings. Refusals say the limit and the road in words.",
56
+ "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.",
57
+ "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.",
58
+ "Approve, steer, answer, pick, mint, configure, merge: those verbs do not exist on this surface, by construction.",
59
+ "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.",
60
+ ].join("\n");
61
+ /** An assignment tool: the adapter reads its call with the same schema `toolroll assignment` does. */
62
+ const assignmentHandler = (operation) => (ctx, args) => {
63
+ const result = assignmentForCoordinator(ctx.store, ctx.token, operation, args, ctx.now, ctx.evidenceRoot);
64
+ return result.ok ? { ok: true, body: result.body } : { ok: false, message: `${result.reason}: ${result.message}` };
65
+ };
66
+ const assignmentTool = (name) => {
67
+ const spec = ASSIGNMENT_TOOLS.find(one => one.name === name);
68
+ return { description: spec.description, handle: assignmentHandler(spec.operation) };
69
+ };
70
+ /** A propose_* tool: it writes one proposal row through the coordinator door. */
71
+ const proposeTool = (kind, description) => ({
72
+ description,
73
+ handle: (ctx, args) => {
74
+ const outcome = proposeAsCoordinator(ctx.store, ctx.token, kind, args, ctx.now, { readDecisions: ctx.readDecisions });
75
+ if (!outcome.ok)
76
+ return { ok: false, message: outcome.message };
77
+ return { ok: true, body: { proposal: outcome.id, kind: outcome.kind, awaiting: outcome.awaiting } };
78
+ },
79
+ });
80
+ const HANDLERS = {
81
+ get_project_context: {
82
+ description: 'Read bounded source excerpts or advisory static import impact in an admitted project. Falls back to text search without an index.',
83
+ handle: (ctx, args) => {
84
+ const repo = args.repo;
85
+ if (!ctx.who.repos.includes(repo) || ctx.enrolled !== null && !ctx.enrolled.includes(repo))
86
+ return { ok: false, message: 'That project is outside your access.' };
87
+ return { ok: true, body: repositoryContextRead({ repo, query: args.query, mode: args.mode === 'impact' ? 'impact' : 'search', audience: 'lead',
88
+ ...(ctx.evidenceRoot === undefined ? {} : { cacheRoot: join(dirname(ctx.evidenceRoot), 'repository-context') }) }) };
89
+ },
90
+ },
91
+ get_assignment: assignmentTool("get_assignment"),
92
+ list_assignment_updates: assignmentTool("list_assignment_updates"),
93
+ claim_assignment: assignmentTool("claim_assignment"),
94
+ acknowledge_assignment: assignmentTool("acknowledge_assignment"),
95
+ get_assignment_brief: assignmentTool("get_assignment_brief"),
96
+ get_assignment_inbox: assignmentTool("get_assignment_inbox"),
97
+ acknowledge_assignment_delivery: assignmentTool("acknowledge_assignment_delivery"),
98
+ status: {
99
+ description: "The plane's liveness facts over your repo allowlist: what waits on the operator, what runs, what finished in the last 24h.",
100
+ handle: ctx => ({ ok: true, body: statusFor(ctx.store, ctx.who, ctx.now) }),
101
+ },
102
+ list_tasks: {
103
+ description: "Tasks in your repo allowlist. Filter by state and repo; cursor-paginated, stable order.",
104
+ handle: (ctx, args) => {
105
+ const filter = {};
106
+ if (args.state !== undefined)
107
+ filter.state = args.state;
108
+ if (args.repo !== undefined)
109
+ filter.repo = args.repo;
110
+ if (args.cursor !== undefined)
111
+ filter.cursor = args.cursor;
112
+ if (args.limit !== undefined)
113
+ filter.limit = args.limit;
114
+ return { ok: true, body: listTasksFor(ctx.store, ctx.who, filter, ctx.now) };
115
+ },
116
+ },
117
+ get_task: {
118
+ 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.",
119
+ handle: (ctx, args) => {
120
+ const detail = taskDetailFor(ctx.store, ctx.who, args.ref, ctx.evidenceRoot, ctx.now);
121
+ if (detail === null)
122
+ return { ok: false, message: `not-found: no task \`${args.ref}\` in your repositories` };
123
+ return { ok: true, body: detail };
124
+ },
125
+ },
126
+ list_repos: {
127
+ description: "The repositories your credential may see and file into, with their operating-mode standing.",
128
+ handle: ctx => {
129
+ // FAIL CLOSED when the project registry cannot be read (round-2
130
+ // finding 4): answering the full allowlist would claim enrollment
131
+ // nobody proved.
132
+ if (ctx.enrolled === null) {
133
+ return { ok: false, message: "the project registry could not be read — the enrolled set is unknown, so nothing lists" };
134
+ }
135
+ return {
136
+ ok: true,
137
+ body: {
138
+ repos: ctx.who.repos
139
+ .filter(repo => ctx.enrolled !== null && ctx.enrolled.includes(repo))
140
+ .map(repo => {
141
+ const mode = ctx.store.activeMode(repo, ctx.now);
142
+ return { repo, mode: mode === null ? "no operating mode — every act is a ceremony" : `mode ${mode.name} signed until ${mode.absoluteExpiry}` };
143
+ }),
144
+ },
145
+ };
146
+ },
147
+ },
148
+ recap: {
149
+ 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.",
150
+ handle: (ctx, args) => {
151
+ const since = args.since;
152
+ if (since !== undefined && (!ISO_STAMP_RULE.test(since) || Number.isNaN(Date.parse(since)))) {
153
+ return { ok: false, message: "since is an ISO timestamp like 2026-09-02T12:00:00Z" };
154
+ }
155
+ return { ok: true, body: labelRepos(recapOver(ctx.store, ctx.who.repos, ctx.now, since ?? null), index => ctx.who.repos[index] ?? "") };
156
+ },
157
+ },
158
+ list_decisions: {
159
+ description: "Open decisions in your allowlist: id, task, question, options (id, label, reversible), age in hours. Never consequences or recommendations; the operator answers them.",
160
+ handle: ctx => ({ ok: true, body: labelRepos(decisionsOver(ctx.store, ctx.who.repos, ctx.now), index => ctx.who.repos[index] ?? "") }),
161
+ },
162
+ queue: {
163
+ description: "One repository's queue by column — the shared column, then each worker's reserved column — each in dispatch order.",
164
+ handle: (ctx, args) => {
165
+ const repo = args.repo;
166
+ if (!ctx.who.repos.includes(repo))
167
+ return { ok: false, message: "not-found: that repository is not in your allowlist" };
168
+ // The installation-wide revision stays home (slice-2 review, finding
169
+ // 12): a coordinator cannot move queues, and the counter would tell it
170
+ // about repos it may not see.
171
+ const { queueRevision: _revision, ...columns } = queueOver(ctx.store, repo, ctx.now);
172
+ return { ok: true, body: { repo, ...columns } };
173
+ },
174
+ },
175
+ get_decision: {
176
+ description: "One open decision in your allowlist in full: question, options with id, label, reversible, and consequence. Never the builder's recommendation.",
177
+ handle: (ctx, args) => {
178
+ const found = decisionOver(ctx.store, ctx.who.repos, args.decision, ctx.now);
179
+ if (found === null)
180
+ return { ok: false, message: "not-found: no such decision in your repositories" };
181
+ ctx.readDecisions.add(args.decision);
182
+ return { ok: true, body: labelRepos(found, index => ctx.who.repos[index] ?? "") };
183
+ },
184
+ },
185
+ propose_next: proposeTool("next", "Propose moving a queued task to the front of its column. An approver confirms; a queue that moved meanwhile refuses."),
186
+ propose_reserve: proposeTool("reserve", "Propose reserving a queued task for one worker, or releasing it to the shared queue with worker null."),
187
+ propose_hold: proposeTool("hold", "Propose holding a task's next attempt, with a reason. A running attempt is never interrupted."),
188
+ propose_unhold: proposeTool("unhold", "Propose lifting the operator's own hold on a task."),
189
+ 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."),
190
+ propose_cancel: proposeTool("cancel", "Propose cancelling a task, with a reason. The approver arms and confirms it on the task itself."),
191
+ 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."),
192
+ get_contract: {
193
+ description: "This surface's contract: what a coordinator may do, the proposal lifecycle, and the admission promise.",
194
+ handle: () => ({ ok: true, body: { contract: CONTRACT_GUIDE } }),
195
+ },
196
+ file_proposal: {
197
+ description: "File a task proposal into one of your repositories. It stays quarantined until the operator signs its scope.",
198
+ handle: (ctx, args) => {
199
+ // The token, not the pre-verified identity: filing re-authenticates
200
+ // INSIDE its own transaction (the session's `who` is a courtesy).
201
+ 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);
202
+ if (!outcome.ok)
203
+ return { ok: false, message: outcome.message };
204
+ return {
205
+ ok: true,
206
+ body: {
207
+ ref: outcome.id,
208
+ replayed: outcome.replayed,
209
+ admission: "quarantined until the operator signs its scope — the ceremony shows them your name",
210
+ },
211
+ };
212
+ },
213
+ },
214
+ };
215
+ /** The gateway's tools in tools/list order, each inputSchema derived from its contract. */
216
+ export const TOOLS = Object.keys(GATEWAY_TOOL_INPUTS).map((name) => {
217
+ const handler = HANDLERS[name];
218
+ return { name, description: handler.description, inputSchema: toModelSchema(GATEWAY_TOOL_INPUTS[name]), handle: (ctx, args) => handler.handle(ctx, args) };
219
+ });
220
+ /** The tools/list payload for a registry. No outputSchema: MCP output schemas describe structuredContent, and these
221
+ * tools return text content only (round-2 finding 1). */
222
+ export const toolsPayload = (tools) => ({
223
+ tools: tools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
224
+ });
225
+ /** A modern result is schema-complete; a cacheable one (tools/list, discover) carries `ttlMs: 0` and the narrowest scope. */
226
+ export function shapeResult(era, result, cacheable) {
227
+ return era === "modern" ? { ...result, resultType: "complete", ...(cacheable ? { ttlMs: 0, cacheScope: "private" } : {}) } : result;
228
+ }
229
+ /** Read one message's text: size, JSON, no batching, depth, `jsonrpc`, and the id rules. A notification is the message
230
+ * that carries NO id — the method name never decides (round-3 finding 3). MCP RequestId is a string or an INTEGER
231
+ * (round-2 finding 2): floats, null, and object shapes refuse wherever an id appears. */
232
+ export function readMessage(text) {
233
+ if (Buffer.byteLength(text, "utf8") > MAX_REQUEST)
234
+ return { ok: false, id: null, code: -32600, message: "request over 256 KiB" };
235
+ let message;
236
+ try {
237
+ message = JSON.parse(text);
238
+ }
239
+ catch {
240
+ return { ok: false, id: null, code: -32700, message: "parse error" };
241
+ }
242
+ // Batching left MCP in 2025-06-18 — neither pinned era accepts it.
243
+ if (Array.isArray(message))
244
+ return { ok: false, id: null, code: -32600, message: "JSON-RPC batching is not part of MCP — send one request per line" };
245
+ if (message === null || typeof message !== "object" || depthOf(message) > MAX_DEPTH)
246
+ return { ok: false, id: null, code: -32600, message: "invalid request" };
247
+ const request = message;
248
+ if (request["jsonrpc"] !== "2.0")
249
+ return { ok: false, id: null, code: -32600, message: 'jsonrpc must be "2.0"' };
250
+ const hasId = "id" in request;
251
+ const rawId = request["id"];
252
+ if (hasId && (rawId === null || (typeof rawId !== "string" && !(typeof rawId === "number" && Number.isInteger(rawId))))) {
253
+ return { ok: false, id: null, code: -32600, message: "id must be a string or an integer" };
254
+ }
255
+ const method = typeof request["method"] === "string" ? request["method"] : "";
256
+ if (method.startsWith("notifications/")) {
257
+ if (hasId)
258
+ return { ok: false, id: rawId, code: -32600, message: "a notification carries no id" };
259
+ }
260
+ else if (!hasId) {
261
+ return { ok: false, id: null, code: -32600, message: "a request carries an id — a string or an integer" };
262
+ }
263
+ const params = (request["params"] ?? {});
264
+ const meta = (params["_meta"] ?? {});
265
+ return { ok: true, hasId, id: (rawId ?? null), method, params, meta };
266
+ }
267
+ /** ClientCapabilities is an OBJECT shape (round-3 finding 5): an array is not a capabilities declaration. */
268
+ export const capabilitiesShape = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
269
+ /** The `arguments` of a call: only an ABSENT value defaults to empty (round-3 finding 2), and the root is an object
270
+ * BEFORE any schema applies (round-2 finding 2): 42 or false never passes an empty schema. */
271
+ export function callArguments(params) {
272
+ const raw = params["arguments"] === undefined ? {} : params["arguments"];
273
+ return typeof raw !== "object" || raw === null || Array.isArray(raw) ? null : raw;
274
+ }
275
+ /** A handler's answer as a tools/call result: a refusal is `isError`, never a protocol error. */
276
+ export function answerResult(answered) {
277
+ return answered.ok ? { content: [{ type: "text", text: JSON.stringify(answered.body) }] } : { content: [{ type: "text", text: answered.message }], isError: true };
278
+ }
279
+ /**
280
+ * One coordinator tools/call. ONE snapshot per call (review finding 3): the version check, the credential re-read, and
281
+ * every data read share a transaction, so a concurrent migration cannot slip between them; filing's own BEGIN IMMEDIATE
282
+ * reenters this one. tools/list visibility is presentation — authorization happens HERE, on every call, against the
283
+ * live credential row.
284
+ */
285
+ export function callCoordinatorTool(call, params) {
286
+ const { store, token, clock, ...rest } = call;
287
+ return store.transact(() => {
288
+ if (!store.schemaCurrent())
289
+ return { kind: "error", code: -32000, message: "the database schema moved underneath this server — restart it", fatal: true };
290
+ const name = typeof params["name"] === "string" ? params["name"] : "";
291
+ const tool = TOOLS.find(one => one.name === name);
292
+ if (tool === undefined)
293
+ return { kind: "error", code: -32602, message: `no tool \`${name}\``, fatal: false };
294
+ const session = authenticateCoordinator(store, token);
295
+ if (!session.ok)
296
+ return { kind: "error", code: -32000, message: "this credential no longer stands — the server exits", fatal: true };
297
+ const rawArgs = callArguments(params);
298
+ if (rawArgs === null)
299
+ return { kind: "error", code: -32602, message: "arguments must be an object", fatal: false };
300
+ // The call is read by the schema tools/list advertised (review finding 2): unknown fields, bad types, bad enums,
301
+ // and bound violations are protocol errors (InvalidParams) naming each path, never tool refusals.
302
+ const read = parseContract(GATEWAY_TOOL_INPUTS[tool.name], rawArgs);
303
+ if (!read.ok)
304
+ return { kind: "error", code: -32602, message: read.issues.map(one => one.line).join("; "), fatal: false };
305
+ const answered = tool.handle({ store, token, who: session.who, now: clock(), ...rest }, read.value);
306
+ // Checked against its output schema before delivery; a disagreement is reported, never refused.
307
+ if (answered.ok)
308
+ reportToolOutput("gateway", tool.name, GATEWAY_TOOL_OUTPUTS[tool.name], answered.body);
309
+ return { kind: "result", result: answerResult(answered) };
310
+ });
311
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The MCP gateway over streamable HTTP, served at /mcp by the console (serve.ts). Stateless: one JSON-RPC message per
3
+ * POST, answered with one JSON body (a notification gets 202 and no body); there is no session and no event stream.
4
+ * The modern revision carries its version in `_meta` as on stdio; a handshake-era client may initialize, and because
5
+ * nothing is kept between requests its tools work without the stdio lifecycle.
6
+ *
7
+ * Every POST signs in again. The caller is a coordinator credential (the coordinator registry, exactly as on stdio) or
8
+ * a person's own API token `so_…` (mcp-person.ts: their role, project grants and token scope). A password
9
+ * (`name:password`) and cookies sign in nothing here, a request carrying a browser Origin is refused, and the console's
10
+ * Host check runs before this route. A revoked or expired token answers 401 on its next request.
11
+ */
12
+ import type { IncomingMessage, ServerResponse } from "node:http";
13
+ import type { Store } from "./store.js";
14
+ import { type RunOperateAs } from "./mcp-person.js";
15
+ export type McpHttpOptions = {
16
+ store: Store;
17
+ clock: () => Date;
18
+ /** The enrolled projects: a coordinator's list_repos answers allowlist ∩ enrolled. */
19
+ enrolled: () => readonly string[] | null;
20
+ evidenceRoot?: string;
21
+ /** The console's own sign-in for an `so_` bearer: the address's tries, expiry, revocation, a removed account. */
22
+ signedIn: (request: IncomingMessage) => boolean;
23
+ /** Runs a person's command on the server (operate.ts); resolved from operate.ts when not injected. */
24
+ runAs?: RunOperateAs;
25
+ };
26
+ export declare function createMcpHttp(options: McpHttpOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The MCP gateway over streamable HTTP, served at /mcp by the console (serve.ts). Stateless: one JSON-RPC message per
3
+ * POST, answered with one JSON body (a notification gets 202 and no body); there is no session and no event stream.
4
+ * The modern revision carries its version in `_meta` as on stdio; a handshake-era client may initialize, and because
5
+ * nothing is kept between requests its tools work without the stdio lifecycle.
6
+ *
7
+ * Every POST signs in again. The caller is a coordinator credential (the coordinator registry, exactly as on stdio) or
8
+ * a person's own API token `so_…` (mcp-person.ts: their role, project grants and token scope). A password
9
+ * (`name:password`) and cookies sign in nothing here, a request carrying a browser Origin is refused, and the console's
10
+ * Host check runs before this route. A revoked or expired token answers 401 on its next request.
11
+ */
12
+ import { PACKAGE_VERSION } from "./version.js";
13
+ import { parseApiToken, secretMatches } from "./api-tokens.js";
14
+ import { authenticateCoordinator } from "./coordinator.js";
15
+ import { LEGACY, MAX_REQUEST, META_CAPABILITIES, META_SERVER, META_VERSION, MODERN, TOOLS, UNSUPPORTED_VERSION, callCoordinatorTool, capabilitiesShape, readMessage, shapeResult, toolsPayload, } from "./mcp-core.js";
16
+ import { callPersonTool, personTools } from "./mcp-person.js";
17
+ /** The most decisions a coordinator credential is remembered to have read (propose_answer needs one). */
18
+ const READ_DECISIONS_KEPT = 200;
19
+ export function createMcpHttp(options) {
20
+ const { store } = options;
21
+ /** Stdio remembers per connection which decisions a coordinator read; stateless HTTP remembers it per credential. */
22
+ const readDecisions = new Map();
23
+ const send = (response, status, body, headers = {}) => {
24
+ response.writeHead(status, { "cache-control": "no-store", "x-content-type-options": "nosniff", ...(body === null ? {} : { "content-type": "application/json" }), ...headers });
25
+ response.end(body === null ? undefined : JSON.stringify(body));
26
+ };
27
+ const refuse = (response, status, message, headers = {}) => send(response, status, { jsonrpc: "2.0", id: null, error: { code: -32000, message } }, headers);
28
+ /** Who signed this request, from the live rows — nothing is cached, so revocation and grant changes apply at once. */
29
+ const callerOf = (request) => {
30
+ const presented = /^Bearer (\S+)$/.exec(request.headers.authorization ?? "")?.[1];
31
+ if (presented === undefined || presented.includes(":"))
32
+ return null;
33
+ if (presented.startsWith("so_")) {
34
+ if (!options.signedIn(request))
35
+ return null;
36
+ const parsed = parseApiToken(presented);
37
+ const kept = parsed === null ? null : store.apiTokenSecret(parsed.id);
38
+ // The secret is checked here too, so this door never rests on how signedIn reads the request.
39
+ if (parsed === null || kept === null || !secretMatches(parsed.secret, kept.secretHash))
40
+ return null;
41
+ if (kept.row.revokedAt !== null || Date.parse(kept.row.expiresAt) <= options.clock().getTime())
42
+ return null;
43
+ const account = store.accountOf(kept.row.account);
44
+ if (account === null || account.revokedAt !== null)
45
+ return null;
46
+ return {
47
+ kind: "person",
48
+ person: {
49
+ principal: { kind: "person", account: kept.row.account, generation: account.generation, scope: kept.row.access, tokenId: kept.row.id, projects: account.projects === null ? null : [...account.projects] },
50
+ role: account.role,
51
+ tokenName: kept.row.name,
52
+ },
53
+ };
54
+ }
55
+ const auth = authenticateCoordinator(store, presented);
56
+ return auth.ok ? { kind: "coordinator", token: presented, cid: auth.who.cid } : null;
57
+ };
58
+ /** Loaded on first use: operate.ts imports serve.ts, which serves this gateway. */
59
+ const runAs = async () => options.runAs ?? (await import("./operate.js")).runOperateAs;
60
+ /** The body, or null the moment it passes MAX_REQUEST: reading stops there and nothing more is buffered. */
61
+ const readBody = (request) => new Promise((resolve, reject) => {
62
+ const chunks = [];
63
+ let size = 0;
64
+ const onData = (chunk) => {
65
+ size += chunk.length;
66
+ if (size <= MAX_REQUEST)
67
+ return void chunks.push(chunk);
68
+ request.off("data", onData);
69
+ request.pause();
70
+ chunks.length = 0;
71
+ resolve(null);
72
+ };
73
+ request.on("data", onData);
74
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
75
+ request.on("error", reject);
76
+ });
77
+ /** 413, then the connection goes: the rest of an oversized body is never read. */
78
+ const tooLarge = (request, response) => {
79
+ response.once("finish", () => request.destroy());
80
+ refuse(response, 413, "request over 256 KiB", { connection: "close" });
81
+ };
82
+ const call = async (caller, params) => {
83
+ if (caller.kind === "person") {
84
+ return callPersonTool(caller.person, store, await runAs(), params);
85
+ }
86
+ let read = readDecisions.get(caller.cid);
87
+ if (read === undefined)
88
+ readDecisions.set(caller.cid, read = new Set());
89
+ const outcome = callCoordinatorTool({ store, token: caller.token, enrolled: options.enrolled(), clock: options.clock, readDecisions: read, ...(options.evidenceRoot === undefined ? {} : { evidenceRoot: options.evidenceRoot }) }, params);
90
+ for (const id of read)
91
+ if (read.size > READ_DECISIONS_KEPT)
92
+ read.delete(id);
93
+ return outcome;
94
+ };
95
+ return async (request, response) => {
96
+ if (request.method !== "POST")
97
+ return refuse(response, 405, "the MCP gateway takes POST only — one JSON-RPC message per request", { allow: "POST" });
98
+ // A browser page must never drive the gateway with whatever it can reach: agents send no Origin.
99
+ if (request.headers.origin !== undefined)
100
+ return refuse(response, 403, "browser requests are refused — connect an MCP client with your API token");
101
+ const caller = callerOf(request);
102
+ if (caller === null) {
103
+ return refuse(response, 401, "sign in with your API token (Authorization: Bearer so_…) or a coordinator credential — passwords and cookies are not accepted here", { "www-authenticate": 'Bearer realm="toolroll-mcp"' });
104
+ }
105
+ if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? ""))
106
+ return refuse(response, 415, "send the JSON-RPC message as application/json");
107
+ if (Number(request.headers["content-length"] ?? 0) > MAX_REQUEST)
108
+ return tooLarge(request, response);
109
+ const text = await readBody(request);
110
+ if (text === null)
111
+ return tooLarge(request, response);
112
+ const message = readMessage(text);
113
+ if (!message.ok)
114
+ return send(response, 400, { jsonrpc: "2.0", id: message.id, error: { code: message.code, message: message.message } });
115
+ const { id, method, params, meta } = message;
116
+ const error = (code, text, data) => send(response, 200, { jsonrpc: "2.0", id, error: { code, message: text, ...(data === undefined ? {} : { data }) } });
117
+ const answer = (era, result, cacheable) => send(response, 200, { jsonrpc: "2.0", id, result: shapeResult(era, result, cacheable) });
118
+ if (!store.schemaCurrent())
119
+ return error(-32000, "the database schema moved underneath this server — try again after it restarts");
120
+ // Notifications (initialized, cancelled) need nothing from a stateless server: accepted, never answered.
121
+ if (method.startsWith("notifications/"))
122
+ return send(response, 202, null);
123
+ const header = request.headers["mcp-protocol-version"];
124
+ const declared = typeof meta[META_VERSION] === "string" ? meta[META_VERSION] : typeof header === "string" ? header : null;
125
+ if (method !== "initialize" && declared !== null && declared !== MODERN && declared !== LEGACY) {
126
+ return error(UNSUPPORTED_VERSION, `unsupported protocol version \`${declared}\``, { supported: [MODERN, LEGACY], requested: declared });
127
+ }
128
+ const tools = () => caller.kind === "person" ? personTools(caller.person) : TOOLS;
129
+ if (method === "initialize") {
130
+ // The handshake era negotiates only itself; nothing is kept, so no session id is issued.
131
+ return answer("legacy", { protocolVersion: LEGACY, capabilities: { tools: {} }, serverInfo: { name: "toolroll", version: PACKAGE_VERSION } }, false);
132
+ }
133
+ const era = declared === MODERN ? "modern" : "legacy";
134
+ if (era === "modern" && !capabilitiesShape(meta[META_CAPABILITIES]))
135
+ return error(-32600, `a ${MODERN} request carries _meta["${META_CAPABILITIES}"]`);
136
+ if (method === "server/discover") {
137
+ if (era !== "modern")
138
+ return error(-32600, `server/discover requires _meta["${META_VERSION}"] = "${MODERN}" and _meta["${META_CAPABILITIES}"]`);
139
+ return answer("modern", { protocolVersion: MODERN, supportedVersions: [MODERN, LEGACY], capabilities: { tools: {} }, _meta: { [META_SERVER]: { name: "toolroll", version: PACKAGE_VERSION } }, ...toolsPayload(tools()) }, true);
140
+ }
141
+ if (method === "ping")
142
+ return era === "modern" ? error(-32601, "ping is not part of the modern revision") : answer("legacy", {}, false);
143
+ if (method === "tools/list")
144
+ return answer(era, toolsPayload(tools()), true);
145
+ if (method === "tools/call") {
146
+ const outcome = await call(caller, params);
147
+ return outcome.kind === "result" ? answer(era, outcome.result, false) : error(outcome.code, outcome.message);
148
+ }
149
+ return error(-32601, `unknown method \`${method}\``);
150
+ };
151
+ }
@@ -0,0 +1,31 @@
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 { type PersonToolName } from "./contracts/gateway-tools.js";
11
+ import { type CallOutcome, type Json } from "./mcp-core.js";
12
+ import type { Store } from "./store.js";
13
+ import type { Principal } from "./operate.js";
14
+ /** The shared remote seam (operate.ts): who runs a command on the server, and the runner itself. Type-only, so a
15
+ * change to either breaks this build instead of drifting; the value is loaded lazily (operate.ts imports serve.ts). */
16
+ export type { Principal } from "./operate.js";
17
+ export type RunOperateAs = typeof import("./operate.js").runOperateAs;
18
+ /** A signed-in person: the principal the command runs as, and what their account may do. */
19
+ export type Person = {
20
+ principal: Principal;
21
+ role: "approver" | "viewer";
22
+ tokenName: string;
23
+ };
24
+ /** What this person may call: every read, and the acts only on an act token of an account that acts. */
25
+ export declare function personTools(person: Person): {
26
+ name: PersonToolName;
27
+ description: string;
28
+ inputSchema: Json;
29
+ }[];
30
+ /** One person tools/call: read by its schema, checked against the person, then run as the command it names. */
31
+ export declare function callPersonTool(person: Person, store: Store, runAs: RunOperateAs, params: Record<string, unknown>): Promise<CallOutcome>;