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.
- package/CHANGELOG.md +31 -1
- package/docs/mcp.md +34 -5
- package/examples/mcp-plugin/.claude-plugin/marketplace.json +13 -0
- package/examples/mcp-plugin/.claude-plugin/plugin.json +1 -2
- package/examples/mcp-plugin/.cursor-plugin/plugin.json +1 -2
- package/examples/mcp-plugin/README.md +25 -23
- package/index.d.ts +62 -0
- package/package.json +1 -1
- package/src/cli-tool/program.ts +1 -1
- package/src/config/validate.test.ts +157 -0
- package/src/config/validate.ts +352 -7
- package/src/core/document-leaf.test.ts +53 -0
- package/src/core/types.ts +33 -0
- package/src/core/validate.ts +4 -0
- package/src/docs/docs.test.ts +7 -0
- package/src/docs/mcp-guide.ts +43 -1
- package/src/headless/tool-call.test.ts +32 -0
- package/src/headless/tool-call.ts +19 -8
- package/src/index.ts +3 -0
- package/src/mcp/bundle.ts +3 -2
- package/src/mcp/claude.ts +4 -2
- package/src/mcp/cursor.ts +4 -2
- package/src/mcp/server.ts +28 -4
- package/src/mcp/tools.test.ts +158 -0
- package/src/mcp/tools.ts +94 -1
- package/src/runtime/cli.ts +6 -1
- package/src/server/context.ts +6 -0
- package/src/test/integration/mcp.test.ts +73 -0
- package/src/test/mcp-integration-fixture.ts +1 -0
- package/src/test/mcp-size-fixture.ts +31 -0
package/src/config/validate.ts
CHANGED
|
@@ -72,15 +72,360 @@ function formatInstancePath(instanceLocation: string): string {
|
|
|
72
72
|
return instanceLocation;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
function
|
|
76
|
-
return
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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:
|
|
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
|
/**
|
package/src/core/validate.ts
CHANGED
|
@@ -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
|
}
|
package/src/docs/docs.test.ts
CHANGED
|
@@ -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);
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
230
|
+
return stripAnsi(result.message).trim();
|
|
220
231
|
}
|
|
221
232
|
|
|
222
233
|
/** Missing-config lookup failures as MCP/HTTP pre-invoke errors. */
|