servers/src/sequentialthinking/__tests__/input-schema.test.ts
Amir Fathi d6402efee6
fix(sequentialthinking): restore nextThoughtNeeded in the advertised inputSchema required array (#4652)
* fix(sequentialthinking): restore nextThoughtNeeded in the advertised inputSchema required array

commit 1cdf806d (#3533) wrapped nextThoughtNeeded in a z.preprocess-based
coercedBoolean to fix a real footgun (string "false" coercing to true).
zod's toJSONSchema(..., { io: "input" }) treats a z.preprocess()-wrapped
field's input type as unknown, so it silently drops that field from the
emitted required array, even though it carries no .optional(). A client
that builds its call arguments from the advertised schema then omits
nextThoughtNeeded and gets a -32602 Invalid params from the runtime
validator, which still requires it.

Rebuild coercedBoolean as a transform on an explicit z.union([z.boolean(),
z.string()]) instead of z.preprocess. The union gives toJSONSchema a
concrete input type to report, so nextThoughtNeeded stays in required,
while parse behavior (including the case-insensitive string coercion
#3533 added) is unchanged.

Fixes #4651

* test(sequentialthinking): pin nextThoughtNeeded in required and string coercion

Runs against the built server so it checks the schema the SDK emits.
Skips when dist/ is absent, matching server-version.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Wo28CHPXyM3DHoLWvKnCK

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:57:34 -07:00

66 lines
2.5 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const distIndexPath = path.join(packageRoot, 'dist', 'index.js');
// Regression coverage for #4651: nextThoughtNeeded must stay in the advertised
// `required` array, and string coercion must keep accepting "True"/"FALSE"
// while rejecting anything else. Runs against the built server so it checks
// the schema the SDK actually emits, not the zod object.
describe.skipIf(!existsSync(distIndexPath))('sequentialthinking input schema', () => {
let client: Client;
beforeAll(async () => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [distIndexPath],
cwd: packageRoot,
stderr: 'pipe',
});
client = new Client({ name: 'input-schema-test', version: '0.0.0' });
await client.connect(transport);
});
afterAll(async () => {
await client?.close();
});
it('advertises nextThoughtNeeded as required', async () => {
const { tools } = await client.listTools();
const tool = tools.find(t => t.name === 'sequentialthinking');
expect(tool).toBeDefined();
expect(tool!.inputSchema.required).toEqual(
expect.arrayContaining(['thought', 'nextThoughtNeeded', 'thoughtNumber', 'totalThoughts'])
);
});
it('rejects a call that omits nextThoughtNeeded', async () => {
const result = await client.callTool({
name: 'sequentialthinking',
arguments: { thought: 't', thoughtNumber: 1, totalThoughts: 1 },
});
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toMatch(/nextThoughtNeeded/);
});
it.each(['True', 'FALSE', 'true', 'false'])('accepts the string %s', async (value) => {
const result = await client.callTool({
name: 'sequentialthinking',
arguments: { thought: 't', nextThoughtNeeded: value, thoughtNumber: 1, totalThoughts: 1 },
});
expect(result.isError).toBeFalsy();
});
it.each(['yes', '', '1'])('rejects the string %j', async (value) => {
const result = await client.callTool({
name: 'sequentialthinking',
arguments: { thought: 't', nextThoughtNeeded: value, thoughtNumber: 1, totalThoughts: 1 },
});
expect(result.isError).toBe(true);
});
});