Skip to content

Commit e6d00fe

Browse files
committed
refactor: rewire CLI, daemon, and MCP server boundaries for event-based rendering
1 parent 23575fb commit e6d00fe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+1098
-1230
lines changed

src/cli/__tests__/output.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { formatToolList } from '../output.ts';
3+
4+
describe('formatToolList', () => {
5+
it('formats ungrouped tool list', () => {
6+
const tools = [
7+
{ cliName: 'build', workflow: 'xcode', description: 'Build project', stateful: false },
8+
{ cliName: 'test', workflow: 'xcode', description: 'Run tests', stateful: true },
9+
];
10+
const output = formatToolList(tools);
11+
expect(output).toContain('xcode build');
12+
expect(output).toContain('xcode test');
13+
expect(output).toContain('[stateful]');
14+
});
15+
});

src/cli/__tests__/register-tool-commands.test.ts

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@ function createTool(overrides: Partial<ToolDefinition> = {}): ToolDefinition {
2323
scheme: z.string().optional(),
2424
},
2525
stateful: false,
26-
handler: vi.fn(async () => ({
27-
content: [createTextContent('ok')],
28-
isError: false,
29-
})),
26+
handler: vi.fn(async () => {}) as ToolDefinition['handler'],
3027
...overrides,
3128
};
3229
}
@@ -97,10 +94,9 @@ describe('registerToolCommands', () => {
9794
});
9895

9996
it('hydrates required args from the active defaults profile', async () => {
100-
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
101-
content: [createTextContent('ok')],
102-
isError: false,
103-
});
97+
const invokeDirect = vi
98+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
99+
.mockResolvedValue(undefined);
104100
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
105101

