@intentius/chant 0.89.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 (42) hide show
  1. package/dist/cli/handlers/serve.d.ts.map +1 -1
  2. package/dist/cli/mcp/server.d.ts +10 -5
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/mcp/types.d.ts +13 -5
  5. package/dist/cli/mcp/types.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-tools.d.ts +53 -0
  7. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  8. package/dist/op/op-verb-class.d.ts.map +1 -1
  9. package/dist/workspace/conformance/index.d.ts +42 -2
  10. package/dist/workspace/conformance/index.d.ts.map +1 -1
  11. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  12. package/dist/workspace/reason-codes.d.ts +3 -0
  13. package/dist/workspace/reason-codes.d.ts.map +1 -1
  14. package/dist/workspace/records-write.d.ts +35 -3
  15. package/dist/workspace/records-write.d.ts.map +1 -1
  16. package/dist/workspace/records.d.ts +4 -1
  17. package/dist/workspace/records.d.ts.map +1 -1
  18. package/dist/workspace/source-block.d.ts +85 -0
  19. package/dist/workspace/source-block.d.ts.map +1 -0
  20. package/package.json +1 -1
  21. package/src/cli/handlers/serve.ts +2 -1
  22. package/src/cli/mcp/docs-parity.test.ts +20 -2
  23. package/src/cli/mcp/server.ts +23 -6
  24. package/src/cli/mcp/types.ts +15 -2
  25. package/src/cli/mcp/workspace-tools.test.ts +198 -0
  26. package/src/cli/mcp/workspace-tools.ts +405 -0
  27. package/src/op/op-verb-class.ts +6 -0
  28. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  29. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  30. package/src/workspace/conformance/index.mjs +3 -0
  31. package/src/workspace/conformance/index.ts +185 -7
  32. package/src/workspace/conformance/vitest.ts +17 -8
  33. package/src/workspace/reason-codes.ts +3 -0
  34. package/src/workspace/records-amend.schema.json +2 -1
  35. package/src/workspace/records-close.schema.json +2 -1
  36. package/src/workspace/records-new.schema.json +4 -1
  37. package/src/workspace/records-review.schema.json +2 -1
  38. package/src/workspace/records-write.ts +85 -7
  39. package/src/workspace/records.schema.json +1 -0
  40. package/src/workspace/records.ts +28 -0
  41. package/src/workspace/source-block.test.ts +167 -0
  42. package/src/workspace/source-block.ts +129 -0
@@ -8,7 +8,9 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import { compositesTool, createCompositesHandler } from "./tools/composites";
10
10
  import type { LexiconPlugin } from "../../lexicon";
