argsbarg 7.1.0 → 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
  "",
@@ -0,0 +1,32 @@
1
+ /* Unit tests for shared headless error text (MCP and HTTP). */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { type HeadlessToolCallFailure, headlessFailureMcpMessage, headlessFailureToHttpResponse } from "./tool-call.ts";
5
+
6
+ /** Builds a failed headless result with the given error message. */
7
+ function invokeFailure(
8
+ /** Full error text from the leaf. */
9
+ message: string,
10
+ ): HeadlessToolCallFailure {
11
+ return {
12
+ exitCode: 1,
13
+ kind: "invoke",
14
+ message,
15
+ ok: false,
16
+ stderr: "",
17
+ stdout: "",
18
+ };
19
+ }
20
+
21
+ describe("headless error text", () => {
22
+ const multiline =
23
+ 'cannot copy tab "Spec" losslessly (1 smart chip(s): "Ada"). Use force: true.\n' +
24
+ ' • node h.x: contains 1 smart chip(s) ("Ada")\n\n' +
25
+ "Google Docs REST API has no native tab duplication endpoint.";
26
+
27
+ test("MCP and HTTP both keep the full multi-line error", async () => {
28
+ expect(headlessFailureMcpMessage(invokeFailure(multiline))).toBe(multiline);
29
+ const body = (await headlessFailureToHttpResponse(invokeFailure(multiline)).json()) as { error: string };
30
+ expect(body.error).toBe(multiline);
31
+ });
32
+ });
@@ -6,7 +6,7 @@ import { bootstrapAppConfig } from "../config/bootstrap.ts";
6
6
  import { formatMcpMissingConfigMessage, missingRequiredConfig } from "../config/resolve.ts";
7
7
  import type { CliInvocation, CliProgram, InvokeFailureKind } from "../core/types.ts";
8
8
  import { failureKindHttpStatus } from "../hooks/run.ts";
9
- import { apiErrorResponse, apiSuccessResponse, firstErrorLine } from "../http/result.ts";
9
+ import { apiErrorResponse, apiSuccessResponse, stripAnsi } from "../http/result.ts";
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";
@@ -188,11 +188,7 @@ export function headlessSuccessToHttpResponse(
188
188
  /** Maps a headless failure result to a JSON HTTP error Response. */
189
189
  export function headlessFailureToHttpResponse(result: HeadlessToolCallFailure, obscureUnexpected = false): Response {
190
190
  const status = resolveHttpErrorStatus(result);
191
- let message = firstErrorLine(result.message);
192
- if (obscureUnexpected && result.failureKind === "unexpected") {
193
- message = obscureUnexpectedClientMessage();
194
- }
195
- return apiErrorResponse(status, { error: message });
191
+ return apiErrorResponse(status, { error: formatHeadlessError(result, obscureUnexpected) });
196
192
  }
197
193
 
198
194
  function resolveHttpErrorStatus(result: HeadlessToolCallFailure): number {
@@ -212,11 +208,26 @@ function resolveHttpErrorStatus(result: HeadlessToolCallFailure): number {
212
208
  }
213
209
 
214
210
  /** Maps invoke failure kind to MCP tools/call error text (respects obscureUnexpected). */
215
- export function headlessFailureMcpMessage(result: HeadlessToolCallFailure, obscureUnexpected = false): string {
211
+ export function headlessFailureMcpMessage(
212
+ /** Failed headless tool invocation. */
213
+ result: HeadlessToolCallFailure,
214
+ /** When true, unexpected failures return a generic client message. */
215
+ obscureUnexpected = false,
216
+ ): string {
217
+ return formatHeadlessError(result, obscureUnexpected);
218
+ }
219
+
220
+ /** Formats a headless failure for MCP text content and HTTP JSON `error` (full message, ANSI stripped). */
221
+ function formatHeadlessError(
222
+ /** Failed headless tool invocation. */
223
+ result: HeadlessToolCallFailure,
224
+ /** When true, unexpected failures return a generic client message. */
225
+ obscureUnexpected: boolean,
226
+ ): string {
216
227
  if (obscureUnexpected && result.failureKind === "unexpected") {
217
228
  return obscureUnexpectedClientMessage();
218
229
  }
219
- return firstErrorLine(result.message);
230
+ return stripAnsi(result.message).trim();
220
231
  }
221
232
 
222
233
  /** Missing-config lookup failures as MCP/HTTP pre-invoke errors. */