fix(core): correctly handle nullable array types in MCP tools (#27228)

This commit is contained in:
Dev Randalpura
2026-05-19 13:24:18 -05:00
committed by GitHub
parent 37f3a4c90a
commit 9de4289287
2 changed files with 136 additions and 0 deletions

View File

@@ -810,6 +810,102 @@ describe('mcp-client', () => {
});
});
it('should transform nullable array schemas and preserve properties during discovery', async () => {
const mockedClient = {
connect: vi.fn(),
discover: vi.fn(),
disconnect: vi.fn(),
getStatus: vi.fn(),
registerCapabilities: vi.fn(),
setRequestHandler: vi.fn(),
setNotificationHandler: vi.fn(),
getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }),
listTools: vi.fn().mockResolvedValue({
tools: [
{
name: 'nullableTool',
description: 'Tool with nullable array',
inputSchema: {
type: 'object',
properties: {
tags: {
type: ['array', 'null'],
items: { type: 'string' },
},
},
$defs: {
SomeType: { type: 'string' },
},
},
},
],
}),
listPrompts: vi.fn().mockResolvedValue({
prompts: [],
}),
request: vi.fn().mockResolvedValue({}),
};
vi.mocked(ClientLib.Client).mockReturnValue(
mockedClient as unknown as ClientLib.Client,
);
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
{} as SdkClientStdioLib.StdioClientTransport,
);
const mockedToolRegistry = {
registerTool: vi.fn(),
sortTools: vi.fn(),
getToolsByServer: vi.fn().mockReturnValue([]),
getMessageBus: vi.fn().mockReturnValue(undefined),
} as unknown as ToolRegistry;
const promptRegistry = {
registerPrompt: vi.fn(),
getPromptsByServer: vi.fn().mockReturnValue([]),
removePromptsByServer: vi.fn(),
} as unknown as PromptRegistry;
const resourceRegistry = {
getResourcesByServer: vi.fn().mockReturnValue([]),
setResourcesForServer: vi.fn(),
removeResourcesByServer: vi.fn(),
} as unknown as ResourceRegistry;
const client = new McpClient(
'test-server',
{
command: 'test-command',
},
workspaceContext,
MOCK_CONTEXT,
false,
'0.0.1',
);
await client.connect();
await client.discoverInto(MOCK_CONTEXT, {
toolRegistry: mockedToolRegistry,
promptRegistry,
resourceRegistry,
});
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
const registeredTool = vi.mocked(mockedToolRegistry.registerTool).mock
.calls[0][0];
expect(registeredTool.schema.parametersJsonSchema).toEqual({
type: 'object',
properties: {
tags: {
type: 'array',
nullable: true,
items: { type: 'string' },
},
wait_for_previous: {
type: 'boolean',
description:
'Set to true to wait for all previously requested tools in this turn to complete before starting. Set to false (or omit) to run in parallel. Use true when this tool depends on the output of previous tools.',
},
},
$defs: {
SomeType: { type: 'string' },
},
});
});
it('should discover resources when a server only exposes resources', async () => {
const mockedClient = {
connect: vi.fn(),

View File

@@ -1308,6 +1308,46 @@ export async function discoverTools(
continue;
}
if (toolDef.inputSchema) {
try {
const transform = (obj: unknown): unknown => {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(transform);
const res = { ...obj } as Record<string, unknown>;
if (Array.isArray(res['type']) && res['type'].length === 2) {
const nIdx = res['type'].indexOf('null');
if (nIdx !== -1 && typeof res['type'][1 - nIdx] === 'string') {
res['type'] = res['type'][1 - nIdx];
res['nullable'] = true;
}
}
for (const k in res) {
if (Object.prototype.hasOwnProperty.call(res, k)) {
res[k] = transform(res[k]);
}
}
return res;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
toolDef.inputSchema = transform(toolDef.inputSchema) as {
type: 'object';
properties?: Record<string, object>;
required?: string[];
};
} catch (error) {
cliConfig.emitMcpDiagnostic(
'error',
`Failed to parse adjusted inputSchema for tool '${toolDef.name}' from server '${mcpServerName}'. Using original schema. Error: ${error instanceof Error ? error.message : String(error)}`,
error,
mcpServerName,
);
}
}
const mcpCallableTool = new McpCallableTool(
mcpClient,
toolDef,