@intentius/chant 0.88.0 → 0.90.0

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 (52) hide show
  1. package/dist/cli/handlers/serve.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts +20 -6
  4. package/dist/cli/mcp/server.d.ts.map +1 -1
  5. package/dist/cli/mcp/types.d.ts +13 -5
  6. package/dist/cli/mcp/types.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  8. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  9. package/dist/cli/mcp/workspace-tools.d.ts +53 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  11. package/dist/cli/registry.d.ts +2 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/op/op-verb-class.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +42 -2
  15. package/dist/workspace/conformance/index.d.ts.map +1 -1
  16. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  17. package/dist/workspace/reason-codes.d.ts +3 -0
  18. package/dist/workspace/reason-codes.d.ts.map +1 -1
  19. package/dist/workspace/records-write.d.ts +35 -3
  20. package/dist/workspace/records-write.d.ts.map +1 -1
  21. package/dist/workspace/records.d.ts +4 -1
  22. package/dist/workspace/records.d.ts.map +1 -1
  23. package/dist/workspace/source-block.d.ts +85 -0
  24. package/dist/workspace/source-block.d.ts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/cli/handlers/serve.ts +11 -1
  27. package/src/cli/main.test.ts +7 -0
  28. package/src/cli/main.ts +40 -1
  29. package/src/cli/mcp/docs-parity.test.ts +20 -2
  30. package/src/cli/mcp/server.ts +32 -6
  31. package/src/cli/mcp/types.ts +15 -2
  32. package/src/cli/mcp/workspace-plugins.ts +123 -0
  33. package/src/cli/mcp/workspace-tools.test.ts +198 -0
  34. package/src/cli/mcp/workspace-tools.ts +405 -0
  35. package/src/cli/registry.ts +2 -0
  36. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  37. package/src/op/op-verb-class.ts +6 -0
  38. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  39. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  40. package/src/workspace/conformance/index.mjs +3 -0
  41. package/src/workspace/conformance/index.ts +185 -7
  42. package/src/workspace/conformance/vitest.ts +17 -8
  43. package/src/workspace/reason-codes.ts +3 -0
  44. package/src/workspace/records-amend.schema.json +2 -1
  45. package/src/workspace/records-close.schema.json +2 -1
  46. package/src/workspace/records-new.schema.json +4 -1
  47. package/src/workspace/records-review.schema.json +2 -1
  48. package/src/workspace/records-write.ts +85 -7
  49. package/src/workspace/records.schema.json +1 -0
  50. package/src/workspace/records.ts +28 -0
  51. package/src/workspace/source-block.test.ts +167 -0
  52. package/src/workspace/source-block.ts +129 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * #2707 — chant serve mcp's workspace tools: served at or inside a declared
