toolroll 0.9.42 → 0.9.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/chat-actions.js +1 -1
  5. package/dist/chat-approval.d.ts +45 -0
  6. package/dist/chat-approval.js +81 -0
  7. package/dist/chat-channel.js +5 -0
  8. package/dist/chat-decide.d.ts +18 -6
  9. package/dist/chat-decide.js +54 -20
  10. package/dist/chat-flow.js +2 -2
  11. package/dist/cli-http.d.ts +64 -0
  12. package/dist/cli-http.js +143 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +8 -1
  15. package/dist/contracts/cli.d.ts +13 -0
  16. package/dist/contracts/cli.js +7 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/gateway-tools.d.ts +82 -0
  20. package/dist/contracts/gateway-tools.js +33 -0
  21. package/dist/contracts/lead-tools.d.ts +11 -0
  22. package/dist/contracts/lead-tools.js +2 -0
  23. package/dist/desktop-update.d.ts +3 -1
  24. package/dist/desktop-update.js +7 -6
  25. package/dist/flow-engine.d.ts +2 -0
  26. package/dist/flow-engine.js +10 -2
  27. package/dist/flows-cli.d.ts +28 -0
  28. package/dist/flows-cli.js +43 -1
  29. package/dist/guides.js +2 -0
  30. package/dist/mate-contract.js +1 -1
  31. package/dist/mate-tools.js +43 -1
  32. package/dist/mcp-core.d.ts +110 -0
  33. package/dist/mcp-core.js +311 -0
  34. package/dist/mcp-http.d.ts +26 -0
  35. package/dist/mcp-http.js +151 -0
  36. package/dist/mcp-person.d.ts +31 -0
  37. package/dist/mcp-person.js +96 -0
  38. package/dist/mcp.d.ts +1 -8
  39. package/dist/mcp.js +21 -322
  40. package/dist/operate-remote-arguments.d.ts +36 -0
  41. package/dist/operate-remote-arguments.js +185 -0
  42. package/dist/operate-remote.d.ts +86 -0
  43. package/dist/operate-remote.js +125 -0
  44. package/dist/operate.d.ts +21 -1
  45. package/dist/operate.js +229 -15
  46. package/dist/process-recovery-eligibility.d.ts +1 -1
  47. package/dist/remote-command.d.ts +45 -0
  48. package/dist/remote-command.js +124 -0
  49. package/dist/remote-exec.d.ts +18 -0
  50. package/dist/remote-exec.js +241 -0
  51. package/dist/remote-run.d.ts +16 -0
  52. package/dist/remote-run.js +13 -0
  53. package/dist/scope.d.ts +4 -2
  54. package/dist/scope.js +10 -0
  55. package/dist/serve.d.ts +7 -0
  56. package/dist/serve.js +75 -2
  57. package/dist/store.d.ts +23 -2
  58. package/dist/store.js +76 -9
  59. package/dist/surface.d.ts +10 -3
  60. package/dist/surface.js +71 -3
  61. package/dist/team-cli.d.ts +19 -0
  62. package/dist/team-cli.js +20 -3
  63. package/dist/teammate-work.js +1 -1
  64. package/dist/telegram-flow.d.ts +41 -1
  65. package/dist/telegram-flow.js +96 -12
  66. package/dist/telegram.js +27 -3
  67. package/dist/toolroll-update.js +4 -1
  68. package/package.json +1 -1
package/dist/flows-cli.js CHANGED
@@ -10,7 +10,8 @@
10
10
  import { readFileSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { resolve } from 'node:path';
12
12
  import { envelopeJson } from './envelope.js';
13
- import { addCardToFlow, advanceFlows, crossProjectProblem, flowDefinitionOf } from './flow-engine.js';
13
+ import { addCardToFlow, advanceFlows, crossProjectProblem, decideFlowCard, flowDefinitionOf } from './flow-engine.js';
14
+ import { chooseFlowCard } from './flow-send.js';
14
15
  import { saveScript } from './flow-scripts.js';
15
16
  import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
16
17
  import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
@@ -51,6 +52,10 @@ export const FLOWS_DESCRIPTORS = [
51
52
  { name: 'title', takesValue: true, meaning: "the card's title" },
52
53
  { name: 'description', takesValue: true, meaning: 'its details' },
53
54
  { name: 'zone', takesValue: true, meaning: 'a zone id or name' }] },
