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.
@@ -72,15 +72,360 @@ function formatInstancePath(instanceLocation: string): string {
72
72
  return instanceLocation;
73
73
  }
74
74
 
75
- function formatValidationErrors(errors: { instanceLocation: string; error: string }[]): string[] {
76
- return errors.map(({ instanceLocation, error }) => {
77
- const path = formatInstancePath(instanceLocation);
78
- return `${path}: ${error}`;
75
+ function decodeJsonPointerSegment(segment: string): string {
76
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
77
+ }
78
+
79
+ /** cfworker keywords that only wrap a deeper, more specific failure — dropped when one survives underneath. */
80
+ const WRAPPER_KEYWORDS = new Set([
81
+ "$ref",
82
+ "$recursiveRef",
83
+ "properties",
84
+ "items",
85
+ "prefixItems",
86
+ "additionalItems",
87
+ "allOf",
88
+ "anyOf",
89
+ "oneOf",
90
+ ]);
91
+
92
+ /** Raw cfworker validation error (the subset of `OutputUnit` this module reads). */
93
+ interface RawError {
94
+ instanceLocation: string;
95
+ keyword: string;
96
+ keywordLocation: string;
97
+ error: string;
98
+ }
99
+
100
+ /** Walks a `keywordLocation` JSON Pointer against `root`, following `$ref` segments through `resolveJsonPointer`. */
101
+ function schemaAtPointer(root: JsonSchema, keywordLocation: string): JsonSchema | unknown[] | undefined {
102
+ if (!keywordLocation.startsWith("#")) {
103
+ return undefined;
104
+ }
105
+ const segments = keywordLocation
106
+ .slice(1)
107
+ .split("/")
108
+ .filter((segment) => segment.length > 0)
109
+ .map(decodeJsonPointerSegment);
110
+ let current: unknown = root;
111
+ for (const segment of segments) {
112
+ if (segment === "$ref") {
113
+ if (typeof current !== "object" || current === null || Array.isArray(current)) {
114
+ return undefined;
115
+ }
116
+ const ref = (current as JsonSchema).$ref;
117
+ if (typeof ref !== "string") {
118
+ return undefined;
119
+ }
120
+ current = resolveJsonPointer(root, ref);
121
+ continue;
122
+ }
123
+ if (typeof current !== "object" || current === null) {
124
+ return undefined;
125
+ }
126
+ current = (current as Record<string, unknown>)[segment];
127
+ }
128
+ return current as JsonSchema | unknown[] | undefined;
129
+ }
130
+
131
+ /** Walks an `instanceLocation` JSON Pointer against the validated payload. */
132
+ function instanceAtPointer(data: unknown, instanceLocation: string): unknown {
133
+ if (!instanceLocation.startsWith("#")) {
134
+ return undefined;
135
+ }
136
+ const segments = instanceLocation
137
+ .slice(1)
138
+ .split("/")
139
+ .filter((segment) => segment.length > 0)
140
+ .map(decodeJsonPointerSegment);
141
+ let current: unknown = data;
142
+ for (const segment of segments) {
143
+ if (typeof current !== "object" || current === null) {
144
+ return undefined;
145
+ }
146
+ current = (current as Record<string, unknown>)[segment];
147
+ }
148
+ return current;
149
+ }
150
+
151
+ /** The parent JSON Pointer of `location` (its last `/segment` removed), or `undefined` at the root. */
152
+ function parentPointer(location: string): string | undefined {
153
+ const idx = location.lastIndexOf("/");
154
+ if (idx < 0) {
155
+ return undefined;
156
+ }
157
+ return location.slice(0, idx) || "#";
158
+ }
159
+
160
+ /** A discriminator property common to every branch, with each branch's set of accepted string values. */
161
+ interface UnionDiscriminator {
162
+ prop: string;
163
+ valuesByBranch: string[][];
164
+ }
165
+
166
+ /**
167
+ * Finds a property present in every branch as a string `const` or all-string `enum`, whose value sets are
168
+ * pairwise disjoint across branches. Prefers `kind`, then `type`, then the alphabetically first eligible name.
169
+ * Each branch is resolved through a bare `$ref` first — a schema built with a `definitions`/`$defs` map
170
+ * (e.g. ts-json-schema-generator output) typically writes `anyOf: [{ $ref: "#/definitions/A" }, …]` rather
171
+ * than inlining each branch, so without this every branch here would otherwise look property-less.
172
+ */
173
+ function unionDiscriminator(branches: unknown[], root: JsonSchema): UnionDiscriminator | undefined {
174
+ const resolvedBranches = branches.map((b) => {
175
+ if (typeof b !== "object" || b === null || Array.isArray(b)) {
176
+ return b;
177
+ }
178
+ const ref = (b as JsonSchema).$ref;
179
+ if (typeof ref !== "string") {
180
+ return b;
181
+ }
182
+ return resolveJsonPointer(root, ref) ?? b;
79
183
  });
184
+ const objectBranches = resolvedBranches.filter(
185
+ (b): b is JsonSchema => typeof b === "object" && b !== null && !Array.isArray(b),
186
+ );
187
+ if (objectBranches.length === 0 || objectBranches.length !== resolvedBranches.length) {
188
+ return undefined;
189
+ }
190
+
191
+ const branchValuesFor = (prop: string): string[][] | undefined => {
192
+ const perBranch: string[][] = [];
193
+ for (const branch of objectBranches) {
194
+ const props = branch.properties;
195
+ const propSchema =
196
+ typeof props === "object" && props !== null && !Array.isArray(props)
197
+ ? (props as Record<string, JsonSchema>)[prop]
198
+ : undefined;
199
+ if (!propSchema || typeof propSchema !== "object") {
200
+ return undefined;
201
+ }
202
+ let values: string[] | undefined;
203
+ if (typeof propSchema.const === "string") {
204
+ values = [propSchema.const];
205
+ } else if (Array.isArray(propSchema.enum) && propSchema.enum.every((v) => typeof v === "string")) {
206
+ values = propSchema.enum as string[];
207
+ }
208
+ if (!values || values.length === 0) {
209
+ return undefined;
210
+ }
211
+ perBranch.push(values);
212
+ }
213
+ const seen = new Set<string>();
214
+ for (const values of perBranch) {
215
+ for (const v of values) {
216
+ if (seen.has(v)) return undefined;
217
+ seen.add(v);
218
+ }
219
+ }
220
+ return perBranch;
221
+ };
222
+
223
+ const candidateProps = new Set<string>();
224
+ for (const branch of objectBranches) {
225
+ const props = branch.properties;
226
+ if (typeof props === "object" && props !== null && !Array.isArray(props)) {
227
+ for (const key of Object.keys(props)) candidateProps.add(key);
228
+ }
229
+ }
230
+
231
+ const eligible: string[] = [];
232
+ for (const prop of candidateProps) {
233
+ if (branchValuesFor(prop)) eligible.push(prop);
234
+ }
235
+ if (eligible.length === 0) {
236
+ return undefined;
237
+ }
238
+ const prop = eligible.includes("kind") ? "kind" : eligible.includes("type") ? "type" : [...eligible].sort()[0]!;
239
+ return { prop, valuesByBranch: branchValuesFor(prop)! };
80
240
  }
81
241
 
82
- function decodeJsonPointerSegment(segment: string): string {
83
- return segment.replace(/~1/g, "/").replace(/~0/g, "~");
242
+ /** Sorted, comma-joined, unquoted list of values for error messages. */
243
+ function joinSorted(values: Iterable<string>): string {
244
+ return [...new Set(values)].sort().join(", ");
245
+ }
246
+
247
+ /** Rewrites a single surviving cfworker error message into a terser, more actionable form. */
248
+ function rewriteErrorMessage(err: RawError, root: JsonSchema): string {
249
+ const additionalPropsMatch = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error);
250
+ if (additionalPropsMatch) {
251
+ const name = additionalPropsMatch[1]!;
252
+ const parentLoc = parentPointer(err.keywordLocation);
253
+ const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
254
+ const props =
255
+ parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
256
+ ? (parentSchema as JsonSchema).properties
257
+ : undefined;
258
+ const keys = props && typeof props === "object" && !Array.isArray(props) ? Object.keys(props as JsonSchema) : [];
259
+ const allowed = keys.sort().slice(0, 20).join(", ");
260
+ return `unknown property "${name}"${allowed ? ` (allowed: ${allowed})` : ""}`;
261
+ }
262
+
263
+ const requiredMatch = /^Instance does not have required property "(.+)"\.$/.exec(err.error);
264
+ if (requiredMatch) {
265
+ return `missing required property "${requiredMatch[1]}"`;
266
+ }
267
+
268
+ const enumMatch = /^Instance does not match any of (\[.*\])\.$/.exec(err.error);
269
+ if (enumMatch) {
270
+ try {
271
+ const values = JSON.parse(enumMatch[1]!) as unknown[];
272
+ return `must be one of: ${values.map((v) => String(v)).join(", ")}`;
273
+ } catch {
274
+ // fall through to the raw message
275
+ }
276
+ }
277
+
278
+ const typeMatch = /^Instance type "(.+)" is invalid\. Expected "(.+)"\.$/.exec(err.error);
279
+ if (typeMatch) {
280
+ return `must be ${typeMatch[2]} (got ${typeMatch[1]})`;
281
+ }
282
+
283
+ return err.error;
284
+ }
285
+
286
+ /** Maximum number of narrowed errors reported before collapsing the remainder into a count. */
287
+ const MAX_NARROWED_ERRORS = 10;
288
+
289
+ /**
290
+ * Post-processes raw cfworker errors: for each `anyOf`/`oneOf` failure with a discriminated union, keeps only
291
+ * the branch matching the instance's discriminator value (or reports one synthetic error naming what a valid
292
+ * discriminator looks like); drops wrapper keywords once a more specific error survives under them; drops the
293
+ * `additionalProperties`+`false` pair cfworker emits even for properties that are legitimately declared; then
294
+ * rewrites the remaining messages into terser, more actionable text.
295
+ */
296
+ function narrowUnionErrors(errors: RawError[], root: JsonSchema, data: unknown): string[] {
297
+ const dropped = new Set<RawError>();
298
+ const synthetic: Array<{ instanceLocation: string; message: string }> = [];
299
+ // Locations of anyOf/oneOf errors resolved into a synthetic message rather than a kept branch — an ancestor
300
+ // wrapper (e.g. the `$ref` pointing at that anyOf, for the same instance) counts as "resolved deeper" too.
301
+ const syntheticReplacedLocations: Array<{ instanceLocation: string; keywordLocation: string }> = [];
302
+
303
+ // Stage 1: discriminated-union narrowing.
304
+ for (const err of errors) {
305
+ if (err.keyword !== "anyOf" && err.keyword !== "oneOf") continue;
306
+ const branches = schemaAtPointer(root, err.keywordLocation);
307
+ if (!Array.isArray(branches)) continue;
308
+ const discriminator = unionDiscriminator(branches, root);
309
+ if (!discriminator) continue;
310
+
311
+ // Array items reuse one schema, so keywordLocation repeats verbatim across indices — scope by
312
+ // instanceLocation too, or narrowing one item would wrongly swallow every other item's errors.
313
+ const under = errors.filter(
314
+ (e) =>
315
+ e !== err &&
316
+ e.keywordLocation.startsWith(`${err.keywordLocation}/`) &&
317
+ (e.instanceLocation === err.instanceLocation || e.instanceLocation.startsWith(`${err.instanceLocation}/`)),
318
+ );
319
+ const validValues = discriminator.valuesByBranch.flat();
320
+ const instance = instanceAtPointer(data, err.instanceLocation);
321
+
322
+ if (typeof instance !== "object" || instance === null || Array.isArray(instance)) {
323
+ dropped.add(err);
324
+ for (const e of under) dropped.add(e);
325
+ syntheticReplacedLocations.push({ instanceLocation: err.instanceLocation, keywordLocation: err.keywordLocation });
326
+ synthetic.push({
327
+ instanceLocation: err.instanceLocation,
328
+ message: `expected an object with "${discriminator.prop}" (one of: ${joinSorted(validValues)})`,
329
+ });
330
+ continue;
331
+ }
332
+
333
+ const propValue = (instance as Record<string, unknown>)[discriminator.prop];
334
+ if (propValue === undefined) {
335
+ dropped.add(err);
336
+ for (const e of under) dropped.add(e);
337
+ syntheticReplacedLocations.push({ instanceLocation: err.instanceLocation, keywordLocation: err.keywordLocation });
338
+ synthetic.push({
339
+ instanceLocation: err.instanceLocation,
340
+ message: `missing "${discriminator.prop}" (expected one of: ${joinSorted(validValues)})`,
341
+ });
342
+ continue;
343
+ }
344
+
345
+ const branchIndex = discriminator.valuesByBranch.findIndex(
346
+ (values) => typeof propValue === "string" && values.includes(propValue),
347
+ );
348
+ if (branchIndex < 0) {
349
+ dropped.add(err);
350
+ for (const e of under) dropped.add(e);
351
+ syntheticReplacedLocations.push({ instanceLocation: err.instanceLocation, keywordLocation: err.keywordLocation });
352
+ synthetic.push({
353
+ instanceLocation: `${err.instanceLocation}/${discriminator.prop}`,
354
+ message: `unknown ${discriminator.prop} "${String(propValue)}" (expected one of: ${joinSorted(validValues)})`,
355
+ });
356
+ continue;
357
+ }
358
+
359
+ const keepPrefix = `${err.keywordLocation}/${branchIndex}`;
360
+ dropped.add(err);
361
+ for (const e of under) {
362
+ if (e.keywordLocation === keepPrefix || e.keywordLocation.startsWith(`${keepPrefix}/`)) continue;
363
+ dropped.add(e);
364
+ }
365
+ }
366
+
367
+ // Stage 2: drop wrapper keywords once a more specific error survives under them, or once a descendant anyOf/oneOf
368
+ // was resolved into a synthetic message instead (which leaves no raw descendant error to detect otherwise).
369
+ const survivingAfterStage1 = errors.filter((e) => !dropped.has(e));
370
+ const nestsUnder = (candidateInstance: string, wrapperInstance: string) =>
371
+ candidateInstance === wrapperInstance || candidateInstance.startsWith(`${wrapperInstance}/`);
372
+ for (const err of survivingAfterStage1) {
373
+ if (!WRAPPER_KEYWORDS.has(err.keyword)) continue;
374
+ const prefix = `${err.keywordLocation}/`;
375
+ // Array items reuse one schema, so a wrapper's keywordLocation repeats across indices — scope by
376
+ // instanceLocation too, or one index's surviving error would mask another index's real problem.
377
+ const hasDeeper = survivingAfterStage1.some(
378
+ (other) =>
379
+ other !== err &&
380
+ !dropped.has(other) &&
381
+ other.keywordLocation.startsWith(prefix) &&
382
+ nestsUnder(other.instanceLocation, err.instanceLocation),
383
+ );
384
+ const hasSyntheticDeeper = syntheticReplacedLocations.some(
385
+ (s) => s.keywordLocation.startsWith(prefix) && nestsUnder(s.instanceLocation, err.instanceLocation),
386
+ );
387
+ if (hasDeeper || hasSyntheticDeeper) dropped.add(err);
388
+ }
389
+
390
+ // Stage 3: drop the additionalProperties+false pair cfworker emits for properties actually in `properties`.
391
+ const additionalPropsInstanceLocations = new Set(
392
+ errors.filter((e) => e.keyword === "additionalProperties").map((e) => e.instanceLocation),
393
+ );
394
+ for (const err of errors) {
395
+ if (dropped.has(err)) continue;
396
+ if (err.keyword === "additionalProperties") {
397
+ const match = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error);
398
+ const parentLoc = parentPointer(err.keywordLocation);
399
+ const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
400
+ const props =
401
+ match && parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
402
+ ? (parentSchema as JsonSchema).properties
403
+ : undefined;
404
+ const declared =
405
+ match && props && typeof props === "object" && !Array.isArray(props)
406
+ ? Object.hasOwn(props as JsonSchema, match[1]!)
407
+ : false;
408
+ if (declared) dropped.add(err);
409
+ continue;
410
+ }
411
+ if (err.keyword === "false") {
412
+ const parent = parentPointer(err.instanceLocation);
413
+ if (parent !== undefined && additionalPropsInstanceLocations.has(parent)) {
414
+ dropped.add(err);
415
+ }
416
+ }
417
+ }
418
+
419
+ // Stage 4: rewrite surviving messages, merge in synthetic ones, cap the total.
420
+ const kept = errors
421
+ .filter((e) => !dropped.has(e))
422
+ .map((e) => `${formatInstancePath(e.instanceLocation)}: ${rewriteErrorMessage(e, root)}`);
423
+ const syntheticFormatted = synthetic.map((s) => `${formatInstancePath(s.instanceLocation)}: ${s.message}`);
424
+ const all = [...syntheticFormatted, ...kept];
425
+ if (all.length <= MAX_NARROWED_ERRORS) {
426
+ return all;
427
+ }
428
+ return [...all.slice(0, MAX_NARROWED_ERRORS), `…and ${all.length - MAX_NARROWED_ERRORS} more errors`];
84
429
  }
85
430
 
86
431
  /** Map a schema `$schema` URI to the @cfworker/json-schema draft (defaults to Draft-07). */
@@ -170,7 +515,7 @@ function validateInstance(
170
515
  if (result.valid) {
171
516
  return { valid: true, errors: [] };
172
517
  }
173
- return { valid: false, errors: formatValidationErrors(result.errors) };
518
+ return { valid: false, errors: narrowUnionErrors(result.errors as RawError[], root, payload) };
174
519
  }
175
520
 
176
521
  function validateAgainstSchema(data: unknown, rootSchema: JsonSchema, partial: boolean): ValidateResult {
@@ -180,6 +180,59 @@ describe("kind: document leaf", () => {
180
180
  expect(result.kind).toBe("error");
181
181
  expect(result.errorMsg).toContain("Document input must be a JSON or YAML object");
182
182
  });
183
+
184
+ /** Tests that a discriminated-union inputSchema surfaces only the matching branch's narrowed error. */
185
+ test("invoke surfaces a narrowed discriminated-union error end to end", async () => {
186
+ const stepSchema = {
187
+ $schema: "http://json-schema.org/draft-07/schema#",
188
+ type: "object",
189
+ properties: { steps: { type: "array", items: { $ref: "#/definitions/Step" } } },
190
+ required: ["steps"],
191
+ additionalProperties: false,
192
+ definitions: {
193
+ Step: {
194
+ anyOf: [
195
+ {
196
+ type: "object",
197
+ properties: { kind: { const: "alpha" }, title: { type: "string" } },
198
+ required: ["kind", "title"],
199
+ additionalProperties: false,
200
+ },
201
+ {
202
+ type: "object",
203
+ properties: { kind: { enum: ["beta", "bravo"] }, count: { type: "number" } },
204
+ required: ["kind"],
205
+ additionalProperties: false,
206
+ },
207
+ {
208
+ type: "object",
209
+ properties: { kind: { const: "gamma" }, flag: { type: "boolean" } },
210
+ required: ["kind"],
211
+ additionalProperties: false,
212
+ },
213
+ ],
214
+ },
215
+ },
216
+ } as const;
217
+ const program = {
218
+ key: "steptest",
219
+ version: "1.0.0",
220
+ description: "steps test",
221
+ commands: [
222
+ {
223
+ key: "run",
224
+ description: "run",
225
+ kind: "document",
226
+ inputSchema: stepSchema,
227
+ handler: (ctx) => ctx.inputsAs(),
228
+ },
229
+ ],
230
+ } satisfies CliProgram;
231
+ const cli = new Cli(program);
232
+ const result = await cli.invoke(["run", JSON.stringify({ steps: [{ kind: "alfa" }] })], { invocation: "cli" });
233
+ expect(result.kind).toBe("error");
234
+ expect(result.errorMsg).toBe('steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta, bravo, gamma)');
235
+ });
183
236
  });
184
237
 
185
238
  /** Tests for parseDocumentText helper function. */
package/src/core/types.ts CHANGED
@@ -187,6 +187,13 @@ export interface CliMcpBundleConfig {
187
187
  export interface CliMcpServerConfig {
188
188
  /** When `true`, enables the `mcp` built-in and MCP stdio server. */
189
189
  enabled: boolean;
190
+ /**
191
+ * Returned as `initialize.result.instructions`. Claude Code adds it to the system prompt of every
192
+ * session; Cursor writes it to `mcps/<server>/INSTRUCTIONS.md`. Both cases cost context whether or
193
+ * not the agent ends up using this server, so keep it to a one- or two-line pointer (e.g. when to
194
+ * reach for this tool, and to read the accompanying skill first) rather than usage documentation.
195
+ */
196
+ instructions?: string;
190
197
  /** MCP error response defaults. */
191
198
  errors?: CliMcpServerErrorsConfig;
192
199
  /** Observe-only hooks for JSON-RPC messages. */
@@ -212,6 +219,25 @@ export interface CliMcpServerConfig {
212
219
  resources?: CliMcpResource[];
213
220
  /** Optional MCP Bundle (`.mcpb`) metadata for `mcp bundle`. */
214
221
  bundle?: CliMcpBundleConfig;
222
+ /** Overrides the default startup size warnings (see {@link CliMcpSizeLimits}). */
223
+ sizeLimits?: CliMcpSizeLimits;
224
+ }
225
+
226
+ /**
227
+ * Size limits for one MCP tool's `description` and pretty-printed definition, and for `instructions`.
228
+ * Set a field to `false` to disable that check. Defaults come from two client behaviors observed in the
229
+ * wild, not from the MCP spec itself, so they may need retuning as those clients change:
230
+ * Claude Code truncates a tool's `description` past `descriptionChars`; Cursor syncs each tool's full
231
+ * definition (`{name, description, inputSchema, outputSchema}`, pretty-printed) to a file under
232
+ * `mcps/<server>/tools/<tool>.json` and its agent reads that file in chunks of at most `definitionBytes`
233
+ * bytes or `definitionLines` lines, whichever comes first — a tool at or beyond either limit is read
234
+ * incompletely on the first pass.
235
+ */
236
+ export interface CliMcpSizeLimits {
237
+ definitionBytes?: number | false;
238
+ definitionLines?: number | false;
239
+ descriptionChars?: number | false;
240
+ instructionsChars?: number | false;
215
241
  }
216
242
 
217
243
  /** JSON Schema for structured error responses (OpenAPI + HTTP/MCP error bodies). */
@@ -334,6 +360,13 @@ export interface CliMcpToolConfig {
334
360
  * Default: auto-generated from command path and description.
335
361
  */
336
362
  description?: string;
363
+ /**
364
+ * Overrides the leaf's `notes` in the MCP description only — CLI help always shows `notes` unchanged.
365
+ * `false` omits notes from the MCP description entirely; a string replaces them. Omit to use `notes` as
366
+ * given. Useful when a note only makes sense with `--help` in front of it (a CLI-only workflow tip), or
367
+ * when the full CLI notes would push a definition past a size limit (see {@link CliMcpSizeLimits}).
368
+ */
369
+ notes?: string | false;
337
370
  }
338
371
 
339
372
  /**
@@ -162,6 +162,10 @@ export function cliValidateProgram(program: CliProgram): void {
162
162
  throw new CliSchemaValidationError("mcpServer requires enabled: true; omit mcpServer to disable MCP");
163
163
  }
164
164
 
165
+ if (program.mcpServer?.instructions !== undefined && program.mcpServer.instructions.trim().length === 0) {
166
+ throw new CliSchemaValidationError("mcpServer.instructions must not be empty; omit it instead");
167
+ }
168
+
165
169
  if (program.httpServer !== undefined && program.httpServer.enabled !== true) {
166
170
  throw new CliSchemaValidationError("httpServer requires enabled: true; omit httpServer to disable HTTP API");
167
171
  }
@@ -280,6 +280,13 @@ test("generateMcpGuide includes schema URI and .agents install", () => {
280
280
  expect(guide).not.toContain("OpenAI Codex");
281
281
  });
282
282
 
283
+ test("generateMcpGuide includes a Tool sizes table", () => {
284
+ const guide = generateMcpGuide(docsFixture(true));
285
+ expect(guide).toContain("## Tool sizes");
286
+ expect(guide).toContain("| Tool | Description (chars) | Definition (bytes) | Definition (lines) | Status |");
287
+ expect(guide).toMatch(/\| `run` \| \d+ \| \d+ \| \d+ \| ok \|/);
288
+ });
289
+
283
290
  test("docs --save writes topic file", async () => {
284
291
  const result = await new Cli(docsFixture()).invoke(["docs", "readme", "--save"]);
285
292
  expect(result.exitCode).toBe(0);
@@ -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
  "",
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;