3
+ * workspace, reads that return the CLI's documents, and writes that keep the
4
+ * CLI's rules and say they came through MCP in the record's source block
5
+ * (#2708). Run against the workspace the reader conformance suite generates.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
13
+ import { createConformanceWorkspace, defaultChantCommand, mcpToolCall, type ConformanceWorkspace } from "../../workspace/conformance";
14
+ import { McpServer } from "./server";
15
+ import { workspaceReadTools, workspaceWriteTools } from "./workspace-tools";
16
+
17
+ const KIND = "decisions/decision.kind.mjs";
18
+ const chant = defaultChantCommand();
19
+
20
+ let ws: ConformanceWorkspace;
21
+ beforeAll(() => {
22
+ ws = createConformanceWorkspace({ chantCommand: chant });
23
+ }, 300_000);
24
+ afterAll(() => ws?.dispose());
25
+
26
+ type Result = { isError?: boolean; structuredContent?: Record<string, unknown>; content: { text: string }[] };
27
+
28
+ function server(cwd = ws.dir): McpServer {
29
+ return new McpServer([], { workspace: { cwd, chantCommand: chant } });
30
+ }
31
+
32
+ let nextId = 1;
33
+ async function rpc(s: McpServer, method: string, params: Record<string, unknown> = {}): Promise<unknown> {
34
+ const res = await s.handleRequest({ jsonrpc: "2.0", id: nextId++, method, params });
35
+ if (res.error) throw new Error(res.error.message);
36
+ return res.result;
37
+ }
38
+
39
+ async function call(s: McpServer, name: string, args: Record<string, unknown>, meta?: Record<string, unknown>): Promise<Result> {
40
+ return (await rpc(s, "tools/call", { name, arguments: args, ...(meta ? { _meta: meta } : {}) })) as Result;
41
+ }
42
+
43
+ function cli(argv: string[], env: NodeJS.ProcessEnv = {}): unknown {
44
+ let out: string;
45
+ try {
46
+ out = execFileSync(chant[0], [...chant.slice(1), ...argv], { cwd: ws.dir, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1", ...env }, stdio: ["ignore", "pipe", "pipe"] });
47
+ } catch (e) {
48
+ out = String((e as { stdout?: string }).stdout ?? "");
49
+ }
50
+ return JSON.parse(out);
51
+ }
52
+
53
+ /** A proposed decision's fields, with no id, state or source. */
54
+ function proposal(title: string): Record<string, unknown> {
55
+ return {
56
+ schema: 1,
57
+ title,
58
+ area: "delivery",
59
+ question: "Where do the app's logs go?",
60
+ options: [
61
+ { id: "a", label: "stdout", how: "The app writes to stdout and the runtime collects it.", tradeoff: "Nothing to configure." },
62
+ { id: "b", label: "a file", how: "The app writes a file.", tradeoff: "A volume to manage." },
63
+ ],
64
+ choice: null,
65
+ rejected: [],
66
+ supersedes: [],
67
+ evidence: [],
68
+ decided_by: null,
69
+ decided_on: null,
70
+ reviews: [],
71
+ constrains: ["member:app"],
72
+ };
73
+ }
74
+
75
+ type RecordView = { id: string; state: string; valid: boolean; data: Record<string, unknown>; quorum?: { agreed: number; met: boolean } };
76
+ function recordsOf(doc: unknown): RecordView[] {
77
+ return (doc as { records: RecordView[] }).records;
78
+ }
79
+
80
+ describe("which servers have the workspace tools", () => {
81
+ const names = [...workspaceReadTools, ...workspaceWriteTools].map((t) => t.name);
82
+
83
+ test("a server at or inside a declared workspace lists them", async () => {
84
+ for (const cwd of [ws.dir, join(ws.dir, "decisions")]) {
85
+ const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
86
+ expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
87
+ }
88
+ expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "records-new", "records-amend", "records-review", "records-close"]);
89
+ });
90
+
91
+ test("a server outside any workspace, or given none, does not", async () => {
92
+ const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-mcp-no-ws-")));
93
+ try {
94
+ for (const s of [server(outside), new McpServer([])]) {
95
+ const { tools } = (await rpc(s, "tools/list")) as { tools: { name: string }[] };
96
+ expect(tools.map((t) => t.name).filter((n) => names.includes(n))).toEqual([]);
97
+ }
98
+ } finally {
99
+ rmSync(outside, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("the descriptions say records are proposals and by names who decided", () => {
104
+ for (const t of workspaceWriteTools) expect(t.description).toMatch(/proposals until they are reviewed.*by must name the person or agent that actually decided/s);
105
+ });
106
+ });
107
+
108
+ describe("reads", () => {
109
+ test("workspace-records returns the document chant workspace records --json prints", async () => {
110
+ const s = server();
111
+ const res = await call(s, "workspace-records", { kind: KIND });
112
+ expect(res.isError).toBeUndefined();
113
+ expect(res.structuredContent).toEqual(cli(["workspace", "records", "--kind", KIND, "--json"]));
114
+ const only = await call(s, "workspace-records", { kind: KIND, id: "fix-001" });
115
+ expect(recordsOf(only.structuredContent).map((r) => r.id)).toEqual(["fix-001"]);
116
+ }, 120_000);
117
+
118
+ test("an error document is returned as a document, and a flag-shaped value is refused before chant runs", async () => {
119
+ const s = server();
120
+ const missing = await call(s, "workspace-records", { kind: "nowhere/none.kind.mjs" });
121
+ expect(missing.structuredContent).toMatchObject({ error: { code: "kind-unreadable" } });
122
+ const flag = await call(s, "workspace-ls", { at: "--output=/tmp/x" });
123
+ expect(flag.isError).toBe(true);
124
+ expect(flag.content[0].text).toMatch(/at may not start with -/);
125
+ }, 120_000);
126
+
127
+ test("the conformance suite's argv maps to the tool that answers it", () => {
128
+ expect(mcpToolCall(["workspace", "records", "--kind", KIND, "--json"])).toEqual({ name: "workspace-records", arguments: { kind: KIND } });
129
+ expect(mcpToolCall(["workspace", "status", "dev", "--json"])).toEqual({ name: "workspace-status", arguments: { env: "dev" } });
130
+ expect(mcpToolCall(["workspace", "graph", "--intent", "a.mjs:1", "--kind", KIND, "--json"])).toEqual({ name: "workspace-graph", arguments: { intent: "a.mjs:1", kind: [KIND] } });
131
+ expect(mcpToolCall(["workspace", "graph", "--composites", "--json"])).toEqual({ name: "workspace-graph", arguments: { composites: true } });
132
+ expect(mcpToolCall(["workspace", "check", "--format", "json"])).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe("writes", () => {
137
+ test("records-new proposes a decision with MCP in its source, records reads it, and reviews move its quorum", async () => {
138
+ const s = server();
139
+ await rpc(s, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.0" } });
140
+ const made = await call(s, "records-new", { kind: KIND, record: proposal("Where the logs go") });
141
+ expect(made.structuredContent).toMatchObject({ id: "fix-002", dryRun: false });
142
+ const read = recordsOf(cli(["workspace", "records", "--kind", KIND, "--json"])).find((r) => r.id === "fix-002")!;
143
+ expect(read).toMatchObject({ state: "proposed", valid: true });
144
+ expect(read.data.source).toEqual({ via: "mcp", client: { name: "claude-code", version: "2.1.0" } });
145
+
146
+ for (const by of ["bob", "carol"]) {
147
+ const res = await call(s, "records-review", { kind: KIND, id: "fix-002", verdict: "agree", by });
148
+ expect(res.structuredContent).toMatchObject({ review: { reviewer: by, verdict: "agree" } });
149
+ }
150
+ const after = await call(s, "workspace-records", { kind: KIND, id: "fix-002" });
151
+ expect(recordsOf(after.structuredContent)[0].quorum).toMatchObject({ agreed: 2, met: true });
152
+ }, 180_000);
153
+
154
+ test("a second client is recorded as itself, from the request's _meta", async () => {
155
+ const s = server();
156
+ await rpc(s, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.0" } });
157
+ const made = await call(s, "records-new", { kind: KIND, record: { ...proposal("Where the metrics go"), source: { kind: "workspace", member: "app" } } }, {
158
+ "io.modelcontextprotocol/clientInfo": { name: "codex", version: "0.40.0" },
159
+ });
160
+ const id = (made.structuredContent as { id: string }).id;
161
+ const read = recordsOf(cli(["workspace", "records", "--kind", KIND, "--json"])).find((r) => r.id === id)!;
162
+ expect(read.data.source).toEqual({ kind: "workspace", member: "app", via: "mcp", client: { name: "codex", version: "0.40.0" } });
163
+ }, 120_000);
164
+
165
+ test("keeps the CLI's rules: a record opens proposed, a decided record's reasoning stays, by and sign", async () => {
166
+ const s = server();
167
+ const decided = await call(s, "records-new", {
168
+ kind: KIND,
169
+ record: { ...proposal("Decided at once"), state: "decided", choice: { option: "a", reason: "Simplest." }, decided_by: "lex00", decided_on: "2026-09-25" },
170
+ });
171
+ expect(decided.structuredContent).toMatchObject({ error: { code: "record-state-not-initial", message: expect.stringContaining("opens proposed") } });
172
+
173
+ const reasoning = await call(s, "records-amend", { kind: KIND, id: "fix-001", fields: { question: "Something else?" } });
174
+ expect(reasoning.structuredContent).toMatchObject({ error: { code: "amend-supersede-instead" } });
175
+
176
+ const both = await call(s, "records-new", { kind: KIND, record: { ...proposal("Two authors"), decided_by: "alice" }, by: "bob", dryRun: true });
177
+ expect(both.structuredContent).toMatchObject({ error: { code: "write-input-invalid" } });
178
+ const by = await call(s, "records-new", { kind: KIND, record: proposal("One author"), by: "alice", dryRun: true });
179
+ expect(by.structuredContent, JSON.stringify(by.structuredContent)).toHaveProperty("text");
180
+ expect((by.structuredContent as { text: string }).text).toContain('decided_by: "alice"');
181
+
182
+ // No key configured on this host: the CLI's refusal and remedy.
183
+ const home = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
184
+ const saved = { ...process.env };
185
+ Object.assign(process.env, home);
186
+ try {
187
+ const signed = await call(s, "records-new", { kind: KIND, record: proposal("Signed"), by: "alice", sign: true, dryRun: true });
188
+ expect(signed.structuredContent).toMatchObject({ error: { code: "record-sign-failed", message: expect.stringContaining("pass --sign <key file>") } });
189
+ } finally {
190
+ for (const k of Object.keys(home)) {
191
+ if (saved[k] === undefined) delete process.env[k];
192
+ else process.env[k] = saved[k];
193
+ }
194
+ }
195
+ // Nothing above wrote a file.
196
+ expect(readFileSync(join(ws.dir, "decisions", "fix-001-how-the-app-is-deployed.md"), "utf-8")).toContain('question: "What declares the app\'s deployment?"');
197
+ }, 180_000);
198
+ });
@@ -0,0 +1,405 @@
1
+ /**
2
+ * #2707 — the workspace tools of `chant serve mcp`: the read contract and the
3
+ * record writes, for a harness that speaks MCP and has no shell.
4
+ *
5
+ * Served when the server starts at or inside a declared workspace. Each tool
6
+ * is a thin call into the code the CLI runs:
7
+ *
8
+ * - The reads (`workspace-ls`, `workspace-status`, `workspace-graph`,
9
+ * `workspace-records`) run `chant workspace <command> ... --json` with the
10
+ * chant this server runs as, in the server's directory, and return the
11
+ * document it printed, unchanged, with its reason codes (#2536). Running the
12
+ * command, rather than calling into it, keeps every rule it has, handing the
13
+ * read to the workspace root's pinned chant included (ws-021), and keeps
14
+ * anything a command prints away from the protocol on stdout.
15
+ * - The writes (`records-new`, `records-amend`, `records-review`,
16
+ * `records-close`) call the functions `chant workspace records new|amend|
17
+ * review|close` call, and return the same JSON result. They keep every rule
18
+ * the CLI keeps: the kind's schema, a closed record never changing, an
19
+ * approved one changing only as its approval rule allows, a dissent needing
20
+ * a note, and `sign` using this host's configured key or refusing with the
21
+ * CLI's remedy. Through MCP a new record also opens in the kind's first
22
+ * state, `proposed` for a decision, and its stored source block (#2708) says
23
+ * it came through MCP: `via: "mcp"` and `client`, the MCP client's
24
+ * `clientInfo`. Computed provenance, from git, is unchanged.
25
+ *
26
+ * Nothing here listens on a port or authenticates anyone (ws-052): the server
27
+ * speaks over stdio, and `by` is recorded as given, as `--by` is.
28
+ */
29
+
30
+ import { spawn } from "node:child_process";
31
+ import type { ToolContext, ToolDefinition, ToolHandler } from "./types";
32
+
33
+ /** How the reads run chant: a command and its leading arguments. */
34
+ export type ChantCommand = string[];
35
+
36
+ /**
37
+ * The chant this process runs as: node with the same flags (the tsx loader
38
+ * `bin/chant` registers) and the same entry script.
39
+ */
40
+ export function ownChantCommand(): ChantCommand {
41
+ return [process.execPath, ...process.execArgv, process.argv[1]];
42
+ }
43
+
44
+ export interface WorkspaceToolsOptions {
45
+ /** Where reads run and writes resolve kinds: the directory the server started in. */
46
+ cwd: string;
47
+ /** The chant the reads run. Defaults to {@link ownChantCommand}. */
48
+ chantCommand?: ChantCommand;
49
+ }
50
+
51
+ const PROTOCOL =
52
+ "Records are proposals until they are reviewed: a new record opens proposed, and people decide it through reviews and amendments. " +
53
+ "by must name the person or agent that actually decided, as it is recorded as given.";
54
+
55
+ const kindProp = {
56
+ type: "string",
57
+ description:
58
+ "The record kind: a kind file relative to the server's directory, such as decisions/decision.kind.mjs, or a kind the workspace declaration names. Without it, the one kind the declaration names.",
59
+ };
60
+ const atProp = { type: "string", description: "Read at this git revision instead of the working tree (--at)." };
61
+ const dryRunProp = { type: "boolean", description: "Return the result, with the text it would write, and write nothing (--dry-run)." };
62
+ const signProp = {
63
+ type: "boolean",
64
+ description:
65
+ "Seal with this host's configured key, git's user.signingkey with gpg.format ssh, as --sign does. Refused, with the CLI's remedy, when none is set.",
66
+ };
67
+
68
+ export const workspaceReadTools: ToolDefinition[] = [
69
+ {
70
+ name: "workspace-ls",
71
+ description:
72
+ "List the workspace's members and groups: chant workspace ls --json. Returns that document unchanged, which follows ls.schema.json of the read contract, reason codes included.",
73
+ inputSchema: { type: "object", properties: { at: atProp } },
74
+ },
75
+ {
76
+ name: "workspace-status",
77
+ description:
78
+ "What each member has released to an environment, from the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ env: { type: "string", description: "The environment, such as dev." },
83
+ compareTo: { type: "string", description: "A second environment to compare with (--compare-to)." },
84
+ },
85
+ required: ["env"],
86
+ },
87
+ },
88
+ {
89
+ name: "workspace-graph",
90
+ description:
91
+ "The workspace graph: chant workspace graph --json (graph.schema.json); with intent, the intent graph over one region (graph --intent, intent.schema.json); with composites, each composite instance and the components that can deploy it (graph --composites, composites.schema.json). Returns the document unchanged.",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ kind: {
96
+ type: "array",
97
+ items: { type: "string" },
98
+ description: "Record kind files whose records join the graph (--kind). The plain graph takes one; intent takes several.",
99
+ },
100
+ intent: { type: "string", description: "The region for the intent graph: a workspace path, path:line or path:start-end (--intent)." },
101
+ composites: { type: "boolean", description: "The composites document instead (--composites). Takes no kind or intent." },
102
+ at: atProp,
103
+ },
104
+ },
105
+ },
106
+ {
107
+ name: "workspace-records",
108
+ description:
109
+ "The records of a kind, validated, with supersession, provenance, quorum and warnings: chant workspace records --json (records.schema.json). With since, what changed since a revision or a review session (records-since.schema.json). Returns the document unchanged; with id, only that record is kept in records. " +
110
+ PROTOCOL,
111
+ inputSchema: {
112
+ type: "object",
113
+ properties: {
114
+ kind: kindProp,
115
+ current: { type: "boolean", description: "Leave out records a closed record supersedes (--current)." },
116
+ since: { type: "string", description: "A revision, or a review session id, to compare with (--since)." },
117
+ id: { type: "string", description: "Keep only the record with this id in the document's records." },
118
+ at: atProp,
119
+ },
120
+ },
121
+ },
122
+ ];
123
+
124
+ export const workspaceWriteTools: ToolDefinition[] = [
125
+ {
126
+ name: "records-new",
127
+ description:
128
+ "Propose a new record: chant workspace records new. The record opens in the kind's first state (proposed for a decision); another state is refused. Its source block records that it came through MCP (via mcp, and this client's clientInfo). Validated against the kind's schema; nothing is written on refusal, and nothing is committed. " +
129
+ PROTOCOL,
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ kind: kindProp,
134
+ record: { type: "object", description: "The record's fields, as the kind's schema describes them. The id is allocated when left out." },
135
+ prefix: { type: "string", description: "The id prefix to allocate under, when the records use several (--prefix)." },
136
+ by: { type: "string", description: "Who decided: written to the kind's decider field, such as decided_by. Required to sign." },
137
+ sign: signProp,
138
+ dryRun: dryRunProp,
139
+ },
140
+ required: ["record"],
141
+ },
142
+ },
143
+ {
144
+ name: "records-amend",
145
+ description:
146
+ "Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A source block given in the fields records that the change came through MCP. " +
147
+ PROTOCOL,
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {
151
+ id: { type: "string", description: "The record's id." },
152
+ kind: kindProp,
153
+ fields: { type: "object", description: "The top-level fields to set; each replaces the whole field." },
154
+ by: { type: "string", description: "Who decided: written to the kind's decider field." },
155
+ sign: signProp,
156
+ dryRun: dryRunProp,
157
+ },
158
+ required: ["id", "fields"],
159
+ },
160
+ },
161
+ {
162
+ name: "records-review",
163
+ description:
164
+ "Give a verdict on a record: chant workspace records review. Appends one entry to its reviews with the digest of the text judged, which moves its quorum. A dissent needs a note. " +
165
+ PROTOCOL,
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ id: { type: "string", description: "The record's id." },
170
+ kind: kindProp,
171
+ verdict: { type: "string", enum: ["agree", "dissent", "abstain"] },
172
+ by: { type: "string", description: "The reviewer: the person or agent giving the verdict (--by)." },
173
+ note: { type: "string", description: "Why; required for a dissent (--note)." },
174
+ session: { type: "string", description: "The open review session the verdict is given in (--session)." },
175
+ sign: signProp,
176
+ dryRun: dryRunProp,
177
+ },
178
+ required: ["id", "verdict", "by"],
179
+ },
180
+ },
181
+ {
182
+ name: "records-close",
183
+ description: "Close an open review session and seal it: chant workspace records close. " + PROTOCOL,
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ id: { type: "string", description: "The session's id." },
188
+ kind: { type: "string", description: "The session kind file. Without it, the one session kind the declaration names." },
189
+ dryRun: dryRunProp,
190
+ },
191
+ required: ["id"],
192
+ },
193
+ },
194
+ ];
195
+
196
+ /** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
197
+ class ToolInputError extends Error {}
198
+
199
+ function str(params: Record<string, unknown>, key: string, required = false): string | undefined {
200
+ const v = params[key];
201
+ if (v === undefined || v === null) {
202
+ if (required) throw new ToolInputError(`${key} is required`);
203
+ return undefined;
204
+ }
205
+ if (typeof v !== "string" || v === "") throw new ToolInputError(`${key} must be a non-empty string`);
206
+ // A value is passed as one argument; one that starts with - would read as a flag.
207
+ if (v.startsWith("-")) throw new ToolInputError(`${key} may not start with -: ${JSON.stringify(v)}`);
208
+ return v;
209
+ }
210
+
211
+ function bool(params: Record<string, unknown>, key: string): boolean {
212
+ const v = params[key];
213
+ if (v === undefined || v === null) return false;
214
+ if (typeof v !== "boolean") throw new ToolInputError(`${key} must be true or false`);
215
+ return v;
216
+ }
217
+
218
+ function obj(params: Record<string, unknown>, key: string): Record<string, unknown> {
219
+ const v = params[key];
220
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new ToolInputError(`${key} must be a JSON object`);
221
+ return v as Record<string, unknown>;
222
+ }
223
+
224
+ function kinds(params: Record<string, unknown>): string[] {
225
+ const v = params.kind;
226
+ if (v === undefined || v === null) return [];
227
+ const list = typeof v === "string" ? [v] : v;
228
+ if (!Array.isArray(list)) throw new ToolInputError("kind must be a kind file, or a list of them");
229
+ return list.map((k, i) => str({ [`kind[${i}]`]: k }, `kind[${i}]`, true)!);
230
+ }
231
+
232
+ /** The `chant` arguments each read tool runs, from its input. Exported for the conformance suite's MCP transport. */
233
+ export function readArgv(tool: string, params: Record<string, unknown>): string[] {
234
+ const at = str(params, "at");
235
+ const atArgs = at !== undefined ? ["--at", at] : [];
236
+ switch (tool) {
237
+ case "workspace-ls":
238
+ return ["workspace", "ls", ...atArgs, "--json"];
239
+ case "workspace-status": {
240
+ const env = str(params, "env", true)!;
241
+ const compareTo = str(params, "compareTo");
242
+ return ["workspace", "status", env, ...(compareTo !== undefined ? ["--compare-to", compareTo] : []), "--json"];
243
+ }
244
+ case "workspace-graph": {
245
+ const intent = str(params, "intent");
246
+ const composites = bool(params, "composites");
247
+ const kindArgs = kinds(params).flatMap((k) => ["--kind", k]);
248
+ if (composites) return ["workspace", "graph", "--composites", ...kindArgs, ...(intent !== undefined ? ["--intent", intent] : []), ...atArgs, "--json"];
249
+ if (intent !== undefined) return ["workspace", "graph", "--intent", intent, ...kindArgs, ...atArgs, "--json"];
250
+ return ["workspace", "graph", ...kindArgs, ...atArgs, "--json"];
251
+ }
252
+ case "workspace-records": {
253
+ const kind = str(params, "kind");
254
+ const since = str(params, "since");
255
+ return [
256
+ "workspace",
257
+ "records",
258
+ ...(kind !== undefined ? ["--kind", kind] : []),
259
+ ...(bool(params, "current") ? ["--current"] : []),
260
+ ...(since !== undefined ? ["--since", since] : []),
261
+ ...atArgs,
262
+ "--json",
263
+ ];
264
+ }
265
+ default:
266
+ throw new ToolInputError(`${tool} is not a workspace read tool`);
267
+ }
268
+ }
269
+
270
+ /** Run chant and parse the one JSON document it printed. The exit code does not matter: an error document is a document. */
271
+ function runRead(command: ChantCommand, argv: string[], cwd: string): Promise<unknown> {
272
+ return new Promise((settle, fail) => {
273
+ const child = spawn(command[0], [...command.slice(1), ...argv], { cwd, env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
274
+ let stdout = "";
275
+ let stderr = "";
276
+ child.stdout.setEncoding("utf-8").on("data", (s: string) => (stdout += s));
277
+ child.stderr.setEncoding("utf-8").on("data", (s: string) => (stderr += s));
278
+ child.on("error", (e) => fail(new Error(`could not run chant ${argv.join(" ")}: ${e.message}`)));
279
+ child.on("close", (status) => {
280
+ try {
281
+ settle(JSON.parse(stdout));
282
+ } catch {
283
+ const why = stderr.trim() || stdout.trim() || `exit ${status}`;
284
+ fail(new Error(`chant ${argv.join(" ")} printed no JSON document: ${why}`));
285
+ }
286
+ });
287
+ });
288
+ }
289
+
290
+ /** Keep only the record with `id` in a records document, or in each kind of a declared set. */
291
+ function onlyRecord(doc: unknown, id: string): unknown {
292
+ if (doc === null || typeof doc !== "object") return doc;
293
+ const d = doc as Record<string, unknown>;
294
+ if (Array.isArray(d.records)) return { ...d, records: (d.records as { id?: unknown }[]).filter((r) => r.id === id) };
295
+ if (Array.isArray(d.kinds)) return { ...d, kinds: d.kinds.map((k) => onlyRecord(k, id)) };
296
+ return doc;
297
+ }
298
+
299
+ /** The source block a write through MCP lays over the record's (#2708). */
300
+ function mcpSource(context: ToolContext | undefined): Record<string, unknown> {
301
+ const c = context?.clientInfo;
302
+ const client =
303
+ c && typeof c.name === "string" && c.name !== ""
304
+ ? {
305
+ name: c.name,
306
+ ...(typeof c.version === "string" && c.version !== "" ? { version: c.version } : {}),
307
+ ...(typeof c.title === "string" && c.title !== "" ? { title: c.title } : {}),
308
+ }
309
+ : undefined;
310
+ return { via: "mcp", ...(client ? { client } : {}) };
311
+ }
312
+
313
+ export interface WorkspaceTool {
314
+ definition: ToolDefinition;
315
+ handler: ToolHandler;
316
+ }
317
+
318
+ /** The workspace tools, reads then writes, bound to the server's directory. */
319
+ export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceTool[] {
320
+ const { cwd } = options;
321
+ const chant = options.chantCommand ?? ownChantCommand();
322
+ const reads: WorkspaceTool[] = workspaceReadTools.map((definition) => ({
323
+ definition,
324
+ handler: async (params) => {
325
+ const argv = readArgv(definition.name, params);
326
+ const id = definition.name === "workspace-records" ? str(params, "id") : undefined;
327
+ const doc = await runRead(chant, argv, cwd);
328
+ return id !== undefined ? onlyRecord(doc, id) : doc;
329
+ },
330
+ }));
331
+
332
+ const write = async () => import("../../workspace/records-write");
333
+ /** The kind file a write goes through: the one named, or the one the declaration names; else the CLI's usage failure. */
334
+ const writeKind = async (params: Record<string, unknown>, schema: string, missing: string): Promise<string | object> => {
335
+ const w = await write();
336
+ const named = str(params, "kind");
337
+ return named !== undefined ? w.resolveWriteKind(named, cwd) : w.declaredWriteKind(schema, cwd, missing);
338
+ };
339
+ const sign = (params: Record<string, unknown>): true | undefined => (bool(params, "sign") ? true : undefined);
340
+
341
+ const handlers: Record<string, ToolHandler> = {
342
+ "records-new": async (params, context) => {
343
+ const w = await write();
344
+ const record = obj(params, "record");
345
+ const kind = await writeKind(params, w.RECORDS_NEW_SCHEMA_ID, "new needs the kind file");
346
+ if (typeof kind !== "string") return kind;
347
+ return w.newRecord({
348
+ kind,
349
+ fields: JSON.stringify(record),
350
+ prefix: str(params, "prefix"),
351
+ by: str(params, "by"),
352
+ sign: sign(params),
353
+ dryRun: bool(params, "dryRun"),
354
+ cwd,
355
+ through: { source: mcpSource(context), opensInitial: true },
356
+ });
357
+ },
358
+ "records-amend": async (params, context) => {
359
+ const w = await write();
360
+ const id = str(params, "id", true)!;
361
+ const fields = obj(params, "fields");
362
+ const kind = await writeKind(params, w.RECORDS_AMEND_SCHEMA_ID, "--kind <kind file> is required");
363
+ if (typeof kind !== "string") return kind;
364
+ return w.amendRecord({
365
+ kind,
366
+ id,
367
+ fields: JSON.stringify(fields),
368
+ by: str(params, "by"),
369
+ sign: sign(params),
370
+ dryRun: bool(params, "dryRun"),
371
+ cwd,
372
+ through: { source: mcpSource(context) },
373
+ });
374
+ },
375
+ "records-review": async (params) => {
376
+ const w = await write();
377
+ const id = str(params, "id", true)!;
378
+ const kind = await writeKind(params, w.RECORDS_REVIEW_SCHEMA_ID, "--kind <kind file> is required");
379
+ if (typeof kind !== "string") return kind;
380
+ return w.reviewRecord({
381
+ kind,
382
+ id,
383
+ verdict: str(params, "verdict", true)!,
384
+ by: str(params, "by", true)!,
385
+ note: typeof params.note === "string" ? params.note : undefined,
386
+ session: str(params, "session"),
387
+ sign: sign(params),
388
+ dryRun: bool(params, "dryRun"),
389
+ cwd,
390
+ });
391
+ },
392
+ "records-close": async (params) => {
393
+ const w = await write();
394
+ const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
395
+ const id = str(params, "id", true)!;
396
+ const named = str(params, "kind");
397
+ const kind = named !== undefined ? w.resolveWriteKind(named, cwd) : await w.declaredSessionKind(RECORDS_CLOSE_SCHEMA_ID, cwd);
398
+ if (typeof kind !== "string") return kind;
399
+ return closeRecord({ kind, id, dryRun: bool(params, "dryRun"), cwd });
400
+ },
401
+ };
402
+
403
+ const writes: WorkspaceTool[] = workspaceWriteTools.map((definition) => ({ definition, handler: handlers[definition.name] }));
404
+ return [...reads, ...writes];
405
+ }
@@ -18,6 +18,8 @@ export interface ParsedArgs {
18
18
  watch: boolean;
19
19
  verbose: boolean;
20
20
  help: boolean;
21
+ /** `--version` / `-V` (#2701): print the installed chant's version. */
22
+ version?: boolean;
21
23
  report?: boolean;
22
24
  /** `chant run` — force the local in-process executor (the default). */
23
25
  local?: boolean;