11
- import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
+ import type { McpClientInfo, McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
12
+ import { findWorkspaceRoot } from "../../project-root";
13
+ import { createWorkspaceTools, type WorkspaceToolsOptions } from "./workspace-tools";
12
14
  import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
13
15
  import { setGateOrigin } from "../../lifecycle/gate-origin";
14
16
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
@@ -47,14 +49,14 @@ export function negotiateProtocolVersion(requested: string | undefined): string
47
49
  * sends on `initialize`. Read-side only — the server holds no handshake
48
50
  * state to update (#1194).
49
51
  */
50
- export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: { name: string; version?: string } } {
52
+ export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: McpClientInfo } {
51
53
  const meta = (params._meta ?? {}) as McpRequestMeta;
52
54
  const protocolVersion =
53
55
  (typeof meta.protocolVersion === "string" ? meta.protocolVersion : undefined) ??
54
56
  (typeof params.protocolVersion === "string" ? params.protocolVersion : undefined);
55
57
  const clientInfo =
56
58
  meta["io.modelcontextprotocol/clientInfo"] ??
57
- (params.clientInfo as { name: string; version?: string } | undefined);
59
+ (params.clientInfo as McpClientInfo | undefined);
58
60
  return { protocolVersion, clientInfo };
59
61
  }
60
62
 
@@ -94,14 +96,20 @@ export class McpServer {
94
96
  private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
95
97
  private plugins: LexiconPlugin[];
96
98
  private instructions: string | undefined;
99
+ /** The `clientInfo` the client gave on `initialize`, for a request that carries none in `_meta` (#2707). */
100
+ private clientInfo: McpClientInfo | undefined;
97
101
 
98
102
  /**
99
103
  * `options.instructions` is sent as the `initialize` result's
100
104
  * `instructions`, the text a client may give its model about this server.
101
105
  * Only a workspace root with no lexicon of its own sets it (#2700), to say
102
106
  * which members' lexicons were loaded.
107
+ *
108
+ * `options.workspace` names the directory the server serves (#2707). When
109
+ * it is at or inside a declared workspace, the workspace read-contract and
110
+ * record-write tools are served too.
103
111
  */
104
- constructor(plugins?: LexiconPlugin[], options: { instructions?: string } = {}) {
112
+ constructor(plugins?: LexiconPlugin[], options: { instructions?: string; workspace?: WorkspaceToolsOptions } = {}) {
105
113
  this.plugins = plugins ?? [];
106
114
  this.instructions = options.instructions;
107
115
  // Register core tools
@@ -126,6 +134,11 @@ export class McpServer {
126
134
  this.registerTool(t.definition, t.handler);
127
135
  }
128
136
 
137
+ // Workspace reads and record writes (#2707), inside a declared workspace only.
138
+ if (options.workspace && findWorkspaceRoot(options.workspace.cwd)) {
139
+ for (const t of createWorkspaceTools(options.workspace)) this.registerTool(t.definition, t.handler);
140
+ }
141
+
129
142
  // Register plugin contributions
130
143
  if (plugins) {
131
144
  for (const plugin of plugins) {
@@ -244,6 +257,8 @@ export class McpServer {
244
257
  private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
245
258
  switch (method) {
246
259
  case "initialize":
260
+ // Kept for a write's source block (#2707): a prior-revision client names itself only here.
261
+ this.clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
247
262
  // Answered for prior-revision clients too — negotiated, not hard-coded (#1194).
248
263
  return this.buildInitializeResult(params);
249
264
 
@@ -290,7 +305,9 @@ export class McpServer {
290
305
  }
291
306
 
292
307
  try {
293
- const result = await handler(toolParams);
308
+ // The client, from this request's _meta (2026-07-28) or else from initialize.
309
+ const clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
310
+ const result = await handler(toolParams, clientInfo ? { clientInfo } : {});
294
311
  const isStructured = typeof result === "object" && result !== null;
295
312
  return {
296
313
  content: [
@@ -374,6 +391,6 @@ export async function startMcpServer(): Promise<void> {
374
391
  // Start without plugins if resolution fails
375
392
  }
376
393
 
377
- const server = new McpServer(plugins);
394
+ const server = new McpServer(plugins, { workspace: { cwd: process.cwd() } });
378
395
  server.start();
379
396
  }
@@ -32,7 +32,7 @@ export interface McpResponse {
32
32
  */
33
33
  export interface McpRequestMeta {
34
34
  protocolVersion?: string;
35
- "io.modelcontextprotocol/clientInfo"?: { name: string; version?: string };
35
+ "io.modelcontextprotocol/clientInfo"?: McpClientInfo;
36
36
  }
37
37
 
38
38
  /**
@@ -68,4 +68,17 @@ export interface ResourceDefinition {
68
68
  mimeType?: string;
69
69
  }
70
70
 
71
- export type ToolHandler = (params: Record<string, unknown>) => Promise<unknown>;
71
+ /** The MCP client's `clientInfo`, as it gave it on `initialize` or in a request's `_meta`. */
72
+ export interface McpClientInfo {
73
+ name: string;
74
+ version?: string;
75
+ title?: string;
76
+ }
77
+
78
+ /** What a handler knows about the call beyond its arguments (#2707). */
79
+ export interface ToolContext {
80
+ /** The client that made the call, when it said. */
81
+ clientInfo?: McpClientInfo;
82
+ }
83
+
84
+ export type ToolHandler = (params: Record<string, unknown>, context?: ToolContext) => Promise<unknown>;
@@ -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
+ });