@intentius/chant 0.72.2 → 0.72.4

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 (56) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/init.d.ts +1 -1
  3. package/dist/cli/commands/init.d.ts.map +1 -1
  4. package/dist/cli/commands/update.d.ts.map +1 -1
  5. package/dist/cli/handlers/init.d.ts.map +1 -1
  6. package/dist/cli/handlers/operator.d.ts +16 -0
  7. package/dist/cli/handlers/operator.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  10. package/dist/cli/mcp/server.d.ts +10 -0
  11. package/dist/cli/mcp/server.d.ts.map +1 -1
  12. package/dist/cli/mcp-config.d.ts +47 -0
  13. package/dist/cli/mcp-config.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +12 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/discovery/fold-import.d.ts +24 -1
  17. package/dist/discovery/fold-import.d.ts.map +1 -1
  18. package/dist/discovery/sandbox/fork.d.ts +0 -5
  19. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  20. package/dist/lifecycle/gate-ledger.d.ts +29 -0
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/gate-origin.d.ts +77 -0
  23. package/dist/lifecycle/gate-origin.d.ts.map +1 -0
  24. package/dist/observation.d.ts +0 -1
  25. package/dist/observation.d.ts.map +1 -1
  26. package/dist/op/activities/converge.d.ts +16 -0
  27. package/dist/op/activities/converge.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/audit/core.ts +1 -1
  30. package/src/cli/commands/doctor.ts +18 -6
  31. package/src/cli/commands/init.ts +9 -68
  32. package/src/cli/commands/update.ts +9 -0
  33. package/src/cli/handlers/init.ts +1 -0
  34. package/src/cli/handlers/operator.ts +48 -1
  35. package/src/cli/main.ts +9 -0
  36. package/src/cli/mcp/docs-parity.test.ts +133 -0
  37. package/src/cli/mcp/op-approve-origin.test.ts +70 -0
  38. package/src/cli/mcp/op-tools.ts +18 -4
  39. package/src/cli/mcp/server.ts +16 -1
  40. package/src/cli/mcp-config.test.ts +168 -0
  41. package/src/cli/mcp-config.ts +76 -0
  42. package/src/cli/registry.ts +12 -0
  43. package/src/discovery/fold-executing-mode.test.ts +115 -0
  44. package/src/discovery/fold-import.ts +87 -14
  45. package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
  46. package/src/discovery/sandbox/fork-diagnostic.test.ts +117 -0
  47. package/src/discovery/sandbox/fork.ts +92 -6
  48. package/src/graph-ir.ts +1 -1
  49. package/src/graph-layout.ts +0 -0
  50. package/src/lifecycle/gate-ledger.ts +39 -1
  51. package/src/lifecycle/gate-origin.test.ts +93 -0
  52. package/src/lifecycle/gate-origin.ts +113 -0
  53. package/src/meta/source-is-text.test.ts +72 -0
  54. package/src/observation.ts +27 -8
  55. package/src/op/activities/converge-push.test.ts +101 -0
  56. package/src/op/activities/converge.ts +31 -1
@@ -1,10 +1,10 @@
1
1
  import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "fs";
