@intentius/chant 0.88.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cli/handlers/serve.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts +20 -6
  4. package/dist/cli/mcp/server.d.ts.map +1 -1
  5. package/dist/cli/mcp/types.d.ts +13 -5
  6. package/dist/cli/mcp/types.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  8. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  9. package/dist/cli/mcp/workspace-tools.d.ts +53 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  11. package/dist/cli/registry.d.ts +2 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/op/op-verb-class.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +42 -2
  15. package/dist/workspace/conformance/index.d.ts.map +1 -1
  16. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  17. package/dist/workspace/reason-codes.d.ts +3 -0
  18. package/dist/workspace/reason-codes.d.ts.map +1 -1
  19. package/dist/workspace/records-write.d.ts +35 -3
  20. package/dist/workspace/records-write.d.ts.map +1 -1
  21. package/dist/workspace/records.d.ts +4 -1
  22. package/dist/workspace/records.d.ts.map +1 -1
  23. package/dist/workspace/source-block.d.ts +85 -0
  24. package/dist/workspace/source-block.d.ts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/cli/handlers/serve.ts +11 -1
  27. package/src/cli/main.test.ts +7 -0
  28. package/src/cli/main.ts +40 -1
  29. package/src/cli/mcp/docs-parity.test.ts +20 -2
  30. package/src/cli/mcp/server.ts +32 -6
  31. package/src/cli/mcp/types.ts +15 -2
  32. package/src/cli/mcp/workspace-plugins.ts +123 -0
  33. package/src/cli/mcp/workspace-tools.test.ts +198 -0
  34. package/src/cli/mcp/workspace-tools.ts +405 -0
  35. package/src/cli/registry.ts +2 -0
  36. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  37. package/src/op/op-verb-class.ts +6 -0
  38. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  39. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  40. package/src/workspace/conformance/index.mjs +3 -0
  41. package/src/workspace/conformance/index.ts +185 -7
  42. package/src/workspace/conformance/vitest.ts +17 -8
  43. package/src/workspace/reason-codes.ts +3 -0
  44. package/src/workspace/records-amend.schema.json +2 -1
  45. package/src/workspace/records-close.schema.json +2 -1
  46. package/src/workspace/records-new.schema.json +4 -1
  47. package/src/workspace/records-review.schema.json +2 -1
  48. package/src/workspace/records-write.ts +85 -7
  49. package/src/workspace/records.schema.json +1 -0
  50. package/src/workspace/records.ts +28 -0
  51. package/src/workspace/source-block.test.ts +167 -0
  52. package/src/workspace/source-block.ts +129 -0
@@ -0,0 +1,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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.88.0",
3
+ "version": "0.90.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -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
- const server = new McpServer(ctx.plugins);
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
@@ -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 loadPluginsOrExit(projectPath)
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 response = await new McpServer().handleRequest({ jsonrpc: "2.0", id: 1, method });
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));
@@ -8,7 +8,9 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import { compositesTool, createCompositesHandler } from "./tools/composites";
10
10
  import type { LexiconPlugin } from "../../lexicon";
11
- import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
+ import type { McpClientInfo, McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
12
+ import { findWorkspaceRoot } from "../../project-root";
13
+ import { createWorkspaceTools, type WorkspaceToolsOptions } from "./workspace-tools";
12
14
  import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
13
15
  import { setGateOrigin } from "../../lifecycle/gate-origin";
14
16
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
@@ -47,14 +49,14 @@ export function negotiateProtocolVersion(requested: string | undefined): string
47
49
  * sends on `initialize`. Read-side only — the server holds no handshake
48
50
  * state to update (#1194).
49
51
  */
50
- export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: { name: string; version?: string } } {
52
+ export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: McpClientInfo } {
51
53
  const meta = (params._meta ?? {}) as McpRequestMeta;
52
54
  const protocolVersion =
53
55
  (typeof meta.protocolVersion === "string" ? meta.protocolVersion : undefined) ??
54
56
  (typeof params.protocolVersion === "string" ? params.protocolVersion : undefined);
55
57
  const clientInfo =
56
58
  meta["io.modelcontextprotocol/clientInfo"] ??
57
- (params.clientInfo as { name: string; version?: string } | undefined);
59
+ (params.clientInfo as McpClientInfo | undefined);
58
60
  return { protocolVersion, clientInfo };
59
61
  }
60
62
 
@@ -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
- constructor(plugins?: LexiconPlugin[]) {
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
- const result = await handler(toolParams);
308
+ // The client, from this request's _meta (2026-07-28) or else from initialize.
309
+ const clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
310
+ const result = await handler(toolParams, clientInfo ? { clientInfo } : {});
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
  }
@@ -32,7 +32,7 @@ export interface McpResponse {
32
32
  */
33
33
  export interface McpRequestMeta {
34
34
  protocolVersion?: string;
35
- "io.modelcontextprotocol/clientInfo"?: { name: string; version?: string };
35
+ "io.modelcontextprotocol/clientInfo"?: McpClientInfo;
36
36
  }
37
37
 
38
38
  /**
@@ -68,4 +68,17 @@ export interface ResourceDefinition {
68
68
  mimeType?: string;
69
69
  }
70
70
 
71
- export type ToolHandler = (params: Record<string, unknown>) => Promise<unknown>;
71
+ /** The MCP client's `clientInfo`, as it gave it on `initialize` or in a request's `_meta`. */
72
+ export interface McpClientInfo {
73
+ name: string;
74
+ version?: string;
75
+ title?: string;
76
+ }
77
+
78
+ /** What a handler knows about the call beyond its arguments (#2707). */
79
+ export interface ToolContext {
80
+ /** The client that made the call, when it said. */
81
+ clientInfo?: McpClientInfo;
82
+ }
83
+
84
+ export type ToolHandler = (params: Record<string, unknown>, context?: ToolContext) => Promise<unknown>;
@@ -0,0 +1,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
+ }