@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.
- package/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +20 -6
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
- package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
- package/dist/cli/mcp/workspace-tools.d.ts +53 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +4 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/serve.ts +11 -1
- package/src/cli/main.test.ts +7 -0
- package/src/cli/main.ts +40 -1
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +32 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-plugins.ts +123 -0
- package/src/cli/mcp/workspace-tools.test.ts +198 -0
- package/src/cli/mcp/workspace-tools.ts +405 -0
- package/src/cli/registry.ts +2 -0
- package/src/cli/serve-mcp-workspace.test.ts +142 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +1 -0
- package/src/workspace/records.ts +28 -0
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A record's stored source block (#2708): where a proposal came from.
|
|
3
|
+
*
|
|
4
|
+
* chant's provenance (`trust/provenance.ts`) is computed from git: who
|
|
5
|
+
* committed a record, and with which key. It says nothing about the harness,
|
|
6
|
+
* model and session a decision was proposed in, or the conversation behind it.
|
|
7
|
+
* A record kind that declares `source: { field }` opts in to a stored block
|
|
8
|
+
* holding that, next to whatever else its source field says (a decision's
|
|
9
|
+
* issue row or workspace member, a work item's finding):
|
|
10
|
+
*
|
|
11
|
+
* - `via`: how the record reached the workspace, `cli`, `mcp` or `harvest`;
|
|
12
|
+
* - `client`: the MCP client's `clientInfo` (`name`, `version`), or the CLI;
|
|
13
|
+
* - `harness`: the harness's id, such as `claude-code` or `codex`;
|
|
14
|
+
* - `model`: the model id the harness reports;
|
|
15
|
+
* - `session`: the harness's session or conversation id, as a string, or as
|
|
16
|
+
* `{ id, record }` with the chant session record it was held in (#2697);
|
|
17
|
+
* - `turns`: the turn range the decision was made in, `{ from, to }`;
|
|
18
|
+
* - `transcript`: `{ path | uri, sha256 }`, pinning a transcript by hash and
|
|
19
|
+
* never copying it, the way evidence pins a file.
|
|
20
|
+
*
|
|
21
|
+
* Every field is optional, and the block is data about the proposal, not
|
|
22
|
+
* trust: nothing here raises or lowers a record's standing. The rules are
|
|
23
|
+
* three. The fields that are present must have these shapes
|
|
24
|
+
* (`record-schema-invalid` otherwise, beside the kind's own schema). A record
|
|
25
|
+
* written with `via: "harvest"` must open in the kind's first state, since a
|
|
26
|
+
* harvest proposes and a person decides (`source-harvest-not-proposed`, a
|
|
27
|
+
* write refusal). And `records` warns `source-transcript-drift` when the
|
|
28
|
+
* pinned transcript can be read here and its bytes hash to something else.
|
|
29
|
+
*/
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
/** How a record reached the workspace. Closed. */
|
|
32
|
+
export declare const SOURCE_VIAS: readonly ["cli", "mcp", "harvest"];
|
|
33
|
+
export type SourceVia = (typeof SOURCE_VIAS)[number];
|
|
34
|
+
/** The proposal fields of a source block. Other fields of the block are the kind's, and left to its schema. */
|
|
35
|
+
export declare const sourceBlockSchema: z.ZodObject<{
|
|
36
|
+
via: z.ZodOptional<z.ZodEnum<{
|
|
37
|
+
cli: "cli";
|
|
38
|
+
mcp: "mcp";
|
|
39
|
+
harvest: "harvest";
|
|
40
|
+
}>>;
|
|
41
|
+
client: z.ZodOptional<z.ZodObject<{
|
|
42
|
+
name: z.ZodString;
|
|
43
|
+
version: z.ZodOptional<z.ZodString>;
|
|
44
|
+
title: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$strict>>;
|
|
46
|
+
harness: z.ZodOptional<z.ZodString>;
|
|
47
|
+
model: z.ZodOptional<z.ZodString>;
|
|
48
|
+
session: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull, z.ZodObject<{
|
|
49
|
+
id: z.ZodOptional<z.ZodString>;
|
|
50
|
+
record: z.ZodOptional<z.ZodString>;
|
|
51
|
+
}, z.core.$strict>]>>;
|
|
52
|
+
turns: z.ZodOptional<z.ZodObject<{
|
|
53
|
+
from: z.ZodNumber;
|
|
54
|
+
to: z.ZodNumber;
|
|
55
|
+
}, z.core.$strict>>;
|
|
56
|
+
transcript: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
57
|
+
path: z.ZodString;
|
|
58
|
+
sha256: z.ZodString;
|
|
59
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
60
|
+
uri: z.ZodString;
|
|
61
|
+
sha256: z.ZodString;
|
|
62
|
+
}, z.core.$strict>]>>;
|
|
63
|
+
}, z.core.$strip>;
|
|
64
|
+
export type SourceBlock = z.infer<typeof sourceBlockSchema>;
|
|
65
|
+
/** The source block of `data`, when `field` holds an object. */
|
|
66
|
+
export declare function sourceBlock(data: Record<string, unknown> | null, field: string): Record<string, unknown> | undefined;
|
|
67
|
+
/** What is wrong with the proposal fields of `block`, as `<path> <message>` lines. Empty when nothing is. */
|
|
68
|
+
export declare function sourceBlockProblems(block: Record<string, unknown>, field: string): string[];
|
|
69
|
+
/**
|
|
70
|
+
* The file a transcript pin names, when it can be read here: a `path`
|
|
71
|
+
* (absolute, `~/` from the home directory, or else from the workspace root)
|
|
72
|
+
* or a `file:` URI. Any other URI is not fetched, so it is never reachable.
|
|
73
|
+
*/
|
|
74
|
+
export declare function transcriptFile(transcript: {
|
|
75
|
+
path?: string;
|
|
76
|
+
uri?: string;
|
|
77
|
+
}, workspaceDir: string): string | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The `source-transcript-drift` message for `block`, or undefined: when its
|
|
80
|
+
* transcript pin is well formed, the file it names can be read here, and its
|
|
81
|
+
* bytes hash to something other than the pin. A transcript that can't be
|
|
82
|
+
* read says nothing either way.
|
|
83
|
+
*/
|
|
84
|
+
export declare function transcriptDrift(block: Record<string, unknown>, field: string, workspaceDir: string): string | undefined;
|
|
85
|
+
//# sourceMappingURL=source-block.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source-block.d.ts","sourceRoot":"","sources":["../../src/workspace/source-block.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAMH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,kDAAkD;AAClD,eAAO,MAAM,WAAW,oCAAqC,CAAC;AAC9D,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAKrD,+GAA+G;AAC/G,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuB5B,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D,gEAAgE;AAChE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAGpH;AAED,6GAA6G;AAC7G,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAI3F;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAcpH;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAgBvH"}
|
package/package.json
CHANGED
|
@@ -11,7 +11,17 @@ export async function runServeLsp(ctx: CommandContext): Promise<number> {
|
|
|
11
11
|
|
|
12
12
|
export async function runServeMcp(ctx: CommandContext): Promise<number> {
|
|
13
13
|
const { McpServer } = await import("../mcp/server");
|
|
14
|
-
|
|
14
|
+
// #2700 — main() hands over no plugins only at a workspace root with no
|
|
15
|
+
// lexicon of its own (anywhere else it refuses first). There the lexicons
|
|
16
|
+
// are the chant members', and the server says which loaded.
|
|
17
|
+
let plugins = ctx.plugins;
|
|
18
|
+
let instructions: string | undefined;
|
|
19
|
+
if (plugins.length === 0) {
|
|
20
|
+
const { loadWorkspacePlugins } = await import("../mcp/workspace-plugins");
|
|
21
|
+
({ plugins, instructions } = await loadWorkspacePlugins(process.cwd()));
|
|
22
|
+
}
|
|
23
|
+
// #2707 — at or inside a declared workspace, the workspace tools are served too.
|
|
24
|
+
const server = new McpServer(plugins, { instructions, workspace: { cwd: process.cwd() } });
|
|
15
25
|
await server.start();
|
|
16
26
|
await new Promise(() => {});
|
|
17
27
|
return 0; // unreachable
|
package/src/cli/main.test.ts
CHANGED
|
@@ -71,6 +71,13 @@ describe("parseArgs", () => {
|
|
|
71
71
|
expect(result.help).toBe(true);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("parses --version and -V (#2701)", () => {
|
|
75
|
+
expect(parseArgs(["--version"]).version).toBe(true);
|
|
76
|
+
expect(parseArgs(["-V"]).version).toBe(true);
|
|
77
|
+
expect(parseArgs(["build"]).version).toBeUndefined();
|
|
78
|
+
expect(() => parseArgs(["--version=1"])).toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
74
81
|
test("parses --output with value", () => {
|
|
75
82
|
const result = parseArgs(["build", "--output", "stack.json"]);
|
|
76
83
|
expect(result.output).toBe("stack.json");
|
package/src/cli/main.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { loadPlugins, resolveProjectLexicons } from "./plugins";
|
|
|
7
7
|
import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
|
|
8
8
|
import { loadChantConfigUpward } from "../config";
|
|
9
9
|
import { findProjectRoot, findWorkspaceRoot } from "../project-root";
|
|
10
|
+
import { isNoLexiconDetected } from "../detectLexicon";
|
|
11
|
+
import { CHANT_VERSION } from "./version";
|
|
10
12
|
import { validateLexiconConfig, formatLexiconConfigProblems } from "../lexicon-config";
|
|
11
13
|
import { armSandboxConfigEvaluation } from "../config-sandbox";
|
|
12
14
|
import { armSandboxPolicyExecution } from "../lint/policy-import";
|
|
@@ -52,6 +54,7 @@ import type { LexiconPlugin } from "../lexicon";
|
|
|
52
54
|
*/
|
|
53
55
|
const BOOLEAN_FLAGS = new Set([
|
|
54
56
|
"--help",
|
|
57
|
+
"--version",
|
|
55
58
|
"--agents",
|
|
56
59
|
"--agent",
|
|
57
60
|
"--all-projects",
|
|
@@ -167,6 +170,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
167
170
|
|
|
168
171
|
if (arg === "--help" || arg === "-h") {
|
|
169
172
|
result.help = true;
|
|
173
|
+
} else if (arg === "--version" || arg === "-V") {
|
|
174
|
+
result.version = true;
|
|
170
175
|
} else if (arg === "--output" || arg === "-o") {
|
|
171
176
|
result.output = args[++i];
|
|
172
177
|
} else if (arg === "--format" || arg === "-f") {
|
|
@@ -971,6 +976,7 @@ Options:
|
|
|
971
976
|
resolved build parameter and per-file fold decision
|
|
972
977
|
instead of the one-line summaries
|
|
973
978
|
-h, --help Show this help message
|
|
979
|
+
-V, --version Print the installed chant's version
|
|
974
980
|
--on <lexicon> Which runtime hosts the run: a configured lexicon with
|
|
975
981
|
an opRuntime, or the built-in local runtime when
|
|
976
982
|
omitted (every run subcommand; #2121)
|
|
@@ -1183,6 +1189,28 @@ async function loadPluginsOrExit(path: string): Promise<import("../lexicon").Lex
|
|
|
1183
1189
|
return plugins;
|
|
1184
1190
|
}
|
|
1185
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* #2700 — whether `path` is the root of a declared workspace and holds no
|
|
1194
|
+
* lexicon of its own: no `lexicons` in a root config, and no lexicon import in
|
|
1195
|
+
* the root's source, which leaves the members' directories out (#2527). A
|
|
1196
|
+
* generated chud repo is this shape: its lexicons are all in `delivery/`.
|
|
1197
|
+
* `chant serve mcp` starts there instead of refusing with "No lexicon
|
|
1198
|
+
* detected", and serves core with the chant members' lexicons.
|
|
1199
|
+
*
|
|
1200
|
+
* A directory with no workspace declaration costs only `findWorkspaceRoot`'s
|
|
1201
|
+
* existence checks, so a level-0 project reaches `loadPluginsOrExit` as before.
|
|
1202
|
+
*/
|
|
1203
|
+
async function isLexiconlessWorkspaceRoot(path: string): Promise<boolean> {
|
|
1204
|
+
const target = resolve(path);
|
|
1205
|
+
if (findWorkspaceRoot(target)?.dir !== target) return false;
|
|
1206
|
+
try {
|
|
1207
|
+
await resolveProjectLexicons(target);
|
|
1208
|
+
return false;
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
return isNoLexiconDetected(error);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1186
1214
|
/** Whether `def` must run without evaluating the project's `chant.config.ts` (chant#2591). */
|
|
1187
1215
|
function commandRunsNoConfig(def: CommandDef, args: ParsedArgs): boolean {
|
|
1188
1216
|
return typeof def.runsNoConfig === "function" ? def.runsNoConfig(args) : def.runsNoConfig === true;
|
|
@@ -1388,6 +1416,15 @@ async function main(): Promise<void> {
|
|
|
1388
1416
|
throw err;
|
|
1389
1417
|
}
|
|
1390
1418
|
|
|
1419
|
+
// #2701 — `chant --version` / `-V` prints the installed chant's version, the
|
|
1420
|
+
// one the MCP server reports (./version.ts). With a command word it answers
|
|
1421
|
+
// only for one of core's own commands; a lexicon-mounted verb keeps the flag.
|
|
1422
|
+
if (args.version && (!args.command || resolveCommand(args, commandRegistry))) {
|
|
1423
|
+
console.log(CHANT_VERSION);
|
|
1424
|
+
await flushAndExit(0);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1391
1428
|
if (args.help || !args.command) {
|
|
1392
1429
|
const groups = await loadPluginsBestEffort().then(collectCommandGroups).catch(() => []);
|
|
1393
1430
|
printHelp(groups);
|
|
@@ -1515,7 +1552,9 @@ async function main(): Promise<void> {
|
|
|
1515
1552
|
const plugins = match.def.requiresPlugins
|
|
1516
1553
|
? isGenerateComponents || isComponentsStatus || isEmulator || isFanOutFromFile
|
|
1517
1554
|
? await loadPlugins(await resolveProjectLexicons(resolve(projectPath)).catch(() => [])).catch(() => [])
|
|
1518
|
-
: await
|
|
1555
|
+
: match.def.name === "serve mcp" && (await isLexiconlessWorkspaceRoot(projectPath))
|
|
1556
|
+
? [] // #2700 — runServeMcp loads the chant members' lexicons itself.
|
|
1557
|
+
: await loadPluginsOrExit(projectPath)
|
|
1519
1558
|
: [];
|
|
1520
1559
|
const serializers = plugins.map((p) => p.serializer);
|
|
1521
1560
|
const ctx = { args, plugins, serializers };
|
|
@@ -39,8 +39,9 @@ const guideDoc = readFileSync(join(repoRoot, GUIDE_DOC), "utf8");
|
|
|
39
39
|
const serverSrc = readFileSync(join(repoRoot, SERVER_SRC), "utf8");
|
|
40
40
|
|
|
41
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
|
|
42
|
+
async function servedListing(method: "tools/list" | "resources/list", inWorkspace = false): Promise<string[]> {
|
|
43
|
+
const server = inWorkspace ? new McpServer([], { workspace: { cwd: repoRoot } }) : new McpServer();
|
|
44
|
+
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method });
|
|
44
45
|
const result = response.result as { tools?: ToolDefinition[]; resources?: ResourceDefinition[] };
|
|
45
46
|
const names = result.tools?.map((t) => t.name) ?? result.resources?.map((r) => r.uri);
|
|
46
47
|
if (!names || names.length === 0) {
|
|
@@ -82,6 +83,23 @@ describe("the MCP docs describe the server that ships (#2385)", () => {
|
|
|
82
83
|
).toEqual(sorted(registered));
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
test("cli/mcp.mdx's Workspace tools table lists exactly the tools a server inside a workspace adds (#2707)", async () => {
|
|
87
|
+
const core = await servedListing("tools/list");
|
|
88
|
+
// This repository declares a workspace, so a server started in it has them.
|
|
89
|
+
const registered = (await servedListing("tools/list", true)).filter((n) => !core.includes(n));
|
|
90
|
+
expect(registered.length).toBeGreaterThan(0);
|
|
91
|
+
const listed = firstColumnCells(section(mcpDoc, "## Workspace tools", MCP_DOC));
|
|
92
|
+
expect(sorted(listed), `${MCP_DOC}'s "Workspace tools" table and the workspace tools disagree`).toEqual(sorted(registered));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("guide/agent-integration.mdx lists exactly the workspace tools (#2707)", async () => {
|
|
96
|
+
const core = await servedListing("tools/list");
|
|
97
|
+
const registered = (await servedListing("tools/list", true)).filter((n) => !core.includes(n));
|
|
98
|
+
expect(registered.length).toBeGreaterThan(0);
|
|
99
|
+
const listed = firstColumnCells(section(guideDoc, "### Workspace Tools", GUIDE_DOC));
|
|
100
|
+
expect(sorted(listed), `${GUIDE_DOC}'s "Workspace Tools" table and the workspace tools a server inside a workspace registers disagree`).toEqual(sorted(registered));
|
|
101
|
+
});
|
|
102
|
+
|
|
85
103
|
test("guide/agent-integration.mdx lists exactly the tools the server registers", async () => {
|
|
86
104
|
const registered = await servedListing("tools/list");
|
|
87
105
|
const listed = firstColumnCells(section(guideDoc, "### Available Tools", GUIDE_DOC));
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -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?:
|
|
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
|
|
59
|
+
(params.clientInfo as McpClientInfo | undefined);
|
|
58
60
|
return { protocolVersion, clientInfo };
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -93,9 +95,23 @@ export class McpServer {
|
|
|
93
95
|
private toolHandlers: Map<string, ToolHandler> = new Map();
|
|
94
96
|
private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
|
|
95
97
|
private plugins: LexiconPlugin[];
|
|
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;
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
/**
|
|
103
|
+
* `options.instructions` is sent as the `initialize` result's
|
|
104
|
+
* `instructions`, the text a client may give its model about this server.
|
|
105
|
+
* Only a workspace root with no lexicon of its own sets it (#2700), to say
|
|
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.
|
|
111
|
+
*/
|
|
112
|
+
constructor(plugins?: LexiconPlugin[], options: { instructions?: string; workspace?: WorkspaceToolsOptions } = {}) {
|
|
98
113
|
this.plugins = plugins ?? [];
|
|
114
|
+
this.instructions = options.instructions;
|
|
99
115
|
// Register core tools
|
|
100
116
|
this.registerTool(buildTool, handleBuild);
|
|
101
117
|
this.registerTool(lintTool, handleLint);
|
|
@@ -118,6 +134,11 @@ export class McpServer {
|
|
|
118
134
|
this.registerTool(t.definition, t.handler);
|
|
119
135
|
}
|
|
120
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
|
+
|
|
121
142
|
// Register plugin contributions
|
|
122
143
|
if (plugins) {
|
|
123
144
|
for (const plugin of plugins) {
|
|
@@ -226,6 +247,7 @@ export class McpServer {
|
|
|
226
247
|
capabilities: { tools: {}, resources: {} },
|
|
227
248
|
// The installed chant's version, so a client can tell which chant it talks to (#2689).
|
|
228
249
|
serverInfo: { name: "chant", version: CHANT_VERSION },
|
|
250
|
+
...(this.instructions ? { instructions: this.instructions } : {}),
|
|
229
251
|
};
|
|
230
252
|
}
|
|
231
253
|
|
|
@@ -235,6 +257,8 @@ export class McpServer {
|
|
|
235
257
|
private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
236
258
|
switch (method) {
|
|
237
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;
|
|
238
262
|
// Answered for prior-revision clients too — negotiated, not hard-coded (#1194).
|
|
239
263
|
return this.buildInitializeResult(params);
|
|
240
264
|
|
|
@@ -281,7 +305,9 @@ export class McpServer {
|
|
|
281
305
|
}
|
|
282
306
|
|
|
283
307
|
try {
|
|
284
|
-
|
|
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 } : {});
|
|
285
311
|
const isStructured = typeof result === "object" && result !== null;
|
|
286
312
|
return {
|
|
287
313
|
content: [
|
|
@@ -365,6 +391,6 @@ export async function startMcpServer(): Promise<void> {
|
|
|
365
391
|
// Start without plugins if resolution fails
|
|
366
392
|
}
|
|
367
393
|
|
|
368
|
-
const server = new McpServer(plugins);
|
|
394
|
+
const server = new McpServer(plugins, { workspace: { cwd: process.cwd() } });
|
|
369
395
|
server.start();
|
|
370
396
|
}
|
package/src/cli/mcp/types.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface McpResponse {
|
|
|
32
32
|
*/
|
|
33
33
|
export interface McpRequestMeta {
|
|
34
34
|
protocolVersion?: string;
|
|
35
|
-
"io.modelcontextprotocol/clientInfo"?:
|
|
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
|
-
|
|
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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2700 — the lexicons `chant serve mcp` loads at the root of a declared
|
|
3
|
+
* workspace that has no lexicon of its own.
|
|
4
|
+
*
|
|
5
|
+
* Such a root (a generated chud repo, say, whose lexicons are all in
|
|
6
|
+
* `delivery/`) used to refuse with "No lexicon detected", so an agent started
|
|
7
|
+
* there got none of chant's tools. The server now starts with core's tools and
|
|
8
|
+
* resources, and with the lexicons of the workspace's members of kind chant,
|
|
9
|
+
* each read from that member's own config the way `chant build` in the
|
|
10
|
+
* member's directory reads it.
|
|
11
|
+
*
|
|
12
|
+
* Loading is best effort, one member and one lexicon at a time: a member whose
|
|
13
|
+
* config does not load, or a lexicon this chant cannot import, is left out and
|
|
14
|
+
* named in the server's `instructions`, and the rest are served. Nothing here
|
|
15
|
+
* fails the server; with nothing loaded it serves core alone and says so.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
21
|
+
|
|
22
|
+
/** One member of kind chant, and what came of reading its lexicons. */
|
|
23
|
+
export interface MemberLexicons {
|
|
24
|
+
member: string;
|
|
25
|
+
dir: string;
|
|
26
|
+
/** The lexicon names its config declares or its source imports, or null when they could not be read. */
|
|
27
|
+
lexicons: string[] | null;
|
|
28
|
+
/** Why they could not be read. */
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkspacePlugins {
|
|
33
|
+
plugins: LexiconPlugin[];
|
|
34
|
+
members: MemberLexicons[];
|
|
35
|
+
/** Lexicons named by a member that did not load, with the reason. */
|
|
36
|
+
failed: { lexicon: string; error: string }[];
|
|
37
|
+
/** The text the MCP server gives as its `instructions`. */
|
|
38
|
+
instructions: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function message(error: unknown): string {
|
|
42
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return text.split("\n")[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Load the lexicons of the chant members of the workspace declared at `root`. */
|
|
47
|
+
export async function loadWorkspacePlugins(root: string): Promise<WorkspacePlugins> {
|
|
48
|
+
const [{ readDeclaration }, { workingTree }, { resolveProjectLexicons, loadPlugins }] = await Promise.all([
|
|
49
|
+
import("../../workspace/declaration"),
|
|
50
|
+
import("../../workspace/tree"),
|
|
51
|
+
import("../plugins"),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const members: MemberLexicons[] = [];
|
|
55
|
+
let workspaceName: string | undefined;
|
|
56
|
+
let declarationError: string | undefined;
|
|
57
|
+
try {
|
|
58
|
+
const declaration = readDeclaration(workingTree(root));
|
|
59
|
+
workspaceName = declaration.name;
|
|
60
|
+
for (const m of declaration.members) {
|
|
61
|
+
// The root member is the directory this server already found no lexicon in.
|
|
62
|
+
if (m.kind !== "chant" || m.dir === ".") continue;
|
|
63
|
+
const abs = join(root, m.dir);
|
|
64
|
+
if (!existsSync(abs)) {
|
|
65
|
+
members.push({ member: m.name, dir: m.dir, lexicons: null, error: "its directory does not exist" });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
members.push({ member: m.name, dir: m.dir, lexicons: await resolveProjectLexicons(abs) });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
members.push({ member: m.name, dir: m.dir, lexicons: null, error: message(error) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
declarationError = message(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const plugins: LexiconPlugin[] = [];
|
|
79
|
+
const failed: { lexicon: string; error: string }[] = [];
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
for (const m of members) {
|
|
82
|
+
for (const name of m.lexicons ?? []) {
|
|
83
|
+
if (seen.has(name)) continue;
|
|
84
|
+
seen.add(name);
|
|
85
|
+
try {
|
|
86
|
+
plugins.push(...(await loadPlugins([name])));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
failed.push({ lexicon: name, error: message(error) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { plugins, members, failed, instructions: describe(workspaceName, members, plugins, failed, declarationError) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function describe(
|
|
97
|
+
name: string | undefined,
|
|
98
|
+
members: MemberLexicons[],
|
|
99
|
+
plugins: LexiconPlugin[],
|
|
100
|
+
failed: { lexicon: string; error: string }[],
|
|
101
|
+
declarationError: string | undefined,
|
|
102
|
+
): string {
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
lines.push(
|
|
105
|
+
`This chant MCP server runs at the root of the workspace ${name ? `"${name}" ` : ""}(chant.workspace.json), which has no lexicon of its own. ` +
|
|
106
|
+
"Core tools and resources are served as in any project. The tools that take a path (build, lint, explain) work on a project directory, so pass a member's directory.",
|
|
107
|
+
);
|
|
108
|
+
if (declarationError) lines.push(`The workspace declaration could not be read: ${declarationError}.`);
|
|
109
|
+
for (const m of members) {
|
|
110
|
+
lines.push(
|
|
111
|
+
m.lexicons
|
|
112
|
+
? `Member ${m.member} (${m.dir}/) declares ${m.lexicons.length ? m.lexicons.join(", ") : "no lexicon"}.`
|
|
113
|
+
: `Member ${m.member} (${m.dir}/) was not read: ${m.error}.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
for (const f of failed) lines.push(`Lexicon ${f.lexicon} did not load: ${f.error}.`);
|
|
117
|
+
lines.push(
|
|
118
|
+
plugins.length > 0
|
|
119
|
+
? `Lexicon tools and resources served: ${plugins.map((p) => p.name).join(", ")}.`
|
|
120
|
+
: "No member lexicon loaded, so only chant's core tools and resources are served.",
|
|
121
|
+
);
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|