toolroll 0.9.43 → 0.9.45
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 +29 -0
- package/dist/action-ledger.d.ts +5 -2
- package/dist/action-ledger.js +13 -3
- package/dist/cli-http.d.ts +53 -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 +4 -2
- package/dist/desktop-update.js +7 -6
- package/dist/dispatch.d.ts +2 -2
- package/dist/dispatch.js +8 -4
- package/dist/lead-status.d.ts +7 -3
- package/dist/lead-status.js +21 -12
- 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 +98 -0
- package/dist/operate-remote.js +145 -0
- package/dist/operate.d.ts +21 -1
- package/dist/operate.js +206 -19
- 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 +20 -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/dist/work-summary.d.ts +4 -1
- package/dist/work-summary.js +1 -1
- package/package.json +1 -1
package/dist/lead-status.js
CHANGED
|
@@ -181,15 +181,20 @@ export function repairStaleStatuses(store, now) {
|
|
|
181
181
|
const exitsRecorded = store.recordFinishedRunExits(now);
|
|
182
182
|
return { exitsRecorded, readyForReview: workReadyForReview(store, now, { principal: "operator", repos: null, includeUnplaced: true }, 0).count };
|
|
183
183
|
}
|
|
184
|
-
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
|
|
185
|
+
* `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
|
|
186
|
+
* A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
|
|
187
|
+
* the release check, plan windows and the lead line are empty, never another project's. */
|
|
188
|
+
export function installationStatus(store, now, viewer = currentActor()?.account ?? null, repos = null) {
|
|
189
|
+
const admitted = (row) => repos === null || typeof row["repo"] === "string" && repos.includes(row["repo"]);
|
|
190
|
+
const access = { principal: "operator", repos, includeUnplaced: repos === null };
|
|
191
|
+
const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task, ref.repo
|
|
187
192
|
FROM run INDEXED BY work_unfinished
|
|
188
193
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
189
194
|
JOIN claim ON claim.lease_id = run.lease_id
|
|
190
195
|
WHERE run.outcome IS NULL AND claim.released_at IS NULL AND claim.expires_at > ?
|
|
191
196
|
AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest INDEXED BY claim_by_task WHERE newest.task_ref = run.task_ref)
|
|
192
|
-
ORDER BY run.id`).all(now.toISOString());
|
|
197
|
+
ORDER BY run.id`).all(now.toISOString()).filter(admitted);
|
|
193
198
|
const phases = new Map();
|
|
194
199
|
const runningTasks = runningRows.map(row => {
|
|
195
200
|
const phase = phaseWords(phaseOf(row));
|
|
@@ -197,7 +202,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
197
202
|
return { task: String(row["task"]), run: Number(row["id"]), phase };
|
|
198
203
|
});
|
|
199
204
|
const runningRefs = new Set(runningRows.map(row => Number(row["task_ref"])));
|
|
200
|
-
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
205
|
+
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.repo, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
201
206
|
scope.profile_state,
|
|
202
207
|
(scope.approved_at IS NOT NULL AND scope.approved_by IS NOT NULL AND scope.approved_digest = scope.digest) AS approved,
|
|
203
208
|
EXISTS (SELECT 1 FROM task_edge WHERE task_edge.blocked = task.id
|
|
@@ -208,7 +213,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
208
213
|
JOIN task_ref AS ref INDEXED BY sqlite_autoindex_task_ref_1 ON ref.backend = ? AND ref.external_id = task.id
|
|
209
214
|
LEFT JOIN task_scope AS scope ON scope.task_id = task.id
|
|
210
215
|
WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
|
|
211
|
-
.filter(row => !runningRefs.has(Number(row["task_ref"])));
|
|
216
|
+
.filter(row => !runningRefs.has(Number(row["task_ref"])) && admitted(row));
|
|
212
217
|
const reasons = new Map();
|
|
213
218
|
const queuedTasks = [];
|
|
214
219
|
const pauses = openAuthPauses(store);
|
|
@@ -227,11 +232,11 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
227
232
|
}
|
|
228
233
|
// One per family, from the same projection as the Tasks list: a completed
|
|
229
234
|
// family (root or any revision) and a superseded version never count.
|
|
230
|
-
const ready = workReadyForReview(store, now,
|
|
235
|
+
const ready = workReadyForReview(store, now, access, 5);
|
|
231
236
|
const checkRow = (run) => store.handle.prepare(`SELECT ${CHECK_COLUMNS} FROM run_check AS rc WHERE rc.run = ?`).get(run);
|
|
232
237
|
// Run ids grow with time, so the newest finished release check is the
|
|
233
238
|
// first finished row down the partial index.
|
|
234
|
-
const release = store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
|
|
239
|
+
const release = repos !== null ? undefined : store.handle.prepare(`SELECT run.id, run.finished_at, ${CHECK_COLUMNS}, ref.external_id AS task
|
|
235
240
|
FROM run_check AS rc INDEXED BY run_check_release
|
|
236
241
|
JOIN run ON run.id = rc.run
|
|
237
242
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
@@ -247,7 +252,7 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
247
252
|
},
|
|
248
253
|
waitingForReview: { count: ready.count, results: ready.results.map(one => ({ task: one.taskId, run: one.run, check: checkOf(one.run === null ? undefined : checkRow(one.run)) })) },
|
|
249
254
|
releaseCheck: release === undefined ? null : { task: String(release["task"]), run: Number(release["id"]), finishedAt: release["finished_at"] == null ? null : String(release["finished_at"]), check: checkOf(release) },
|
|
250
|
-
planWindows: store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
|
|
255
|
+
planWindows: repos !== null ? [] : store.handle.prepare(`SELECT provider, window, used_percent, window_minutes, resets_at, reached, plan, observed_at
|
|
251
256
|
FROM provider_limit INDEXED BY sqlite_autoindex_provider_limit_1 ORDER BY provider, window`).all().map(row => ({
|
|
252
257
|
provider: String(row["provider"]),
|
|
253
258
|
plan: row["plan"] == null ? null : String(row["plan"]),
|
|
@@ -259,16 +264,17 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
259
264
|
observedAt: String(row["observed_at"]),
|
|
260
265
|
})),
|
|
261
266
|
signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
|
|
262
|
-
tasks: workIndexPage(store, now, {
|
|
267
|
+
tasks: workIndexPage(store, now, { ...access, viewer }, { limit: 8 }).items
|
|
263
268
|
.map(one => ({ task: one.rootId, title: one.title, headline: one.status.label, sentence: one.status.detail })),
|
|
264
|
-
lead: (() => {
|
|
269
|
+
lead: repos !== null ? null : (() => {
|
|
265
270
|
// Only the asker's own lead: another person's lead is never "Your lead".
|
|
266
271
|
const activity = leadActivity(store, viewer);
|
|
267
272
|
return activity === null ? null : { owner: activity.owner, doing: activity.doing, at: activity.at, task: activity.taskId, line: leadActivityLine(activity, now) };
|
|
268
273
|
})(),
|
|
269
274
|
};
|
|
270
275
|
}
|
|
271
|
-
|
|
276
|
+
/** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
|
|
277
|
+
export function renderInstallationStatus(status, limited = false) {
|
|
272
278
|
const lines = [];
|
|
273
279
|
// A paused provider comes first: it is the one thing a person must do.
|
|
274
280
|
for (const one of status.signIn)
|
|
@@ -284,6 +290,9 @@ export function renderInstallationStatus(status) {
|
|
|
284
290
|
lines.push(status.running.count === 0 ? "Building: none" : `Building: ${status.running.count} — ${status.running.tasks.map(one => `${one.task} (#${one.run}, ${one.phase})`).join(", ")}${status.running.count > status.running.tasks.length ? ", …" : ""}`);
|
|
285
291
|
lines.push(status.queued.count === 0 ? "Queued: none" : `Queued: ${status.queued.count} — ${status.queued.tasks.map(one => `${one.task} (${one.reason})`).join(", ")}${status.queued.count > status.queued.tasks.length ? ", …" : ""}`);
|
|
286
292
|
lines.push(status.waitingForReview.count === 0 ? "Ready for review: none" : `Ready for review: ${status.waitingForReview.count} — ${status.waitingForReview.results.map(one => one.run === null ? one.task : `${one.task} (#${one.run})`).join(", ")}${status.waitingForReview.count > status.waitingForReview.results.length ? ", …" : ""}`);
|
|
293
|
+
// Neither the release check nor plan windows belong to a person's projects: a limited report leaves both out.
|
|
294
|
+
if (limited)
|
|
295
|
+
return lines;
|
|
287
296
|
if (status.releaseCheck === null) {
|
|
288
297
|
lines.push("Release check: none recorded");
|
|
289
298
|
}
|
|
@@ -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 {};
|
package/dist/mcp-core.js
ADDED
|
@@ -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>;
|