@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.
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +16 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp-config.d.ts +47 -0
- package/dist/cli/mcp-config.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +24 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +0 -5
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +29 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-origin.d.ts +77 -0
- package/dist/lifecycle/gate-origin.d.ts.map +1 -0
- package/dist/observation.d.ts +0 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/activities/converge.d.ts +16 -0
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/core.ts +1 -1
- package/src/cli/commands/doctor.ts +18 -6
- package/src/cli/commands/init.ts +9 -68
- package/src/cli/commands/update.ts +9 -0
- package/src/cli/handlers/init.ts +1 -0
- package/src/cli/handlers/operator.ts +48 -1
- package/src/cli/main.ts +9 -0
- package/src/cli/mcp/docs-parity.test.ts +133 -0
- package/src/cli/mcp/op-approve-origin.test.ts +70 -0
- package/src/cli/mcp/op-tools.ts +18 -4
- package/src/cli/mcp/server.ts +16 -1
- package/src/cli/mcp-config.test.ts +168 -0
- package/src/cli/mcp-config.ts +76 -0
- package/src/cli/registry.ts +12 -0
- package/src/discovery/fold-executing-mode.test.ts +115 -0
- package/src/discovery/fold-import.ts +87 -14
- package/src/discovery/fold-no-invoke-declared.test.ts +118 -0
- package/src/discovery/sandbox/fork-diagnostic.test.ts +117 -0
- package/src/discovery/sandbox/fork.ts +92 -6
- package/src/graph-ir.ts +1 -1
- package/src/graph-layout.ts +0 -0
- package/src/lifecycle/gate-ledger.ts +39 -1
- package/src/lifecycle/gate-origin.test.ts +93 -0
- package/src/lifecycle/gate-origin.ts +113 -0
- package/src/meta/source-is-text.test.ts +72 -0
- package/src/observation.ts +27 -8
- package/src/op/activities/converge-push.test.ts +101 -0
- package/src/op/activities/converge.ts +31 -1
package/src/cli/commands/init.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
395
|
+
mcpConfigPath(targetDir),
|
|
455
396
|
generateMcpConfig(detectPackageManager(targetDir)),
|
|
456
|
-
|
|
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,
|
package/src/cli/handlers/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/cli/mcp/op-tools.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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)
|
|
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
|
};
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -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,
|