import net from 'node:net';
import { randomUUID } from 'node:crypto';
import { writeFrame, createFrameReader } from '../daemon/framing.ts';
import {
DAEMON_PROTOCOL_VERSION,
type DaemonRequest,
type DaemonResponse,
type DaemonMethod,
type ToolInvokeParams,
type ToolInvokeResult,
type DaemonStatusResult,
type ToolListItem,
type XcodeIdeListParams,
type XcodeIdeListResult,
type XcodeIdeToolListItem,
type XcodeIdeInvokeParams,
type XcodeIdeInvokeResult,
} from '../daemon/protocol.ts';
import type { ToolResponse } from '../types/common.ts';
import { getSocketPath } from '../daemon/socket-path.ts';
export interface DaemonClientOptions {
socketPath?: string;
timeout?: number;
}
export class DaemonClient {
private socketPath: string;
private timeout: number;
constructor(opts: DaemonClientOptions = {}) {
this.socketPath = opts.socketPath ?? getSocketPath();
this.timeout = opts.timeout ?? 30000;
}
/**
* Send a request to the daemon and wait for a response.
*/
async request<TResult>(method: DaemonMethod, params?: unknown): Promise<TResult> {
const id = randomUUID();
const req: DaemonRequest = {
v: DAEMON_PROTOCOL_VERSION,
id,
method,
params,
};
return new Promise<TResult>((resolve, reject) => {
const socket = net.createConnection(this.socketPath);
let resolved = false;
const cleanup = (): void => {
if (!resolved) {
resolved = true;
socket.destroy();
}
};
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Daemon request timed out after ${this.timeout}ms`));
}, this.timeout);
socket.on('error', (err) => {
clearTimeout(timeoutId);
cleanup();
if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOENT')) {
reject(new Error('Daemon is not running. Start it with: xcodebuildmcp daemon start'));
} else {
reject(err);
}
});
const onData = createFrameReader(
(msg) => {
const res = msg as DaemonResponse<TResult>;
if (res.id !== id) return;
clearTimeout(timeoutId);
resolved = true;
socket.end();
if (res.error) {
reject(new Error(`${res.error.code}: ${res.error.message}`));
} else {
resolve(res.result as TResult);
}
},
(err) => {
clearTimeout(timeoutId);
cleanup();
reject(err);
},
);
socket.on('data', onData);
socket.on('connect', () => {
writeFrame(socket, req);
});
});
}
/**
* Get daemon status.
*/
async status(): Promise<DaemonStatusResult> {
return this.request<DaemonStatusResult>('daemon.status');
}
/**
* Stop the daemon.
*/
async stop(): Promise<void> {
await this.request<{ ok: boolean }>('daemon.stop');
}
/**
* List available tools.
*/
async listTools(): Promise<ToolListItem[]> {
return this.request<ToolListItem[]>('tool.list');
}
/**
* Invoke a tool.
*/
async invokeTool(tool: string, args: Record<string, unknown>): Promise<ToolResponse> {
const result = await this.request<ToolInvokeResult>('tool.invoke', {
tool,
args,
} satisfies ToolInvokeParams);
return result.response;
}
/**
* List dynamic xcode-ide bridge tools from the daemon-managed bridge session.
*/
async listXcodeIdeTools(params?: XcodeIdeListParams): Promise<XcodeIdeToolListItem[]> {
const result = await this.request<XcodeIdeListResult>('xcode-ide.list', params);
return result.tools;
}
/**
* Invoke a dynamic xcode-ide bridge tool through the daemon-managed bridge session.
*/
async invokeXcodeIdeTool(
remoteTool: string,
args: Record<string, unknown>,
): Promise<ToolResponse> {
const result = await this.request<XcodeIdeInvokeResult>('xcode-ide.invoke', {
remoteTool,
args,
} satisfies XcodeIdeInvokeParams);
return result.response as ToolResponse;
}
/**
* Check if daemon is running by attempting to connect.
*/
async isRunning(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = net.createConnection(this.socketPath);
let settled = false;
const finish = (value: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
const timeoutId = setTimeout(() => {
finish(false);
}, this.timeout);
socket.on('connect', () => {
clearTimeout(timeoutId);
finish(true);
});
socket.on('error', () => {
clearTimeout(timeoutId);
finish(false);
});
});
}
}