argsbarg 7.1.1 → 7.1.2

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.
@@ -0,0 +1,158 @@
1
+ /*
2
+ Tests for mcp/tools module: MCP tool derivation, size reporting, and per-leaf MCP-only notes.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { cliPresentationRoot } from "../builtins/presentation.ts";
7
+ import { CliOptionKind } from "../core/types.ts";
8
+ import { cliHelpRender } from "../help.ts";
9
+ import { testProgram } from "../test/fixtures.ts";
10
+ import { collectMcpTools, DEFAULT_MCP_SIZE_LIMITS, mcpSizeReport } from "./tools.ts";
11
+
12
+ describe("mcpSizeReport", () => {
13
+ test("measures description and pretty definition against a hand-built JSON.stringify", () => {
14
+ const program = testProgram({
15
+ key: "sizetest",
16
+ description: "size test",
17
+ mcpServer: { enabled: true },
18
+ commands: [{ key: "run", description: "Run it.", handler: () => {} }],
19
+ });
20
+ const report = mcpSizeReport(program);
21
+ const [tool] = collectMcpTools(program);
22
+ expect(tool).toBeDefined();
23
+
24
+ const expectedJson = JSON.stringify(
25
+ { name: tool?.name, description: tool?.description, inputSchema: tool?.inputSchema },
26
+ null,
27
+ 2,
28
+ );
29
+ expect(report.tools).toHaveLength(1);
30
+ expect(report.tools[0]).toEqual({
31
+ name: tool?.name,
32
+ descriptionChars: tool?.description.length,
33
+ definitionBytes: Buffer.byteLength(expectedJson, "utf8"),
34
+ definitionLines: expectedJson.split("\n").length,
35
+ });
36
+ expect(report.warnings).toEqual([]);
37
+ expect(report.instructionsChars).toBe(0);
38
+ });
39
+
40
+ test("warns past default limits for description and definition size", () => {
41
+ const program = testProgram({
42
+ key: "sizetest2",
43
+ description: "size test 2",
44
+ mcpServer: { enabled: true },
45
+ commands: [
46
+ {
47
+ key: "small",
48
+ description: "Small tool.",
49
+ notes: "x".repeat(3_000),
50
+ handler: () => {},
51
+ },
52
+ {
53
+ key: "big",
54
+ description: "Big tool.",
55
+ options: [
56
+ {
57
+ name: "mode",
58
+ description: "Mode.",
59
+ kind: CliOptionKind.Enum,
60
+ choices: Array.from({ length: 4_000 }, (_, i) => `choice-${i}`),
61
+ },
62
+ ],
63
+ handler: () => {},
64
+ },
65
+ ],
66
+ });
67
+ const report = mcpSizeReport(program);
68
+
69
+ const smallWarning = report.warnings.find((w) => w.includes('"small"'));
70
+ expect(smallWarning).toBeDefined();
71
+ expect(smallWarning).toContain("description is");
72
+ expect(smallWarning).toContain(`limit ${DEFAULT_MCP_SIZE_LIMITS.descriptionChars.toLocaleString()}`);
73
+
74
+ const bigWarning = report.warnings.find((w) => w.includes('"big"'));
75
+ expect(bigWarning).toBeDefined();
76
+ expect(bigWarning).toContain("definition is");
77
+ expect(bigWarning).toContain("pretty-printed");
78
+ });
79
+
80
+ test("sizeLimits overrides raise or lower the threshold", () => {
81
+ const program = testProgram({
82
+ key: "sizetest3",
83
+ description: "size test 3",
84
+ mcpServer: { enabled: true, sizeLimits: { descriptionChars: 5 } },
85
+ commands: [
86
+ { key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
87
+ ],
88
+ });
89
+ const report = mcpSizeReport(program);
90
+ expect(report.warnings.some((w) => w.includes("description is"))).toBe(true);
91
+ });
92
+
93
+ test("sizeLimits: false disables a check entirely", () => {
94
+ const program = testProgram({
95
+ key: "sizetest4",
96
+ description: "size test 4",
97
+ mcpServer: { enabled: true, sizeLimits: { descriptionChars: false } },
98
+ commands: [
99
+ { key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
100
+ ],
101
+ });
102
+ const report = mcpSizeReport(program);
103
+ expect(report.warnings.some((w) => w.includes("description is"))).toBe(false);
104
+ });
105
+
106
+ test("warns when instructions exceed the limit", () => {
107
+ const program = testProgram({
108
+ key: "sizetest5",
109
+ description: "size test 5",
110
+ mcpServer: { enabled: true, instructions: "x".repeat(3_000) },
111
+ commands: [{ key: "run", description: "Run it.", handler: () => {} }],
112
+ });
113
+ const report = mcpSizeReport(program);
114
+ expect(report.instructionsChars).toBe(3_000);
115
+ expect(report.warnings.some((w) => w.startsWith("MCP instructions are"))).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe("mcpTool.notes", () => {
120
+ function programWithNotesOverride(notesOverride: string | false | undefined) {
121
+ return testProgram({
122
+ key: "notestest",
123
+ description: "notes test",
124
+ mcpServer: { enabled: true },
125
+ commands: [
126
+ {
127
+ key: "run",
128
+ description: "Run it.",
129
+ notes: "Original CLI notes.",
130
+ ...(notesOverride === undefined ? {} : { mcpTool: { notes: notesOverride } }),
131
+ handler: () => {},
132
+ },
133
+ ],
134
+ });
135
+ }
136
+
137
+ test("false omits notes from the MCP description", () => {
138
+ const [tool] = collectMcpTools(programWithNotesOverride(false));
139
+ expect(tool?.description).not.toContain("Original CLI notes.");
140
+ });
141
+
142
+ test("a string replaces the leaf's notes in the MCP description", () => {
143
+ const [tool] = collectMcpTools(programWithNotesOverride("Custom MCP-only note."));
144
+ expect(tool?.description).toContain("Custom MCP-only note.");
145
+ expect(tool?.description).not.toContain("Original CLI notes.");
146
+ });
147
+
148
+ test("omitted falls through to the leaf's own notes", () => {
149
+ const [tool] = collectMcpTools(programWithNotesOverride(undefined));
150
+ expect(tool?.description).toContain("Original CLI notes.");
151
+ });
152
+
153
+ test("CLI help always shows the leaf's own notes regardless of mcpTool.notes", () => {
154
+ const program = programWithNotesOverride(false);
155
+ const help = cliHelpRender(cliPresentationRoot(program), ["run"], false);
156
+ expect(help).toContain("Original CLI notes.");
157
+ });
158
+ });
package/src/mcp/tools.ts CHANGED
@@ -6,6 +6,7 @@ flat JSON tool arguments into argv for Cli.invoke.
6
6
  import { cliSchemaJson } from "../core/schema.ts";
7
7
  import {
8
8
  type CliLeaf,
9
+ type CliMcpSizeLimits,
9
10
  type CliNode,
10
11
  type CliOption,
11
12
  CliOptionKind,
@@ -107,7 +108,13 @@ function resolveToolDescription(root: CliProgram, path: string[], leaf: CliLeaf)
107
108
  } else {
108
109
  desc = mcpToolDescription(path, root.key, leaf.description);
109
110
  }
110
- const notes = (leaf.notes ?? "").trim();
111
+ // `mcpTool.notes` overrides what CLI help shows (leaf.notes) for the MCP description only:
112
+ // `false` omits notes entirely; a string replaces them; omitted falls through to leaf.notes.
113
+ const notesOverride = leaf.mcpTool?.notes;
114
+ if (notesOverride === false) {
115
+ return desc;
116
+ }
117
+ const notes = (typeof notesOverride === "string" ? notesOverride : (leaf.notes ?? "")).trim();
111
118
  if (notes.length > 0) {
112
119
  desc += `\n\n${cliResolveNotes(notes, root.key)}`;
113
120
  }
@@ -272,3 +279,89 @@ export function mcpToolCallToArgv(
272
279
 
273
280
  return argv;
274
281
  }
282
+
283
+ /** Default {@link CliMcpSizeLimits}; see that type for what each limit approximates and why. */
284
+ export const DEFAULT_MCP_SIZE_LIMITS: Required<CliMcpSizeLimits> = {
285
+ definitionBytes: 51_200,
286
+ definitionLines: 2_000,
287
+ descriptionChars: 2_048,
288
+ instructionsChars: 2_048,
289
+ };
290
+
291
+ /** Measured size of one MCP tool's description and pretty-printed definition. */
292
+ export interface McpToolSize {
293
+ /** Pretty-printed `{name, description, inputSchema, outputSchema}`, in UTF-8 bytes. */
294
+ definitionBytes: number;
295
+ /** Line count of the same pretty-printed definition. */
296
+ definitionLines: number;
297
+ /** Character length of `description` alone. */
298
+ descriptionChars: number;
299
+ /** MCP tool name. */
300
+ name: string;
301
+ }
302
+
303
+ /** Per-tool sizes plus any warnings past {@link CliMcpSizeLimits} (defaults or `mcpServer.sizeLimits`). */
304
+ export interface McpSizeReport {
305
+ /** Character length of `mcpServer.instructions`, or 0 when unset. */
306
+ instructionsChars: number;
307
+ /** One entry per MCP tool, in `tools/list` order. */
308
+ tools: McpToolSize[];
309
+ /** Human-readable warnings for anything past its limit; empty when everything fits. */
310
+ warnings: string[];
311
+ }
312
+
313
+ /** Formats a definition's pretty-printed JSON exactly as Cursor's synced tool file would show it. */
314
+ function mcpToolDefinitionJson(tool: McpToolDef): string {
315
+ return JSON.stringify(
316
+ {
317
+ name: tool.name,
318
+ description: tool.description,
319
+ inputSchema: tool.inputSchema,
320
+ ...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
321
+ },
322
+ null,
323
+ 2,
324
+ );
325
+ }
326
+
327
+ /** Measures every MCP tool's description and definition size against {@link CliMcpSizeLimits}. */
328
+ export function mcpSizeReport(root: CliProgram): McpSizeReport {
329
+ const limits = { ...DEFAULT_MCP_SIZE_LIMITS, ...root.mcpServer?.sizeLimits };
330
+ const warnings: string[] = [];
331
+
332
+ const tools = collectMcpTools(root).map((tool): McpToolSize => {
333
+ const definitionJson = mcpToolDefinitionJson(tool);
334
+ const definitionBytes = Buffer.byteLength(definitionJson, "utf8");
335
+ const definitionLines = definitionJson.split("\n").length;
336
+ const descriptionChars = tool.description.length;
337
+
338
+ if (limits.descriptionChars !== false && descriptionChars > limits.descriptionChars) {
339
+ warnings.push(
340
+ `MCP tool "${tool.name}" description is ${descriptionChars.toLocaleString()} chars ` +
341
+ `(limit ${limits.descriptionChars.toLocaleString()}; Claude Code truncates longer descriptions)`,
342
+ );
343
+ }
344
+ const overBytes = limits.definitionBytes !== false && definitionBytes > limits.definitionBytes;
345
+ const overLines = limits.definitionLines !== false && definitionLines > limits.definitionLines;
346
+ if (overBytes || overLines) {
347
+ const byteLimit = limits.definitionBytes === false ? "∞" : limits.definitionBytes.toLocaleString();
348
+ const lineLimit = limits.definitionLines === false ? "∞" : limits.definitionLines.toLocaleString();
349
+ warnings.push(
350
+ `MCP tool "${tool.name}" definition is ${definitionBytes.toLocaleString()} bytes / ` +
351
+ `${definitionLines.toLocaleString()} lines pretty-printed (limit ${byteLimit} bytes / ${lineLimit} lines; ` +
352
+ `Cursor reads tool definitions in chunks of at most that size)`,
353
+ );
354
+ }
355
+
356
+ return { definitionBytes, definitionLines, descriptionChars, name: tool.name };
357
+ });
358
+
359
+ const instructionsChars = (root.mcpServer?.instructions ?? "").length;
360
+ if (limits.instructionsChars !== false && instructionsChars > limits.instructionsChars) {
361
+ warnings.push(
362
+ `MCP instructions are ${instructionsChars.toLocaleString()} chars (limit ${limits.instructionsChars.toLocaleString()})`,
363
+ );
364
+ }
365
+
366
+ return { instructionsChars, tools, warnings };
367
+ }
@@ -33,7 +33,8 @@ import { buildInvokeHookContext, classifyFailureKind, runErrorPipeline, runHook
33
33
  import { httpServeHttp } from "../http/server.ts";
34
34
  import { LogEmitter } from "../log/emitter.ts";
35
35
  import { bootstrapMcpEnv } from "../mcp/env.ts";
36
- import { mcpServeStdioLoop } from "../mcp/server.ts";
36
+ import { MCP_PROTOCOL_VERSIONS, mcpServeStdioLoop } from "../mcp/server.ts";
37
+ import { mcpSizeReport } from "../mcp/tools.ts";
37
38
  import { createServerRuntime, type ServerHandleContext } from "../server/context.ts";
38
39
  import { resolveHttpServeConfig, resolveMcpServeConfig, type ServeOverrides } from "../server/overrides.ts";
39
40
  import {
@@ -414,6 +415,7 @@ export class Cli {
414
415
  emitter,
415
416
  mcp: resolved,
416
417
  mcpHooks: this.program.mcpServer?.hooks,
418
+ mcpProtocolVersion: MCP_PROTOCOL_VERSIONS[0],
417
419
  };
418
420
  bootstrapAppConfig(this.program, { validateFile: "soft", runtime, emitter });
419
421
  const shutdown = () => {
@@ -422,6 +424,9 @@ export class Cli {
422
424
  };
423
425
  process.once("SIGINT", shutdown);
424
426
  process.once("SIGTERM", shutdown);
427
+ for (const message of mcpSizeReport(this.program).warnings) {
428
+ emitter.emit({ level: "warn", message, action: "mcp.size" });
429
+ }
425
430
  emitter.emitLifecycle(`${this.program.key} ${this.program.version} — MCP ready (stdio)`, "mcp.server.ready");
426
431
  await mcpServeStdioLoop(this);
427
432
  process.exit(0);
@@ -14,6 +14,12 @@ export interface ServerHandleContext {
14
14
  mcp?: ResolvedMcpServeConfig;
15
15
  httpHooks?: CliHttpWireHooks;
16
16
  mcpHooks?: CliMcpWireHooks;
17
+ /**
18
+ * The MCP protocol version negotiated with `initialize`, or the newest supported version before
19
+ * `initialize` has been handled. Later requests (`tools/list`, `tools/call`) gate version-specific
20
+ * response fields (e.g. `outputSchema`, `structuredContent`) on this.
21
+ */
22
+ mcpProtocolVersion?: string;
17
23
  }
18
24
 
19
25
  /** Creates a fresh {@link ServerRuntime} for HTTP or MCP. */
@@ -353,6 +353,79 @@ test("MCP initialize returns tools and resources capabilities", async () => {
353
353
  expect(res.result.capabilities.resources).toBeDefined();
354
354
  });
355
355
 
356
+ test("MCP initialize echoes a supported protocol version", async () => {
357
+ for (const version of ["2024-11-05", "2025-06-18"]) {
358
+ const responses = await mcpRequest([
359
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: version } },
360
+ ]);
361
+ const res = responses.get(1) as { result: { protocolVersion: string } };
362
+ expect(res.result.protocolVersion).toBe(version);
363
+ }
364
+ });
365
+
366
+ test("MCP initialize answers unsupported or missing versions with the newest", async () => {
367
+ for (const params of [{ protocolVersion: "2099-01-01" }, {}]) {
368
+ const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params }]);
369
+ const res = responses.get(1) as { result: { protocolVersion: string } };
370
+ expect(res.result.protocolVersion).toBe("2025-06-18");
371
+ }
372
+ });
373
+
374
+ test("2024-11-05 sessions omit outputSchema and structuredContent", async () => {
375
+ const readme = join(import.meta.dir, "..", "..", "..", "README.md");
376
+ const responses = await mcpRequest([
377
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05" } },
378
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
379
+ {
380
+ jsonrpc: "2.0",
381
+ id: 3,
382
+ method: "tools/call",
383
+ params: { name: "stat_owner_lookup", arguments: { path: readme, "user-name": "test" } },
384
+ },
385
+ ]);
386
+ const listRes = responses.get(2) as { result: { tools: { name: string; outputSchema?: unknown }[] } };
387
+ const lookup = listRes.result.tools.find((t) => t.name === "stat_owner_lookup");
388
+ expect(lookup).toBeDefined();
389
+ expect(lookup?.outputSchema).toBeUndefined();
390
+
391
+ const callRes = responses.get(3) as { result: { structuredContent?: unknown; isError: boolean } };
392
+ expect(callRes.result.isError).toBe(false);
393
+ expect(callRes.result.structuredContent).toBeUndefined();
394
+ });
395
+
396
+ test("MCP initialize includes configured instructions", async () => {
397
+ const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }], {
398
+ script: "src/test/mcp-integration-fixture.ts",
399
+ });
400
+ const res = responses.get(1) as { result: { instructions?: string } };
401
+ expect(res.result.instructions).toBe("Read the fixture skill.");
402
+ });
403
+
404
+ test("MCP startup warns about oversized tools on stderr", async () => {
405
+ const proc = Bun.spawn(["bun", "run", "src/test/mcp-size-fixture.ts", "mcp"], {
406
+ stdin: "pipe",
407
+ stdout: "pipe",
408
+ stderr: "pipe",
409
+ });
410
+ proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`);
411
+ proc.stdin.end();
412
+ const timeout = setTimeout(() => proc.kill(), 10_000);
413
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
414
+ await proc.exited;
415
+ clearTimeout(timeout);
416
+
417
+ expect(stderr).toContain("description is 3,");
418
+
419
+ const lines = stdout
420
+ .split("\n")
421
+ .map((l) => l.trim())
422
+ .filter(Boolean);
423
+ expect(lines.length).toBeGreaterThan(0);
424
+ for (const line of lines) {
425
+ expect(() => JSON.parse(line)).not.toThrow();
426
+ }
427
+ });
428
+
356
429
  test("MCP tools/list includes stat_owner_lookup", async () => {
357
430
  const responses = await mcpRequest([{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }]);
358
431
  const res = responses.get(2) as {
@@ -68,6 +68,7 @@ const program = {
68
68
  key: "mcp-test",
69
69
  mcpServer: {
70
70
  enabled: true,
71
+ instructions: "Read the fixture skill.",
71
72
  resources: [
72
73
  {
73
74
  uri: "test://hello",
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bun
2
+ /*
3
+ MCP size-warning integration test fixture (not a public example). One leaf with oversized notes,
4
+ triggering the "description" startup size warning on stderr.
5
+ */
6
+
7
+ import { Cli, type CliProgram } from "../index.ts";
8
+
9
+ const program = {
10
+ commands: [
11
+ {
12
+ key: "run",
13
+ description: "Run it.",
14
+ notes: "x".repeat(3_000),
15
+ handler: (ctx) => {
16
+ if (ctx.invocation === "cli") {
17
+ console.log("ran");
18
+ return;
19
+ }
20
+ return "ran";
21
+ },
22
+ },
23
+ ],
24
+ description: "MCP size-warning test fixture.",
25
+ key: "mcp-size-test",
26
+ mcpServer: { enabled: true },
27
+ version: "0.0.0-test",
28
+ } satisfies CliProgram;
29
+
30
+ const cli = new Cli(program);
31
+ await cli.run();