argsbarg 7.1.1 → 7.1.3

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 (71) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +7 -7
  3. package/docs/README.md +1 -1
  4. package/docs/cli-program.md +1 -1
  5. package/docs/configure.md +2 -2
  6. package/docs/mcp.md +53 -4
  7. package/docs/output-schema.md +6 -0
  8. package/examples/full-example/AGENTS.md +1 -1
  9. package/examples/full-example/bun.lock +83 -1
  10. package/examples/full-example/justfile +5 -3
  11. package/examples/full-example-json/AGENTS.md +1 -1
  12. package/examples/full-example-json/README.md +1 -0
  13. package/examples/full-example-json/bun.lock +83 -1
  14. package/examples/full-example-json/docs/cli-schema.json +284 -9
  15. package/examples/full-example-json/docs/cli.md +236 -18
  16. package/examples/full-example-json/docs/http.md +1 -0
  17. package/examples/full-example-json/docs/mcp.md +18 -0
  18. package/examples/full-example-json/docs/openapi.json +155 -0
  19. package/examples/full-example-json/justfile +5 -3
  20. package/examples/full-example-json/src/commands/shape-area/__generated__/ShapeAreaInputSchema.json +59 -0
  21. package/examples/full-example-json/src/commands/shape-area/__generated__/index.ts +5 -0
  22. package/examples/full-example-json/src/commands/shape-area/command.test.ts +34 -0
  23. package/examples/full-example-json/src/commands/shape-area/command.ts +31 -0
  24. package/examples/full-example-json/src/commands/shape-area/types.ts +28 -0
  25. package/examples/full-example-json/src/program.ts +2 -1
  26. package/examples/mcp-plugin/.claude-plugin/plugin.json +7 -1
  27. package/examples/mcp-plugin/.cursor-plugin/plugin.json +7 -1
  28. package/examples/mcp-plugin/AGENTS.md +14 -1
  29. package/examples/mcp-plugin/README.md +19 -10
  30. package/examples/mcp-plugin/bun.lock +83 -1
  31. package/examples/mcp-plugin/bunfig.toml +4 -0
  32. package/examples/mcp-plugin/docs/node-distro.md +97 -0
  33. package/examples/mcp-plugin/justfile +12 -11
  34. package/examples/mcp-plugin/package.json +2 -1
  35. package/examples/mcp-plugin/scripts/release.ts +10 -11
  36. package/index.d.ts +62 -0
  37. package/package.json +1 -1
  38. package/src/cli-tool/create.test.ts +14 -0
  39. package/src/cli-tool/create.ts +9 -0
  40. package/src/cli-tool/main.ts +1 -1
  41. package/src/cli-tool/schemagen/run.ts +41 -2
  42. package/src/cli-tool/schemagen/schemagen.test.ts +87 -2
  43. package/src/config/validate.test.ts +157 -0
  44. package/src/config/validate.ts +353 -26
  45. package/src/core/document-leaf.test.ts +53 -0
  46. package/src/core/json-pointer.ts +46 -0
  47. package/src/core/types.ts +33 -0
  48. package/src/core/validate.ts +68 -1
  49. package/src/docs/docs.test.ts +7 -0
  50. package/src/docs/mcp-guide.ts +43 -1
  51. package/src/headless/tool-call.test.ts +74 -2
  52. package/src/headless/tool-call.ts +44 -22
  53. package/src/http/schema-deref.ts +1 -23
  54. package/src/index.ts +3 -0
  55. package/src/mcp/server.ts +28 -4
  56. package/src/mcp/tools.test.ts +292 -0
  57. package/src/mcp/tools.ts +144 -6
  58. package/src/runtime/cli.ts +6 -1
  59. package/src/server/context.ts +6 -0
  60. package/src/test/integration/mcp.test.ts +73 -0
  61. package/src/test/mcp-integration-fixture.ts +1 -0
  62. package/src/test/mcp-size-fixture.ts +31 -0
  63. package/examples/mcp-plugin/.mcp.json +0 -6
  64. package/examples/mcp-plugin/mcp.json +0 -8
  65. package/examples/mcp-plugin/scripts/mcp.mjs +0 -11106
  66. package/examples/mcp-plugin/src/commands/render-json/__generated__/RenderJsonInputSchema.json +0 -15
  67. package/examples/mcp-plugin/src/commands/render-json/__generated__/index.ts +0 -5
  68. package/examples/mcp-plugin/src/commands/status/__generated__/StatusJsonOutputSchema.json +0 -15
  69. package/examples/mcp-plugin/src/commands/status/__generated__/index.ts +0 -5
  70. package/examples/mcp-plugin/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +0 -15
  71. package/examples/mcp-plugin/src/commands/workspaces/__generated__/index.ts +0 -5