106102
const tool = createTool();
@@ -126,10 +122,9 @@ describe('registerToolCommands', () => {
126122
it('hydrates required args from the explicit --profile override', async () => {
127123
process.argv = ['node', 'xcodebuildmcp', 'simulator', 'run-tool', '--profile', 'qa'];
128124

129-
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
130-
content: [createTextContent('ok')],
131-
isError: false,
132-
});
125+
const invokeDirect = vi
126+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
127+
.mockResolvedValue(undefined);
133128
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
134129

135130
const tool = createTool();
@@ -159,6 +154,8 @@ describe('registerToolCommands', () => {
159154
});
160155

161156
it('keeps the normal missing-argument error when no hydrated default exists', async () => {
157+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
158+
162159
const tool = createTool();
163160
const app = createApp(createCatalog([tool]), {
164161
...baseRuntimeConfig,
@@ -167,22 +164,16 @@ describe('registerToolCommands', () => {
167164
activeSessionDefaultsProfile: undefined,
168165
});
169166

170-
let error: Error | undefined;
171-
try {
172-
await app.parseAsync(['simulator', 'run-tool']);
173-
} catch (thrown) {
174-
error = thrown as Error;
175-
}
167+
await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined();
176168

177-
expect(error?.message).toContain('Missing required argument: workspace-path');
178-
expect(error?.message).not.toMatch(/session defaults/i);
169+
expect(consoleError).toHaveBeenCalledWith('Missing required argument: workspace-path');
170+
expect(process.exitCode).toBe(1);
179171
});
180172

181173
it('hydrates args before daemon-routed invocation', async () => {
182-
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
183-
content: [createTextContent('ok')],
184-
isError: false,
185-
});
174+
const invokeDirect = vi
175+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
176+
.mockResolvedValue(undefined);
186177
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
187178

188179
const tool = createTool({ stateful: true });
@@ -202,10 +193,9 @@ describe('registerToolCommands', () => {
202193
});
203194

204195
it('lets explicit args override conflicting defaults before invocation', async () => {
205-
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
206-
content: [createTextContent('ok')],
207-
isError: false,
208-
});
196+
const invokeDirect = vi
197+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
198+
.mockResolvedValue(undefined);
209199
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
210200

211201
const tool = createTool({
@@ -253,10 +243,9 @@ describe('registerToolCommands', () => {
253243
});
254244

255245
it('lets --json override configured defaults', async () => {
256-
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
257-
content: [createTextContent('ok')],
258-
isError: false,
259-
});
246+
const invokeDirect = vi
247+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
248+
.mockResolvedValue(undefined);
260249
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
261250

262251
const tool = createTool();
@@ -281,4 +270,84 @@ describe('registerToolCommands', () => {
281270

282271
stdoutWrite.mockRestore();
283272
});
273+
274+
it('allows --json to satisfy required arguments', async () => {
275+
const invokeDirect = vi
276+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
277+
.mockResolvedValue(undefined);
278+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
279+
280+
const tool = createTool();
281+
const app = createApp(createCatalog([tool]), {
282+
...baseRuntimeConfig,
283+
sessionDefaults: undefined,
284+
sessionDefaultsProfiles: undefined,
285+
activeSessionDefaultsProfile: undefined,
286+
});
287+
288+
await expect(
289+
app.parseAsync([
290+
'simulator',
291+
'run-tool',
292+
'--json',
293+
JSON.stringify({ workspacePath: 'FromJson.xcworkspace' }),
294+
]),
295+
).resolves.toBeDefined();
296+
297+
expect(invokeDirect).toHaveBeenCalledWith(
298+
tool,
299+
{
300+
workspacePath: 'FromJson.xcworkspace',
301+
},
302+
expect.any(Object),
303+
);
304+
305+
stdoutWrite.mockRestore();
306+
});
307+
308+
it('allows array args that begin with a dash', async () => {
309+
const invokeDirect = vi
310+
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
311+
.mockResolvedValue(undefined);
312+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
313+
314+
const tool = createTool({
315+
cliSchema: {
316+
workspacePath: z.string().describe('Workspace path'),
317+
extraArgs: z.array(z.string()).optional().describe('Extra args'),
318+
},
319+
mcpSchema: {
320+
workspacePath: z.string().describe('Workspace path'),
321+
extraArgs: z.array(z.string()).optional().describe('Extra args'),
322+
},
323+
});
324+
const app = createApp(createCatalog([tool]), {
325+
...baseRuntimeConfig,
326+
sessionDefaults: undefined,
327+
sessionDefaultsProfiles: undefined,
328+
activeSessionDefaultsProfile: undefined,
329+
});
330+
331+
await expect(
332+
app.parseAsync([
333+
'simulator',
334+
'run-tool',
335+
'--workspace-path',
336+
'App.xcworkspace',
337+
'--extra-args',
338+
'-only-testing:AppTests',
339+
]),
340+
).resolves.toBeDefined();
341+
342+
expect(invokeDirect).toHaveBeenCalledWith(
343+
tool,
344+
{
345+
workspacePath: 'App.xcworkspace',
346+
extraArgs: ['-only-testing:AppTests'],
347+
},
348+
expect.any(Object),
349+
);
350+
351+
stdoutWrite.mockRestore();
352+
});
284353
});

src/cli/cli-tool-catalog.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { DaemonClient } from './daemon-client.ts';
55
import { buildCliToolCatalogFromManifest, createToolCatalog } from '../runtime/tool-catalog.ts';
66
import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts';
77
import { toKebabCase } from '../runtime/naming.ts';
8-
import type { ToolResponse } from '../types/common.ts';
8+
import type { ToolHandlerContext } from '../rendering/types.ts';
9+
import type { PipelineEvent } from '../types/pipeline-events.ts';
910
import { jsonSchemaToZod } from '../integrations/xcode-tools-bridge/jsonschema-to-zod.ts';
1011
import { XcodeIdeToolService } from '../integrations/xcode-tools-bridge/tool-service.ts';
1112
import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts';
1213
import { log } from '../utils/logging/index.ts';
14+
import { statusLine } from '../utils/tool-event-builders.ts';
1315

1416
interface BuildCliToolCatalogOptions {
1517
socketPath: string;
@@ -52,12 +54,28 @@ function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape {
5254
async function invokeRemoteToolOneShot(
5355
remoteToolName: string,
5456
args: Record<string, unknown>,
55-
): Promise<ToolResponse> {
57+
ctx: ToolHandlerContext,
58+
): Promise<void> {
5659
const service = new XcodeIdeToolService();
5760
service.setWorkflowEnabled(true);
5861
try {
59-
const response = await service.invokeTool(remoteToolName, args);
60-
return response as unknown as ToolResponse;
62+
const response = (await service.invokeTool(remoteToolName, args)) as unknown as {
63+
content?: Array<{ type: string; text: string }>;
64+
isError?: boolean;
65+
_meta?: Record<string, unknown>;
66+
};
67+
const events = response._meta?.events;
68+
if (Array.isArray(events)) {
69+
for (const event of events as PipelineEvent[]) {
70+
ctx.emit(event);
71+
}
72+
} else if (response.content) {
73+
for (const item of response.content) {
74+
if (item.type === 'text') {
75+
ctx.emit(statusLine(response.isError ? 'error' : 'success', item.text));
76+
}
77+
}
78+
}
6179
} finally {
6280
await service.disconnect();
6381
}
@@ -83,8 +101,8 @@ function createCliXcodeProxyTool(remoteTool: DynamicBridgeTool): ToolDefinition
83101
cliSchema,
84102
stateful: false,
85103
xcodeIdeRemoteToolName: remoteTool.name,
86-
handler: async (params): Promise<ToolResponse> => {
87-
return invokeRemoteToolOneShot(remoteTool.name, params);
104+
handler: async (params, ctx): Promise<void> => {
105+
return invokeRemoteToolOneShot(remoteTool.name, params, ctx);
88106
},
89107
};
90108
}

src/cli/daemon-client.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type DaemonRequest,
77
type DaemonResponse,
88
type DaemonMethod,
9+
type DaemonToolResult,
910
type ToolInvokeParams,
1011
type ToolInvokeResult,
1112
type DaemonStatusResult,
@@ -16,9 +17,15 @@ import {
1617
type XcodeIdeInvokeParams,
1718
type XcodeIdeInvokeResult,
1819
} from '../daemon/protocol.ts';
19-
import type { ToolResponse } from '../types/common.ts';
2020
import { getSocketPath } from '../daemon/socket-path.ts';
2121

22+
export class DaemonVersionMismatchError extends Error {
23+
constructor(message: string) {
24+
super(message);
25+
this.name = 'DaemonVersionMismatchError';
26+
}
27+
}
28+
2229
export interface DaemonClientOptions {
2330
socketPath?: string;
2431
timeout?: number;
@@ -81,7 +88,14 @@ export class DaemonClient {
8188
socket.end();
8289

8390
if (res.error) {
84-
reject(new Error(`${res.error.code}: ${res.error.message}`));
91+
if (
92+
res.error.code === 'BAD_REQUEST' &&
93+
res.error.message.startsWith('Unsupported protocol version')
94+
) {
95+
reject(new DaemonVersionMismatchError(res.error.message));
96+
} else {
97+
reject(new Error(`${res.error.code}: ${res.error.message}`));
98+
}
8599
} else {
86100
resolve(res.result as TResult);
87101
}
@@ -124,12 +138,12 @@ export class DaemonClient {
124138
/**
125139
* Invoke a tool.
126140
*/
127-
async invokeTool(tool: string, args: Record<string, unknown>): Promise<ToolResponse> {
141+
async invokeTool(tool: string, args: Record<string, unknown>): Promise<DaemonToolResult> {
128142
const result = await this.request<ToolInvokeResult>('tool.invoke', {
129143
tool,
130144
args,
131145
} satisfies ToolInvokeParams);
132-
return result.response;
146+
return result.result;
133147
}
134148

135149
/**
@@ -146,12 +160,12 @@ export class DaemonClient {
146160
async invokeXcodeIdeTool(
147161
remoteTool: string,
148162
args: Record<string, unknown>,
149-
): Promise<ToolResponse> {
163+
): Promise<DaemonToolResult> {
150164
const result = await this.request<XcodeIdeInvokeResult>('xcode-ide.invoke', {
151165
remoteTool,
152166
args,
153167
} satisfies XcodeIdeInvokeParams);
154-
return result.response as ToolResponse;
168+
return result.result;
155169
}
156170

157171
/**

0 commit comments

Comments
 (0)