Skip to content

Commit bbf1246

Browse files
fix(cli,mcp): harden MCP probe and call params
1 parent b9858b7 commit bbf1246

6 files changed

Lines changed: 158 additions & 111 deletions

File tree

packages/cli/src/commands/mcp.mjs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ function stdioSpecFromServer(target, server) {
355355

356356
function listMcpTools(spec, timeoutMs) {
357357
return new Promise((resolve, reject) => {
358-
const child = spawn(spec.command, spec.args || [], mcpSpawnOptions(spec.env));
358+
const childSpec = mcpSpawnCommand(spec);
359+
const child = spawn(childSpec.command, childSpec.args, mcpSpawnOptions(spec.env));
359360
let stderr = "";
360361
let settled = false;
361362
let timer;
@@ -447,10 +448,33 @@ export function mcpSpawnOptions(env = {}, os = platform()) {
447448
return {
448449
env: { ...process.env, ...(env || {}) },
449450
stdio: ["pipe", "pipe", "pipe"],
450-
shell: os === "win32",
451+
shell: false,
451452
};
452453
}
453454

455+
export function mcpSpawnCommand(spec, os = platform()) {
456+
const args = spec.args || [];
457+
if (os !== "win32" || isWindowsExecutable(spec.command)) {
458+
return { command: spec.command, args };
459+
}
460+
461+
return {
462+
command: process.env.ComSpec || "cmd.exe",
463+
args: ["/d", "/s", "/c", wrapWindowsCommandLine([spec.command, ...args])],
464+
};
465+
}
466+
467+
function isWindowsExecutable(command) {
468+
return /\.(?:exe|com)$/i.test(command);
469+
}
470+
471+
function wrapWindowsCommandLine(parts) {
472+
const commandLine = parts
473+
.map((part) => shellQuoteForPlatform(part, "win32"))
474+
.join(" ");
475+
return `"${commandLine}"`;
476+
}
477+
454478
function extractServer(target, config) {
455479
if (target === "cursor" || target === "claude-desktop") return config?.mcpServers?.qveris;
456480
if (target === "opencode") return config?.mcp?.qveris;

packages/cli/test/mcp.test.mjs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import test from "node:test";
88

99
import {
1010
extractEnvAssignmentValue,
11+
mcpSpawnCommand,
1112
mcpSpawnOptions,
1213
resolveProbeTimeoutMs,
1314
shellQuoteForPlatform,
@@ -58,16 +59,47 @@ test("mcp configure enforces owner-only permissions on existing config files", (
5859
}
5960
});
6061

61-
test("mcp live probe enables shell execution on Windows only", () => {
62+
test("mcp live probe disables shell execution for spawn options", () => {
6263
const windowsOptions = mcpSpawnOptions({ QVERIS_API_KEY: "sk-test" }, "win32");
63-
assert.equal(windowsOptions.shell, true);
64+
assert.equal(windowsOptions.shell, false);
6465
assert.equal(windowsOptions.env.QVERIS_API_KEY, "sk-test");
6566
assert.deepEqual(windowsOptions.stdio, ["pipe", "pipe", "pipe"]);
6667

6768
const posixOptions = mcpSpawnOptions({}, "darwin");
6869
assert.equal(posixOptions.shell, false);
6970
});
7071

72+
test("mcp live probe runs Windows executables directly", () => {
73+
const spec = mcpSpawnCommand(
74+
{
75+
command: "C:\\Program Files\\nodejs\\node.exe",
76+
args: ["fake server.mjs", "--package=@qverisai/mcp"],
77+
},
78+
"win32",
79+
);
80+
81+
assert.equal(spec.command, "C:\\Program Files\\nodejs\\node.exe");
82+
assert.deepEqual(spec.args, ["fake server.mjs", "--package=@qverisai/mcp"]);
83+
});
84+
85+
test("mcp live probe wraps Windows command shims through cmd.exe without shell option", () => {
86+
const spec = mcpSpawnCommand(
87+
{
88+
command: "npx",
89+
args: ["fake server.mjs", "--package=@qverisai/mcp"],
90+
},
91+
"win32",
92+
);
93+
94+
assert.equal(spec.command.endsWith("cmd.exe"), true);
95+
assert.deepEqual(spec.args, [
96+
"/d",
97+
"/s",
98+
"/c",
99+
"\"\"npx\" \"fake server.mjs\" \"--package=@qverisai/mcp\"\"",
100+
]);
101+
});
102+
71103
test("mcp command quoting follows the target shell platform", () => {
72104
assert.equal(shellQuoteForPlatform("L'Ondon", "darwin"), `'L'\\''Ondon'`);
73105
assert.equal(shellQuoteForPlatform('say "hi"', "win32"), `"say ""hi"""`);

packages/mcp/src/index.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ describe('MCP public tool interface', () => {
119119
'call',
120120
{ tool_id: 'weather.tool.v1' },
121121
);
122+
const invalidCallParams = await executeQverisMcpTool(
123+
client,
124+
'session-1',
125+
'call',
126+
{
127+
tool_id: 'weather.tool.v1',
128+
search_id: 'search-1',
129+
params_to_tool: '{"city":"London"}',
130+
},
131+
);
122132
const unknown = await executeQverisMcpTool(client, 'session-1', 'not_a_tool', {});
123133
const apiError = await executeQverisMcpTool(
124134
client,
@@ -131,6 +141,8 @@ describe('MCP public tool interface', () => {
131141
expect(payload(missingQuery).error).toContain('query');
132142
expect(missingCallParams.isError).toBe(true);
133143
expect(payload(missingCallParams).error).toContain('search_id');
144+
expect(invalidCallParams.isError).toBe(true);
145+
expect(payload(invalidCallParams).error).toContain('JSON object');
134146
expect(unknown.isError).toBe(true);
135147
expect(payload(unknown).available_tools).toEqual([
136148
'discover',

packages/mcp/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export async function executeQverisMcpTool(
232232
const missingFields: string[] = [];
233233
if (!input.tool_id) missingFields.push('tool_id');
234234
if (!input.search_id) missingFields.push('search_id');
235-
if (!input.params_to_tool) missingFields.push('params_to_tool');
235+
if (input.params_to_tool === undefined) missingFields.push('params_to_tool');
236236

237237
if (missingFields.length > 0) {
238238
return {

packages/mcp/src/tools/execute.test.ts

Lines changed: 53 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('call (execute_tool)', () => {
5353
vi.restoreAllMocks();
5454
});
5555

56-
it('should parse JSON params and call client.executeTool', async () => {
56+
it('should pass object params and call client.executeTool', async () => {
5757
const mockResponse: ExecuteResponse = {
5858
execution_id: 'exec-123',
5959
tool_id: 'weather-tool',
@@ -67,13 +67,13 @@ describe('call (execute_tool)', () => {
6767

6868
const result = await executeExecuteTool(
6969
mockClient,
70-
{
71-
tool_id: 'weather-tool',
72-
search_id: 'search-123',
73-
params_to_tool: '{"city": "Tokyo", "units": "metric"}',
74-
},
75-
'default-session'
76-
);
70+
{
71+
tool_id: 'weather-tool',
72+
search_id: 'search-123',
73+
params_to_tool: { city: 'Tokyo', units: 'metric' },
74+
},
75+
'default-session'
76+
);
7777

7878
expect(executeToolMock).toHaveBeenCalledWith('weather-tool', {
7979
search_id: 'search-123',
@@ -96,13 +96,13 @@ describe('call (execute_tool)', () => {
9696

9797
await executeExecuteTool(
9898
mockClient,
99-
{
100-
tool_id: 'tool-1',
101-
search_id: 'search-123',
102-
params_to_tool: '{}',
103-
session_id: 'custom-session',
104-
},
105-
'default-session'
99+
{
100+
tool_id: 'tool-1',
101+
search_id: 'search-123',
102+
params_to_tool: {},
103+
session_id: 'custom-session',
104+
},
105+
'default-session'
106106
);
107107

108108
expect(executeToolMock).toHaveBeenCalledWith('tool-1', {
@@ -124,13 +124,13 @@ describe('call (execute_tool)', () => {
124124

125125
await executeExecuteTool(
126126
mockClient,
127-
{
128-
tool_id: 'tool-1',
129-
search_id: 'search-123',
130-
params_to_tool: '{}',
131-
max_response_size: 102400,
132-
},
133-
'default-session'
127+
{
128+
tool_id: 'tool-1',
129+
search_id: 'search-123',
130+
params_to_tool: {},
131+
max_response_size: 102400,
132+
},
133+
'default-session'
134134
);
135135

136136
expect(executeToolMock).toHaveBeenCalledWith('tool-1', {
@@ -141,35 +141,24 @@ describe('call (execute_tool)', () => {
141141
});
142142
});
143143

144-
it('should throw error for invalid JSON in params_to_tool', async () => {
145-
await expect(
146-
executeExecuteTool(
147-
mockClient,
148-
{
149-
tool_id: 'tool-1',
150-
search_id: 'search-123',
151-
params_to_tool: 'not valid json',
152-
},
153-
'default-session'
154-
)
155-
).rejects.toThrow('Invalid JSON in params_to_tool');
156-
});
157-
158-
it('should include original params in JSON parse error message', async () => {
159-
await expect(
160-
executeExecuteTool(
161-
mockClient,
162-
{
163-
tool_id: 'tool-1',
164-
search_id: 'search-123',
165-
params_to_tool: '{broken',
166-
},
167-
'default-session'
168-
)
169-
).rejects.toThrow('Received: {broken');
170-
});
171-
172-
it('should handle complex nested parameters', async () => {
144+
it.each(['not valid json', null, ['city']])(
145+
'should reject non-object params_to_tool: %s',
146+
async (paramsToTool) => {
147+
await expect(
148+
executeExecuteTool(
149+
mockClient,
150+
{
151+
tool_id: 'tool-1',
152+
search_id: 'search-123',
153+
params_to_tool: paramsToTool as unknown as Record<string, unknown>,
154+
},
155+
'default-session'
156+
)
157+
).rejects.toThrow('params_to_tool must be a JSON object');
158+
}
159+
);
160+
161+
it('should handle complex nested parameters', async () => {
173162
const complexParams = {
174163
filters: {
175164
category: 'electronics',
@@ -189,12 +178,12 @@ describe('call (execute_tool)', () => {
189178

190179
await executeExecuteTool(
191180
mockClient,
192-
{
193-
tool_id: 'search-products',
194-
search_id: 'search-123',
195-
params_to_tool: JSON.stringify(complexParams),
196-
},
197-
'default-session'
181+
{
182+
tool_id: 'search-products',
183+
search_id: 'search-123',
184+
params_to_tool: complexParams,
185+
},
186+
'default-session'
198187
);
199188

200189
expect(executeToolMock).toHaveBeenCalledWith('search-products', {
@@ -212,13 +201,13 @@ describe('call (execute_tool)', () => {
212201
await expect(
213202
executeExecuteTool(
214203
mockClient,
215-
{
216-
tool_id: 'tool-1',
217-
search_id: 'search-123',
218-
params_to_tool: '{}',
219-
},
220-
'session'
221-
)
204+
{
205+
tool_id: 'tool-1',
206+
search_id: 'search-123',
207+
params_to_tool: {},
208+
},
209+
'session'
210+
)
222211
).rejects.toEqual(error);
223212
});
224213
});

packages/mcp/src/tools/execute.ts

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,14 @@ export interface ExecuteToolInput {
2626
*/
2727
search_id: string;
2828

29-
/**
30-
* Dictionary of parameters to pass to the remote tool.
31-
* Keys are parameter names, values can be of any type.
32-
* Can be provided as an object directly or as a JSON string (legacy).
33-
*
34-
* @example {"city": "London", "units": "metric"}
35-
* @example {"query": "AI news", "limit": 10}
36-
*/
37-
params_to_tool: Record<string, unknown> | string;
29+
/**
30+
* Dictionary of parameters to pass to the remote tool.
31+
* Keys are parameter names, values can be of any type.
32+
*
33+
* @example {"city": "London", "units": "metric"}
34+
* @example {"query": "AI news", "limit": 10}
35+
*/
36+
params_to_tool: Record<string, unknown>;
3837

3938
/**
4039
* Session identifier for tracking user sessions.
@@ -103,37 +102,28 @@ export const executeToolSchema = {
103102
* @param input - Execution parameters
104103
* @param defaultSessionId - Fallback session ID if not provided in input
105104
* @returns Execution result from the tool
106-
* @throws {Error} If params_to_tool is not valid JSON
107-
*/
108-
export async function executeExecuteTool(
109-
client: QverisClient,
110-
input: ExecuteToolInput,
111-
defaultSessionId: string
112-
): Promise<ExecuteResponse> {
113-
// Resolve parameters: accept object directly or JSON string (legacy compat)
114-
let parameters: Record<string, unknown>;
115-
if (typeof input.params_to_tool === 'string') {
116-
try {
117-
parameters = JSON.parse(input.params_to_tool) as Record<string, unknown>;
118-
} catch (error) {
119-
throw new Error(
120-
`Invalid JSON in params_to_tool: ${error instanceof Error ? error.message : 'Unknown parse error'}. ` +
121-
`Received: ${input.params_to_tool}`
122-
);
123-
}
124-
} else if (typeof input.params_to_tool === 'object' && input.params_to_tool !== null) {
125-
parameters = input.params_to_tool;
126-
} else {
127-
parameters = {};
128-
}
129-
130-
const response = await client.executeTool(input.tool_id, {
131-
search_id: input.search_id,
132-
session_id: input.session_id ?? defaultSessionId,
133-
parameters,
134-
max_response_size: input.max_response_size,
135-
});
136-
137-
return response;
138-
}
105+
* @throws {Error} If params_to_tool is not a JSON object
106+
*/
107+
export async function executeExecuteTool(
108+
client: QverisClient,
109+
input: ExecuteToolInput,
110+
defaultSessionId: string
111+
): Promise<ExecuteResponse> {
112+
if (!isParamsObject(input.params_to_tool)) {
113+
throw new Error('params_to_tool must be a JSON object.');
114+
}
115+
116+
const response = await client.executeTool(input.tool_id, {
117+
search_id: input.search_id,
118+
session_id: input.session_id ?? defaultSessionId,
119+
parameters: input.params_to_tool,
120+
max_response_size: input.max_response_size,
121+
});
122+
123+
return response;
124+
}
125+
126+
function isParamsObject(value: unknown): value is Record<string, unknown> {
127+
return typeof value === 'object' && value !== null && !Array.isArray(value);
128+
}
139129

0 commit comments

Comments
 (0)