2
2
  import { join, resolve, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { homedir } from "os";
5
4
  import { createInterface } from "readline";
6
5
  import { formatSuccess, formatWarning } from "../format";
7
6
  import { loadPlugin } from "../plugins";
7
+ import { MCP_CONFIG_FILENAME, detectPackageManager, generateMcpConfig, mcpConfigPath } from "../mcp-config";
8
8
 
9
9
  /** Read the current chant package version from our own package.json. */
10
10
  export function getChantVersion(): string {
@@ -29,7 +29,7 @@ export interface InitOptions {
29
29
  template?: string;
30
30
  /** Force init even in non-empty directory */
31
31
  force?: boolean;
32
- /** Skip MCP config generation */
32
+ /** Skip writing the project's `.mcp.json` (`--skip-mcp`) */
33
33
  skipMcp?: boolean;
34
34
  /** Skip interactive install prompt */
35
35
  skipInstall?: boolean;
@@ -55,46 +55,6 @@ export interface InitResult {
55
55
  error?: string;
56
56
  }
57
57
 
58
- /**
59
- * Detect whether the user's project uses bun or npm.
60
- * Checks for lock files.
61
- */
62
- function detectPackageManager(dir?: string): "bun" | "npm" {
63
- if (dir && (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))) return "bun";
64
- return "npm";
65
- }
66
-
67
- /**
68
- * Detect the IDE environment for MCP config
69
- */
70
- function detectIdeEnvironment(): "claude-code" | "cursor" | "generic" {
71
- // Check for Claude Code
72
- if (existsSync(join(homedir(), ".claude"))) {
73
- return "claude-code";
74
- }
75
-
76
- // Check for Cursor
77
- if (existsSync(join(homedir(), ".cursor"))) {
78
- return "cursor";
79
- }
80
-
81
- return "generic";
82
- }
83
-
84
- /**
85
- * Get MCP config directory based on IDE
86
- */
87
- function getMcpConfigDir(ide: "claude-code" | "cursor" | "generic"): string {
88
- switch (ide) {
89
- case "claude-code":
90
- return join(homedir(), ".claude");
91
- case "cursor":
92
- return join(homedir(), ".cursor");
93
- default:
94
- return join(homedir(), ".config", "mcp");
95
- }
96
- }
97
-
98
58
  /**
99
59
  * Generate package.json content
100
60
  */
@@ -224,22 +184,6 @@ export interface ChantConfig {
224
184
  `;
225
185
  }
226
186
 
227
- /**
228
- * Generate MCP config
229
- */
230
- function generateMcpConfig(pm: "bun" | "npm"): string {
231
- const config = {
232
- mcpServers: {
233
- chant: {
234
- command: pm === "bun" ? "bunx" : "npx",
235
- args: ["chant", "serve", "mcp"],
236
- },
237
- },
238
- };
239
-
240
- return JSON.stringify(config, null, 2);
241
- }
242
-
243
187
  /**
244
188
  * Prompt user for install
245
189
  */
@@ -441,19 +385,16 @@ export async function initCommand(options: InitOptions): Promise<InitResult> {
441
385
  warnings,
442
386
  );
443
387
 
444
- // Generate MCP config
388
+ // Generate the project's MCP config. It lands in the directory init was
389
+ // pointed at, like everything else init produces, and at the path
390
+ // `chant doctor` checks — see ../mcp-config.ts for why project scope, and
391
+ // chant #2383 for the three-way disagreement that came of writing it into
392
+ // the user's home directory instead.
445
393
  if (!options.skipMcp) {
446
- const ide = detectIdeEnvironment();
447
- const mcpDir = getMcpConfigDir(ide);
448
-
449
- if (!existsSync(mcpDir)) {
450
- mkdirSync(mcpDir, { recursive: true });
451
- }
452
-
453
394
  writeIfNotExists(
454
- join(mcpDir, "mcp.json"),
395
+ mcpConfigPath(targetDir),
455
396
  generateMcpConfig(detectPackageManager(targetDir)),
456
- `~/.${ide === "generic" ? "config/mcp" : ide}/mcp.json`,
397
+ MCP_CONFIG_FILENAME,
457
398
  createdFiles,
458
399
  warnings,
459
400
  );
@@ -4,6 +4,7 @@ import { createRequire } from "module";
4
4
  import { formatSuccess, formatWarning, formatError } from "../format";
5
5
  import { loadChantConfig } from "../../config";
6
6
  import { loadPlugins } from "../plugins";
7
+ import { writeProjectMcpConfig } from "../mcp-config";
7
8
 
8
9
  /**
9
10
  * Update command options
@@ -178,6 +179,14 @@ export async function updateCommand(options: UpdateOptions): Promise<UpdateResul
178
179
  warnings.push("Could not load plugins for skill installation");
179
180
  }
180
181
 
182
+ // Restore the project's MCP registration if it went missing. This is what
183
+ // makes the doctor's `mcp-config` remediation runnable: `chant init`
184
+ // refuses a non-empty directory without --force, so an already scaffolded
185
+ // project needs some other command to put the file back, and this is the
186
+ // same command the doctor's skills check already points at (chant #2383).
187
+ const writtenMcp = writeProjectMcpConfig(projectDir);
188
+ if (writtenMcp) synced.push(writtenMcp);
189
+
181
190
  return {
182
191
  success: true,
183
192
  synced,
@@ -19,6 +19,7 @@ export async function runInit(ctx: CommandContext): Promise<number> {
19
19
  template: args.template,
20
20
  skill: args.skill,
21
21
  force: args.force,
22
+ skipMcp: args.skipMcp,
22
23
  skipInstall: true,
23
24
  });
24
25
  await printInitResult(result, { skipInstall: false, cwd: args.path });
@@ -29,6 +29,9 @@ import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/con
29
29
  import { readRunLedger } from "../../lifecycle/run-ledger";
30
30
  import type { OpRunRecord } from "../../op/runtime";
31
31
  import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
32
+ import {
33
+ currentGateOrigin, isModelAuthored, sameOriginRefusal, UNATTESTED_APPROVER, type GateOrigin,
34
+ } from "../../lifecycle/gate-origin";
32
35
  import {
33
36
  appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
34
37
  latestResolutionSince, latestPendingGate, isPendingGateExpired,
@@ -694,6 +697,7 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
694
697
  note: ctx.args.note,
695
698
  url: ctx.args.url,
696
699
  plan: ctx.args.plan,
700
+ allowSameOrigin: ctx.args.allowSameOrigin,
697
701
  });
698
702
  if (!outcome.ok) return 1;
699
703
 
@@ -708,6 +712,21 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
708
712
 
709
713
  /** What a caller supplies alongside the op and gate names. */
710
714
  export interface GateApprovalOptions {
715
+ /**
716
+ * The channel this resolution arrived on (chant#2384). Defaults to the
717
+ * process's own — `"cli"` unless an entry point declared otherwise — and is
718
+ * a parameter only so a caller that knows better can say so.
719
+ */
720
+ origin?: GateOrigin;
721
+ /**
722
+ * Record a resolution that the same-origin rule would refuse (chant#2384).
723
+ *
724
+ * There is a legitimate case: a single operator driving an agent, who wants
725
+ * the model's run resolved from the same session and accepts what that
726
+ * means. The override exists so that is a deliberate act with a trace on the
727
+ * record, rather than the default.
728
+ */
729
+ allowSameOrigin?: boolean;
711
730
  /** `--actor`/`--approver`; falls back to the CI or shell identity. */
712
731
  actor?: string;
713
732
  /** `--note` — free-text prose. */
@@ -815,7 +834,33 @@ export async function recordGateApproval(
815
834
  planDigest = standing.planDigest;
816
835
  }
817
836
 
818
- const resolvedBy = opts.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
837
+ // chant#2384 — the gate's whole value is that its two halves have different
838
+ // authors. At a shell they do: a person runs, reads the plan, and approves.
839
+ // On MCP and ACP the person's only act was launching the server, and every
840
+ // call after is the model's, so a gate reached over one of those channels and
841
+ // resolved over the same one has no second party in it. Refused by default,
842
+ // the way #2300 refuses a plan nobody approved.
843
+ const origin = opts.origin ?? currentGateOrigin();
844
+ const standingForOrigin = latestPendingGate((await readGateLedger(opName)).pending, gate);
845
+ const refusal = sameOriginRefusal(standingForOrigin?.origin, origin);
846
+ if (refusal && !opts.allowSameOrigin) {
847
+ console.error(formatError({
848
+ message: `Gate "${gate}" on "${opName}" cannot be resolved from here: ${refusal}`,
849
+ hint:
850
+ `Approve it from a channel the run did not come from — \`chant approve ${opName} ${gate}\` ` +
851
+ `at a shell is the usual one. If resolving from the same channel genuinely is the intent, ` +
852
+ `pass --allow-same-origin, which records that it was deliberate.`,
853
+ }));
854
+ return { ok: false };
855
+ }
856
+
857
+ const resolvedBy = opts.actor
858
+ ?? (isModelAuthored(origin)
859
+ // A channel that cannot attest to a name does not get to write one down.
860
+ // "unattested" reads as what it is; a name the model chose would read in
861
+ // the ledger exactly like a name a person gave.
862
+ ? UNATTESTED_APPROVER
863
+ : process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown");
819
864
 
820
865
  const { record } = await appendGateResolution({
821
866
  op: opName,
@@ -825,6 +870,8 @@ export async function recordGateApproval(
825
870
  ...(opts.note ? { note: opts.note } : {}),
826
871
  ...(url ? { url } : {}),
827
872
  ...(planDigest !== undefined ? { planDigest } : {}),
873
+ origin,
874
+ ...(refusal && opts.allowSameOrigin ? { sameOriginOverride: true } : {}),
828
875
  });
829
876
  const pushed = await reportedPush(
830
877
  `The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
package/src/cli/main.ts CHANGED
@@ -91,6 +91,7 @@ const BOOLEAN_FLAGS = new Set([
91
91
  "--check-snapshot",
92
92
  "--fail-on-drift",
93
93
  "--durable-requests",
94
+ "--skip-mcp",
94
95
  ]);
95
96
 
96
97
  /**
@@ -132,6 +133,7 @@ export function parseArgs(args: string[]): ParsedArgs {
132
133
  useComposites: false,
133
134
  reportFile: undefined,
134
135
  skill: undefined,
136
+ skipMcp: undefined,
135
137
  src: undefined,
136
138
  env: undefined,
137
139
  };
@@ -252,6 +254,8 @@ export function parseArgs(args: string[]): ParsedArgs {
252
254
  result.useComposites = true;
253
255
  } else if (arg === "--skill") {
254
256
  result.skill = args[++i];
257
+ } else if (arg === "--skip-mcp") {
258
+ result.skipMcp = true;
255
259
  } else if (arg === "--src") {
256
260
  result.src = args[++i];
257
261
  } else if (arg === "--env") {
@@ -411,6 +415,10 @@ export function parseArgs(args: string[]): ParsedArgs {
411
415
  result.note = args[++i];
412
416
  } else if (arg === "--expire") {
413
417
  result.expire = true;
418
+ } else if (arg === "--allow-same-origin") {
419
+ // chant#2384 — record a resolution the same-origin rule would refuse,
420
+ // deliberately. Flagged on the record, not just accepted quietly.
421
+ result.allowSameOrigin = true;
414
422
  } else if (arg === "--plan") {
415
423
  result.plan = args[++i];
416
424
  } else if (arg === "--url") {
@@ -687,6 +695,7 @@ Options:
687
695
  \`environments\` when declared.
688
696
  -t, --template <name> Init template (e.g. node-pipeline, docker-build)
689
697
  --skill <name> Init: install only this skill from the lexicon
698
+ --skip-mcp Init: scaffold without writing the project's .mcp.json
690
699
  --fix Auto-fix fixable issues (lint command)
691
700
  --force Force overwrite existing files (import command)
692
701
  -w, --watch Watch for changes and rebuild/re-lint (build, lint)
@@ -0,0 +1,133 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { join } from "node:path";
5
+ import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from "./server";
6
+ import type { ToolDefinition, ResourceDefinition } from "./types";
7
+
8
+ /**
9
+ * The MCP docs say what this server offers; this test says they are right.
10
+ *
11
+ * `docs/src/content/docs/cli/mcp.mdx` is a hand-written list of tools,
12
+ * resources, methods and protocol revisions, and the server is the thing that
13
+ * actually registers them. Nothing connected the two, so the page fell two
14
+ * revisions behind twice over (#2385): it advertised six tools while the
15
+ * constructor registered thirteen, left `lifecycle-snapshot` and
16
+ * `lifecycle-diff` undocumented anywhere, omitted `server/discover` (#1194)
17
+ * and `chant://knowledge` (#1867), and claimed protocol version 2024-11-05
18
+ * long after #1194 made 2026-07-28 the preferred one.
19
+ *
20
+ * Each claim here is read back against the running server rather than against
21
+ * source text: the tool and resource lists come from a real `McpServer`
22
+ * answering `tools/list` and `resources/list`, which is what a client sees.
23
+ * The dispatch methods are the exception and are read out of `server.ts` by
24
+ * regex, because a switch statement has no listing API; that regex fails
25
+ * loudly if the switch is ever restructured.
26
+ *
27
+ * Same shape as `scripts/fold-depth-bound-claims.test.ts` (#2367) and
28
+ * `scripts/lexicon-count-claims.test.ts` (#2316): a doc claim pinned to
29
+ * ground truth, with a message that says what to edit.
30
+ */
31
+
32
+ const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
33
+ const MCP_DOC = join("docs", "src", "content", "docs", "cli", "mcp.mdx");
34
+ const GUIDE_DOC = join("docs", "src", "content", "docs", "guide", "agent-integration.mdx");
35
+ const SERVER_SRC = join("packages", "core", "src", "cli", "mcp", "server.ts");
36
+
37
+ const mcpDoc = readFileSync(join(repoRoot, MCP_DOC), "utf8");
38
+ const guideDoc = readFileSync(join(repoRoot, GUIDE_DOC), "utf8");
39
+ const serverSrc = readFileSync(join(repoRoot, SERVER_SRC), "utf8");
40
+
41
+ /** What a client is told when it asks, built from a server with no plugins loaded. */
42
+ async function servedListing(method: "tools/list" | "resources/list"): Promise<string[]> {
43
+ const response = await new McpServer().handleRequest({ jsonrpc: "2.0", id: 1, method });
44
+ const result = response.result as { tools?: ToolDefinition[]; resources?: ResourceDefinition[] };
45
+ const names = result.tools?.map((t) => t.name) ?? result.resources?.map((r) => r.uri);
46
+ if (!names || names.length === 0) {
47
+ throw new Error(`mcp docs parity: ${method} returned nothing to compare the docs against`);
48
+ }
49
+ return names;
50
+ }
51
+
52
+ /** The lines of one `##`/`###` section, up to the next heading at any level. */
53
+ function section(doc: string, heading: string, label: string): string {
54
+ const start = doc.indexOf(heading);
55
+ if (start < 0) throw new Error(`mcp docs parity: no "${heading}" heading in ${label} — was it renamed? Update this test to match.`);
56
+ const rest = doc.slice(start + heading.length);
57
+ const end = rest.search(/\n#{1,4} /);
58
+ return end < 0 ? rest : rest.slice(0, end);
59
+ }
60
+
61
+ /** Every `` | `thing` | `` first column of a markdown table in `text`. */
62
+ function firstColumnCells(text: string): string[] {
63
+ return [...text.matchAll(/^\| `([^`]+)` \|/gm)].map((m) => m[1]);
64
+ }
65
+
66
+ function sorted(values: readonly string[]): string[] {
67
+ return [...values].sort();
68
+ }
69
+
70
+ describe("the MCP docs describe the server that ships (#2385)", () => {
71
+ test("cli/mcp.mdx documents exactly the tools the server registers", async () => {
72
+ const registered = await servedListing("tools/list");
73
+ // Every core tool gets its own `### \`name\`` heading. The `#### ` heading
74
+ // under op-approve and the un-backticked "### Plugin Tools" are not tools.
75
+ const documented = [...mcpDoc.matchAll(/^### `([^`]+)`$/gm)].map((m) => m[1]);
76
+ expect(documented.length, `${MCP_DOC}: found no "### \`tool-name\`" headings — was the Tools section reformatted?`).toBeGreaterThan(0);
77
+ expect(
78
+ sorted(documented),
79
+ `${MCP_DOC} and the McpServer constructor disagree about the core tools. ` +
80
+ `Registered: ${sorted(registered).join(", ")}. Documented: ${sorted(documented).join(", ")}. ` +
81
+ `Give every registered tool a "### \`name\`" section on that page, and delete the sections for tools that no longer exist.`,
82
+ ).toEqual(sorted(registered));
83
+ });
84
+
85
+ test("guide/agent-integration.mdx lists exactly the tools the server registers", async () => {
86
+ const registered = await servedListing("tools/list");
87
+ const listed = firstColumnCells(section(guideDoc, "### Available Tools", GUIDE_DOC));
88
+ expect(
89
+ sorted(listed),
90
+ `${GUIDE_DOC}'s "Available Tools" table and the McpServer constructor disagree. ` +
91
+ `Registered: ${sorted(registered).join(", ")}. Listed: ${sorted(listed).join(", ")}. ` +
92
+ `Add or remove rows so the table matches; the parameter detail lives on the cli/mcp reference page.`,
93
+ ).toEqual(sorted(registered));
94
+ });
95
+
96
+ test("cli/mcp.mdx documents exactly the resources the server serves", async () => {
97
+ const served = await servedListing("resources/list");
98
+ // Rows of the Resources table: `| `chant://...` | `mime` | description |`.
99
+ const documented = [...mcpDoc.matchAll(/^\| `(chant:\/\/[^`]+)` \| `[^`]+` \|/gm)].map((m) => m[1]);
100
+ expect(
101
+ sorted(documented),
102
+ `${MCP_DOC}'s Resources table and the server's resources/list disagree. ` +
103
+ `Served: ${sorted(served).join(", ")}. Documented: ${sorted(documented).join(", ")}. ` +
104
+ `A URI the server reads but never lists (chant://examples/{name}) belongs in prose under the table, not in it.`,
105
+ ).toEqual(sorted(served));
106
+ });
107
+
108
+ test("cli/mcp.mdx states the protocol revisions the server negotiates, preferred first", () => {
109
+ const documented = [...mcpDoc.matchAll(/^\| `(\d{4}-\d{2}-\d{2})` \|/gm)].map((m) => m[1]);
110
+ expect(
111
+ documented,
112
+ `${MCP_DOC} claims protocol revisions ${documented.join(", ") || "(none found)"}, but server.ts ` +
113
+ `negotiates ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}. The first row is the preferred revision, ` +
114
+ `the one a client that names no version gets back.`,
115
+ ).toEqual([...SUPPORTED_PROTOCOL_VERSIONS]);
116
+ });
117
+
118
+ test("cli/mcp.mdx lists exactly the JSON-RPC methods dispatch answers", () => {
119
+ const dispatched = [...serverSrc.matchAll(/^ case "([^"]+)":$/gm)].map((m) => m[1]);
120
+ if (dispatched.length === 0) {
121
+ throw new Error(
122
+ `mcp docs parity: no \`case "method":\` labels found in ${SERVER_SRC} — the dispatch switch was ` +
123
+ `restructured, so this test can no longer read the method list out of it. Update the test to match.`,
124
+ );
125
+ }
126
+ const documented = firstColumnCells(section(mcpDoc, "### Supported Methods", MCP_DOC));
127
+ expect(
128
+ sorted(documented),
129
+ `${MCP_DOC}'s "Supported Methods" table and the dispatch switch in ${SERVER_SRC} disagree. ` +
130
+ `Dispatched: ${sorted(dispatched).join(", ")}. Documented: ${sorted(documented).join(", ")}.`,
131
+ ).toEqual(sorted(dispatched));
132
+ });
133
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, test, expect, afterEach } from "vitest";
2
+ import { createOpApproveTool } from "./op-tools";
3
+ import { McpServer } from "./server";
4
+ import { readFileSync } from "node:fs";
5
+ import { resetGateOrigin, currentGateOrigin } from "../../lifecycle/gate-origin";
6
+
7
+ /**
8
+ * chant#2384 — the MCP surface cannot both produce a gate and resolve it.
9
+ *
10
+ * `op-run` executes the Op in-process and returns the gate it stopped on;
11
+ * `op-approve` then wrote the resolution with the approver taken from the
12
+ * request. So the agent that produced a pending gate resolved it under any
13
+ * name it liked, and #2300's plan binding did not close it: the digest comes
14
+ * off the standing pending fact, which is the plan the same caller produced one
15
+ * tool call earlier. The approval was for the right plan and meant nothing.
16
+ *
17
+ * These assert the two halves of the fix that are visible without a ledger: the
18
+ * tool no longer offers a name it cannot verify, and constructing the server
19
+ * declares the channel. The rule itself is exercised in
20
+ * `../../lifecycle/gate-origin.test.ts`.
21
+ */
22
+ describe("op-approve on the MCP channel (chant#2384)", () => {
23
+ afterEach(() => resetGateOrigin());
24
+
25
+ test("the tool no longer accepts a free-text approver", () => {
26
+ const schema = createOpApproveTool().definition.inputSchema as {
27
+ properties: Record<string, unknown>;
28
+ required: string[];
29
+ };
30
+
31
+ // The hole, stated as a test: `approver` was free text on a channel that
32
+ // cannot verify one, and the ledger recorded it indistinguishably from a
33
+ // name a person gave. Recording "unattested" is more honest than recording
34
+ // a name the model chose.
35
+ expect(Object.keys(schema.properties)).not.toContain("approver");
36
+
37
+ // The rest of the tool is unchanged — this narrows one field, it does not
38
+ // remove the tool. The narrower alternative in the issue was to drop
39
+ // op-approve entirely; this keeps it useful for the cross-channel case.
40
+ expect(Object.keys(schema.properties).sort()).toEqual(["gate", "name", "note", "runtime", "url"]);
41
+ expect(schema.required.sort()).toEqual(["gate", "name"]);
42
+ });
43
+
44
+ test("the description tells the caller where an approval has to come from", () => {
45
+ // A model reads this string and nothing else. If it does not say that the
46
+ // gate must be approved elsewhere, the model's only signal is a refusal it
47
+ // cannot act on.
48
+ const description = createOpApproveTool().definition.description;
49
+ expect(description).toMatch(/refuses a gate this same channel reached/i);
50
+ expect(description).toContain("chant approve");
51
+ expect(description).toMatch(/unattested/i);
52
+ });
53
+
54
+ test("merely constructing a server does not change what the process is", () => {
55
+ // Deliberate: the channel is declared in `start()`, not the constructor.
56
+ // Building a server object in a test must not make every later gate fact in
57
+ // that worker read as model-authored — which is exactly the contamination
58
+ // the first version of this caused.
59
+ new McpServer();
60
+ expect(currentGateOrigin()).toBe("cli");
61
+ });
62
+
63
+ test("op-approve names its own channel, so it holds even on a server that never started", () => {
64
+ // The ambient value covers the pending fact written during `op-run`. The
65
+ // resolution does not rely on it: the tool passes `origin: "mcp"` outright,
66
+ // so the rule holds regardless of what the process thinks it is.
67
+ const source = readFileSync(new URL("./op-tools.ts", import.meta.url), "utf8");
68
+ expect(source).toContain('origin: "mcp"');
69
+ });
70
+ });
@@ -181,13 +181,18 @@ export function createOpApproveTool(): ToolRegistration {
181
181
  definition: {
182
182
  name: "op-approve",
183
183
  description:
184
- "Record a gate's resolution on the gate ledger and wake the runtime hosting the gated run. The rename of op-signal: a gate is resolved by recording the fact, not by sending a message.",
184
+ "Record a gate's resolution on the gate ledger and wake the runtime hosting the gated run. " +
185
+ "Refuses a gate this same channel reached: a run started with op-run must be approved from " +
186
+ "somewhere else, normally `chant approve <op> <gate>` at a shell. The approver is recorded as " +
187
+ "unattested, because this channel cannot verify a name.",
185
188
  inputSchema: {
186
189
  type: "object",
187
190
  properties: {
188
191
  name: { type: "string", description: "Op name (e.g. alb-deploy)" },
189
192
  gate: { type: "string", description: "Gate name (e.g. gate-dns-delegation)" },
190
- approver: { type: "string", description: "Who approved; defaults to the CI or shell identity" },
193
+ // chant#2384 — no `approver`. It was free text on a channel that
194
+ // cannot verify one, so the model named itself whatever it liked and
195
+ // the ledger recorded it indistinguishably from a name a person gave.
191
196
  note: { type: "string", description: "Free-text context recorded on the resolution" },
192
197
  url: { type: "string", description: "Absolute http/https URL this resolution happened at" },
193
198
  runtime: RUNTIME_PARAM,
@@ -202,11 +207,19 @@ export function createOpApproveTool(): ToolRegistration {
202
207
 
203
208
  const runtime = await runtimeFor(params.runtime);
204
209
  const outcome = await recordGateApproval(name, gate, {
205
- actor: params.approver as string | undefined,
206
210
  note: params.note as string | undefined,
207
211
  url: params.url as string | undefined,
212
+ // Named rather than left to the ambient value, so this tool states its
213
+ // own channel even if it is ever registered on a server that did not.
214
+ origin: "mcp",
208
215
  });
209
- if (!outcome.ok) throw new Error(`Gate "${gate}" on "${name}" was not recorded`);
216
+ if (!outcome.ok) {
217
+ throw new Error(
218
+ `Gate "${gate}" on "${name}" was not recorded. If the gate was reached over MCP, it cannot ` +
219
+ `also be resolved over MCP — approve it from another channel, normally ` +
220
+ `\`chant approve ${name} ${gate}\` at a shell.`,
221
+ );
222
+ }
210
223
 
211
224
  if (runtime.resolveGate) await runtime.resolveGate(name, gate, outcome.record);
212
225
 
@@ -214,6 +227,7 @@ export function createOpApproveTool(): ToolRegistration {
214
227
  op: name,
215
228
  gate,
216
229
  resolvedBy: outcome.record.resolvedBy,
230
+ origin: outcome.record.origin,
217
231
  timestamp: outcome.record.timestamp,
218
232
  runtimeNotified: Boolean(runtime.resolveGate),
219
233
  };
@@ -9,6 +9,7 @@ import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import type { LexiconPlugin } from "../../lexicon";
10
10
  import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
11
  import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
12
+ import { setGateOrigin } from "../../lifecycle/gate-origin";
12
13
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
13
14
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
14
15
 
@@ -16,8 +17,12 @@ import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
16
17
  * Protocol versions this server understands, newest first. `initialize` and
17
18
  * `server/discover` both negotiate against this list rather than assuming
18
19
  * the client's revision (#1194).
20
+ *
21
+ * Exported because `docs-parity.test.ts` reads it: `cli/mcp.mdx` states these
22
+ * revisions in prose, and stating them twice is how the page came to claim
23
+ * 2024-11-05 for two releases after this list moved past it (#2385).
19
24
  */
20
- const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
25
+ export const SUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28", "2024-11-05"] as const;
21
26
  const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
22
27
 
23
28
  /**
@@ -302,6 +307,16 @@ export class McpServer {
302
307
  * Start the MCP server on stdio
303
308
  */
304
309
  async start(): Promise<void> {
310
+ // chant#2384 — declared here rather than in the constructor, because this
311
+ // is where the PROCESS becomes an MCP server. Constructing the object does
312
+ // not change what the process is, and a test that builds one should not
313
+ // silently make every later gate fact in that worker read as model-authored.
314
+ //
315
+ // From here on a person's only act was launching this; every call is the
316
+ // model's. So every gate fact written records the channel it came through,
317
+ // and a gate reached here cannot also be resolved here.
318
+ setGateOrigin("mcp");
319
+
305
320
  const rl = createInterface({
306
321
  input: process.stdin,
307
322
  output: process.stdout,