Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Argument completions for prompts and resource templates, tasks on MCP 2026-07-28
- `tools-call`, `prompts-get` and `completion-complete` no longer send arguments other than what you typed: a JSON array passed inline or on stdin is now rejected (MCP arguments must be an object), a `__proto__:=...` argument is no longer dropped, and a number too large to represent (e.g. `n:=1e400`) is kept as a string instead of being sent as `null`. (#448)
- Bridge log rotation no longer races the file it rotates: under load the rename could run before the log file was opened or flushed, so the rotation was silently skipped and the first lines of the new file were lost.
- Windows sessions no longer pile up duplicate bridge processes when `tasklist` is slow or unavailable. A liveness check that could not run used to report every bridge dead, so each command spawned a replacement without stopping the healthy original; the check now falls back to a signal probe, the old bridge is always asked to shut down, and a bridge that finds another one registered for its session exits on its own. (#427)
- The `tools-get` call example now shows `["something"]`, `[]` or `{}` for array and object arguments that have no default, instead of a string the server rejects.
- The `tools-get` call example now shows a real array or object (e.g. `[1]`, `{"x":1}`) for array and object arguments that have no default, including nullable ones and ones defined via `$ref`, instead of a string the server rejects.

## [0.7.0] - 2026-09-23

Expand Down
87 changes: 72 additions & 15 deletions src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,18 +633,49 @@
* Generate an example placeholder value for a JSON Schema property.
* Uses the default value if available, otherwise a reasonable placeholder.
*/
function exampleValue(propSchema: Record<string, unknown>): string {
function exampleValue(
propSchema: Record<string, unknown>,
rootSchema?: Record<string, unknown>,
depth = 0
): string {
// Use default value if available
if (propSchema.default !== undefined) {
return JSON.stringify(propSchema.default);
}
if (propSchema.const !== undefined) {
return JSON.stringify(propSchema.const);
}

// Use first enum value if available
if (propSchema.enum && Array.isArray(propSchema.enum) && propSchema.enum.length > 0) {
return JSON.stringify(propSchema.enum[0]);
}

const schemaType = propSchema.type;
// Nested schemas are followed only so deep, so a recursive $ref cannot loop forever
const canRecurse = depth < MAX_EXAMPLE_DEPTH;
const recurse = (schema: unknown): string =>
exampleValue(asSchema(schema) ?? {}, rootSchema, depth + 1);

// Local references like '#/$defs/Item', as emitted by Pydantic and zod
if (typeof propSchema.$ref === 'string' && canRecurse) {
const target = resolveLocalRef(propSchema.$ref, rootSchema);
if (target) return recurse(target);
}

// anyOf/oneOf, e.g. Pydantic's Optional[...]: use the first non-null branch
const variants = propSchema.anyOf ?? propSchema.oneOf;
if (Array.isArray(variants)) {
const variant = variants.find((v) => asSchema(v)?.type !== 'null');
if (variant) return exampleValue(asSchema(variant) ?? {}, rootSchema, depth);
}

// Union types like ['string', 'null'] are reduced to their most likely member
let schemaType = propSchema.type;
if (Array.isArray(schemaType)) {
schemaType = ['string', 'number', 'integer', 'boolean', 'array', 'object'].find((t) =>
(schemaType as unknown[]).includes(t)
);
}

if (schemaType === 'string') return '"something"';
if (schemaType === 'number') return '1';
Expand All @@ -655,25 +686,51 @@
}
if (schemaType === 'boolean') return 'true';
if (schemaType === 'array') {
const items = propSchema.items;
if (items && typeof items === 'object' && !Array.isArray(items)) {
return `[${exampleValue(items as Record<string, unknown>)}]`;
}
if (!canRecurse) return '[]';
// Tuples: draft 2020-12 `prefixItems`, or the older array form of `items`
const tuple = Array.isArray(propSchema.prefixItems) ? propSchema.prefixItems : propSchema.items;
if (Array.isArray(tuple)) return `[${tuple.map(recurse).join(',')}]`;
if (asSchema(propSchema.items)) return `[${recurse(propSchema.items)}]`;
return '[]';
}
if (schemaType === 'object') return '{}';

// Union types like ['string', 'null']
if (Array.isArray(schemaType)) {
const nonNull = schemaType.filter((t) => t !== 'null');
if (nonNull.includes('string')) return '"something"';
if (nonNull.includes('number') || nonNull.includes('integer')) return '1';
if (nonNull.includes('boolean')) return 'true';
if (schemaType === 'object') {
if (!canRecurse) return '{}';
// Fill in required properties only, so the example passes validation but stays short
const properties = asSchema(propSchema.properties) ?? {};
const required = Array.isArray(propSchema.required) ? propSchema.required : [];
const entries = required
.filter((name): name is string => typeof name === 'string' && name in properties)
.map((name) => `${JSON.stringify(name)}:${recurse(properties[name])}`);
return `{${entries.join(',')}}`;
}

return '"something"';
}

const MAX_EXAMPLE_DEPTH = 5;

function asSchema(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

/**
* Resolve a same-document JSON pointer reference (e.g. '#/$defs/Item') against the root schema.
*/
function resolveLocalRef(
ref: string,
rootSchema: Record<string, unknown> | undefined
): Record<string, unknown> | undefined {
if (!ref.startsWith('#/')) return undefined;
let node: unknown = rootSchema;
for (const segment of ref.slice(2).split('/')) {
const key = segment.replace(/~1/g, '/').replace(/~0/g, '~');
node = asSchema(node)?.[key];
}
return asSchema(node);
}

/**
* Wrap a JSON-stringified example value in single quotes if it contains
* characters that would be mangled by a POSIX shell (double quotes, brackets,
Expand Down Expand Up @@ -729,7 +786,7 @@
}

const argParts = params.map((name) => {
const val = shellSafeExampleValue(exampleValue(properties[name] ?? {}));
const val = shellSafeExampleValue(exampleValue(properties[name] ?? {}, schema));
return `${name}:=${val}`;
});

Expand Down Expand Up @@ -1068,7 +1125,7 @@
);
}
if (options?.sessionName) {
const first = supporting[0]!;

Check warning on line 1128 in src/cli/output.ts

View workflow job for this annotation

GitHub Actions / Node.js 24

Forbidden non-null assertion

Check warning on line 1128 in src/cli/output.ts

View workflow job for this annotation

GitHub Actions / Node.js 22

Forbidden non-null assertion

Check warning on line 1128 in src/cli/output.ts

View workflow job for this annotation

GitHub Actions / Node.js 26

Forbidden non-null assertion
const name = first.uri.startsWith(`${root}/`)
? first.uri.slice(root.length + 1)
: first.uri;
Expand Down
72 changes: 72 additions & 0 deletions test/unit/cli/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,78 @@ describe('formatToolCallExample', () => {
expect(output).toContain(`ids:='[]'`);
expect(output).toContain(`options:='{}'`);
});

it('should use an array or object example for nullable array and object params', () => {
const tool: Tool = {
name: 'crawl',
inputSchema: {
type: 'object',
properties: {
ids: { type: ['array', 'null'], items: { type: 'integer' } },
options: { type: ['null', 'object'] },
tags: { anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }] },
},
required: ['ids', 'options', 'tags'],
},
};

const output = formatToolCallExample(tool, '@s');
expect(output).toContain(`ids:='[1]'`);
expect(output).toContain(`options:='{}'`);
expect(output).toContain(`tags:='["something"]'`);
});

it('should resolve $ref, tuple and required object properties in examples', () => {
const tool: Tool = {
name: 'plot',
inputSchema: {
type: 'object',
$defs: {
Point: {
type: 'object',
properties: { x: { type: 'number' }, label: { type: 'string' } },
required: ['x'],
},
},
properties: {
points: { type: 'array', items: { $ref: '#/$defs/Point' } },
range: {
type: 'array',
prefixItems: [{ type: 'integer', minimum: 0 }, { type: 'number' }],
},
mode: { const: 'fast' },
},
required: ['points', 'range', 'mode'],
},
};

const output = formatToolCallExample(tool, '@s');
expect(output).toContain(`points:='[{"x":1}]'`);
expect(output).toContain(`range:='[0,1]'`);
expect(output).toContain(`mode:='"fast"'`);
});

it('should stop following a recursive $ref', () => {
const tool: Tool = {
name: 'tree',
inputSchema: {
type: 'object',
$defs: {
Node: {
type: 'object',
properties: { child: { $ref: '#/$defs/Node' } },
required: ['child'],
},
},
properties: { root: { $ref: '#/$defs/Node' }, bad: { $ref: '#/$defs/%zz' } },
required: ['root', 'bad'],
},
};

const output = formatToolCallExample(tool, '@s');
expect(output).toContain(`root:='{"child":{"child":{}}}'`);
expect(output).toContain(`bad:='"something"'`);
});
});

describe('formatToolHints', () => {
Expand Down
Loading