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
@@ -5,6 +5,7 @@ CLI value coercion for configure set remains here (comma-separated arrays, boole
5
5
 
6
6
  import { format as jsonSchemaFormats, type Schema, type SchemaDraft, Validator } from "@cfworker/json-schema";
7
7
  import { parseCommaList, parseDate, parseDateTime, validateCommaList } from "../core/formats.ts";
8
+ import { decodeJsonPointerSegment, resolveJsonPointer } from "../core/json-pointer.ts";
8
9
  import { isFrameworkConfigKey } from "./bindings.ts";
9
10
 
10
11
  type JsonSchema = Record<string, unknown>;
@@ -72,15 +73,360 @@ function formatInstancePath(instanceLocation: string): string {
72
73
  return instanceLocation;
73
74
  }
74
75
 
75
- function formatValidationErrors(errors: { instanceLocation: string; error: string }[]): string[] {
76
- return errors.map(({ instanceLocation, error }) => {
77
- const path = formatInstancePath(instanceLocation);
78
- return `${path}: ${error}`;
76
+ /** cfworker keywords that only wrap a deeper, more specific failure — dropped when one survives underneath. */
77
+ const WRAPPER_KEYWORDS = new Set([
78
+ "$ref",
79
+ "$recursiveRef",
80
+ "properties",
81
+ "items",
82
+ "prefixItems",
83
+ "additionalItems",
84
+ "allOf",
85
+ "anyOf",
86
+ "oneOf",
87
+ ]);
88
+
89
+ /** Raw cfworker validation error (the subset of `OutputUnit` this module reads). */
90
+ interface RawError {
91
+ instanceLocation: string;
92
+ keyword: string;
93
+ keywordLocation: string;
94
+ error: string;
95
+ }
96
+
97
+ /** Walks a `keywordLocation` JSON Pointer against `root`, following `$ref` segments through `resolveJsonPointer`. */
98
+ function schemaAtPointer(root: JsonSchema, keywordLocation: string): JsonSchema | unknown[] | undefined {
99
+ if (!keywordLocation.startsWith("#")) {
100
+ return undefined;
101
+ }
102
+ const segments = keywordLocation
103
+ .slice(1)
104
+ .split("/")
105
+ .filter((segment) => segment.length > 0)
106
+ .map(decodeJsonPointerSegment);
107
+ let current: unknown = root;
108
+ for (const segment of segments) {
109
+ if (segment === "$ref") {
110
+ if (typeof current !== "object" || current === null || Array.isArray(current)) {
111
+ return undefined;
112
+ }
113
+ const ref = (current as JsonSchema).$ref;
114
+ if (typeof ref !== "string") {
115
+ return undefined;
116
+ }
117
+ current = resolveJsonPointer(root, ref);
118
+ continue;
119
+ }
120
+ if (typeof current !== "object" || current === null) {
121
+ return undefined;
122
+ }
123
+ current = (current as Record<string, unknown>)[segment];
124
+ }
125
+ return current as JsonSchema | unknown[] | undefined;
126
+ }
127
+
128
+ /** Walks an `instanceLocation` JSON Pointer against the validated payload. */
129
+ function instanceAtPointer(data: unknown, instanceLocation: string): unknown {
130
+ if (!instanceLocation.startsWith("#")) {
131
+ return undefined;
132
+ }
133
+ const segments = instanceLocation
134
+ .slice(1)
135
+ .split("/")
136
+ .filter((segment) => segment.length > 0)
137
+ .map(decodeJsonPointerSegment);
138
+ let current: unknown = data;
139
+ for (const segment of segments) {
140
+ if (typeof current !== "object" || current === null) {
141
+ return undefined;
142
+ }
143
+ current = (current as Record<string, unknown>)[segment];
144
+ }
145
+ return current;
146
+ }
147
+
148
+ /** The parent JSON Pointer of `location` (its last `/segment` removed), or `undefined` at the root. */
149
+ function parentPointer(location: string): string | undefined {
150
+ const idx = location.lastIndexOf("/");
151
+ if (idx < 0) {
152
+ return undefined;
153
+ }
154
+ return location.slice(0, idx) || "#";
155
+ }
156
+
157
+ /** A discriminator property common to every branch, with each branch's set of accepted string values. */
158
+ interface UnionDiscriminator {
159
+ prop: string;
160
+ valuesByBranch: string[][];
161
+ }
162
+
163
+ /**
164
+ * Finds a property present in every branch as a string `const` or all-string `enum`, whose value sets are
165
+ * pairwise disjoint across branches. Prefers `kind`, then `type`, then the alphabetically first eligible name.
166
+ * Each branch is resolved through a bare `$ref` first — a schema built with a `definitions`/`$defs` map
167
+ * (e.g. ts-json-schema-generator output) typically writes `anyOf: [{ $ref: "#/definitions/A" }, …]` rather
168
+ * than inlining each branch, so without this every branch here would otherwise look property-less.
169
+ */
170
+ function unionDiscriminator(branches: unknown[], root: JsonSchema): UnionDiscriminator | undefined {
171
+ const resolvedBranches = branches.map((b) => {
172
+ if (typeof b !== "object" || b === null || Array.isArray(b)) {
173
+ return b;
174
+ }
175
+ const ref = (b as JsonSchema).$ref;
176
+ if (typeof ref !== "string") {
177
+ return b;
178
+ }
179
+ return resolveJsonPointer(root, ref) ?? b;
79
180
  });
181
+ const objectBranches = resolvedBranches.filter(
182
+ (b): b is JsonSchema => typeof b === "object" && b !== null && !Array.isArray(b),
183
+ );
184
+ if (objectBranches.length === 0 || objectBranches.length !== resolvedBranches.length) {
185
+ return undefined;
186
+ }
187
+
188
+ const branchValuesFor = (prop: string): string[][] | undefined => {
189
+ const perBranch: string[][] = [];
190
+ for (const branch of objectBranches) {
191
+ const props = branch.properties;
192
+ const propSchema =
193
+ typeof props === "object" && props !== null && !Array.isArray(props)
194
+ ? (props as Record<string, JsonSchema>)[prop]
195
+ : undefined;
196
+ if (!propSchema || typeof propSchema !== "object") {
197
+ return undefined;
198
+ }
199
+ let values: string[] | undefined;
200
+ if (typeof propSchema.const === "string") {
201
+ values = [propSchema.const];
202
+ } else if (Array.isArray(propSchema.enum) && propSchema.enum.every((v) => typeof v === "string")) {
203
+ values = propSchema.enum as string[];
204
+ }
205
+ if (!values || values.length === 0) {
206
+ return undefined;
207
+ }
208
+ perBranch.push(values);
209
+ }
210
+ const seen = new Set<string>();
211
+ for (const values of perBranch) {
212
+ for (const v of values) {
213
+ if (seen.has(v)) return undefined;
214
+ seen.add(v);
215
+ }
216
+ }
217
+ return perBranch;
218
+ };
219
+
220
+ const candidateProps = new Set<string>();
221
+ for (const branch of objectBranches) {
222
+ const props = branch.properties;
223
+ if (typeof props === "object" && props !== null && !Array.isArray(props)) {
224
+ for (const key of Object.keys(props)) candidateProps.add(key);
225
+ }
226
+ }
227
+
228
+ const eligible: string[] = [];
229
+ for (const prop of candidateProps) {
230
+ if (branchValuesFor(prop)) eligible.push(prop);
231
+ }
232
+ if (eligible.length === 0) {
233
+ return undefined;
234
+ }
235
+ const prop = eligible.includes("kind") ? "kind" : eligible.includes("type") ? "type" : [...eligible].sort()[0];
236
+ const valuesByBranch = prop === undefined ? undefined : branchValuesFor(prop);
237
+ if (prop === undefined || valuesByBranch === undefined) {
238
+ return undefined;
239
+ }
240
+ return { prop, valuesByBranch };
241
+ }
242
+
243
+ /** Sorted, comma-joined, unquoted list of values for error messages. */
244
+ function joinSorted(values: Iterable<string>): string {
245
+ return [...new Set(values)].sort().join(", ");
246
+ }
247
+
248
+ /** Rewrites a single surviving cfworker error message into a terser, more actionable form. */
249
+ function rewriteErrorMessage(err: RawError, root: JsonSchema): string {
250
+ const additionalPropsMatch = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error);
251
+ if (additionalPropsMatch) {
252
+ const name = additionalPropsMatch[1] ?? "";
253
+ const parentLoc = parentPointer(err.keywordLocation);
254
+ const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
255
+ const props =
256
+ parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
257
+ ? (parentSchema as JsonSchema).properties
258
+ : undefined;
259
+ const keys = props && typeof props === "object" && !Array.isArray(props) ? Object.keys(props as JsonSchema) : [];
260
+ const allowed = keys.sort().slice(0, 20).join(", ");
261
+ return `unknown property "${name}"${allowed ? ` (allowed: ${allowed})` : ""}`;
262
+ }
263
+
264
+ const requiredMatch = /^Instance does not have required property "(.+)"\.$/.exec(err.error);
265
+ if (requiredMatch) {
266
+ return `missing required property "${requiredMatch[1]}"`;
267
+ }
268
+
269
+ const enumMatch = /^Instance does not match any of (\[.*\])\.$/.exec(err.error);
270
+ if (enumMatch) {
271
+ try {
272
+ const values = JSON.parse(enumMatch[1] ?? "") as unknown[];
273
+ return `must be one of: ${values.map((v) => String(v)).join(", ")}`;
274
+ } catch {
275
+ // fall through to the raw message
276
+ }
277
+ }
278
+
279
+ const typeMatch = /^Instance type "(.+)" is invalid\. Expected "(.+)"\.$/.exec(err.error);
280
+ if (typeMatch) {
281
+ return `must be ${typeMatch[2]} (got ${typeMatch[1]})`;
282
+ }
283
+
284
+ return err.error;
80
285
  }
81
286
 
82
- function decodeJsonPointerSegment(segment: string): string {
83
- return segment.replace(/~1/g, "/").replace(/~0/g, "~");
287
+ /** Maximum number of narrowed errors reported before collapsing the remainder into a count. */
288
+ const MAX_NARROWED_ERRORS = 10;
289
+
290
+ /**
291
+ * Post-processes raw cfworker errors: for each `anyOf`/`oneOf` failure with a discriminated union, keeps only
292
+ * the branch matching the instance's discriminator value (or reports one synthetic error naming what a valid
293
+ * discriminator looks like); drops wrapper keywords once a more specific error survives under them; drops the
294
+ * `additionalProperties`+`false` pair cfworker emits even for properties that are legitimately declared; then
295
+ * rewrites the remaining messages into terser, more actionable text.
296
+ */
297
+ function narrowUnionErrors(errors: RawError[], root: JsonSchema, data: unknown): string[] {
298
+ const dropped = new Set<RawError>();
299
+ const synthetic: Array<{ instanceLocation: string; message: string }> = [];
300
+ // Locations of anyOf/oneOf errors resolved into a synthetic message rather than a kept branch — an ancestor
301
+ // wrapper (e.g. the `$ref` pointing at that anyOf, for the same instance) counts as "resolved deeper" too.
302
+ const syntheticReplacedLocations: Array<{ instanceLocation: string; keywordLocation: string }> = [];
303
+
304
+ // Stage 1: discriminated-union narrowing.
305
+ for (const err of errors) {
306
+ if (err.keyword !== "anyOf" && err.keyword !== "oneOf") continue;
307
+ const branches = schemaAtPointer(root, err.keywordLocation);
308
+ if (!Array.isArray(branches)) continue;
309
+ const discriminator = unionDiscriminator(branches, root);
310
+ if (!discriminator) continue;
311
+
312
+ // Array items reuse one schema, so keywordLocation repeats verbatim across indices — scope by
313
+ // instanceLocation too, or narrowing one item would wrongly swallow every other item's errors.
314
+ const under = errors.filter(
315
+ (e) =>
316
+ e !== err &&
317
+ e.keywordLocation.startsWith(`${err.keywordLocation}/`) &&
318
+ (e.instanceLocation === err.instanceLocation || e.instanceLocation.startsWith(`${err.instanceLocation}/`)),
319
+ );
320
+ const validValues = discriminator.valuesByBranch.flat();
321
+ const instance = instanceAtPointer(data, err.instanceLocation);
322
+
323
+ if (typeof instance !== "object" || instance === null || Array.isArray(instance)) {
324
+ dropped.add(err);
325
+ for (const e of under) dropped.add(e);
326
+ syntheticReplacedLocations.push({ instanceLocation: err.instanceLocation, keywordLocation: err.keywordLocation });
327
+ synthetic.push({
328
+ instanceLocation: err.instanceLocation,
329
+ message: `expected an object with "${discriminator.prop}" (one of: ${joinSorted(validValues)})`,
330
+ });
331
+ continue;
332
+ }
333
+
334
+ const propValue = (instance as Record<string, unknown>)[discriminator.prop];
335
+ if (propValue === undefined) {
336
+ dropped.add(err);
337
+ for (const e of under) dropped.add(e);
338
+ syntheticReplacedLocations.push({ instanceLocation: err.instanceLocation, keywordLocation: err.keywordLocation });
339
+ synthetic.push({
340
+ instanceLocation: err.instanceLocation,
341
+ message: `missing "${discriminator.prop}" (expected one of: ${joinSorted(validValues)})`,
342
+ });
343
+ continue;
344
+ }
345
+
346
+ const branchIndex = discriminator.valuesByBranch.findIndex(
347
+ (values) => typeof propValue === "string" && values.includes(propValue),
348
+ );
349
+ if (branchIndex < 0) {
350
+ dropped.add(err);
351
+ for (const e of under) dropped.add(e);
352
+ syntheticReplacedLocations.push({ instanceLocation: err.instanceLocation, keywordLocation: err.keywordLocation });
353
+ synthetic.push({
354
+ instanceLocation: `${err.instanceLocation}/${discriminator.prop}`,
355
+ message: `unknown ${discriminator.prop} "${String(propValue)}" (expected one of: ${joinSorted(validValues)})`,
356
+ });
357
+ continue;
358
+ }
359
+
360
+ const keepPrefix = `${err.keywordLocation}/${branchIndex}`;
361
+ dropped.add(err);
362
+ for (const e of under) {
363
+ if (e.keywordLocation === keepPrefix || e.keywordLocation.startsWith(`${keepPrefix}/`)) continue;
364
+ dropped.add(e);
365
+ }
366
+ }
367
+
368
+ // Stage 2: drop wrapper keywords once a more specific error survives under them, or once a descendant anyOf/oneOf
369
+ // was resolved into a synthetic message instead (which leaves no raw descendant error to detect otherwise).
370
+ const survivingAfterStage1 = errors.filter((e) => !dropped.has(e));
371
+ const nestsUnder = (candidateInstance: string, wrapperInstance: string) =>
372
+ candidateInstance === wrapperInstance || candidateInstance.startsWith(`${wrapperInstance}/`);
373
+ for (const err of survivingAfterStage1) {
374
+ if (!WRAPPER_KEYWORDS.has(err.keyword)) continue;
375
+ const prefix = `${err.keywordLocation}/`;
376
+ // Array items reuse one schema, so a wrapper's keywordLocation repeats across indices — scope by
377
+ // instanceLocation too, or one index's surviving error would mask another index's real problem.
378
+ const hasDeeper = survivingAfterStage1.some(
379
+ (other) =>
380
+ other !== err &&
381
+ !dropped.has(other) &&
382
+ other.keywordLocation.startsWith(prefix) &&
383
+ nestsUnder(other.instanceLocation, err.instanceLocation),
384
+ );
385
+ const hasSyntheticDeeper = syntheticReplacedLocations.some(
386
+ (s) => s.keywordLocation.startsWith(prefix) && nestsUnder(s.instanceLocation, err.instanceLocation),
387
+ );
388
+ if (hasDeeper || hasSyntheticDeeper) dropped.add(err);
389
+ }
390
+
391
+ // Stage 3: drop the additionalProperties+false pair cfworker emits for properties actually in `properties`.
392
+ const additionalPropsInstanceLocations = new Set(
393
+ errors.filter((e) => e.keyword === "additionalProperties").map((e) => e.instanceLocation),
394
+ );
395
+ for (const err of errors) {
396
+ if (dropped.has(err)) continue;
397
+ if (err.keyword === "additionalProperties") {
398
+ const name = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error)?.[1];
399
+ const parentLoc = parentPointer(err.keywordLocation);
400
+ const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
401
+ const props =
402
+ name !== undefined && parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
403
+ ? (parentSchema as JsonSchema).properties
404
+ : undefined;
405
+ const declared =
406
+ name !== undefined && props && typeof props === "object" && !Array.isArray(props)
407
+ ? Object.hasOwn(props as JsonSchema, name)
408
+ : false;
409
+ if (declared) dropped.add(err);
410
+ continue;
411
+ }
412
+ if (err.keyword === "false") {
413
+ const parent = parentPointer(err.instanceLocation);
414
+ if (parent !== undefined && additionalPropsInstanceLocations.has(parent)) {
415
+ dropped.add(err);
416
+ }
417
+ }
418
+ }
419
+
420
+ // Stage 4: rewrite surviving messages, merge in synthetic ones, cap the total.
421
+ const kept = errors
422
+ .filter((e) => !dropped.has(e))
423
+ .map((e) => `${formatInstancePath(e.instanceLocation)}: ${rewriteErrorMessage(e, root)}`);
424
+ const syntheticFormatted = synthetic.map((s) => `${formatInstancePath(s.instanceLocation)}: ${s.message}`);
425
+ const all = [...syntheticFormatted, ...kept];
426
+ if (all.length <= MAX_NARROWED_ERRORS) {
427
+ return all;
428
+ }
429
+ return [...all.slice(0, MAX_NARROWED_ERRORS), `…and ${all.length - MAX_NARROWED_ERRORS} more errors`];
84
430
  }
85
431
 
86
432
  /** Map a schema `$schema` URI to the @cfworker/json-schema draft (defaults to Draft-07). */
@@ -105,25 +451,6 @@ export function resolveSchemaDraft(schema: JsonSchema): SchemaDraft {
105
451
  return "7";
106
452
  }
107
453
 
108
- function resolveJsonPointer(root: JsonSchema, ref: string): unknown {
109
- if (!ref.startsWith("#/")) {
110
- return undefined;
111
- }
112
- const segments = ref
113
- .slice(2)
114
- .split("/")
115
- .filter((segment) => segment.length > 0)
116
- .map(decodeJsonPointerSegment);
117
- let current: unknown = root;
118
- for (const segment of segments) {
119
- if (typeof current !== "object" || current === null || Array.isArray(current)) {
120
- return undefined;
121
- }
122
- current = (current as Record<string, unknown>)[segment];
123
- }
124
- return current;
125
- }
126
-
127
454
  function attachRootCompanionSchemas(validator: Validator, root: JsonSchema, active: JsonSchema): void {
128
455
  if (active === root) {
129
456
  return;
@@ -170,7 +497,7 @@ function validateInstance(
170
497
  if (result.valid) {
171
498
  return { valid: true, errors: [] };
172
499
  }
173
- return { valid: false, errors: formatValidationErrors(result.errors) };
500
+ return { valid: false, errors: narrowUnionErrors(result.errors as RawError[], root, payload) };
174
501
  }
175
502
 
176
503
  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. */
@@ -0,0 +1,46 @@
1
+ /*
2
+ Same-document JSON Pointer resolution for JSON Schema `$ref` values (`#/definitions/Foo`).
3
+ Shared by schemagen root hoisting, MCP tool schema checks, config validation, and OpenAPI dereferencing.
4
+ */
5
+
6
+ /**
7
+ * Decodes one JSON Pointer segment: URI percent-escapes first (ts-json-schema-generator writes
8
+ * `#/definitions/Box%3Cstring%3E`), then the `~1` → `/` and `~0` → `~` pointer escapes.
9
+ */
10
+ export function decodeJsonPointerSegment(
11
+ /** Raw segment between `/` separators. */
12
+ segment: string,
13
+ ): string {
14
+ let decoded = segment;
15
+ try {
16
+ decoded = decodeURIComponent(segment);
17
+ } catch {
18
+ // Malformed percent-escape: fall back to the raw segment.
19
+ }
20
+ return decoded.replace(/~1/g, "/").replace(/~0/g, "~");
21
+ }
22
+
23
+ /** Resolves a same-document JSON Pointer (`#/definitions/Foo`) against `root`; `undefined` when it does not resolve. */
24
+ export function resolveJsonPointer(
25
+ /** Document the pointer is resolved against (the schema root). */
26
+ root: unknown,
27
+ /** `$ref` value; only `#/…` pointers are resolved. */
28
+ ref: string,
29
+ ): unknown {
30
+ if (!ref.startsWith("#/")) {
31
+ return undefined;
32
+ }
33
+ const segments = ref
34
+ .slice(2)
35
+ .split("/")
36
+ .filter((segment) => segment.length > 0)
37
+ .map(decodeJsonPointerSegment);
38
+ let current: unknown = root;
39
+ for (const segment of segments) {
40
+ if (typeof current !== "object" || current === null || Array.isArray(current)) {
41
+ return undefined;
42
+ }
43
+ current = (current as Record<string, unknown>)[segment];
44
+ }
45
+ return current;
46
+ }
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
  /**
@@ -5,9 +5,10 @@ This module validates CLI schemas before execution.
5
5
  import { reservedDocsTopicResourceUris } from "../docs/mcp-resources.ts";
6
6
  import { DOCS_BUILTIN_TOPIC_KEYS, docsEnabled } from "../docs/resolve.ts";
7
7
  import { HTTP_RESERVED_TOP_LEVEL_SEGMENTS } from "../http/paths.ts";
8
- import { resolveMcpSchemaUri } from "../mcp/tools.ts";
8
+ import { collectMcpTools, resolveMcpSchemaUri } from "../mcp/tools.ts";
9
9
  import { reservedCommandNames, resolveCapabilities } from "../runtime/capabilities.ts";
10
10
  import { validateFormatValue } from "./formats.ts";
11
+ import { resolveJsonPointer } from "./json-pointer.ts";
11
12
  import {
12
13
  type CliLeaf,
13
14
  type CliNode,
@@ -162,6 +163,10 @@ export function cliValidateProgram(program: CliProgram): void {
162
163
  throw new CliSchemaValidationError("mcpServer requires enabled: true; omit mcpServer to disable MCP");
163
164
  }
164
165
 
166
+ if (program.mcpServer?.instructions !== undefined && program.mcpServer.instructions.trim().length === 0) {
167
+ throw new CliSchemaValidationError("mcpServer.instructions must not be empty; omit it instead");
168
+ }
169
+
165
170
  if (program.httpServer !== undefined && program.httpServer.enabled !== true) {
166
171
  throw new CliSchemaValidationError("httpServer requires enabled: true; omit httpServer to disable HTTP API");
167
172
  }
@@ -192,6 +197,68 @@ export function cliValidateProgram(program: CliProgram): void {
192
197
  }
193
198
 
194
199
  walkNode(program, program, true);
200
+
201
+ if (caps.mcp) {
202
+ validateMcpToolSchemas(program);
203
+ }
204
+ }
205
+
206
+ /** Keywords whose values are instance data, not subschemas; a `$ref` string inside them is not a reference. */
207
+ const SCHEMA_DATA_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
208
+
209
+ /** Collects every `$ref` string in a schema (skipping instance-data keywords). */
210
+ function collectSchemaRefs(
211
+ /** Schema fragment to walk. */
212
+ node: unknown,
213
+ /** Accumulator for found `$ref` values. */
214
+ out: string[],
215
+ ): string[] {
216
+ if (Array.isArray(node)) {
217
+ for (const item of node) {
218
+ collectSchemaRefs(item, out);
219
+ }
220
+ } else if (typeof node === "object" && node !== null) {
221
+ for (const [key, value] of Object.entries(node)) {
222
+ if (key === "$ref" && typeof value === "string") {
223
+ out.push(value);
224
+ } else if (!SCHEMA_DATA_KEYWORDS.has(key)) {
225
+ collectSchemaRefs(value, out);
226
+ }
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * Checks the schemas MCP clients will see (after object-root wrapping in `collectMcpTools`):
234
+ * every local `$ref` must resolve, and wrapped schemas cannot use `$ref: "#"` (it would point at the wrapper).
235
+ */
236
+ function validateMcpToolSchemas(
237
+ /** Program with `mcpServer.enabled`. */
238
+ program: CliProgram,
239
+ ): void {
240
+ for (const tool of collectMcpTools(program)) {
241
+ const schemas = [
242
+ { label: "inputSchema", schema: tool.inputSchema, wrapped: tool.inputWrapped },
243
+ { label: "outputSchema", schema: tool.outputSchema, wrapped: tool.outputWrapped },
244
+ ];
245
+ for (const { label, schema, wrapped } of schemas) {
246
+ if (schema === undefined) {
247
+ continue;
248
+ }
249
+ for (const ref of collectSchemaRefs(schema, [])) {
250
+ if (ref === "#" && wrapped) {
251
+ throw new CliSchemaValidationError(
252
+ `MCP tool "${tool.name}" ${label} uses $ref "#" but its root is not type "object", so MCP wraps it; ` +
253
+ "reference a named definition instead",
254
+ );
255
+ }
256
+ if (ref.startsWith("#/") && resolveJsonPointer(schema, ref) === undefined) {
257
+ throw new CliSchemaValidationError(`MCP tool "${tool.name}" ${label} has an unresolved $ref: ${ref}`);
258
+ }
259
+ }
260
+ }
261
+ }
195
262
  }
196
263
 
197
264
  const PARAM_ROUTER_KEY = /^:[a-zA-Z][a-zA-Z0-9_]*$/;
@@ -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);