55
+ { action: 'card approve', synopsis: "approve a card waiting at a Person decides step (or pick --option at a Person chooses step), as the console's Approve does", mutation: 'identity-idempotent', positionals: ['flow', 'card'], flags: [...write,
56
+ { name: 'option', takesValue: true, meaning: "at a Person chooses step: the option's words or its number" }] },
57
+ { action: 'card send-back', synopsis: "send a waiting card back with a note, as the console's Send back (or a reply at a Person chooses step) does", mutation: 'identity-idempotent', positionals: ['flow', 'card'], flags: [...write,
58
+ { name: 'note', takesValue: true, meaning: 'what should change' }] },
54
59
  { action: 'export', synopsis: 'write a flow as a *.toolroll-flow.json file: zones, paths, trigger settings and scripts; never secrets, webhook addresses, names or cards', mutation: 'none', positionals: ['flow'], flags: [...read,
55
60
  { name: 'out', takesValue: true, meaning: 'write the file here (default: print it)' }] },
56
61
  { action: 'import', synopsis: 'make a flow from a flow file or a gist/GitHub https address; previews in plain words until --yes; triggers arrive off and scripts wait for approval', mutation: 'unkeyed', positionals: ['file'], flags: [...write, yes, repo,
@@ -407,6 +412,43 @@ export async function runFlowsCommand(positional, flags, context) {
407
412
  const card = store.getFlowCard(added.card);
408
413
  return ok({ applied: true, card: card === null ? { id: added.card } : cardOf(definition, card), said: added.said }, [`${added.said} Card #${added.card}.`]);
409
414
  }
415
+ if (action === 'card approve' || action === 'card send-back') {
416
+ // The console's own doors (decideFlowCard, chooseFlowCard): the same decider, visit and project checks, and the same card history.
417
+ const approving = action === 'card approve';
418
+ const note = text('note')?.trim() ?? null;
419
+ if (args[1] === undefined || (!approving && (note === null || note === '')))
420
+ return fail('usage', approving ? 'Use flows card approve <flow> <card> [--option <words|number>].' : 'Use flows card send-back <flow> <card> --note "<what should change>".', EXIT.usage);
421
+ const card = /^[1-9][0-9]{0,9}$/.test(args[1]) ? store.getFlowCard(Number(args[1])) : null;
422
+ if (card === null || card.flow !== flow.id)
423
+ return refuse(flow.repo, 'unknown-card', `Flow #${flow.id} has no card #${args[1]}. toolroll flows show ${flow.id} lists its cards.`);
424
+ const stage = definition.stages.find(one => one.id === card.stage);
425
+ let decided;
426
+ if (stage?.kind === 'choose') {
427
+ if (approving) {
428
+ const given = text('option')?.trim() ?? '';
429
+ const options = stage.options ?? [];
430
+ const choice = /^[1-9]$/.test(given) && Number(given) <= options.length ? Number(given) - 1 : options.findIndex(one => one.label.toLowerCase() === given.toLowerCase());
431
+ if (given === '' || choice < 0)
432
+ return refuse(flow.repo, 'choose-option', `${stage.title} asks for a choice: give --option with one of ${options.map((one, at) => `${at + 1} “${one.label}”`).join(', ')}.`, EXIT.usage);
433
+ decided = chooseFlowCard(store, { card: card.id, entry: card.entry, choice, label: options[choice].label, note: null, actor: who, where: 'the command line', repos: reachable, evidenceRoot: context.evidenceRoot }, now);
434
+ }
435
+ else
436
+ decided = chooseFlowCard(store, { card: card.id, entry: card.entry, choice: null, note, actor: who, where: 'the command line', repos: reachable, evidenceRoot: context.evidenceRoot }, now);
437
+ }
438
+ else if (stage?.kind === 'approval') {
439
+ if (text('option') !== null)
440
+ return fail('usage', '--option is for a Person chooses step; this card waits for a decision.', EXIT.usage);
441
+ decided = decideFlowCard(store, { card: card.id, decision: approving ? 'approve' : 'send-back', note, actor: who, repos: reachable, evidenceRoot: context.evidenceRoot, entry: card.entry, where: 'the command line' }, now);
442
+ }
443
+ else
444
+ return refuse(flow.repo, 'not-waiting', card.state === 'active' ? `Card #${card.id} isn't waiting for a decision; it's in ${stage?.title ?? card.stage}.` : `Card #${card.id} is ${card.state}; nothing is waiting on it.`);
445
+ if (!decided.ok)
446
+ return refuse(flow.repo, 'refused', decided.message);
447
+ record(flow.repo, 'accepted', `flow #${flow.id} card #${card.id} · ${stage.title}`);
448
+ settle(flow.repo);
449
+ const after = store.getFlowCard(card.id);
450
+ return ok({ applied: true, card: after === null ? { id: card.id } : cardOf(definition, after), said: decided.said }, [decided.said]);
451
+ }
410
452
  if (action === 'trigger add') {
411
453
  if (args[1] === undefined)
412
454
  return fail('usage', 'Use flows trigger add <flow> <json|file|->, like \'{"kind":"schedule","schedule":"daily 09:00","title":"Standup"}\'.', EXIT.usage);
package/dist/guides.js CHANGED
@@ -141,6 +141,8 @@ applies the console's rules exactly.
141
141
  \`flows trigger pause|resume|remove|check <id> <trigger>\`; \`flows script
142
142
  save --repo PATH --name <n> (--file <path in project> | --body <file>)
143
143
  --about "<line>"\`; \`flows card add <id> --title <t> [--zone <z>]\`;
144
+ \`flows card approve <id> <card>\` and \`flows card send-back <id> <card>
145
+ --note <text>\` only when the person asked for that decision;
144
146
  \`flows archive <id>\`.
145
147
  - create, edit, archive and trigger add answer with a preview
146
148
  (\`applied: false\`, \`terms\`) until \`--yes\`. Show the person the terms
@@ -16,7 +16,7 @@ export const MATE_CONTRACT = [
16
16
  "Investigate before reporting a limitation: when a tool refuses or something looks unsupported, read get_capabilities and the relevant skill (get_skills, then that skill's instructions) before telling the owner it can't be done. Then say what you checked, what is missing, and the one next step.",
17
17
  "Ask rarely: only when the answer changes the work and you cannot settle it from tools or project memory. Then use ask_owner: one question, 2-4 short options (Something else is added), shown as buttons after your reply; the tapped option comes back as the owner's next message. Keep your reply to one short line leading into it, and do not repeat the options in your text.",
18
18
  "propose_* drafts, never completes. Confirmation and execution approvals still apply. Never request credentials in chat. Read get_task currentExecution before acting; revisions retain task identity and old cards retain exact targets.",
19
- "The operator talks plainly and names work by what it is about ('the login page', 'that payout fix'), rarely by id. Use the current task when there is one; otherwise find it with list_tasks search, and if several fit, ask which by title. When they want work moved forward, read get_task and draft its next step: changes to a result still waiting on them → propose_review revise; guidance for work in progress → propose_steer; stopped → resume and failed → retry with propose_task_action; waiting for approval → show_control approval; new work, or more on something already completed → propose_task in the same project, naming the earlier task in its goal.",
19
+ "The operator talks plainly and names work by what it is about ('the login page', 'that payout fix'), rarely by id. Use the current task when there is one; otherwise find it with list_tasks search, and if several fit, ask which by title. When they want work moved forward, read get_task and draft its next step: changes to a result still waiting on them → propose_review revise; guidance for work in progress → propose_steer; stopped → resume and failed → retry with propose_task_action; waiting for approval, or 'approve X' → offer_approval for exactly that plan or flow card, which sends its own Approve buttons to the owner's chat (their tap approves it; you never do), and only when it says why not, say that in one line and show_control approval; new work, or more on something already completed → propose_task in the same project, naming the earlier task in its goal.",
20
20
  "Talk like a colleague in everyday words: task titles, 'the changes', 'the checks', 'try again'. Never say execution, run, scope, digest, dispatch or lane to the operator; say what happens next and what they can do.",
21
21
  "Read get_result for the exact execution/run; page nextFeedbackOffset. propose_review revise requests changes; note saves feedback. Use read notes only; clarify ambiguous intent. Ready is a saved result, not a reviewer stage. Historical missing assessments never require rerunning work. Failed checks and missing files remain visible; completion never deploys or changes checks.",
22
22
  "For screenshots, call get_result_images for that exact execution and run. On Telegram say they follow, never that they were delivered; elsewhere name result and image count. Limit 8/reply. If nextImageOffset exists, name remaining count; call it again with that offset or with the image ids it listed when asked. Select a newer revision explicitly; never describe unselected images or expose paths.",
@@ -48,7 +48,9 @@ import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommand
48
48
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowFromSteps, stepsFor, withKeptSteps } from "./flows.js";
49
49
  import { parseContract, toModelSchema } from "./contracts/contract.js";
50
50
  import { LEAD_TOOL_INPUTS, LEAD_TOOL_READ_INPUTS, LEAD_TOOL_OPTIONS, LEAD_TOOL_OUTPUTS, reportToolOutput } from "./contracts/lead-tools.js";
51
- import { flowDefinitionOf } from "./flow-engine.js";
51
+ import { flowCardHref, flowDefinitionOf } from "./flow-engine.js";
52
+ import { planInChat } from "./chat-decide.js";
53
+ import { flowDecisionAt } from "./telegram-flow.js";
52
54
  import { flowPersonOf } from "./flow-send.js";
53
55
  import { flowInsights } from "./flow-insights.js";
54
56
  import { describeTrigger, FLOW_TRIGGER_KINDS, triggerConfigOf } from "./flow-triggers.js";
@@ -491,6 +493,46 @@ const MATE_TOOL_HANDLERS = {
491
493
  return id === null ? tooMany() : { ok: true, body: { card: id, label: entry.label, action: "open existing control; nothing changed" } };
492
494
  },
493
495
  },
496
+ offer_approval: {
497
+ description: "When the owner asks to approve one thing (a plan waiting on them, or a flow card waiting at a Person decides step), send that item's own Approve and Send back buttons to their paired chat right away. Their tap there is the approval (two taps, as on any decision card); you never approve. Give task for a plan, or card for a flow card (its number from get_flows). When it can't be approved in chat, the answer says why: say that in one line and open show_control approval (a plan) or the flow, never that you can't approve.",
498
+ handle: (ctx, args) => {
499
+ const pairedHere = ctx.channel === "telegram";
500
+ const paired = ctx.store.handle.prepare("SELECT 1 FROM telegram_binding WHERE approver = ? AND approver_generation = ? AND revoked_at IS NULL LIMIT 1")
501
+ .get(ctx.who.name, ctx.store.accountOf(ctx.who.name)?.generation ?? -1) !== undefined;
502
+ const stamp = ctx.now.getTime();
503
+ if (args["card"] !== undefined) {
504
+ const cardId = Number(args["card"]);
505
+ const card = Number.isSafeInteger(cardId) ? ctx.store.getFlowCard(cardId) : null;
506
+ const flow = card === null ? null : ctx.store.getFlow(card.flow);
507
+ if (card === null || flow === null || !ctx.who.repos.includes(flow.repo))
508
+ return { ok: false, message: "not-found: no such flow card in your projects" };
509
+ const waiting = flowDecisionAt(ctx.store, card.id, card.entry);
510
+ const href = flowCardHref(flow.id, card.id);
511
+ if (waiting === null)
512
+ return { ok: true, body: { offered: false, why: card.state === "active" ? "That card isn't waiting for a decision now." : "That card isn't waiting any more.", open: href } };
513
+ const decider = deciderOf(waiting.stage, waiting.flow);
514
+ if (decider !== null && decider !== ctx.who.name)
515
+ return { ok: true, body: { offered: false, why: `Only ${decider} decides here.`, open: href } };
516
+ if (!pairedHere || !paired)
517
+ return { ok: true, body: { offered: false, why: pairedHere ? "Your chat isn't paired for decisions." : "Buttons go to your paired chat; here, approve it on the card.", open: href } };
518
+ ctx.store.enqueueNotification({ dedupeKey: `flow-decide:${card.id}:${card.entry}:ask:${stamp}`, kind: "flow-decision", pushClass: "attention", recipient: ctx.who.name,
519
+ subject: `${flow.name}: ${card.title} needs your decision`.slice(0, 200), body: `${waiting.stage.title}: approve it, or send it back with a note.`, link: href, source: { project: flow.repo } }, ctx.now);
520
+ return { ok: true, body: { offered: true, item: `flow card ${card.id}`, shown: "Approve and Send back buttons follow your reply in this chat; the owner's tap decides it" } };
521
+ }
522
+ const task = taskIdOf(args);
523
+ if (task === null || admittedRef(ctx, task) === null)
524
+ return notFound();
525
+ const current = ctx.store.taskFamilyOf(task, ctx.who.repos, false)?.current.id ?? task;
526
+ const plan = planInChat(ctx.store, current, ctx.who.name, ctx.now, ctx.evidenceRoot);
527
+ if (!plan.ok)
528
+ return { ok: true, body: { offered: false, task: current, why: plan.why, control: "approval" } };
529
+ if (!pairedHere || !paired)
530
+ return { ok: true, body: { offered: false, task: current, why: pairedHere ? "Your chat isn't paired for decisions." : "Buttons go to your paired chat; here, approve it on its card.", control: "approval" } };
531
+ ctx.store.enqueueNotification({ dedupeKey: `plan-ready:${current}:ask:${stamp}`, kind: "plan-ready", recipient: ctx.who.name, subject: `${current}: plan ready for review`,
532
+ body: "Approve it here, or open it to edit first.", source: { taskRef: ctx.store.lookupRef(current).id } }, ctx.now);
533
+ return { ok: true, body: { offered: true, item: `plan for ${current}`, task: current, shown: "Approve & start follows your reply in this chat; the owner's two taps approve it" } };
534
+ },
535
+ },
494
536
  get_result: {
495
537
  description: "Read exact execution/run and feedback. Use get_task currentExecution; page nextFeedbackOffset.",
496
538
  handle: (ctx, args) => {
@@ -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>;