@@ -3,7 +3,15 @@ import { displayAppConfigPath } from "../config/file.ts";
3
3
  import { expectedMcpEntry } from "../configure/artifacts/mcp-config.ts";
4
4
  import { resolveClaudeDesktopMcpPath, userHome } from "../configure/artifacts/paths.ts";
5
5
  import { CliOptionKind, type CliProgram } from "../core/types.ts";
6
- import { collectMcpTools, leafWireOptions, type McpToolDef, mcpServerId, resolveMcpSchemaUri } from "../mcp/tools.ts";
6
+ import {
7
+ collectMcpTools,
8
+ DEFAULT_MCP_SIZE_LIMITS,
9
+ leafWireOptions,
10
+ type McpToolDef,
11
+ mcpServerId,
12
+ mcpSizeReport,
13
+ resolveMcpSchemaUri,
14
+ } from "../mcp/tools.ts";
7
15
  import { resolveCapabilities } from "../runtime/capabilities.ts";
8
16
  import { resolveDocsTopicResourceUri } from "./mcp-resources.ts";
9
17
  import { docsEnabled, docsUserTopicKeys, resolveDocsConfig } from "./resolve.ts";
@@ -173,6 +181,9 @@ export function generateMcpGuide(root: CliProgram): string {
173
181
  "| `tools/call` | Runs handlers headlessly; JSON stdout becomes `structuredContent` when valid |",
174
182
  `| Schema resource | \`${schemaUri}\` — same JSON as \`${root.key} docs cli-schema\` |`,
175
183
  );
184
+ if (mcp.instructions) {
185
+ lines.push(`| \`initialize.instructions\` | ${mcp.instructions} |`);
186
+ }
176
187
  if (docsEnabled(root)) {
177
188
  const docs = resolveDocsConfig(root);
178
189
  for (const key of docsUserTopicKeys(docs)) {
@@ -191,6 +202,37 @@ export function generateMcpGuide(root: CliProgram): string {
191
202
  lines.push("");
192
203
  }
193
204
 
205
+ const sizeReport = mcpSizeReport(root);
206
+ if (sizeReport.tools.length > 0) {
207
+ const limits = { ...DEFAULT_MCP_SIZE_LIMITS, ...root.mcpServer?.sizeLimits };
208
+ lines.push(
209
+ "## Tool sizes",
210
+ "",
211
+ `Clients read tool definitions with their own limits — a definition or description past those is truncated ` +
212
+ `or read incompletely. Default limits here: description ${limits.descriptionChars === false ? "unchecked" : `${limits.descriptionChars.toLocaleString()} chars`}, ` +
213
+ `definition ${limits.definitionBytes === false ? "unchecked" : `${limits.definitionBytes.toLocaleString()} bytes`} / ` +
214
+ `${limits.definitionLines === false ? "unchecked" : `${limits.definitionLines.toLocaleString()} lines`} (override with \`mcpServer.sizeLimits\`).`,
215
+ "",
216
+ "| Tool | Description (chars) | Definition (bytes) | Definition (lines) | Status |",
217
+ "| --- | --- | --- | --- | --- |",
218
+ );
219
+ for (const t of sizeReport.tools) {
220
+ const over: string[] = [];
221
+ if (limits.descriptionChars !== false && t.descriptionChars > limits.descriptionChars) over.push("description");
222
+ if (
223
+ (limits.definitionBytes !== false && t.definitionBytes > limits.definitionBytes) ||
224
+ (limits.definitionLines !== false && t.definitionLines > limits.definitionLines)
225
+ ) {
226
+ over.push("definition");
227
+ }
228
+ const status = over.length === 0 ? "ok" : `over: ${over.join(", ")}`;
229
+ lines.push(
230
+ `| \`${t.name}\` | ${t.descriptionChars.toLocaleString()} | ${t.definitionBytes.toLocaleString()} | ${t.definitionLines.toLocaleString()} | ${status} |`,
231
+ );
232
+ }
233
+ lines.push("");
234
+ }
235
+
194
236
  lines.push(
195
237
  "## Tool arguments",
196
238
  "",
@@ -1,7 +1,15 @@
1
- /* Unit tests for shared headless error text (MCP and HTTP). */
1
+ /* Unit tests for shared headless error text (MCP and HTTP) and wrapped MCP tool calls. */
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
- import { type HeadlessToolCallFailure, headlessFailureMcpMessage, headlessFailureToHttpResponse } from "./tool-call.ts";
4
+ import { Cli, type CliContext } from "../index.ts";
5
+ import { collectMcpTools } from "../mcp/tools.ts";
6
+ import { requireMcpTool, testProgram } from "../test/fixtures.ts";
7
+ import {
8
+ executeHeadlessToolCall,
9
+ type HeadlessToolCallFailure,
10
+ headlessFailureMcpMessage,
11
+ headlessFailureToHttpResponse,
12
+ } from "./tool-call.ts";
5
13
 
6
14
  /** Builds a failed headless result with the given error message. */
7
15
  function invokeFailure(
@@ -30,3 +38,67 @@ describe("headless error text", () => {
30
38
  expect(body.error).toBe(multiline);
31
39
  });
32
40
  });
41
+
42
+ describe("wrapped MCP tools", () => {
43
+ /** Union input leaf that echoes its inputs; output is an array so structuredContent gets wrapped too. */
44
+ const program = testProgram({
45
+ key: "wrapcall",
46
+ description: "Wrapped call demo.",
47
+ mcpServer: { enabled: true },
48
+ commands: [
49
+ {
50
+ key: "edit",
51
+ description: "Edit.",
52
+ kind: "document",
53
+ inputSchema: {
54
+ anyOf: [{ $ref: "#/definitions/Append" }, { $ref: "#/definitions/Replace" }],
55
+ definitions: {
56
+ Append: {
57
+ type: "object",
58
+ properties: { kind: { const: "append" }, text: { type: "string" } },
59
+ required: ["kind", "text"],
60
+ additionalProperties: false,
61
+ },
62
+ Replace: {
63
+ type: "object",
64
+ properties: { kind: { const: "replace" }, find: { type: "string" }, text: { type: "string" } },
65
+ required: ["kind", "find", "text"],
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ },
70
+ outputSchema: { type: "array" },
71
+ handler: (ctx: CliContext) => [ctx.inputs],
72
+ },
73
+ ],
74
+ });
75
+ const cli = new Cli(program);
76
+ const tool = requireMcpTool(collectMcpTools(program), "edit");
77
+
78
+ test("unwraps input and wraps structuredContent under result", async () => {
79
+ const result = await executeHeadlessToolCall(cli, tool, { input: { kind: "append", text: "hi" } }, "mcp");
80
+ expect(result.ok).toBe(true);
81
+ if (result.ok) {
82
+ expect(result.mcpResult.structuredContent).toEqual({ result: [{ kind: "append", text: "hi" }] });
83
+ }
84
+ });
85
+
86
+ test("still validates the exact union after unwrapping", async () => {
87
+ const result = await executeHeadlessToolCall(
88
+ cli,
89
+ tool,
90
+ { input: { kind: "append", text: "hi", find: "x" } },
91
+ "mcp",
92
+ );
93
+ expect(result.ok).toBe(false);
94
+ });
95
+
96
+ test("rejects arguments that are not wrapped", async () => {
97
+ const result = await executeHeadlessToolCall(cli, tool, { kind: "append", text: "hi" }, "mcp");
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) {
100
+ expect(result.kind).toBe("argv");
101
+ expect(result.message).toContain('single "input" object property');
102
+ }
103
+ });
104
+ });
@@ -10,7 +10,13 @@ import { apiErrorResponse, apiSuccessResponse, stripAnsi } from "../http/result.
10
10
  import { type HttpRouteDef, httpRequestToArgv } from "../http/routes.ts";
11
11
  import { obscureUnexpectedClientMessage } from "../log/emitter.ts";
12
12
  import { buildToolCallSuccessFromResponse } from "../mcp/result.ts";
13
- import { collectMcpTools, type McpToolDef, mcpToolCallToArgv } from "../mcp/tools.ts";
13
+ import {
14
+ collectMcpTools,
15
+ MCP_INPUT_WRAPPER_KEY,
16
+ MCP_OUTPUT_WRAPPER_KEY,
17
+ type McpToolDef,
18
+ mcpToolCallToArgv,
19
+ } from "../mcp/tools.ts";
14
20
  import type { Cli, CliInvokeResult } from "../runtime/cli.ts";
15
21
 
16
22
  /** Outcome of resolving a tool name against the program schema. */
@@ -86,8 +92,31 @@ function noResponseFailure(result: CliInvokeResult): HeadlessToolCallFailure {
86
92
  };
87
93
  }
88
94
 
95
+ /** Pre-invoke argument failure (bad shape or argv conversion error). */
96
+ function argvFailure(
97
+ /** Client-facing error message. */
98
+ message: string,
99
+ ): HeadlessToolCallFailure {
100
+ return { ok: false, kind: "argv", message, exitCode: 1, stdout: "", stderr: "", failureKind: "validation" };
101
+ }
102
+
103
+ /** Returns the leaf input from wrapped tool arguments (`{ input: {...} }`), or `undefined` when malformed. */
104
+ function unwrapToolArgs(
105
+ /** Raw tools/call arguments. */
106
+ args: Record<string, unknown>,
107
+ ): Record<string, unknown> | undefined {
108
+ const inner = args[MCP_INPUT_WRAPPER_KEY];
109
+ const onlyWrapperKey = Object.keys(args).every((key) => key === MCP_INPUT_WRAPPER_KEY);
110
+ if (!onlyWrapperKey || typeof inner !== "object" || inner === null || Array.isArray(inner)) {
111
+ return undefined;
112
+ }
113
+ return inner as Record<string, unknown>;
114
+ }
115
+
89
116
  /**
90
117
  * Converts flat tool arguments to argv and invokes the leaf handler headlessly.
118
+ * Wrapped tools (see `wrapMcpRootSchema`) receive `{ input: {...} }`, unwrapped here, and return
119
+ * `structuredContent` wrapped as `{ result: ... }` to match their `outputSchema`.
91
120
  */
92
121
  export async function executeHeadlessToolCall(
93
122
  cli: Cli,
@@ -96,20 +125,19 @@ export async function executeHeadlessToolCall(
96
125
  invocation: CliInvocation,
97
126
  mcp?: { rpcMethod: string; toolName?: string; requestId: string },
98
127
  ): Promise<HeadlessToolCallResult> {
99
- const argvResult = mcpToolCallToArgv(cli.program, tool, args);
128
+ const leafArgs = tool.inputWrapped ? unwrapToolArgs(args) : args;
129
+ if (leafArgs === undefined) {
130
+ return argvFailure(
131
+ `Tool arguments must be an object with a single "${MCP_INPUT_WRAPPER_KEY}" object property (see inputSchema)`,
132
+ );
133
+ }
134
+
135
+ const argvResult = mcpToolCallToArgv(cli.program, tool, leafArgs);
100
136
  if ("error" in argvResult) {
101
- return {
102
- ok: false,
103
- kind: "argv",
104
- message: argvResult.error,
105
- exitCode: 1,
106
- stdout: "",
107
- stderr: "",
108
- failureKind: "validation",
109
- };
137
+ return argvFailure(argvResult.error);
110
138
  }
111
139
 
112
- const invokeResult = await cli.invoke(argvResult, { invocation, toolArgs: args, mcp });
140
+ const invokeResult = await cli.invoke(argvResult, { invocation, toolArgs: leafArgs, mcp });
113
141
  if (invokeResult.kind === "help") {
114
142
  return invokeFailure(invokeResult);
115
143
  }
@@ -119,7 +147,9 @@ export async function executeHeadlessToolCall(
119
147
  return {
120
148
  ok: true,
121
149
  response: invokeResult.response,
122
- mcpResult,
150
+ mcpResult: tool.outputWrapped
151
+ ? { ...mcpResult, structuredContent: { [MCP_OUTPUT_WRAPPER_KEY]: mcpResult.structuredContent } }
152
+ : mcpResult,
123
153
  };
124
154
  }
125
155
 
@@ -143,15 +173,7 @@ export async function executeHttpRouteCall(
143
173
  ): Promise<HeadlessToolCallResult> {
144
174
  const argvResult = httpRequestToArgv(cli.program, route, pathParams, query, body);
145
175
  if ("error" in argvResult) {
146
- return {
147
- ok: false,
148
- kind: "argv",
149
- message: argvResult.error,
150
- exitCode: 1,
151
- stdout: "",
152
- stderr: "",
153
- failureKind: "validation",
154
- };
176
+ return argvFailure(argvResult.error);
155
177
  }
156
178
 
157
179
  const toolArgs = { ...body, ...query, ...pathParams };
@@ -2,34 +2,12 @@
2
2
  Inline JSON Schema $ref dereferencing for OpenAPI embedding.
3
3
  */
4
4
 
5
- function decodeJsonPointerSegment(segment: string): string {
6
- return segment.replace(/~1/g, "/").replace(/~0/g, "~");
7
- }
5
+ import { resolveJsonPointer } from "../core/json-pointer.ts";
8
6
 
9
7
  function isPlainObject(value: unknown): value is Record<string, unknown> {
10
8
  return value !== null && typeof value === "object" && !Array.isArray(value);
11
9
  }
12
10
 
13
- /** Resolves a same-document JSON Pointer (`#/definitions/Foo`). */
14
- function resolveJsonPointer(root: Record<string, unknown>, ref: string): unknown {
15
- if (!ref.startsWith("#/")) {
16
- return undefined;
17
- }
18
- const segments = ref
19
- .slice(2)
20
- .split("/")
21
- .filter((segment) => segment.length > 0)
22
- .map(decodeJsonPointerSegment);
23
- let current: unknown = root;
24
- for (const segment of segments) {
25
- if (!isPlainObject(current)) {
26
- return undefined;
27
- }
28
- current = current[segment];
29
- }
30
- return current;
31
- }
32
-
33
11
  function derefValue(value: unknown, root: Record<string, unknown>, resolving: Set<string>): unknown {
34
12
  if (Array.isArray(value)) {
35
13
  return value.map((item) => derefValue(item, root, resolving));
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export type {
44
44
  CliMcpBundleConfig,
45
45
  CliMcpResource,
46
46
  CliMcpServerConfig,
47
+ CliMcpSizeLimits,
47
48
  CliMcpToolConfig,
48
49
  CliMcpWireContext,
49
50
  CliMcpWireHooks,
@@ -87,6 +88,8 @@ export type { EcsLogEvent, LogEnrichContext } from "./log/ecs.ts";
87
88
  export { ECS_VERSION, formatEcsLine } from "./log/ecs.ts";
88
89
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
89
90
  export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
91
+ export type { McpSizeReport, McpToolSize } from "./mcp/tools.ts";
92
+ export { DEFAULT_MCP_SIZE_LIMITS, mcpSizeReport } from "./mcp/tools.ts";
90
93
  export { userHome } from "./paths/host.ts";
91
94
  export { Cli, type CliInvokeKind, type CliInvokeResult } from "./runtime/cli.ts";
92
95
  export { cliErrWithHelp } from "./runtime/cli-errors.ts";
package/src/mcp/server.ts CHANGED
@@ -8,7 +8,11 @@ import { executeHeadlessToolCall, headlessFailureMcpMessage, lookupHeadlessTool
8
8
  import type { Cli } from "../runtime/cli.ts";
9
9
  import { allMcpResources, collectMcpTools, resolveMcpServerInfo } from "./tools.ts";
10
10
 
11
- const MCP_PROTOCOL_VERSION = "2024-11-05";
11
+ /** Protocol versions this server understands, newest first. `initialize` echoes a match or answers the first. */
12
+ export const MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2024-11-05"] as const;
13
+
14
+ /** The first protocol version to define `outputSchema` (tools/list) and `structuredContent` (tools/call). */
15
+ const MCP_STRUCTURED_OUTPUT_SINCE = "2025-06-18";
12
16
 
13
17
  /** JSON-RPC request shape from stdin. */
14
18
  interface JsonRpcRequest {
@@ -35,6 +39,13 @@ function writeError(id: string | number | null | undefined, code: number, messag
35
39
  });
36
40
  }
37
41
 
42
+ /** True when `version` is at or after {@link MCP_STRUCTURED_OUTPUT_SINCE} in {@link MCP_PROTOCOL_VERSIONS}. */
43
+ function supportsStructuredOutput(version: string | undefined): boolean {
44
+ const idx = version ? (MCP_PROTOCOL_VERSIONS as readonly string[]).indexOf(version) : -1;
45
+ const sinceIdx = (MCP_PROTOCOL_VERSIONS as readonly string[]).indexOf(MCP_STRUCTURED_OUTPUT_SINCE);
46
+ return idx !== -1 && idx <= sinceIdx;
47
+ }
48
+
38
49
  /** Handles one NDJSON request line. */
39
50
  async function handleRequestLine(cli: Cli, line: string): Promise<void> {
40
51
  const root = cli.program;
@@ -98,13 +109,23 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
98
109
  try {
99
110
  if (method === "initialize") {
100
111
  const info = resolveMcpServerInfo(root);
112
+ const requested = params.protocolVersion;
113
+ const negotiated =
114
+ typeof requested === "string" && (MCP_PROTOCOL_VERSIONS as readonly string[]).includes(requested)
115
+ ? requested
116
+ : MCP_PROTOCOL_VERSIONS[0];
117
+ if (cli.server) {
118
+ cli.server.mcpProtocolVersion = negotiated;
119
+ }
120
+ const instructions = root.mcpServer?.instructions;
101
121
  writeResponse({
102
122
  jsonrpc: "2.0",
103
123
  id,
104
124
  result: {
105
- protocolVersion: MCP_PROTOCOL_VERSION,
125
+ protocolVersion: negotiated,
106
126
  capabilities: { tools: {}, resources: {} },
107
127
  serverInfo: { name: info.name, version: info.version },
128
+ ...(instructions ? { instructions } : {}),
108
129
  },
109
130
  });
110
131
  await finish();
@@ -118,11 +139,12 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
118
139
  }
119
140
 
120
141
  if (method === "tools/list") {
142
+ const structured = supportsStructuredOutput(cli.server?.mcpProtocolVersion);
121
143
  const tools = collectMcpTools(root).map((t) => ({
122
144
  name: t.name,
123
145
  description: t.description,
124
146
  inputSchema: t.inputSchema,
125
- ...(t.outputSchema === undefined ? {} : { outputSchema: t.outputSchema }),
147
+ ...(structured && t.outputSchema !== undefined ? { outputSchema: t.outputSchema } : {}),
126
148
  }));
127
149
  writeResponse({ jsonrpc: "2.0", id, result: { tools } });
128
150
  await finish();
@@ -168,10 +190,12 @@ async function handleRequestLine(cli: Cli, line: string): Promise<void> {
168
190
  { rpcMethod: method, toolName: name, requestId },
169
191
  );
170
192
  if (invokeResult.ok) {
193
+ const structured = supportsStructuredOutput(cli.server?.mcpProtocolVersion);
194
+ const { structuredContent: _structuredContent, ...rest } = invokeResult.mcpResult;
171
195
  writeResponse({
172
196
  jsonrpc: "2.0",
173
197
  id,
174
- result: invokeResult.mcpResult,
198
+ result: structured ? invokeResult.mcpResult : rest,
175
199
  });
176
200
  await finish();
177
201
  return;
@@ -0,0 +1,292 @@
1
+ /*
2
+ Tests for mcp/tools module: MCP tool derivation, size reporting, per-leaf MCP-only notes,
3
+ object-root schema wrapping, and the MCP tool schema startup check.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { cliPresentationRoot } from "../builtins/presentation.ts";
8
+ import { CliOptionKind } from "../core/types.ts";
9
+ import { cliValidateProgram } from "../core/validate.ts";
10
+ import { cliHelpRender } from "../help.ts";
11
+ import { requireMcpTool, testProgram } from "../test/fixtures.ts";
12
+ import {
13
+ collectMcpTools,
14
+ DEFAULT_MCP_SIZE_LIMITS,
15
+ MCP_INPUT_WRAPPER_KEY,
16
+ mcpSizeReport,
17
+ wrapMcpRootSchema,
18
+ } from "./tools.ts";
19
+
20
+ describe("mcpSizeReport", () => {
21
+ test("measures description and pretty definition against a hand-built JSON.stringify", () => {
22
+ const program = testProgram({
23
+ key: "sizetest",
24
+ description: "size test",
25
+ mcpServer: { enabled: true },
26
+ commands: [{ key: "run", description: "Run it.", handler: () => {} }],
27
+ });
28
+ const report = mcpSizeReport(program);
29
+ const [tool] = collectMcpTools(program);
30
+ expect(tool).toBeDefined();
31
+
32
+ const expectedJson = JSON.stringify(
33
+ { name: tool?.name, description: tool?.description, inputSchema: tool?.inputSchema },
34
+ null,
35
+ 2,
36
+ );
37
+ expect(report.tools).toHaveLength(1);
38
+ expect(report.tools[0]).toEqual({
39
+ name: tool?.name,
40
+ descriptionChars: tool?.description.length,
41
+ definitionBytes: Buffer.byteLength(expectedJson, "utf8"),
42
+ definitionLines: expectedJson.split("\n").length,
43
+ });
44
+ expect(report.warnings).toEqual([]);
45
+ expect(report.instructionsChars).toBe(0);
46
+ });
47
+
48
+ test("warns past default limits for description and definition size", () => {
49
+ const program = testProgram({
50
+ key: "sizetest2",
51
+ description: "size test 2",
52
+ mcpServer: { enabled: true },
53
+ commands: [
54
+ {
55
+ key: "small",
56
+ description: "Small tool.",
57
+ notes: "x".repeat(3_000),
58
+ handler: () => {},
59
+ },
60
+ {
61
+ key: "big",
62
+ description: "Big tool.",
63
+ options: [
64
+ {
65
+ name: "mode",
66
+ description: "Mode.",
67
+ kind: CliOptionKind.Enum,
68
+ choices: Array.from({ length: 4_000 }, (_, i) => `choice-${i}`),
69
+ },
70
+ ],
71
+ handler: () => {},
72
+ },
73
+ ],
74
+ });
75
+ const report = mcpSizeReport(program);
76
+
77
+ const smallWarning = report.warnings.find((w) => w.includes('"small"'));
78
+ expect(smallWarning).toBeDefined();
79
+ expect(smallWarning).toContain("description is");
80
+ expect(smallWarning).toContain(`limit ${DEFAULT_MCP_SIZE_LIMITS.descriptionChars.toLocaleString()}`);
81
+
82
+ const bigWarning = report.warnings.find((w) => w.includes('"big"'));
83
+ expect(bigWarning).toBeDefined();
84
+ expect(bigWarning).toContain("definition is");
85
+ expect(bigWarning).toContain("pretty-printed");
86
+ });
87
+
88
+ test("sizeLimits overrides raise or lower the threshold", () => {
89
+ const program = testProgram({
90
+ key: "sizetest3",
91
+ description: "size test 3",
92
+ mcpServer: { enabled: true, sizeLimits: { descriptionChars: 5 } },
93
+ commands: [
94
+ { key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
95
+ ],
96
+ });
97
+ const report = mcpSizeReport(program);
98
+ expect(report.warnings.some((w) => w.includes("description is"))).toBe(true);
99
+ });
100
+
101
+ test("sizeLimits: false disables a check entirely", () => {
102
+ const program = testProgram({
103
+ key: "sizetest4",
104
+ description: "size test 4",
105
+ mcpServer: { enabled: true, sizeLimits: { descriptionChars: false } },
106
+ commands: [
107
+ { key: "run", description: "Run it, with a description longer than five characters.", handler: () => {} },
108
+ ],
109
+ });
110
+ const report = mcpSizeReport(program);
111
+ expect(report.warnings.some((w) => w.includes("description is"))).toBe(false);
112
+ });
113
+
114
+ test("warns when instructions exceed the limit", () => {
115
+ const program = testProgram({
116
+ key: "sizetest5",
117
+ description: "size test 5",
118
+ mcpServer: { enabled: true, instructions: "x".repeat(3_000) },
119
+ commands: [{ key: "run", description: "Run it.", handler: () => {} }],
120
+ });
121
+ const report = mcpSizeReport(program);
122
+ expect(report.instructionsChars).toBe(3_000);
123
+ expect(report.warnings.some((w) => w.startsWith("MCP instructions are"))).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe("mcpTool.notes", () => {
128
+ function programWithNotesOverride(notesOverride: string | false | undefined) {
129
+ return testProgram({
130
+ key: "notestest",
131
+ description: "notes test",
132
+ mcpServer: { enabled: true },
133
+ commands: [
134
+ {
135
+ key: "run",
136
+ description: "Run it.",
137
+ notes: "Original CLI notes.",
138
+ ...(notesOverride === undefined ? {} : { mcpTool: { notes: notesOverride } }),
139
+ handler: () => {},
140
+ },
141
+ ],
142
+ });
143
+ }
144
+
145
+ test("false omits notes from the MCP description", () => {
146
+ const [tool] = collectMcpTools(programWithNotesOverride(false));
147
+ expect(tool?.description).not.toContain("Original CLI notes.");
148
+ });
149
+
150
+ test("a string replaces the leaf's notes in the MCP description", () => {
151
+ const [tool] = collectMcpTools(programWithNotesOverride("Custom MCP-only note."));
152
+ expect(tool?.description).toContain("Custom MCP-only note.");
153
+ expect(tool?.description).not.toContain("Original CLI notes.");
154
+ });
155
+
156
+ test("omitted falls through to the leaf's own notes", () => {
157
+ const [tool] = collectMcpTools(programWithNotesOverride(undefined));
158
+ expect(tool?.description).toContain("Original CLI notes.");
159
+ });
160
+
161
+ test("CLI help always shows the leaf's own notes regardless of mcpTool.notes", () => {
162
+ const program = programWithNotesOverride(false);
163
+ const help = cliHelpRender(cliPresentationRoot(program), ["run"], false);
164
+ expect(help).toContain("Original CLI notes.");
165
+ });
166
+ });
167
+
168
+ /** Discriminated-union input: `anyOf` root with `$ref` branches, as ts-json-schema-generator writes it. */
169
+ const unionInputSchema: Record<string, unknown> = {
170
+ $schema: "http://json-schema.org/draft-07/schema#",
171
+ description: "Edit operation.",
172
+ anyOf: [{ $ref: "#/definitions/Append" }, { $ref: "#/definitions/Replace" }],
173
+ definitions: {
174
+ Append: {
175
+ type: "object",
176
+ properties: { kind: { const: "append" }, text: { type: "string" } },
177
+ required: ["kind", "text"],
178
+ additionalProperties: false,
179
+ },
180
+ Replace: {
181
+ type: "object",
182
+ properties: { kind: { const: "replace" }, find: { type: "string" }, text: { type: "string" } },
183
+ required: ["kind", "find", "text"],
184
+ additionalProperties: false,
185
+ },
186
+ },
187
+ };
188
+
189
+ describe("MCP object-root wrapping", () => {
190
+ test("object-rooted schemas pass through unchanged", () => {
191
+ const schema = { type: "object", properties: { a: { type: "string" } } };
192
+ expect(wrapMcpRootSchema(schema, MCP_INPUT_WRAPPER_KEY)).toEqual({ schema, wrapped: false });
193
+ });
194
+
195
+ test("union roots wrap under input with $schema and definitions moved to the new root", () => {
196
+ const { schema, wrapped } = wrapMcpRootSchema(unionInputSchema, MCP_INPUT_WRAPPER_KEY);
197
+ expect(wrapped).toBe(true);
198
+ expect(schema).toEqual({
199
+ $schema: unionInputSchema.$schema,
200
+ type: "object",
201
+ properties: { input: { description: "Edit operation.", anyOf: unionInputSchema.anyOf } },
202
+ required: ["input"],
203
+ additionalProperties: false,
204
+ definitions: unionInputSchema.definitions,
205
+ });
206
+ });
207
+
208
+ test("collectMcpTools wraps non-object input and output schemas and flags them", () => {
209
+ const program = testProgram({
210
+ key: "wrapapp",
211
+ description: "Wrap demo.",
212
+ mcpServer: { enabled: true },
213
+ commands: [
214
+ {
215
+ key: "edit",
216
+ description: "Edit.",
217
+ kind: "document",
218
+ inputSchema: unionInputSchema,
219
+ outputSchema: { type: "array", items: { type: "string" } },
220
+ handler: () => [],
221
+ },
222
+ { key: "plain", description: "Plain.", handler: () => {} },
223
+ ],
224
+ });
225
+ const tools = collectMcpTools(program);
226
+ const edit = requireMcpTool(tools, "edit");
227
+ expect(edit.inputWrapped).toBe(true);
228
+ expect(edit.inputSchema.type).toBe("object");
229
+ expect(edit.outputWrapped).toBe(true);
230
+ expect(edit.outputSchema).toEqual({
231
+ type: "object",
232
+ properties: { result: { type: "array", items: { type: "string" } } },
233
+ required: ["result"],
234
+ additionalProperties: false,
235
+ });
236
+ const plain = requireMcpTool(tools, "plain");
237
+ expect(plain.inputWrapped).toBe(false);
238
+ expect(plain.outputWrapped).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe("MCP tool schema startup check", () => {
243
+ /** Program with one document leaf using `inputSchema`, MCP on or off. */
244
+ function schemaProgram(inputSchema: Record<string, unknown>, mcpEnabled: boolean) {
245
+ return testProgram({
246
+ key: "checkapp",
247
+ description: "Check demo.",
248
+ ...(mcpEnabled ? { mcpServer: { enabled: true } } : {}),
249
+ commands: [{ key: "run", description: "Run.", kind: "document", inputSchema, handler: () => {} }],
250
+ });
251
+ }
252
+
253
+ test("accepts wrapped schemas whose definitions still resolve", () => {
254
+ expect(() => cliValidateProgram(schemaProgram(unionInputSchema, true))).not.toThrow();
255
+ });
256
+
257
+ test("rejects an unresolved local $ref when MCP is enabled", () => {
258
+ const dangling = { $ref: "#/definitions/Missing", definitions: {} };
259
+ expect(() => cliValidateProgram(schemaProgram(dangling, true))).toThrow(
260
+ 'MCP tool "run" inputSchema has an unresolved $ref: #/definitions/Missing',
261
+ );
262
+ });
263
+
264
+ test('rejects $ref "#" in a wrapped schema', () => {
265
+ const recursive = { anyOf: [{ type: "object" }, { type: "array", items: { $ref: "#" } }] };
266
+ expect(() => cliValidateProgram(schemaProgram(recursive, true))).toThrow('uses $ref "#"');
267
+ });
268
+
269
+ test("skips the check when MCP is disabled", () => {
270
+ const dangling = { $ref: "#/definitions/Missing", definitions: {} };
271
+ expect(() => cliValidateProgram(schemaProgram(dangling, false))).not.toThrow();
272
+ });
273
+
274
+ test("skips MCP-hidden leaves", () => {
275
+ const program = testProgram({
276
+ key: "checkapp",
277
+ description: "Check demo.",
278
+ mcpServer: { enabled: true },
279
+ commands: [
280
+ {
281
+ key: "run",
282
+ description: "Run.",
283
+ kind: "document",
284
+ inputSchema: { $ref: "#/definitions/Missing", definitions: {} },
285
+ mcpTool: { hidden: true },
286
+ handler: () => {},
287
+ },
288
+ ],
289
+ });
290
+ expect(() => cliValidateProgram(program)).not.toThrow();
291
+ });
292
+ });