diff --git a/src/cli/__tests__/output.test.ts b/src/cli/__tests__/output.test.ts new file mode 100644 index 00000000..dd7a5693 --- /dev/null +++ b/src/cli/__tests__/output.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { formatToolList } from '../output.ts'; + +describe('formatToolList', () => { + it('formats ungrouped tool list', () => { + const tools = [ + { cliName: 'build', workflow: 'xcode', description: 'Build project', stateful: false }, + { cliName: 'test', workflow: 'xcode', description: 'Run tests', stateful: true }, + ]; + const output = formatToolList(tools); + expect(output).toContain('xcode build'); + expect(output).toContain('xcode test'); + expect(output).toContain('[stateful]'); + }); +}); diff --git a/src/cli/__tests__/register-tool-commands.test.ts b/src/cli/__tests__/register-tool-commands.test.ts index 8099f94a..1e8dc1c5 100644 --- a/src/cli/__tests__/register-tool-commands.test.ts +++ b/src/cli/__tests__/register-tool-commands.test.ts @@ -23,10 +23,7 @@ function createTool(overrides: Partial = {}): ToolDefinition { scheme: z.string().optional(), }, stateful: false, - handler: vi.fn(async () => ({ - content: [createTextContent('ok')], - isError: false, - })), + handler: vi.fn(async () => {}) as ToolDefinition['handler'], ...overrides, }; } @@ -97,10 +94,9 @@ describe('registerToolCommands', () => { }); it('hydrates required args from the active defaults profile', async () => { - const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ - content: [createTextContent('ok')], - isError: false, - }); + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const tool = createTool(); @@ -126,10 +122,9 @@ describe('registerToolCommands', () => { it('hydrates required args from the explicit --profile override', async () => { process.argv = ['node', 'xcodebuildmcp', 'simulator', 'run-tool', '--profile', 'qa']; - const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ - content: [createTextContent('ok')], - isError: false, - }); + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const tool = createTool(); @@ -159,6 +154,8 @@ describe('registerToolCommands', () => { }); it('keeps the normal missing-argument error when no hydrated default exists', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const tool = createTool(); const app = createApp(createCatalog([tool]), { ...baseRuntimeConfig, @@ -167,22 +164,16 @@ describe('registerToolCommands', () => { activeSessionDefaultsProfile: undefined, }); - let error: Error | undefined; - try { - await app.parseAsync(['simulator', 'run-tool']); - } catch (thrown) { - error = thrown as Error; - } + await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined(); - expect(error?.message).toContain('Missing required argument: workspace-path'); - expect(error?.message).not.toMatch(/session defaults/i); + expect(consoleError).toHaveBeenCalledWith('Missing required argument: workspace-path'); + expect(process.exitCode).toBe(1); }); it('hydrates args before daemon-routed invocation', async () => { - const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ - content: [createTextContent('ok')], - isError: false, - }); + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const tool = createTool({ stateful: true }); @@ -202,10 +193,9 @@ describe('registerToolCommands', () => { }); it('lets explicit args override conflicting defaults before invocation', async () => { - const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ - content: [createTextContent('ok')], - isError: false, - }); + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const tool = createTool({ @@ -253,10 +243,9 @@ describe('registerToolCommands', () => { }); it('lets --json override configured defaults', async () => { - const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ - content: [createTextContent('ok')], - isError: false, - }); + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const tool = createTool(); @@ -281,4 +270,84 @@ describe('registerToolCommands', () => { stdoutWrite.mockRestore(); }); + + it('allows --json to satisfy required arguments', async () => { + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const tool = createTool(); + const app = createApp(createCatalog([tool]), { + ...baseRuntimeConfig, + sessionDefaults: undefined, + sessionDefaultsProfiles: undefined, + activeSessionDefaultsProfile: undefined, + }); + + await expect( + app.parseAsync([ + 'simulator', + 'run-tool', + '--json', + JSON.stringify({ workspacePath: 'FromJson.xcworkspace' }), + ]), + ).resolves.toBeDefined(); + + expect(invokeDirect).toHaveBeenCalledWith( + tool, + { + workspacePath: 'FromJson.xcworkspace', + }, + expect.any(Object), + ); + + stdoutWrite.mockRestore(); + }); + + it('allows array args that begin with a dash', async () => { + const invokeDirect = vi + .spyOn(DefaultToolInvoker.prototype, 'invokeDirect') + .mockResolvedValue(undefined); + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const tool = createTool({ + cliSchema: { + workspacePath: z.string().describe('Workspace path'), + extraArgs: z.array(z.string()).optional().describe('Extra args'), + }, + mcpSchema: { + workspacePath: z.string().describe('Workspace path'), + extraArgs: z.array(z.string()).optional().describe('Extra args'), + }, + }); + const app = createApp(createCatalog([tool]), { + ...baseRuntimeConfig, + sessionDefaults: undefined, + sessionDefaultsProfiles: undefined, + activeSessionDefaultsProfile: undefined, + }); + + await expect( + app.parseAsync([ + 'simulator', + 'run-tool', + '--workspace-path', + 'App.xcworkspace', + '--extra-args', + '-only-testing:AppTests', + ]), + ).resolves.toBeDefined(); + + expect(invokeDirect).toHaveBeenCalledWith( + tool, + { + workspacePath: 'App.xcworkspace', + extraArgs: ['-only-testing:AppTests'], + }, + expect.any(Object), + ); + + stdoutWrite.mockRestore(); + }); }); diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts index cbe0cd21..a32c58fc 100644 --- a/src/cli/cli-tool-catalog.ts +++ b/src/cli/cli-tool-catalog.ts @@ -5,11 +5,13 @@ import { DaemonClient } from './daemon-client.ts'; import { buildCliToolCatalogFromManifest, createToolCatalog } from '../runtime/tool-catalog.ts'; import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts'; import { toKebabCase } from '../runtime/naming.ts'; -import type { ToolResponse } from '../types/common.ts'; +import type { ToolHandlerContext } from '../rendering/types.ts'; +import type { PipelineEvent } from '../types/pipeline-events.ts'; import { jsonSchemaToZod } from '../integrations/xcode-tools-bridge/jsonschema-to-zod.ts'; import { XcodeIdeToolService } from '../integrations/xcode-tools-bridge/tool-service.ts'; import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts'; import { log } from '../utils/logging/index.ts'; +import { statusLine } from '../utils/tool-event-builders.ts'; interface BuildCliToolCatalogOptions { socketPath: string; @@ -52,12 +54,28 @@ function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape { async function invokeRemoteToolOneShot( remoteToolName: string, args: Record, -): Promise { + ctx: ToolHandlerContext, +): Promise { const service = new XcodeIdeToolService(); service.setWorkflowEnabled(true); try { - const response = await service.invokeTool(remoteToolName, args); - return response as unknown as ToolResponse; + const response = (await service.invokeTool(remoteToolName, args)) as unknown as { + content?: Array<{ type: string; text: string }>; + isError?: boolean; + _meta?: Record; + }; + const events = response._meta?.events; + if (Array.isArray(events)) { + for (const event of events as PipelineEvent[]) { + ctx.emit(event); + } + } else if (response.content) { + for (const item of response.content) { + if (item.type === 'text') { + ctx.emit(statusLine(response.isError ? 'error' : 'success', item.text)); + } + } + } } finally { await service.disconnect(); } @@ -83,8 +101,8 @@ function createCliXcodeProxyTool(remoteTool: DynamicBridgeTool): ToolDefinition cliSchema, stateful: false, xcodeIdeRemoteToolName: remoteTool.name, - handler: async (params): Promise => { - return invokeRemoteToolOneShot(remoteTool.name, params); + handler: async (params, ctx): Promise => { + return invokeRemoteToolOneShot(remoteTool.name, params, ctx); }, }; } diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts index 6c25a1f7..1f139932 100644 --- a/src/cli/daemon-client.ts +++ b/src/cli/daemon-client.ts @@ -6,6 +6,7 @@ import { type DaemonRequest, type DaemonResponse, type DaemonMethod, + type DaemonToolResult, type ToolInvokeParams, type ToolInvokeResult, type DaemonStatusResult, @@ -16,9 +17,15 @@ import { type XcodeIdeInvokeParams, type XcodeIdeInvokeResult, } from '../daemon/protocol.ts'; -import type { ToolResponse } from '../types/common.ts'; import { getSocketPath } from '../daemon/socket-path.ts'; +export class DaemonVersionMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = 'DaemonVersionMismatchError'; + } +} + export interface DaemonClientOptions { socketPath?: string; timeout?: number; @@ -81,7 +88,14 @@ export class DaemonClient { socket.end(); if (res.error) { - reject(new Error(`${res.error.code}: ${res.error.message}`)); + if ( + res.error.code === 'BAD_REQUEST' && + res.error.message.startsWith('Unsupported protocol version') + ) { + reject(new DaemonVersionMismatchError(res.error.message)); + } else { + reject(new Error(`${res.error.code}: ${res.error.message}`)); + } } else { resolve(res.result as TResult); } @@ -124,12 +138,12 @@ export class DaemonClient { /** * Invoke a tool. */ - async invokeTool(tool: string, args: Record): Promise { + async invokeTool(tool: string, args: Record): Promise { const result = await this.request('tool.invoke', { tool, args, } satisfies ToolInvokeParams); - return result.response; + return result.result; } /** @@ -146,12 +160,12 @@ export class DaemonClient { async invokeXcodeIdeTool( remoteTool: string, args: Record, - ): Promise { + ): Promise { const result = await this.request('xcode-ide.invoke', { remoteTool, args, } satisfies XcodeIdeInvokeParams); - return result.response as ToolResponse; + return result.result; } /** diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts index eb5ad843..dd367b48 100644 --- a/src/cli/daemon-control.ts +++ b/src/cli/daemon-control.ts @@ -1,8 +1,10 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; +import { dirname, resolve, basename } from 'node:path'; import { existsSync } from 'node:fs'; -import { DaemonClient } from './daemon-client.ts'; +import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts'; +import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts'; +import { removeStaleSocket } from '../daemon/socket-path.ts'; /** * Default timeout for daemon startup in milliseconds. @@ -30,6 +32,26 @@ export function getDaemonExecutablePath(): string { return resolve(buildDir, '..', 'daemon.ts'); } +/** + * Force-stop a daemon that cannot be stopped gracefully (e.g. protocol version mismatch). + * Derives the workspace key from the socket path, reads the registry for the PID, + * sends SIGTERM, and removes the stale socket. + */ +export async function forceStopDaemon(socketPath: string): Promise { + const workspaceKey = basename(dirname(socketPath)); + const entry = readDaemonRegistryEntry(workspaceKey); + if (entry?.pid) { + try { + process.kill(entry.pid, 'SIGTERM'); + } catch { + // Process may already be gone. + } + // Brief wait for the process to exit. + await new Promise((resolve) => setTimeout(resolve, 500)); + } + removeStaleSocket(socketPath); +} + export interface StartDaemonBackgroundOptions { socketPath: string; workspaceRoot?: string; @@ -111,25 +133,26 @@ export async function ensureDaemonRunning(opts: EnsureDaemonRunningOptions): Pro const client = new DaemonClient({ socketPath: opts.socketPath }); const timeoutMs = opts.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS; - // Check if already running const isRunning = await client.isRunning(); if (isRunning) { - return; + try { + await client.status(); + return; + } catch (error) { + if (error instanceof DaemonVersionMismatchError) { + await forceStopDaemon(opts.socketPath); + } else { + return; + } + } } - // Start daemon in background - const startOptions: StartDaemonBackgroundOptions = { + startDaemonBackground({ socketPath: opts.socketPath, workspaceRoot: opts.workspaceRoot, - }; - - if (opts.env) { - startOptions.env = { ...opts.env }; - } - - startDaemonBackground(startOptions); + env: opts.env, + }); - // Wait for it to be ready await waitForDaemonReady({ socketPath: opts.socketPath, timeoutMs, diff --git a/src/cli/output.ts b/src/cli/output.ts index dca2ddd7..636dde2e 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,60 +1,5 @@ -import type { ToolResponse, OutputStyle } from '../types/common.ts'; -import { processToolResponse } from '../utils/responses/index.ts'; +export type OutputFormat = 'text' | 'json' | 'raw'; -export type OutputFormat = 'text' | 'json'; - -export interface PrintToolResponseOptions { - format?: OutputFormat; - style?: OutputStyle; -} - -function writeLine(text: string): void { - process.stdout.write(`${text}\n`); -} - -/** - * Print a tool response to the terminal. - * Applies runtime-aware rendering of next steps for CLI output. - */ -export function printToolResponse( - response: ToolResponse, - options: PrintToolResponseOptions = {}, -): void { - const { format = 'text', style = 'normal' } = options; - - // Apply next steps rendering for CLI runtime - const processed = processToolResponse(response, 'cli', style); - - if (format === 'json') { - writeLine(JSON.stringify(processed, null, 2)); - } else { - printToolResponseText(processed); - } - - if (response.isError) { - process.exitCode = 1; - } -} - -/** - * Print tool response content as text. - */ -function printToolResponseText(response: ToolResponse): void { - for (const item of response.content ?? []) { - if (item.type === 'text') { - writeLine(item.text); - } else if (item.type === 'image') { - // For images, show a placeholder with metadata - const sizeKb = Math.round((item.data.length * 3) / 4 / 1024); - writeLine(`[Image: ${item.mimeType}, ~${sizeKb}KB base64]`); - writeLine(' Use --output json to get the full image data'); - } - } -} - -/** - * Format a tool list for display. - */ export function formatToolList( tools: Array<{ cliName: string; workflow: string; description?: string; stateful: boolean }>, options: { grouped?: boolean; verbose?: boolean } = {}, @@ -64,8 +9,12 @@ export function formatToolList( if (options.grouped) { const byWorkflow = new Map(); for (const tool of tools) { - const existing = byWorkflow.get(tool.workflow) ?? []; - byWorkflow.set(tool.workflow, [...existing, tool]); + let group = byWorkflow.get(tool.workflow); + if (!group) { + group = []; + byWorkflow.set(tool.workflow, group); + } + group.push(tool); } const sortedWorkflows = [...byWorkflow.keys()].sort(); diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 1e776513..15bbc654 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -1,11 +1,10 @@ import type { Argv } from 'yargs'; import yargsParser from 'yargs-parser'; import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts'; -import type { OutputStyle } from '../types/common.ts'; import { DefaultToolInvoker } from '../runtime/tool-invoker.ts'; import { schemaToYargsOptions, getUnsupportedSchemaKeys } from './schema-to-yargs.ts'; import { convertArgvToToolParams } from '../runtime/naming.ts'; -import { printToolResponse, type OutputFormat } from './output.ts'; +import type { OutputFormat } from './output.ts'; import { groupToolsByWorkflow } from '../runtime/tool-catalog.ts'; import { getWorkflowMetadataFromManifest } from '../core/manifest/load-manifest.ts'; import type { ResolvedRuntimeConfig } from '../utils/config-store.ts'; @@ -14,6 +13,7 @@ import { isKnownCliSessionDefaultsProfile, mergeCliSessionDefaults, } from './session-defaults.ts'; +import { createRenderSession } from '../rendering/render.ts'; export interface RegisterToolCommandsOptions { workspaceRoot: string; @@ -46,6 +46,26 @@ function readProfileOverrideFromProcessArgv(): string | undefined { return typeof profile === 'string' ? profile : undefined; } +function formatMissingRequiredError(missingFlags: string[]): string { + if (missingFlags.length === 1) { + return `Missing required argument: ${missingFlags[0]}`; + } + + return `Missing required arguments: ${missingFlags.join(', ')}`; +} + +function setEnvScoped(key: string, value: string): () => void { + const previous = process.env[key]; + process.env[key] = value; + return () => { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + }; +} + /** * Register all tool commands from the catalog with yargs, grouped by workflow. */ @@ -124,6 +144,9 @@ function registerToolSubcommand( const unsupportedKeys = getUnsupportedSchemaKeys(tool.cliSchema); const commandName = tool.cliName; + const requiredFlagNames = [...yargsOptions.entries()] + .filter(([, config]) => Boolean(config.demandOption)) + .map(([flagName]) => flagName); yargs.command( commandName, @@ -132,10 +155,15 @@ function registerToolSubcommand( // Hide root-level options from tool help subYargs.option('log-level', { hidden: true }).option('style', { hidden: true }); + // Parse option-like values as arguments (e.g. --extra-args "-only-testing:...") + subYargs.parserConfiguration({ + 'unknown-options-as-args': true, + }); + // Register schema-derived options (tool arguments) const toolArgNames: string[] = []; for (const [flagName, config] of yargsOptions) { - subYargs.option(flagName, config); + subYargs.option(flagName, { ...config, demandOption: false }); toolArgNames.push(flagName); } @@ -154,7 +182,7 @@ function registerToolSubcommand( // Add --output option for format control subYargs.option('output', { type: 'string', - choices: ['text', 'json'] as const, + choices: ['text', 'json', 'raw'] as const, default: 'text', describe: 'Output format', }); @@ -176,11 +204,22 @@ function registerToolSubcommand( return subYargs; }, async (argv) => { + const unexpectedArgs = (argv._ as unknown[]) + .slice(2) + .filter((value): value is string => typeof value === 'string' && value.startsWith('-')); + + if (unexpectedArgs.length > 0) { + console.error( + `Unknown argument${unexpectedArgs.length === 1 ? '' : 's'}: ${unexpectedArgs.join(', ')}`, + ); + process.exitCode = 1; + return; + } + // Extract our options const jsonArg = argv.json as string | undefined; const profileOverride = argv.profile as string | undefined; const outputFormat = (argv.output as OutputFormat) ?? 'text'; - const outputStyle = (argv.style as OutputStyle) ?? 'normal'; const socketPath = argv.socket as string; const logLevel = argv['log-level'] as string | undefined; @@ -237,16 +276,51 @@ function registerToolSubcommand( explicitArgs, }); - // Invoke the tool - const response = await invoker.invokeDirect(tool, args, { - runtime: 'cli', - cliExposedWorkflowIds, - socketPath, - workspaceRoot: opts.workspaceRoot, - logLevel, + const missingRequiredFlags = requiredFlagNames.filter((flagName) => { + const camelKey = convertArgvToToolParams({ [flagName]: true }); + const [toolKey] = Object.keys(camelKey); + const value = args[toolKey]; + return value === undefined || value === null || value === ''; }); - printToolResponse(response, { format: outputFormat, style: outputStyle }); + if (missingRequiredFlags.length > 0) { + console.error(formatMissingRequiredError(missingRequiredFlags)); + process.exitCode = 1; + return; + } + + const restoreCliOutputFormat = setEnvScoped('XCODEBUILDMCP_CLI_OUTPUT_FORMAT', outputFormat); + const restoreVerbose = + outputFormat === 'raw' ? setEnvScoped('XCODEBUILDMCP_VERBOSE', '1') : undefined; + + try { + const session = + outputFormat === 'json' + ? createRenderSession('cli-json') + : outputFormat === 'raw' + ? createRenderSession('text') + : createRenderSession('cli-text', { + interactive: process.stdout.isTTY === true, + }); + + await invoker.invokeDirect(tool, args, { + runtime: 'cli', + renderSession: session, + cliExposedWorkflowIds, + socketPath, + workspaceRoot: opts.workspaceRoot, + logLevel, + }); + + session.finalize(); + + if (session.isError()) { + process.exitCode = 1; + } + } finally { + restoreCliOutputFormat(); + restoreVerbose?.(); + } }, ); } diff --git a/src/core/__tests__/resources.test.ts b/src/core/__tests__/resources.test.ts index 9cb51ec7..ecb52cbc 100644 --- a/src/core/__tests__/resources.test.ts +++ b/src/core/__tests__/resources.test.ts @@ -1,7 +1,66 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PredicateContext } from '../../visibility/predicate-types.ts'; +import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; +import type { ResourceManifestEntry, ResolvedManifest } from '../manifest/schema.ts'; + +vi.mock('../manifest/load-manifest.ts', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + loadManifest: vi.fn(), + }; +}); + +vi.mock('../manifest/import-resource-module.ts', () => ({ + importResourceModule: vi.fn(), +})); + import { registerResources, getAvailableResources, loadResources } from '../resources.ts'; +import { loadManifest } from '../manifest/load-manifest.ts'; +import { importResourceModule } from '../manifest/import-resource-module.ts'; + +function createTestContext(overrides: Partial = {}): PredicateContext { + return { + runtime: 'mcp', + config: {} as ResolvedRuntimeConfig, + runningUnderXcode: false, + ...overrides, + }; +} + +const mockHandler = vi.fn(async () => ({ contents: [{ text: 'mock' }] })); + +function createMockManifest(resources: ResourceManifestEntry[]): ResolvedManifest { + return { + tools: new Map(), + workflows: new Map(), + resources: new Map(resources.map((r) => [r.id, r])), + }; +} + +const simulatorsResource: ResourceManifestEntry = { + id: 'simulators', + module: 'mcp/resources/simulators', + name: 'simulators', + uri: 'xcodebuildmcp://simulators', + description: 'Available iOS simulators with their UUIDs and states', + mimeType: 'text/plain', + availability: { mcp: true }, + predicates: [], +}; + +const xcodeIdeStateResource: ResourceManifestEntry = { + id: 'xcode-ide-state', + module: 'mcp/resources/xcode-ide-state', + name: 'xcode-ide-state', + uri: 'xcodebuildmcp://xcode-ide-state', + description: "Current Xcode IDE selection (scheme and simulator) from Xcode's UI state", + mimeType: 'application/json', + availability: { mcp: true }, + predicates: ['runningUnderXcodeAgent'], +}; describe('resources', () => { let mockServer: McpServer; @@ -13,8 +72,8 @@ describe('resources', () => { }>; beforeEach(() => { + vi.clearAllMocks(); registeredResources = []; - // Create a mock MCP server using simple object structure mockServer = { resource: ( name: string, @@ -25,6 +84,11 @@ describe('resources', () => { registeredResources.push({ name, uri, metadata, handler }); }, } as unknown as McpServer; + + vi.mocked(loadManifest).mockReturnValue( + createMockManifest([simulatorsResource, xcodeIdeStateResource]), + ); + vi.mocked(importResourceModule).mockResolvedValue({ handler: mockHandler }); }); describe('Exports', () => { @@ -42,16 +106,17 @@ describe('resources', () => { }); describe('loadResources', () => { - it('should load resources from generated loaders', async () => { - const resources = await loadResources(); + it('should load resources from manifests', async () => { + const ctx = createTestContext(); + const resources = await loadResources(ctx); - // Should have at least the simulators resource expect(resources.size).toBeGreaterThan(0); expect(resources.has('xcodebuildmcp://simulators')).toBe(true); }); it('should validate resource structure', async () => { - const resources = await loadResources(); + const ctx = createTestContext(); + const resources = await loadResources(ctx); for (const [uri, resource] of resources) { expect(resource.uri).toBe(uri); @@ -60,44 +125,64 @@ describe('resources', () => { expect(typeof resource.handler).toBe('function'); } }); + + it('should filter out xcode-ide-state when not running under Xcode', async () => { + const ctx = createTestContext({ runningUnderXcode: false }); + const resources = await loadResources(ctx); + + expect(resources.has('xcodebuildmcp://xcode-ide-state')).toBe(false); + }); + + it('should include xcode-ide-state when running under Xcode', async () => { + const ctx = createTestContext({ runningUnderXcode: true }); + const resources = await loadResources(ctx); + + expect(resources.has('xcodebuildmcp://xcode-ide-state')).toBe(true); + }); }); describe('registerResources', () => { it('should register all loaded resources with the server and return true', async () => { - const result = await registerResources(mockServer); + const ctx = createTestContext(); + const result = await registerResources(mockServer, ctx); expect(result).toBe(true); - - // Should have registered at least one resource expect(registeredResources.length).toBeGreaterThan(0); - // Check simulators resource was registered - const simulatorsResource = registeredResources.find( - (r) => r.uri === 'xcodebuildmcp://simulators', - ); - expect(typeof simulatorsResource?.handler).toBe('function'); - expect(simulatorsResource?.metadata.title).toBe( + const simResource = registeredResources.find((r) => r.uri === 'xcodebuildmcp://simulators'); + expect(typeof simResource?.handler).toBe('function'); + expect(simResource?.metadata.title).toBe( 'Available iOS simulators with their UUIDs and states', ); - expect(simulatorsResource?.metadata.mimeType).toBe('text/plain'); - expect(simulatorsResource?.name).toBe('simulators'); + expect(simResource?.metadata.mimeType).toBe('text/plain'); + expect(simResource?.name).toBe('simulators'); }); it('should register resources with correct handlers', async () => { - const result = await registerResources(mockServer); + const ctx = createTestContext(); + const result = await registerResources(mockServer, ctx); expect(result).toBe(true); - const simulatorsResource = registeredResources.find( - (r) => r.uri === 'xcodebuildmcp://simulators', + const simResource = registeredResources.find((r) => r.uri === 'xcodebuildmcp://simulators'); + expect(typeof simResource?.handler).toBe('function'); + }); + + it('should not register xcode-ide-state outside of Xcode', async () => { + const ctx = createTestContext({ runningUnderXcode: false }); + await registerResources(mockServer, ctx); + + const xcodeResource = registeredResources.find( + (r) => r.uri === 'xcodebuildmcp://xcode-ide-state', ); - expect(typeof simulatorsResource?.handler).toBe('function'); + expect(xcodeResource).toBeUndefined(); }); }); describe('getAvailableResources', () => { it('should return array of available resource URIs', async () => { - const resources = await getAvailableResources(); + const ctx = createTestContext(); + const resources = await getAvailableResources(ctx); expect(Array.isArray(resources)).toBe(true); expect(resources.length).toBeGreaterThan(0); @@ -105,7 +190,8 @@ describe('resources', () => { }); it('should return unique URIs', async () => { - const resources = await getAvailableResources(); + const ctx = createTestContext(); + const resources = await getAvailableResources(ctx); const uniqueResources = [...new Set(resources)]; expect(resources.length).toBe(uniqueResources.length); diff --git a/src/core/resources.ts b/src/core/resources.ts index c62722e8..4d1cf647 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -1,67 +1,67 @@ /** * Resource Management - MCP Resource handlers and URI management * - * This module manages MCP resources, providing a unified interface for exposing - * data through the Model Context Protocol resource system. + * This module manages MCP resources using manifest-driven discovery and + * predicate-aware registration through the Model Context Protocol resource system. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; import { log } from '../utils/logging/index.ts'; -import type { CommandExecutor } from '../utils/execution/index.ts'; - -// Direct imports - no codegen needed -import devicesResource from '../mcp/resources/devices.ts'; -import doctorResource from '../mcp/resources/doctor.ts'; -import sessionStatusResource from '../mcp/resources/session-status.ts'; -import simulatorsResource from '../mcp/resources/simulators.ts'; -import xcodeIdeStateResource from '../mcp/resources/xcode-ide-state.ts'; +import { loadManifest } from './manifest/load-manifest.ts'; +import { importResourceModule } from './manifest/import-resource-module.ts'; +import type { ResourceManifestEntry } from './manifest/schema.ts'; +import type { PredicateContext } from '../visibility/predicate-types.ts'; +import { isResourceExposedForRuntime } from '../visibility/exposure.ts'; /** - * Resource metadata interface + * Resource metadata interface (runtime-assembled from manifest + imported module). */ export interface ResourceMeta { uri: string; name: string; description: string; mimeType: string; - handler: ( - uri: URL, - executor?: CommandExecutor, - ) => Promise<{ - contents: Array<{ text: string }>; - }>; + handler: (uri: URL) => Promise<{ contents: Array<{ text: string }> }>; } /** - * All available resources - */ -const RESOURCES: ResourceMeta[] = [ - devicesResource, - doctorResource, - sessionStatusResource, - simulatorsResource, - xcodeIdeStateResource, -]; - -/** - * Load all resources + * Load resources from manifests, filtered by predicate context. + * @param ctx Predicate context for visibility filtering * @returns Map of resource URI to resource metadata */ -export async function loadResources(): Promise> { +export async function loadResources(ctx: PredicateContext): Promise> { + const manifest = loadManifest(); const resources = new Map(); - for (const resource of RESOURCES) { - if (!resource.uri || !resource.handler || typeof resource.handler !== 'function') { + for (const resource of manifest.resources.values()) { + if (!isResourceExposedForRuntime(resource, ctx)) { + log('info', `Skipped resource '${resource.name}' (hidden by predicates)`); + continue; + } + + let resourceModule; + try { + resourceModule = await importResourceModule(resource.module); + } catch (err) { log( 'error', - `[infra/resources] invalid resource structure for ${resource.name ?? 'unknown'}`, - { sentry: true }, + `[infra/resources] failed to import resource module '${resource.module}': ${err}`, + { + sentry: true, + }, ); continue; } - resources.set(resource.uri, resource); + resources.set(resource.uri, { + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + handler: resourceModule.handler, + }); + log('info', `Loaded resource: ${resource.name} (${resource.uri})`); } @@ -69,12 +69,16 @@ export async function loadResources(): Promise> { } /** - * Register all resources with the MCP server + * Register resources with the MCP server using manifest-driven discovery. * @param server The MCP server instance + * @param ctx Predicate context for visibility filtering * @returns true if resources were registered */ -export async function registerResources(server: McpServer): Promise { - const resources = await loadResources(); +export async function registerResources( + server: McpServer, + ctx: PredicateContext, +): Promise { + const resources = await loadResources(ctx); for (const [uri, resource] of resources) { const readCallback = async (resourceUri: URL): Promise => { @@ -106,10 +110,11 @@ export async function registerResources(server: McpServer): Promise { } /** - * Get all available resource URIs + * Get all available resource URIs for the given context. + * @param ctx Predicate context for visibility filtering * @returns Array of resource URI strings */ -export async function getAvailableResources(): Promise { - const resources = await loadResources(); +export async function getAvailableResources(ctx: PredicateContext): Promise { + const resources = await loadResources(ctx); return Array.from(resources.keys()); } diff --git a/src/daemon.ts b/src/daemon.ts index 6c5468e0..e20930ce 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -25,9 +25,9 @@ import { DAEMON_IDLE_TIMEOUT_ENV_KEY, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS, resolveDaemonIdleTimeoutMs, - getDaemonRuntimeActivitySnapshot, hasActiveRuntimeSessions, } from './daemon/idle-shutdown.ts'; +import { getDaemonActivitySnapshot } from './daemon/activity-registry.ts'; import { getDefaultCommandExecutor } from './utils/command.ts'; import { resolveAxeBinary } from './utils/axe/index.ts'; import { @@ -208,9 +208,9 @@ async function main(): Promise { return { success: result.success, output: result.output }; }); const xcodeAvailable = Boolean( - xcodeVersion.version || - xcodeVersion.buildVersion || - xcodeVersion.developerDir || + xcodeVersion.version ?? + xcodeVersion.buildVersion ?? + xcodeVersion.developerDir ?? xcodeVersion.xcodebuildPath, ); const axeVersion = await getAxeVersionMetadata(async (command) => { @@ -308,10 +308,7 @@ async function main(): Promise { const emitRequestGauges = (): void => { recordDaemonGaugeMetric('inflight_requests', inFlightRequests); - recordDaemonGaugeMetric( - 'active_sessions', - getDaemonRuntimeActivitySnapshot().activeOperationCount, - ); + recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount); }; const server = startDaemonServer({ @@ -354,7 +351,7 @@ async function main(): Promise { return; } - if (hasActiveRuntimeSessions(getDaemonRuntimeActivitySnapshot())) { + if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) { return; } diff --git a/src/daemon/__tests__/idle-shutdown.test.ts b/src/daemon/__tests__/idle-shutdown.test.ts index 7d72e31b..ee068379 100644 --- a/src/daemon/__tests__/idle-shutdown.test.ts +++ b/src/daemon/__tests__/idle-shutdown.test.ts @@ -2,11 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DAEMON_IDLE_TIMEOUT_ENV_KEY, DEFAULT_DAEMON_IDLE_TIMEOUT_MS, - getDaemonRuntimeActivitySnapshot, hasActiveRuntimeSessions, resolveDaemonIdleTimeoutMs, } from '../idle-shutdown.ts'; -import { acquireDaemonActivity, clearDaemonActivityRegistry } from '../activity-registry.ts'; +import { + acquireDaemonActivity, + clearDaemonActivityRegistry, + getDaemonActivitySnapshot, +} from '../activity-registry.ts'; describe('daemon idle shutdown', () => { beforeEach(() => { @@ -51,11 +54,11 @@ describe('daemon idle shutdown', () => { }); }); - describe('getDaemonRuntimeActivitySnapshot', () => { + describe('getDaemonActivitySnapshot', () => { it('reports category counters for active daemon activity', () => { const release = acquireDaemonActivity('swift-package.background-process'); - const snapshot = getDaemonRuntimeActivitySnapshot(); + const snapshot = getDaemonActivitySnapshot(); expect(snapshot.activeOperationCount).toBe(1); expect(snapshot.byCategory).toEqual({ 'swift-package.background-process': 1, diff --git a/src/daemon/activity-registry.ts b/src/daemon/activity-registry.ts index 17185856..1a6e27c8 100644 --- a/src/daemon/activity-registry.ts +++ b/src/daemon/activity-registry.ts @@ -1,21 +1,16 @@ const activityCounts = new Map(); -function normalizeActivityKey(activityKey: string): string { - return activityKey.trim(); +function incrementActivity(key: string): void { + activityCounts.set(key, (activityCounts.get(key) ?? 0) + 1); } -function incrementActivity(activityKey: string): void { - const current = activityCounts.get(activityKey) ?? 0; - activityCounts.set(activityKey, current + 1); -} - -function decrementActivity(activityKey: string): void { - const current = activityCounts.get(activityKey) ?? 0; +function decrementActivity(key: string): void { + const current = activityCounts.get(key) ?? 0; if (current <= 1) { - activityCounts.delete(activityKey); + activityCounts.delete(key); return; } - activityCounts.set(activityKey, current - 1); + activityCounts.set(key, current - 1); } /** @@ -23,12 +18,12 @@ function decrementActivity(activityKey: string): void { * Call the returned release function once the activity has finished. */ export function acquireDaemonActivity(activityKey: string): () => void { - const normalizedKey = normalizeActivityKey(activityKey); - if (!normalizedKey) { + const key = activityKey.trim(); + if (!key) { throw new Error('activityKey must be a non-empty string'); } - incrementActivity(normalizedKey); + incrementActivity(key); let released = false; return (): void => { @@ -36,7 +31,7 @@ export function acquireDaemonActivity(activityKey: string): () => void { return; } released = true; - decrementActivity(normalizedKey); + decrementActivity(key); }; } diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts index 722f15f5..f8206891 100644 --- a/src/daemon/daemon-server.ts +++ b/src/daemon/daemon-server.ts @@ -1,9 +1,12 @@ import net from 'node:net'; import { writeFrame, createFrameReader } from './framing.ts'; import type { ToolCatalog } from '../runtime/types.ts'; +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import type { ToolResponse } from '../types/common.ts'; import type { DaemonRequest, DaemonResponse, + DaemonToolResult, ToolInvokeParams, DaemonStatusResult, ToolListItem, @@ -14,6 +17,9 @@ import type { } from './protocol.ts'; import { DAEMON_PROTOCOL_VERSION } from './protocol.ts'; import { DefaultToolInvoker } from '../runtime/tool-invoker.ts'; +import { createRenderSession } from '../rendering/render.ts'; +import type { ToolHandlerContext } from '../rendering/types.ts'; +import { statusLine } from '../utils/tool-event-builders.ts'; import { log } from '../utils/logger.ts'; import { XcodeIdeToolService } from '../integrations/xcode-tools-bridge/tool-service.ts'; import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts'; @@ -35,6 +41,28 @@ export interface DaemonServerContext { onRequestFinished?: () => void; } +function toolResponseToDaemonResult(response: ToolResponse): DaemonToolResult { + const events: PipelineEvent[] = []; + const metaEvents = response._meta?.events; + if (Array.isArray(metaEvents) && metaEvents.length > 0) { + for (const event of metaEvents as PipelineEvent[]) { + events.push(event); + } + } else { + for (const item of response.content) { + if (item.type === 'text' && item.text) { + events.push(statusLine(response.isError ? 'error' : 'success', item.text)); + } + } + } + return { + events, + isError: response.isError === true, + nextStepParams: response.nextStepParams, + nextSteps: response.nextSteps, + }; +} + /** * Start the daemon server listening on a Unix domain socket. */ @@ -117,12 +145,26 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { } log('info', `[Daemon] Invoking tool: ${params.tool}`); - const response = await invoker.invoke(params.tool, params.args ?? {}, { + const session = createRenderSession('text'); + const handlerContext: ToolHandlerContext = { + emit: (event) => session.emit(event), + attach: (image) => session.attach(image), + }; + await invoker.invoke(params.tool, params.args ?? {}, { runtime: 'daemon', + renderSession: session, + handlerContext, enabledWorkflows: ctx.enabledWorkflows, }); - return writeFrame(socket, { ...base, result: { response } }); + const daemonResult: DaemonToolResult = { + events: [...session.getEvents()], + isError: session.isError(), + nextStepParams: handlerContext.nextStepParams, + nextSteps: handlerContext.nextSteps, + }; + + return writeFrame(socket, { ...base, result: { result: daemonResult } }); } case 'xcode-ide.list': { @@ -187,7 +229,8 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { params.remoteTool, params.args ?? {}, ); - const result: XcodeIdeInvokeResult = { response }; + const xcodeResult = toolResponseToDaemonResult(response as ToolResponse); + const result: XcodeIdeInvokeResult = { result: xcodeResult }; return writeFrame(socket, { ...base, result }); } diff --git a/src/daemon/framing.ts b/src/daemon/framing.ts index ded554fa..5d024692 100644 --- a/src/daemon/framing.ts +++ b/src/daemon/framing.ts @@ -27,18 +27,13 @@ export function createFrameReader( while (buffer.length >= 4) { const len = buffer.readUInt32BE(0); - // Sanity check: reject messages larger than 100MB if (len > 100 * 1024 * 1024) { - const err = new Error(`Message too large: ${len} bytes`); - if (onError) { - onError(err); - } + onError?.(new Error(`Message too large: ${len} bytes`)); buffer = Buffer.alloc(0); return; } if (buffer.length < 4 + len) { - // Not enough data yet, wait for more return; } @@ -49,9 +44,7 @@ export function createFrameReader( const msg = JSON.parse(payload.toString('utf8')) as unknown; onMessage(msg); } catch (err) { - if (onError) { - onError(err instanceof Error ? err : new Error(String(err))); - } + onError?.(err instanceof Error ? err : new Error(String(err))); } } }; diff --git a/src/daemon/idle-shutdown.ts b/src/daemon/idle-shutdown.ts index fee5cc05..f4c96c10 100644 --- a/src/daemon/idle-shutdown.ts +++ b/src/daemon/idle-shutdown.ts @@ -1,11 +1,9 @@ -import { getDaemonActivitySnapshot, type DaemonActivitySnapshot } from './activity-registry.ts'; +import type { DaemonActivitySnapshot } from './activity-registry.ts'; export const DAEMON_IDLE_TIMEOUT_ENV_KEY = 'XCODEBUILDMCP_DAEMON_IDLE_TIMEOUT_MS'; export const DEFAULT_DAEMON_IDLE_TIMEOUT_MS = 10 * 60 * 1000; export const DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS = 30 * 1000; -export type DaemonRuntimeActivitySnapshot = DaemonActivitySnapshot; - export function resolveDaemonIdleTimeoutMs( env: NodeJS.ProcessEnv = process.env, fallbackMs: number = DEFAULT_DAEMON_IDLE_TIMEOUT_MS, @@ -23,10 +21,6 @@ export function resolveDaemonIdleTimeoutMs( return Math.floor(parsed); } -export function getDaemonRuntimeActivitySnapshot(): DaemonRuntimeActivitySnapshot { - return getDaemonActivitySnapshot(); -} - -export function hasActiveRuntimeSessions(snapshot: DaemonRuntimeActivitySnapshot): boolean { +export function hasActiveRuntimeSessions(snapshot: DaemonActivitySnapshot): boolean { return snapshot.activeOperationCount > 0; } diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 61fd9802..ee1fef57 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -1,7 +1,8 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; -import type { ToolResponse } from '../types/common.ts'; +import type { PipelineEvent } from '../types/pipeline-events.ts'; +import type { NextStep, NextStepParamsMap } from '../types/common.ts'; -export const DAEMON_PROTOCOL_VERSION = 1 as const; +export const DAEMON_PROTOCOL_VERSION = 2 as const; export type DaemonMethod = | 'daemon.status' @@ -43,8 +44,15 @@ export interface ToolInvokeParams { args: Record; } +export interface DaemonToolResult { + events: PipelineEvent[]; + isError: boolean; + nextStepParams?: NextStepParamsMap; + nextSteps?: NextStep[]; +} + export interface ToolInvokeResult { - response: ToolResponse; + result: DaemonToolResult; } export interface DaemonStatusResult { @@ -91,5 +99,5 @@ export interface XcodeIdeInvokeParams { } export interface XcodeIdeInvokeResult { - response: unknown; + result: DaemonToolResult; } diff --git a/src/daemon/socket-path.ts b/src/daemon/socket-path.ts index fd4fce23..05fad29f 100644 --- a/src/daemon/socket-path.ts +++ b/src/daemon/socket-path.ts @@ -3,29 +3,16 @@ import { mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; -/** - * Base directory for all daemon-related files. - */ export function daemonBaseDir(): string { return join(homedir(), '.xcodebuildmcp'); } -/** - * Directory containing all workspace daemons. - */ export function daemonsDir(): string { return join(daemonBaseDir(), 'daemons'); } -/** - * Resolve the workspace root from the given context. - * - * If a project config was found (path to .xcodebuildmcp/config.yaml), use its parent directory. - * Otherwise, use realpath(cwd). - */ export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: string }): string { if (opts.projectConfigPath) { - // Config is at .xcodebuildmcp/config.yaml, so parent of parent is workspace root const configDir = dirname(opts.projectConfigPath); return dirname(configDir); } @@ -36,40 +23,24 @@ export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: st } } -/** - * Generate a short, stable key from a workspace root path. - * Uses first 12 characters of SHA-256 hash. - */ export function workspaceKeyForRoot(workspaceRoot: string): string { const hash = createHash('sha256').update(workspaceRoot).digest('hex'); return hash.slice(0, 12); } -/** - * Get the daemon directory for a specific workspace key. - */ export function daemonDirForWorkspaceKey(key: string): string { return join(daemonsDir(), key); } -/** - * Get the socket path for a specific workspace root. - */ export function socketPathForWorkspaceRoot(workspaceRoot: string): string { const key = workspaceKeyForRoot(workspaceRoot); return join(daemonDirForWorkspaceKey(key), 'daemon.sock'); } -/** - * Get the registry file path for a specific workspace key. - */ export function registryPathForWorkspaceKey(key: string): string { return join(daemonDirForWorkspaceKey(key), 'daemon.json'); } -/** - * Get the log file path for a specific workspace key. - */ export function logPathForWorkspaceKey(key: string): string { return join(daemonDirForWorkspaceKey(key), 'daemon.log'); } @@ -80,23 +51,13 @@ export interface GetSocketPathOptions { env?: NodeJS.ProcessEnv; } -/** - * Get the socket path from environment or compute per-workspace. - * - * Resolution order: - * 1. If env.XCODEBUILDMCP_SOCKET is set, use it (explicit override) - * 2. If cwd is provided, compute workspace root and return per-workspace socket - * 3. Fall back to process.cwd() and compute workspace socket from that - */ export function getSocketPath(opts?: GetSocketPathOptions): string { const env = opts?.env ?? process.env; - // Explicit override takes precedence if (env.XCODEBUILDMCP_SOCKET) { return env.XCODEBUILDMCP_SOCKET; } - // Compute workspace-derived socket path const cwd = opts?.cwd ?? process.cwd(); const workspaceRoot = resolveWorkspaceRoot({ cwd, @@ -106,9 +67,6 @@ export function getSocketPath(opts?: GetSocketPathOptions): string { return socketPathForWorkspaceRoot(workspaceRoot); } -/** - * Get the workspace key for the current context. - */ export function getWorkspaceKey(opts?: GetSocketPathOptions): string { const cwd = opts?.cwd ?? process.cwd(); const workspaceRoot = resolveWorkspaceRoot({ @@ -118,9 +76,6 @@ export function getWorkspaceKey(opts?: GetSocketPathOptions): string { return workspaceKeyForRoot(workspaceRoot); } -/** - * Ensure the directory for the socket exists with proper permissions. - */ export function ensureSocketDir(socketPath: string): void { const dir = dirname(socketPath); if (!existsSync(dir)) { @@ -128,10 +83,6 @@ export function ensureSocketDir(socketPath: string): void { } } -/** - * Remove a stale socket file if it exists. - * Should only be called after confirming no daemon is running. - */ export function removeStaleSocket(socketPath: string): void { if (existsSync(socketPath)) { unlinkSync(socketPath); diff --git a/src/doctor-cli.ts b/src/doctor-cli.ts index 2f191135..68036d23 100644 --- a/src/doctor-cli.ts +++ b/src/doctor-cli.ts @@ -31,11 +31,9 @@ async function runDoctor(): Promise { ); } - // Run the doctor tool logic directly with CLI flag enabled const executor = getDefaultCommandExecutor(); - const result = await doctorLogic({ nonRedacted }, executor, true); // showAsciiLogo = true for CLI + const result = await doctorLogic({ nonRedacted }, executor); - // Output the doctor information if (result.content && result.content.length > 0) { const textContent = result.content.find((item) => item.type === 'text'); if (textContent?.type === 'text') { diff --git a/src/integrations/xcode-tools-bridge/bridge-tool-result.ts b/src/integrations/xcode-tools-bridge/bridge-tool-result.ts new file mode 100644 index 00000000..dc2c657d --- /dev/null +++ b/src/integrations/xcode-tools-bridge/bridge-tool-result.ts @@ -0,0 +1,30 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { PipelineEvent } from '../../types/pipeline-events.ts'; +import type { NextStepParamsMap } from '../../types/common.ts'; + +export interface BridgeToolResult { + events: PipelineEvent[]; + images?: Array<{ data: string; mimeType: string }>; + isError?: boolean; + nextStepParams?: NextStepParamsMap; +} + +export function callToolResultToBridgeResult(result: CallToolResult): BridgeToolResult { + const meta = result._meta as Record | undefined; + const events = Array.isArray(meta?.events) ? (meta.events as PipelineEvent[]) : []; + const images: Array<{ data: string; mimeType: string }> = []; + + for (const item of result.content ?? []) { + if (item.type === 'image' && 'data' in item && 'mimeType' in item) { + images.push({ data: item.data as string, mimeType: item.mimeType as string }); + } + } + + return { + events, + ...(images.length > 0 ? { images } : {}), + isError: result.isError || undefined, + nextStepParams: (result as Record) + .nextStepParams as BridgeToolResult['nextStepParams'], + }; +} diff --git a/src/integrations/xcode-tools-bridge/index.ts b/src/integrations/xcode-tools-bridge/index.ts index a0fe12a4..0dba39ac 100644 --- a/src/integrations/xcode-tools-bridge/index.ts +++ b/src/integrations/xcode-tools-bridge/index.ts @@ -1,21 +1,23 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { ToolResponse } from '../../types/common.ts'; +import type { BridgeToolResult } from './bridge-tool-result.ts'; import { XcodeToolsBridgeManager } from './manager.ts'; import { StandaloneXcodeToolsBridge } from './standalone.ts'; +export type { BridgeToolResult } from './bridge-tool-result.ts'; + let manager: XcodeToolsBridgeManager | null = null; let standalone: StandaloneXcodeToolsBridge | null = null; export interface XcodeToolsBridgeToolHandler { - statusTool(): Promise; - syncTool(): Promise; - disconnectTool(): Promise; - listToolsTool(params: { refresh?: boolean }): Promise; + statusTool(): Promise; + syncTool(): Promise; + disconnectTool(): Promise; + listToolsTool(params: { refresh?: boolean }): Promise; callToolTool(params: { remoteTool: string; arguments: Record; timeoutMs?: number; - }): Promise; + }): Promise; } export function getXcodeToolsBridgeManager(server?: McpServer): XcodeToolsBridgeManager | null { diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index 9df3a74f..bf59a424 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -1,10 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { log } from '../../utils/logger.ts'; -import { - createErrorResponse, - createTextResponse, - type ToolResponse, -} from '../../utils/responses/index.ts'; +import { callToolResultToBridgeResult, type BridgeToolResult } from './bridge-tool-result.ts'; +import { header, statusLine, section } from '../../utils/tool-event-builders.ts'; import { XcodeToolsProxyRegistry, type ProxySyncResult } from './registry.ts'; import { buildXcodeToolsBridgeStatus, @@ -87,7 +84,6 @@ export class XcodeToolsBridgeManager { } this.lastError = null; - // Notify clients that our own tool list changed. this.server.sendToolListChanged(); return sync; @@ -112,45 +108,59 @@ export class XcodeToolsBridgeManager { await this.service.disconnect(); } - async statusTool(): Promise { + async statusTool(): Promise { const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return { + events: [header('Bridge Status'), section('Status', [JSON.stringify(status, null, 2)])], + }; } - async syncTool(): Promise { + async syncTool(): Promise { try { const sync = await this.syncTools({ reason: 'manual' }); const status = await this.getStatus(); - return createTextResponse( - JSON.stringify( - { - sync, - status, - }, - null, - 2, - ), - ); + return { + events: [ + header('Bridge Sync'), + section('Sync Result', [JSON.stringify({ sync, status }, null, 2)]), + statusLine('success', 'Bridge sync completed'), + ], + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge sync failed', message); + return { + events: [header('Bridge Sync'), statusLine('error', `Bridge sync failed: ${message}`)], + isError: true, + }; } } - async disconnectTool(): Promise { + async disconnectTool(): Promise { try { await this.disconnect(); const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return { + events: [ + header('Bridge Disconnect'), + section('Status', [JSON.stringify(status, null, 2)]), + statusLine('success', 'Bridge disconnected'), + ], + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge disconnect failed', message); + return { + events: [ + header('Bridge Disconnect'), + statusLine('error', `Bridge disconnect failed: ${message}`), + ], + isError: true, + }; } } - async listToolsTool(params: { refresh?: boolean }): Promise { + async listToolsTool(params: { refresh?: boolean }): Promise { if (!this.workflowEnabled) { - return this.createBridgeFailureResponse( + return this.createBridgeFailureResult( 'XCODE_MCP_UNAVAILABLE', 'xcode-ide workflow is not enabled', ); @@ -162,9 +172,15 @@ export class XcodeToolsBridgeManager { toolCount: tools.length, tools: tools.map(serializeBridgeTool), }; - return createTextResponse(JSON.stringify(payload, null, 2)); + return { + events: [ + header('Xcode IDE List Tools'), + section('Tools', [JSON.stringify(payload, null, 2)]), + statusLine('success', `Found ${tools.length} tool(s)`), + ], + }; } catch (error) { - return this.createBridgeFailureResponse( + return this.createBridgeFailureResult( classifyBridgeError(error, 'list', { connected: this.service.getClientStatus().connected, }), @@ -177,9 +193,9 @@ export class XcodeToolsBridgeManager { remoteTool: string; arguments: Record; timeoutMs?: number; - }): Promise { + }): Promise { if (!this.workflowEnabled) { - return this.createBridgeFailureResponse( + return this.createBridgeFailureResult( 'XCODE_MCP_UNAVAILABLE', 'xcode-ide workflow is not enabled', ); @@ -189,9 +205,9 @@ export class XcodeToolsBridgeManager { const response = await this.service.invokeTool(params.remoteTool, params.arguments, { timeoutMs: params.timeoutMs, }); - return response as ToolResponse; + return callToolResultToBridgeResult(response); } catch (error) { - return this.createBridgeFailureResponse( + return this.createBridgeFailureResult( classifyBridgeError(error, 'call', { connected: this.service.getClientStatus().connected, }), @@ -200,8 +216,11 @@ export class XcodeToolsBridgeManager { } } - private createBridgeFailureResponse(code: string, error: unknown): ToolResponse { + private createBridgeFailureResult(code: string, error: unknown): BridgeToolResult { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse(code, message); + return { + events: [header('Xcode IDE Call Tool'), statusLine('error', `[${code}] ${message}`)], + isError: true, + }; } } diff --git a/src/integrations/xcode-tools-bridge/registry.ts b/src/integrations/xcode-tools-bridge/registry.ts index 4dd98ac4..51c746f7 100644 --- a/src/integrations/xcode-tools-bridge/registry.ts +++ b/src/integrations/xcode-tools-bridge/registry.ts @@ -139,8 +139,7 @@ function buildBestEffortInputSchema(tool: Tool): z.ZodTypeAny { if (!tool.inputSchema) { return z.object({}).passthrough(); } - const zod = jsonSchemaToZod(tool.inputSchema); - return zod; + return jsonSchemaToZod(tool.inputSchema); } function buildBestEffortAnnotations(tool: Tool, localName: string): ToolAnnotations { @@ -158,10 +157,9 @@ function buildBestEffortAnnotations(tool: Tool, localName: string): ToolAnnotati } function inferReadOnlyHint(localToolName: string): boolean { - // Default to conservative: most IDE tools can mutate project state. const name = localToolName.toLowerCase(); - const definitelyReadOnlyPrefixes = [ + const readOnlyPrefixes = [ 'xcode_tools_xcodelist', 'xcode_tools_xcodeglob', 'xcode_tools_xcodegrep', @@ -172,9 +170,7 @@ function inferReadOnlyHint(localToolName: string): boolean { 'xcode_tools_gettestlist', ]; - if (definitelyReadOnlyPrefixes.some((p) => name.startsWith(p))) return true; - - return false; + return readOnlyPrefixes.some((p) => name.startsWith(p)); } function inferDestructiveHint(localToolName: string, readOnlyHint: boolean): boolean { diff --git a/src/integrations/xcode-tools-bridge/standalone.ts b/src/integrations/xcode-tools-bridge/standalone.ts index cc1a042e..8c331bc1 100644 --- a/src/integrations/xcode-tools-bridge/standalone.ts +++ b/src/integrations/xcode-tools-bridge/standalone.ts @@ -1,8 +1,5 @@ -import { - createErrorResponse, - createTextResponse, - type ToolResponse, -} from '../../utils/responses/index.ts'; +import { callToolResultToBridgeResult, type BridgeToolResult } from './bridge-tool-result.ts'; +import { header, statusLine, section } from '../../utils/tool-event-builders.ts'; import { buildXcodeToolsBridgeStatus, classifyBridgeError, @@ -32,12 +29,14 @@ export class StandaloneXcodeToolsBridge { }); } - async statusTool(): Promise { + async statusTool(): Promise { const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return { + events: [header('Bridge Status'), section('Status', [JSON.stringify(status, null, 2)])], + }; } - async syncTool(): Promise { + async syncTool(): Promise { try { const remoteTools = await this.service.listTools({ refresh: true }); @@ -48,42 +47,68 @@ export class StandaloneXcodeToolsBridge { total: remoteTools.length, }; const status = await this.getStatus(); - return createTextResponse(JSON.stringify({ sync, status }, null, 2)); + return { + events: [ + header('Bridge Sync'), + section('Sync Result', [JSON.stringify({ sync, status }, null, 2)]), + statusLine('success', 'Bridge sync completed'), + ], + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge sync failed', message); + return { + events: [header('Bridge Sync'), statusLine('error', `Bridge sync failed: ${message}`)], + isError: true, + }; } finally { await this.service.disconnect(); } } - async disconnectTool(): Promise { + async disconnectTool(): Promise { try { await this.service.disconnect(); const status = await this.getStatus(); - return createTextResponse(JSON.stringify(status, null, 2)); + return { + events: [ + header('Bridge Disconnect'), + section('Status', [JSON.stringify(status, null, 2)]), + statusLine('success', 'Bridge disconnected'), + ], + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse('Bridge disconnect failed', message); + return { + events: [ + header('Bridge Disconnect'), + statusLine('error', `Bridge disconnect failed: ${message}`), + ], + isError: true, + }; } } - async listToolsTool(params: { refresh?: boolean }): Promise { + async listToolsTool(params: { refresh?: boolean }): Promise { try { const tools = await this.service.listTools({ refresh: params.refresh !== false }); - return createTextResponse( - JSON.stringify( - { - toolCount: tools.length, - tools: tools.map(serializeBridgeTool), - }, - null, - 2, - ), - ); + const payload = { + toolCount: tools.length, + tools: tools.map(serializeBridgeTool), + }; + return { + events: [ + header('Xcode IDE List Tools'), + section('Tools', [JSON.stringify(payload, null, 2)]), + statusLine('success', `Found ${tools.length} tool(s)`), + ], + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse(classifyBridgeError(error, 'list'), message); + const code = classifyBridgeError(error, 'list'); + return { + events: [header('Xcode IDE List Tools'), statusLine('error', `[${code}] ${message}`)], + isError: true, + }; } finally { await this.service.disconnect(); } @@ -93,15 +118,19 @@ export class StandaloneXcodeToolsBridge { remoteTool: string; arguments: Record; timeoutMs?: number; - }): Promise { + }): Promise { try { const response = await this.service.invokeTool(params.remoteTool, params.arguments, { timeoutMs: params.timeoutMs, }); - return response as ToolResponse; + return callToolResultToBridgeResult(response); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createErrorResponse(classifyBridgeError(error, 'call'), message); + const code = classifyBridgeError(error, 'call'); + return { + events: [header('Xcode IDE Call Tool'), statusLine('error', `[${code}] ${message}`)], + isError: true, + }; } finally { await this.service.disconnect(); } diff --git a/src/mcp/resources/__tests__/devices.test.ts b/src/mcp/resources/__tests__/devices.test.ts index aabed34f..0d411bed 100644 --- a/src/mcp/resources/__tests__/devices.test.ts +++ b/src/mcp/resources/__tests__/devices.test.ts @@ -1,29 +1,9 @@ import { describe, it, expect } from 'vitest'; -import devicesResource, { devicesResourceLogic } from '../devices.ts'; +import { devicesResourceLogic } from '../devices.ts'; import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; describe('devices resource', () => { - describe('Export Field Validation', () => { - it('should export correct uri', () => { - expect(devicesResource.uri).toBe('xcodebuildmcp://devices'); - }); - - it('should export correct description', () => { - expect(devicesResource.description).toBe( - 'Connected physical Apple devices with their UUIDs, names, and connection status', - ); - }); - - it('should export correct mimeType', () => { - expect(devicesResource.mimeType).toBe('text/plain'); - }); - - it('should export handler function', () => { - expect(typeof devicesResource.handler).toBe('function'); - }); - }); - describe('Handler Functionality', () => { it('should handle successful device data retrieval with xctrace fallback', async () => { const mockExecutor = createMockExecutor({ diff --git a/src/mcp/resources/__tests__/doctor.test.ts b/src/mcp/resources/__tests__/doctor.test.ts index 28534afd..6c1edd51 100644 --- a/src/mcp/resources/__tests__/doctor.test.ts +++ b/src/mcp/resources/__tests__/doctor.test.ts @@ -1,29 +1,9 @@ import { describe, it, expect } from 'vitest'; -import doctorResource, { doctorResourceLogic } from '../doctor.ts'; +import { doctorResourceLogic } from '../doctor.ts'; import { createMockExecutor } from '../../../test-utils/mock-executors.ts'; describe('doctor resource', () => { - describe('Export Field Validation', () => { - it('should export correct uri', () => { - expect(doctorResource.uri).toBe('xcodebuildmcp://doctor'); - }); - - it('should export correct description', () => { - expect(doctorResource.description).toBe( - 'Comprehensive development environment diagnostic information and configuration status', - ); - }); - - it('should export correct mimeType', () => { - expect(doctorResource.mimeType).toBe('text/plain'); - }); - - it('should export handler function', () => { - expect(typeof doctorResource.handler).toBe('function'); - }); - }); - describe('Handler Functionality', () => { it('should handle successful environment data retrieval', async () => { const mockExecutor = createMockExecutor({ @@ -32,24 +12,24 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); - expect(result.contents[0].text).toContain('## System Information'); - expect(result.contents[0].text).toContain('## Node.js Information'); - expect(result.contents[0].text).toContain('## Dependencies'); - expect(result.contents[0].text).toContain('## Environment Variables'); - expect(result.contents[0].text).toContain('## Feature Status'); + expect(text).toContain('Doctor'); + expect(text).toContain('Node.js Information'); + expect(text).toContain('Dependencies'); + expect(text).toContain('Environment Variables'); }); it('should handle spawn errors by showing doctor info', async () => { const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); - expect(result.contents[0].text).toContain('Error: spawn xcrun ENOENT'); + expect(text).toContain('Doctor'); + expect(text).toContain('spawn xcrun ENOENT'); }); it('should include required doctor sections', async () => { @@ -59,10 +39,11 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); - expect(result.contents[0].text).toContain('## Troubleshooting Tips'); - expect(result.contents[0].text).toContain('brew tap cameroncooke/axe'); - expect(result.contents[0].text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); + expect(text).toContain('Troubleshooting Tips'); + expect(text).toContain('brew tap cameroncooke/axe'); + expect(text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); }); it('should provide feature status information', async () => { @@ -72,11 +53,12 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); - expect(result.contents[0].text).toContain('### UI Automation (axe)'); - expect(result.contents[0].text).toContain('### Incremental Builds'); - expect(result.contents[0].text).toContain('### Mise Integration'); - expect(result.contents[0].text).toContain('## Tool Availability Summary'); + expect(text).toContain('UI Automation (axe)'); + expect(text).toContain('Incremental Builds'); + expect(text).toContain('Mise Integration'); + expect(text).toContain('Tool Availability Summary'); }); it('should handle error conditions gracefully', async () => { @@ -87,9 +69,10 @@ describe('doctor resource', () => { }); const result = await doctorResourceLogic(mockExecutor); + const text = result.contents.map((c) => c.text).join('\n'); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('XcodeBuildMCP Doctor'); + expect(text).toContain('Doctor'); }); }); }); diff --git a/src/mcp/resources/__tests__/session-status.test.ts b/src/mcp/resources/__tests__/session-status.test.ts index a074d230..fb483531 100644 --- a/src/mcp/resources/__tests__/session-status.test.ts +++ b/src/mcp/resources/__tests__/session-status.test.ts @@ -4,7 +4,7 @@ import { getDefaultDebuggerManager } from '../../../utils/debugger/index.ts'; import { activeLogSessions } from '../../../utils/log_capture.ts'; import { activeDeviceLogSessions } from '../../../utils/log-capture/device-log-sessions.ts'; import { clearAllProcesses } from '../../tools/swift-package/active-processes.ts'; -import sessionStatusResource, { sessionStatusResourceLogic } from '../session-status.ts'; +import { sessionStatusResourceLogic } from '../session-status.ts'; describe('session-status resource', () => { beforeEach(async () => { @@ -23,26 +23,6 @@ describe('session-status resource', () => { await getDefaultDebuggerManager().disposeAll(); }); - describe('Export Field Validation', () => { - it('should export correct uri', () => { - expect(sessionStatusResource.uri).toBe('xcodebuildmcp://session-status'); - }); - - it('should export correct description', () => { - expect(sessionStatusResource.description).toBe( - 'Runtime session state for log capture and debugging', - ); - }); - - it('should export correct mimeType', () => { - expect(sessionStatusResource.mimeType).toBe('application/json'); - }); - - it('should export handler function', () => { - expect(typeof sessionStatusResource.handler).toBe('function'); - }); - }); - describe('Handler Functionality', () => { it('should return empty status when no sessions exist', async () => { const result = await sessionStatusResourceLogic(); diff --git a/src/mcp/resources/__tests__/simulators.test.ts b/src/mcp/resources/__tests__/simulators.test.ts index eadc7a99..a301403e 100644 --- a/src/mcp/resources/__tests__/simulators.test.ts +++ b/src/mcp/resources/__tests__/simulators.test.ts @@ -1,33 +1,12 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import * as z from 'zod'; +import { describe, it, expect } from 'vitest'; -import simulatorsResource, { simulatorsResourceLogic } from '../simulators.ts'; +import { simulatorsResourceLogic } from '../simulators.ts'; import { createMockCommandResponse, createMockExecutor, } from '../../../test-utils/mock-executors.ts'; describe('simulators resource', () => { - describe('Export Field Validation', () => { - it('should export correct uri', () => { - expect(simulatorsResource.uri).toBe('xcodebuildmcp://simulators'); - }); - - it('should export correct description', () => { - expect(simulatorsResource.description).toBe( - 'Available iOS simulators with their UUIDs and states', - ); - }); - - it('should export correct mimeType', () => { - expect(simulatorsResource.mimeType).toBe('text/plain'); - }); - - it('should export handler function', () => { - expect(typeof simulatorsResource.handler).toBe('function'); - }); - }); - describe('Handler Functionality', () => { it('should handle successful simulator data retrieval', async () => { const mockExecutor = createMockExecutor({ @@ -49,9 +28,10 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('Available iOS Simulators:'); - expect(result.contents[0].text).toContain('iPhone 15 Pro'); - expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); + const text = result.contents[0].text; + expect(text).toContain('List Simulators'); + expect(text).toContain('iPhone 15 Pro'); + expect(text).toContain('ABC123-DEF456-GHI789'); }); it('should handle command execution failure', async () => { @@ -74,7 +54,6 @@ describe('simulators resource', () => { iPhone 15 (test-uuid-123) (Shutdown)`; const mockExecutor = async (command: string[]) => { - // JSON command returns invalid JSON if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -83,7 +62,6 @@ describe('simulators resource', () => { }); } - // Text command returns valid text output return createMockCommandResponse({ success: true, output: mockTextOutput, @@ -94,8 +72,10 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('iPhone 15 (test-uuid-123)'); - expect(result.contents[0].text).toContain('iOS 17.0'); + const text = result.contents[0].text; + expect(text).toContain('iPhone 15'); + expect(text).toContain('test-uuid-123'); + expect(text).toContain('iOS 17.0'); }); it('should handle spawn errors', async () => { @@ -117,7 +97,7 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); - expect(result.contents[0].text).toContain('Available iOS Simulators:'); + expect(result.contents[0].text).toContain('List Simulators'); }); it('should handle booted simulators correctly', async () => { @@ -139,7 +119,7 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); - expect(result.contents[0].text).toContain('[Booted]'); + expect(result.contents[0].text).toContain('Booted'); }); it('should filter out unavailable simulators', async () => { @@ -190,10 +170,9 @@ describe('simulators resource', () => { const result = await simulatorsResourceLogic(mockExecutor); - // The resource returns text content with simulator list and hint - expect(result.contents[0].text).toContain('iPhone 15 Pro'); - expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789'); - expect(result.contents[0].text).toContain('session-set-defaults'); + const text = result.contents[0].text; + expect(text).toContain('iPhone 15 Pro'); + expect(text).toContain('ABC123-DEF456-GHI789'); }); }); }); diff --git a/src/mcp/resources/__tests__/xcode-ide-state.test.ts b/src/mcp/resources/__tests__/xcode-ide-state.test.ts index b7713a75..af083a44 100644 --- a/src/mcp/resources/__tests__/xcode-ide-state.test.ts +++ b/src/mcp/resources/__tests__/xcode-ide-state.test.ts @@ -1,31 +1,7 @@ import { describe, it, expect } from 'vitest'; -import xcodeIdeStateResource, { xcodeIdeStateResourceLogic } from '../xcode-ide-state.ts'; +import { xcodeIdeStateResourceLogic } from '../xcode-ide-state.ts'; describe('xcode-ide-state resource', () => { - describe('Export Field Validation', () => { - it('should export correct uri', () => { - expect(xcodeIdeStateResource.uri).toBe('xcodebuildmcp://xcode-ide-state'); - }); - - it('should export correct name', () => { - expect(xcodeIdeStateResource.name).toBe('xcode-ide-state'); - }); - - it('should export correct description', () => { - expect(xcodeIdeStateResource.description).toBe( - "Current Xcode IDE selection (scheme and simulator) from Xcode's UI state", - ); - }); - - it('should export correct mimeType', () => { - expect(xcodeIdeStateResource.mimeType).toBe('application/json'); - }); - - it('should export handler function', () => { - expect(typeof xcodeIdeStateResource.handler).toBe('function'); - }); - }); - describe('Handler Functionality', () => { it('should return JSON response with expected structure', async () => { const result = await xcodeIdeStateResourceLogic(); @@ -33,10 +9,8 @@ describe('xcode-ide-state resource', () => { expect(result.contents).toHaveLength(1); const parsed = JSON.parse(result.contents[0].text); - // Response should have the expected structure expect(typeof parsed.detected).toBe('boolean'); - // Optional fields may or may not be present if (parsed.scheme !== undefined) { expect(typeof parsed.scheme).toBe('string'); } @@ -52,13 +26,9 @@ describe('xcode-ide-state resource', () => { }); it('should indicate detected=false when no Xcode project found', async () => { - // Running from the XcodeBuildMCP repo root (not an iOS project) - // should return detected=false with an error const result = await xcodeIdeStateResourceLogic(); const parsed = JSON.parse(result.contents[0].text); - // In our test environment without a proper iOS project, - // we expect either an error or detected=false expect(parsed.detected === false || parsed.error !== undefined).toBe(true); }); }); diff --git a/src/mcp/resources/devices.ts b/src/mcp/resources/devices.ts index cb8d5e39..1a4fa8d5 100644 --- a/src/mcp/resources/devices.ts +++ b/src/mcp/resources/devices.ts @@ -9,27 +9,30 @@ import { log } from '../../utils/logging/index.ts'; import type { CommandExecutor } from '../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../utils/execution/index.ts'; import { list_devicesLogic } from '../tools/device/list_devices.ts'; +import { createRenderSession } from '../../rendering/render.ts'; +import { handlerContextStorage } from '../../utils/typed-tool-factory.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; -// Testable resource logic separated from MCP handler export async function devicesResourceLogic( executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise<{ contents: Array<{ text: string }> }> { + const session = createRenderSession('text'); + const ctx: ToolHandlerContext = { + emit: (event) => session.emit(event), + attach: () => {}, + }; + try { log('info', 'Processing devices resource request'); - const result = await list_devicesLogic({}, executor); - - if (result.isError) { - const errorText = result.content[0]?.text; - throw new Error(typeof errorText === 'string' ? errorText : 'Failed to retrieve device data'); + await handlerContextStorage.run(ctx, () => list_devicesLogic({}, executor)); + const text = session.finalize(); + if (session.isError()) { + throw new Error(text || 'Failed to retrieve device data'); } - return { contents: [ { - text: - typeof result.content[0]?.text === 'string' - ? result.content[0].text - : 'No device data available', + text: text || 'No device data available', }, ], }; @@ -47,12 +50,6 @@ export async function devicesResourceLogic( } } -export default { - uri: 'xcodebuildmcp://devices', - name: 'devices', - description: 'Connected physical Apple devices with their UUIDs, names, and connection status', - mimeType: 'text/plain', - async handler(): Promise<{ contents: Array<{ text: string }> }> { - return devicesResourceLogic(); - }, -}; +export async function handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return devicesResourceLogic(); +} diff --git a/src/mcp/resources/doctor.ts b/src/mcp/resources/doctor.ts index ee07509c..a55f8e7c 100644 --- a/src/mcp/resources/doctor.ts +++ b/src/mcp/resources/doctor.ts @@ -10,7 +10,6 @@ import type { CommandExecutor } from '../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../utils/execution/index.ts'; import { doctorLogic } from '../tools/doctor/doctor.ts'; -// Testable resource logic separated from MCP handler export async function doctorResourceLogic( executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise<{ contents: Array<{ text: string }> }> { @@ -35,13 +34,14 @@ export async function doctorResourceLogic( }; } - const okTextItem = result.content.find((i) => i.type === 'text') as - | { type: 'text'; text: string } - | undefined; + const allText = result.content + .filter((i): i is { type: 'text'; text: string } => i.type === 'text') + .map((i) => i.text) + .join('\n'); return { contents: [ { - text: okTextItem?.text ?? 'No doctor data available', + text: allText || 'No doctor data available', }, ], }; @@ -59,13 +59,6 @@ export async function doctorResourceLogic( } } -export default { - uri: 'xcodebuildmcp://doctor', - name: 'doctor', - description: - 'Comprehensive development environment diagnostic information and configuration status', - mimeType: 'text/plain', - async handler(): Promise<{ contents: Array<{ text: string }> }> { - return doctorResourceLogic(); - }, -}; +export async function handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return doctorResourceLogic(); +} diff --git a/src/mcp/resources/session-status.ts b/src/mcp/resources/session-status.ts index dbe46c78..ceaf9b2d 100644 --- a/src/mcp/resources/session-status.ts +++ b/src/mcp/resources/session-status.ts @@ -33,12 +33,6 @@ export async function sessionStatusResourceLogic(): Promise<{ contents: Array<{ } } -export default { - uri: 'xcodebuildmcp://session-status', - name: 'session-status', - description: 'Runtime session state for log capture and debugging', - mimeType: 'application/json', - async handler(): Promise<{ contents: Array<{ text: string }> }> { - return sessionStatusResourceLogic(); - }, -}; +export async function handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return sessionStatusResourceLogic(); +} diff --git a/src/mcp/resources/simulators.ts b/src/mcp/resources/simulators.ts index 2da3afeb..7724409e 100644 --- a/src/mcp/resources/simulators.ts +++ b/src/mcp/resources/simulators.ts @@ -9,29 +9,30 @@ import { log } from '../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../utils/execution/index.ts'; import type { CommandExecutor } from '../../utils/execution/index.ts'; import { list_simsLogic } from '../tools/simulator/list_sims.ts'; +import { createRenderSession } from '../../rendering/render.ts'; +import { handlerContextStorage } from '../../utils/typed-tool-factory.ts'; +import type { ToolHandlerContext } from '../../rendering/types.ts'; -// Testable resource logic separated from MCP handler export async function simulatorsResourceLogic( executor: CommandExecutor = getDefaultCommandExecutor(), ): Promise<{ contents: Array<{ text: string }> }> { + const session = createRenderSession('text'); + const ctx: ToolHandlerContext = { + emit: (event) => session.emit(event), + attach: () => {}, + }; + try { log('info', 'Processing simulators resource request'); - const result = await list_simsLogic({}, executor); - - if (result.isError) { - const errorText = result.content[0]?.text; - throw new Error( - typeof errorText === 'string' ? errorText : 'Failed to retrieve simulator data', - ); + await handlerContextStorage.run(ctx, () => list_simsLogic({}, executor)); + const text = session.finalize(); + if (session.isError()) { + throw new Error(text || 'Failed to retrieve simulator data'); } - return { contents: [ { - text: - typeof result.content[0]?.text === 'string' - ? result.content[0].text - : 'No simulator data available', + text: text || 'No simulator data available', }, ], }; @@ -49,12 +50,6 @@ export async function simulatorsResourceLogic( } } -export default { - uri: 'xcodebuildmcp://simulators', - name: 'simulators', - description: 'Available iOS simulators with their UUIDs and states', - mimeType: 'text/plain', - async handler(): Promise<{ contents: Array<{ text: string }> }> { - return simulatorsResourceLogic(); - }, -}; +export async function handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return simulatorsResourceLogic(); +} diff --git a/src/mcp/resources/xcode-ide-state.ts b/src/mcp/resources/xcode-ide-state.ts index 950969e7..f0e1100e 100644 --- a/src/mcp/resources/xcode-ide-state.ts +++ b/src/mcp/resources/xcode-ide-state.ts @@ -4,7 +4,7 @@ * Provides read-only access to Xcode's current IDE selection (scheme and simulator). * Reads from UserInterfaceState.xcuserstate without modifying session defaults. * - * Only available when running under Xcode's coding agent. + * Visibility is controlled by the `runningUnderXcodeAgent` predicate in the resource manifest. */ import { log } from '../../utils/logging/index.ts'; @@ -64,12 +64,6 @@ export async function xcodeIdeStateResourceLogic(): Promise<{ } } -export default { - uri: 'xcodebuildmcp://xcode-ide-state', - name: 'xcode-ide-state', - description: "Current Xcode IDE selection (scheme and simulator) from Xcode's UI state", - mimeType: 'application/json', - async handler(): Promise<{ contents: Array<{ text: string }> }> { - return xcodeIdeStateResourceLogic(); - }, -}; +export async function handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return xcodeIdeStateResourceLogic(); +} diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 9aafde72..abe0e2b9 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -100,7 +100,7 @@ export async function bootstrapServer( xcodeToolsBridge?.setWorkflowEnabled(xcodeIdeEnabled); stageStartMs = getStartupProfileNowMs(); - await registerResources(server); + await registerResources(server, ctx); profiler.mark('registerResources', stageStartMs); return { diff --git a/src/server/mcp-lifecycle.ts b/src/server/mcp-lifecycle.ts index 0716a576..52bf156c 100644 --- a/src/server/mcp-lifecycle.ts +++ b/src/server/mcp-lifecycle.ts @@ -132,23 +132,23 @@ function parseElapsedSeconds(value: string): number | null { const daySplit = trimmed.split('-'); const timePart = daySplit.length === 2 ? daySplit[1] : daySplit[0]; const dayCount = daySplit.length === 2 ? Number(daySplit[0]) : 0; - const parts = timePart.split(':').map((part) => Number(part)); + const parts = timePart.split(':').map(Number); - if (!Number.isFinite(dayCount) || parts.some((part) => !Number.isFinite(part))) { + if (!Number.isFinite(dayCount) || parts.some((p) => !Number.isFinite(p))) { return null; } - if (parts.length === 1) { - return dayCount * 86400 + parts[0]; + const daySeconds = dayCount * 86400; + switch (parts.length) { + case 1: + return daySeconds + parts[0]; + case 2: + return daySeconds + parts[0] * 60 + parts[1]; + case 3: + return daySeconds + parts[0] * 3600 + parts[1] * 60 + parts[2]; + default: + return null; } - if (parts.length === 2) { - return dayCount * 86400 + parts[0] * 60 + parts[1]; - } - if (parts.length === 3) { - return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]; - } - - return null; } export function classifyMcpLifecycleAnomalies( @@ -178,15 +178,12 @@ export function classifyMcpLifecycleAnomalies( function isLikelyMcpProcessCommand(command: string): boolean { const normalized = command.toLowerCase(); - const hasMcpArg = /(^|\s)mcp(\s|$)/.test(normalized); - if (!hasMcpArg) { + if (!/(^|\s)mcp(\s|$)/.test(normalized)) { return false; } - if (/(^|\s)daemon(\s|$)/.test(normalized)) { return false; } - return ( normalized.includes('xcodebuildmcp') || normalized.includes('build/cli.js') || @@ -199,7 +196,7 @@ function isBrokenPipeLikeError(error: unknown): boolean { return false; } - const code = 'code' in error ? String((error as Error & { code?: unknown }).code ?? '') : ''; + const code = String((error as NodeJS.ErrnoException).code ?? ''); return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED'; } @@ -267,13 +264,15 @@ async function sampleMcpPeerProcesses( } } +const TRANSPORT_DISCONNECT_REASONS: ReadonlySet = new Set([ + 'stdin-end', + 'stdin-close', + 'stdout-error', + 'stderr-error', +]); + export function isTransportDisconnectReason(reason: McpShutdownReason): boolean { - return ( - reason === 'stdin-end' || - reason === 'stdin-close' || - reason === 'stdout-error' || - reason === 'stderr-error' - ); + return TRANSPORT_DISCONNECT_REASONS.has(reason); } export async function buildMcpLifecycleSnapshot(options: { diff --git a/src/server/mcp-shutdown.ts b/src/server/mcp-shutdown.ts index cf622073..9c923610 100644 --- a/src/server/mcp-shutdown.ts +++ b/src/server/mcp-shutdown.ts @@ -55,12 +55,6 @@ function stringifyError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function createTimer(timeoutMs: number, callback: () => void): NodeJS.Timeout { - const timer = setTimeout(callback, timeoutMs); - timer.unref?.(); - return timer; -} - async function runStep( name: string, timeoutMs: number, @@ -71,7 +65,8 @@ async function runStep( try { const timeoutPromise = new Promise>((resolve) => { - timeoutHandle = createTimer(timeoutMs, () => resolve({ kind: 'timed_out' })); + timeoutHandle = setTimeout(() => resolve({ kind: 'timed_out' }), timeoutMs); + timeoutHandle.unref?.(); }); const operationOutcome = operation() @@ -111,12 +106,14 @@ async function runStep( } } +const FAILURE_REASONS: ReadonlySet = new Set([ + 'startup-failure', + 'uncaught-exception', + 'unhandled-rejection', +]); + function buildExitCode(reason: McpShutdownReason): number { - return reason === 'startup-failure' || - reason === 'uncaught-exception' || - reason === 'unhandled-rejection' - ? 1 - : 0; + return FAILURE_REASONS.has(reason) ? 1 : 0; } export async function closeServerWithTimeout( diff --git a/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts b/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts index 99991a47..4efa7866 100644 --- a/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts +++ b/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts @@ -133,13 +133,6 @@ describe('MCP Discovery (e2e)', () => { expect(names).toContain('debug_stack'); }); - it('includes logging tools', async () => { - const result = await harness.client.listTools(); - const names = result.tools.map((t) => t.name); - expect(names).toContain('start_sim_log_cap'); - expect(names).toContain('stop_sim_log_cap'); - }); - it('includes project scaffolding tools', async () => { const result = await harness.client.listTools(); const names = result.tools.map((t) => t.name); diff --git a/src/smoke-tests/mcp-test-harness.ts b/src/smoke-tests/mcp-test-harness.ts index 25c1f549..2aec99c7 100644 --- a/src/smoke-tests/mcp-test-harness.ts +++ b/src/smoke-tests/mcp-test-harness.ts @@ -10,6 +10,10 @@ import { __setTestFileSystemExecutorOverride, __clearTestExecutorOverrides, } from '../utils/command.ts'; +import { + __setTestInteractiveSpawnerOverride, + __clearTestInteractiveSpawnerOverride, +} from '../utils/execution/interactive-process.ts'; import { __resetConfigStoreForTests, initConfigStore, @@ -17,7 +21,10 @@ import { } from '../utils/config-store.ts'; import { __resetServerStateForTests } from '../server/server-state.ts'; import { __resetToolRegistryForTests } from '../utils/tool-registry.ts'; -import { createMockFileSystemExecutor } from '../test-utils/mock-executors.ts'; +import { + createMockFileSystemExecutor, + createNoopInteractiveSpawner, +} from '../test-utils/mock-executors.ts'; import { createServer } from '../server/server.ts'; import { bootstrapServer } from '../server/bootstrap.ts'; import { sessionStore } from '../utils/session-store.ts'; @@ -97,6 +104,7 @@ export async function createMcpTestHarness(opts?: McpTestHarnessOptions): Promis // Set executor overrides on the vitest-resolved source modules __setTestCommandExecutorOverride(capturingExecutor); __setTestFileSystemExecutorOverride(mockFs); + __setTestInteractiveSpawnerOverride(createNoopInteractiveSpawner()); // Also set overrides on the built module instances (used by dynamically imported tool handlers) const buildRoot = resolve(getPackageRoot(), 'build'); @@ -117,6 +125,15 @@ export async function createMcpTestHarness(opts?: McpTestHarnessOptions): Promis builtCommandModule.__setTestCommandExecutorOverride(capturingExecutor); builtCommandModule.__setTestFileSystemExecutorOverride(mockFs); + // Set interactive spawner override (built module) + const builtInteractiveModule = (await import( + pathToFileURL(resolve(buildRoot, 'utils/execution/interactive-process.js')).href + )) as { + __setTestInteractiveSpawnerOverride: typeof __setTestInteractiveSpawnerOverride; + __clearTestInteractiveSpawnerOverride: typeof __clearTestInteractiveSpawnerOverride; + }; + builtInteractiveModule.__setTestInteractiveSpawnerOverride(createNoopInteractiveSpawner()); + // Set debugger tool context override (source module) __setTestDebuggerToolContextOverride({ executor: capturingExecutor, @@ -217,6 +234,8 @@ export async function createMcpTestHarness(opts?: McpTestHarnessOptions): Promis await shutdownXcodeToolsBridge(); __clearTestExecutorOverrides(); builtCommandModule.__clearTestExecutorOverrides(); + __clearTestInteractiveSpawnerOverride(); + builtInteractiveModule.__clearTestInteractiveSpawnerOverride(); __clearTestDebuggerToolContextOverride(); builtDebuggerModule.__clearTestDebuggerToolContextOverride(); __resetConfigStoreForTests(); diff --git a/src/test-utils/mock-executors.ts b/src/test-utils/mock-executors.ts index 03b09000..50cadcef 100644 --- a/src/test-utils/mock-executors.ts +++ b/src/test-utils/mock-executors.ts @@ -19,7 +19,11 @@ import { ChildProcess } from 'child_process'; import type { WriteStream } from 'fs'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; -import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; +import type { + CommandExecutor, + CommandResponse, + CommandExecOptions, +} from '../utils/CommandExecutor.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import type { InteractiveProcess, InteractiveSpawner } from '../utils/execution/index.ts'; @@ -57,7 +61,7 @@ export function createMockExecutor( command: string[], logPrefix?: string, useShell?: boolean, - opts?: { env?: Record; cwd?: string }, + opts?: CommandExecOptions, detached?: boolean, ) => void; } @@ -416,6 +420,21 @@ export function createNoopFileSystemExecutor(): FileSystemExecutor { }; } +/** + * Create a no-op interactive spawner that throws an error if called. + * Use this for tests where a spawner is required but should never be called. + */ +export function createNoopInteractiveSpawner(): InteractiveSpawner { + return () => { + throw new Error( + `🚨 NOOP INTERACTIVE SPAWNER CALLED! 🚨\n` + + `This spawner should never be called in this test context.\n` + + `If you see this error, it means the test is exercising a code path that wasn't expected.\n` + + `Either fix the test to avoid this code path, or use createMockInteractiveSpawner() instead.`, + ); + }; +} + /** * Create a mock environment detector for testing * @param options Mock options for environment detection diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index 94e65b0d..ba1df30b 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -6,11 +6,10 @@ export const _typeModule = true as const; export interface CommandExecOptions { env?: Record; cwd?: string; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; } -/** - * Command executor function type for dependency injection - */ /** * NOTE: `detached` only changes when the promise resolves; it does not detach/unref * the OS process. Callers must still manage lifecycle and open streams. @@ -22,9 +21,6 @@ export type CommandExecutor = ( opts?: CommandExecOptions, detached?: boolean, ) => Promise; -/** - * Command execution response interface - */ export interface CommandResponse { success: boolean; diff --git a/src/utils/FileSystemExecutor.ts b/src/utils/FileSystemExecutor.ts index 4baf499c..0d3a732e 100644 --- a/src/utils/FileSystemExecutor.ts +++ b/src/utils/FileSystemExecutor.ts @@ -1,7 +1,3 @@ -/** - * File system executor interface for dependency injection - */ - import type { WriteStream } from 'fs'; // Runtime marker to prevent empty output in unbundled builds diff --git a/src/utils/__tests__/nskeyedarchiver-parser.test.ts b/src/utils/__tests__/nskeyedarchiver-parser.test.ts index 19a74a8f..f60b2aaa 100644 --- a/src/utils/__tests__/nskeyedarchiver-parser.test.ts +++ b/src/utils/__tests__/nskeyedarchiver-parser.test.ts @@ -1,6 +1,4 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; +import { describe, it, expect } from 'vitest'; import { parseXcuserstate, parseXcuserstateBuffer, @@ -9,53 +7,8 @@ import { findDictWithKey, } from '../nskeyedarchiver-parser.ts'; -// Path to the example project's xcuserstate (used as test fixture) -const EXAMPLE_PROJECT_XCUSERSTATE = join( - process.cwd(), - 'example_projects/iOS/MCPTest.xcodeproj/project.xcworkspace/xcuserdata/johndoe.xcuserdatad/UserInterfaceState.xcuserstate', -); - -// Expected values for the MCPTest example project -const EXPECTED_MCPTEST = { - scheme: 'MCPTest', - simulatorId: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', - simulatorPlatform: 'iphonesimulator', -}; - describe('NSKeyedArchiver Parser', () => { describe('parseXcuserstate (file path)', () => { - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))( - 'extracts scheme name from example project', - () => { - const result = parseXcuserstate(EXAMPLE_PROJECT_XCUSERSTATE); - expect(result.scheme).toBe(EXPECTED_MCPTEST.scheme); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))( - 'extracts simulator UUID from example project', - () => { - const result = parseXcuserstate(EXAMPLE_PROJECT_XCUSERSTATE); - expect(result.simulatorId).toBe(EXPECTED_MCPTEST.simulatorId); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))( - 'extracts simulator platform from example project', - () => { - const result = parseXcuserstate(EXAMPLE_PROJECT_XCUSERSTATE); - expect(result.simulatorPlatform).toBe(EXPECTED_MCPTEST.simulatorPlatform); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))( - 'extracts device location from example project', - () => { - const result = parseXcuserstate(EXAMPLE_PROJECT_XCUSERSTATE); - expect(result.deviceLocation).toMatch(/^dvtdevice-iphonesimulator:[A-F0-9-]{36}$/); - }, - ); - it('returns empty result for non-existent file', () => { const result = parseXcuserstate('/non/existent/file.xcuserstate'); expect(result).toEqual({}); @@ -63,40 +16,6 @@ describe('NSKeyedArchiver Parser', () => { }); describe('parseXcuserstateBuffer (buffer)', () => { - let fixtureBuffer: Buffer; - - beforeAll(() => { - if (existsSync(EXAMPLE_PROJECT_XCUSERSTATE)) { - fixtureBuffer = readFileSync(EXAMPLE_PROJECT_XCUSERSTATE); - } - }); - - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))('extracts scheme name from buffer', () => { - const result = parseXcuserstateBuffer(fixtureBuffer); - expect(result.scheme).toBe(EXPECTED_MCPTEST.scheme); - }); - - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))( - 'extracts simulator UUID from buffer', - () => { - const result = parseXcuserstateBuffer(fixtureBuffer); - expect(result.simulatorId).toBe(EXPECTED_MCPTEST.simulatorId); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_PROJECT_XCUSERSTATE))( - 'extracts all fields correctly from buffer', - () => { - const result = parseXcuserstateBuffer(fixtureBuffer); - expect(result).toMatchObject({ - scheme: EXPECTED_MCPTEST.scheme, - simulatorId: EXPECTED_MCPTEST.simulatorId, - simulatorPlatform: EXPECTED_MCPTEST.simulatorPlatform, - }); - expect(result.deviceLocation).toBeDefined(); - }, - ); - it('returns empty result for empty buffer', () => { const result = parseXcuserstateBuffer(Buffer.from([])); expect(result).toEqual({}); diff --git a/src/utils/__tests__/snapshot-normalize.test.ts b/src/utils/__tests__/snapshot-normalize.test.ts new file mode 100644 index 00000000..afec0beb --- /dev/null +++ b/src/utils/__tests__/snapshot-normalize.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeSnapshotOutput } from '../../snapshot-tests/normalize.ts'; + +describe('normalizeSnapshotOutput tilde handling', () => { + it('normalizes ~/ paths to /', () => { + const input = 'Derived Data: ~/Library/Developer/XcodeBuildMCP/DerivedData\n'; + const result = normalizeSnapshotOutput(input); + expect(result).toContain('/Library/Developer/XcodeBuildMCP/DerivedData'); + expect(result).not.toContain('~/'); + }); + + it('normalizes bare ~ (exact home directory) to ', () => { + const input = 'Home: ~\nDone\n'; + const result = normalizeSnapshotOutput(input); + expect(result).toContain('Home: '); + expect(result).not.toMatch(/: ~\n/); + }); + + it('does not alter tildes that are part of approximate numbers', () => { + const input = 'Approximately ~50 items\n'; + const result = normalizeSnapshotOutput(input); + expect(result).toContain('~50'); + }); +}); diff --git a/src/utils/__tests__/tool-registry.test.ts b/src/utils/__tests__/tool-registry.test.ts index 70cf5aab..46569bb5 100644 --- a/src/utils/__tests__/tool-registry.test.ts +++ b/src/utils/__tests__/tool-registry.test.ts @@ -41,6 +41,7 @@ function createManifestFixture(): ResolvedManifest { }, ], ]), + resources: new Map(), }; } diff --git a/src/utils/__tests__/xcode-state-reader.test.ts b/src/utils/__tests__/xcode-state-reader.test.ts index d79a810f..83344553 100644 --- a/src/utils/__tests__/xcode-state-reader.test.ts +++ b/src/utils/__tests__/xcode-state-reader.test.ts @@ -1,6 +1,4 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { existsSync } from 'fs'; -import { join } from 'path'; import { findXcodeStateFile, lookupSimulatorName, @@ -8,13 +6,6 @@ import { } from '../xcode-state-reader.ts'; import { createCommandMatchingMockExecutor } from '../../test-utils/mock-executors.ts'; -// Path to the example project's xcuserstate (used as test fixture) -const EXAMPLE_PROJECT_PATH = join(process.cwd(), 'example_projects/iOS/MCPTest.xcodeproj'); -const EXAMPLE_XCUSERSTATE = join( - EXAMPLE_PROJECT_PATH, - 'project.xcworkspace/xcuserdata/johndoe.xcuserdatad/UserInterfaceState.xcuserstate', -); - describe('findXcodeStateFile', () => { it('returns undefined when no project/workspace found', async () => { const executor = createCommandMatchingMockExecutor({ @@ -227,73 +218,3 @@ describe('readXcodeIdeState', () => { expect(result.error).toBeDefined(); }); }); - -describe('readXcodeIdeState integration', () => { - // These tests use the actual example project fixture - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( - 'reads scheme and simulator from example project', - async () => { - // Mock executor that returns real paths - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, - stat: { output: '1704067200\n' }, - 'xcrun simctl': { - output: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { - udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', - name: 'Apple Vision Pro', - }, - ], - }, - }), - }, - }); - - const result = await readXcodeIdeState({ - executor, - cwd: join(process.cwd(), 'example_projects/iOS'), - }); - - expect(result.error).toBeUndefined(); - expect(result.scheme).toBe('MCPTest'); - expect(result.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - expect(result.simulatorName).toBe('Apple Vision Pro'); - }, - ); - - it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( - 'reads scheme using configured projectPath', - async () => { - const executor = createCommandMatchingMockExecutor({ - whoami: { output: 'johndoe\n' }, - 'test -f': { success: true }, - 'xcrun simctl': { - output: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-2-0': [ - { - udid: 'B38FE93D-578B-454B-BE9A-C6FA0CE5F096', - name: 'Apple Vision Pro', - }, - ], - }, - }), - }, - }); - - const result = await readXcodeIdeState({ - executor, - cwd: '/some/other/path', - projectPath: EXAMPLE_PROJECT_PATH, - }); - - expect(result.error).toBeUndefined(); - expect(result.scheme).toBe('MCPTest'); - expect(result.simulatorId).toBe('B38FE93D-578B-454B-BE9A-C6FA0CE5F096'); - }, - ); -}); diff --git a/src/utils/command.ts b/src/utils/command.ts index 9d85fb3d..e131d57b 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -1,35 +1,14 @@ -/** - * Command Utilities - Generic command execution utilities - * - * This utility module provides functions for executing shell commands. - * It serves as a foundation for other utility modules that need to execute commands. - * - * Responsibilities: - * - Executing shell commands with proper argument handling - * - Managing process spawning, output capture, and error handling - */ - import { spawn } from 'child_process'; import { createWriteStream, existsSync } from 'fs'; +import * as fsPromises from 'fs/promises'; import { tmpdir as osTmpdir } from 'os'; import { log } from './logger.ts'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; -// Re-export types for backward compatibility export type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; export type { FileSystemExecutor } from './FileSystemExecutor.ts'; -/** - * Default executor implementation using spawn (current production behavior) - * Private instance - use getDefaultCommandExecutor() for access - * @param command An array of command and arguments - * @param logPrefix Prefix for logging - * @param useShell Whether to use shell execution (true) or direct execution (false) - * @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory) - * @param detached Whether to resolve without waiting for completion (does not detach/unref the process) - * @returns Promise resolving to command response with the process - */ async function defaultExecutor( command: string[], logPrefix?: string, @@ -37,15 +16,11 @@ async function defaultExecutor( opts?: CommandExecOptions, detached: boolean = false, ): Promise { - // Properly escape arguments for shell let escapedCommand = command; if (useShell) { - // For shell execution, we need to format as ['/bin/sh', '-c', 'full command string'] const commandString = command .map((arg) => { - // Shell metacharacters that require quoting: space, quotes, equals, dollar, backticks, semicolons, pipes, etc. if (/[\s,"'=$`;&|<>(){}[\]\\*?~]/.test(arg) && !/^".*"$/.test(arg)) { - // Escape all quotes and backslashes, then wrap in double quotes return `"${arg.replace(/(["\\])/g, '\\$1')}"`; } return arg; @@ -67,13 +42,19 @@ async function defaultExecutor( } } - // Log the actual command that will be executed const displayCommand = useShell && escapedCommand.length === 3 ? escapedCommand[2] : [executable, ...args].join(' '); log('debug', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); + const verbose = process.env.XCODEBUILDMCP_VERBOSE === '1'; + if (verbose) { + const dim = process.stderr.isTTY ? '\x1B[2m' : ''; + const reset = process.stderr.isTTY ? '\x1B[0m' : ''; + process.stderr.write(`${dim}$ ${displayCommand}${reset}\n`); + } + const spawnOpts: Parameters[2] = { - stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...(opts?.env ?? {}) }, cwd: opts?.cwd, }; @@ -95,20 +76,27 @@ async function defaultExecutor( const childProcess = spawn(executable, args, spawnOpts); + if (verbose) { + childProcess.stdout?.pipe(process.stderr, { end: false }); + childProcess.stderr?.pipe(process.stderr, { end: false }); + } + let stdout = ''; let stderr = ''; childProcess.stdout?.on('data', (data: Buffer) => { - stdout += data.toString(); + const chunk = data.toString(); + stdout += chunk; + opts?.onStdout?.(chunk); }); childProcess.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); + const chunk = data.toString(); + stderr += chunk; + opts?.onStderr?.(chunk); }); - // For detached processes, handle differently to avoid race conditions if (detached) { - // For detached processes, only wait for spawn success/failure let resolved = false; childProcess.on('error', (err) => { @@ -119,14 +107,13 @@ async function defaultExecutor( } }); - // Give a small delay to ensure the process starts successfully setTimeout(() => { if (!resolved) { resolved = true; if (childProcess.pid) { resolve({ success: true, - output: '', // No output for detached processes + output: '', process: childProcess, }); } else { @@ -140,7 +127,6 @@ async function defaultExecutor( } }, 100); } else { - // For non-detached processes, handle normally childProcess.on('close', (code) => { const success = code === 0; const response: CommandResponse = { @@ -162,25 +148,17 @@ async function defaultExecutor( }); } -/** - * Default file system executor implementation using Node.js fs/promises - * Private instance - use getDefaultFileSystemExecutor() for access - */ const defaultFileSystemExecutor: FileSystemExecutor = { async mkdir(path: string, options?: { recursive?: boolean }): Promise { - const fs = await import('fs/promises'); - await fs.mkdir(path, options); + await fsPromises.mkdir(path, options); }, async readFile(path: string, encoding: BufferEncoding = 'utf8'): Promise { - const fs = await import('fs/promises'); - const content = await fs.readFile(path, encoding); - return content; + return await fsPromises.readFile(path, encoding); }, async writeFile(path: string, content: string, encoding: BufferEncoding = 'utf8'): Promise { - const fs = await import('fs/promises'); - await fs.writeFile(path, content, encoding); + await fsPromises.writeFile(path, content, encoding); }, createWriteStream(path: string, options?: { flags?: string }) { @@ -188,18 +166,15 @@ const defaultFileSystemExecutor: FileSystemExecutor = { }, async cp(source: string, destination: string, options?: { recursive?: boolean }): Promise { - const fs = await import('fs/promises'); - await fs.cp(source, destination, options); + await fsPromises.cp(source, destination, options); }, async readdir(path: string, options?: { withFileTypes?: boolean }): Promise { - const fs = await import('fs/promises'); - return await fs.readdir(path, options as Record); + return await fsPromises.readdir(path, options as Record); }, async rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise { - const fs = await import('fs/promises'); - await fs.rm(path, options); + await fsPromises.rm(path, options); }, existsSync(path: string): boolean { @@ -207,13 +182,11 @@ const defaultFileSystemExecutor: FileSystemExecutor = { }, async stat(path: string): Promise<{ isDirectory(): boolean; mtimeMs: number }> { - const fs = await import('fs/promises'); - return await fs.stat(path); + return await fsPromises.stat(path); }, async mkdtemp(prefix: string): Promise { - const fs = await import('fs/promises'); - return await fs.mkdtemp(prefix); + return await fsPromises.mkdtemp(prefix); }, tmpdir(): string { @@ -237,38 +210,18 @@ export function __clearTestExecutorOverrides(): void { _testFileSystemExecutorOverride = null; } -/** - * Get default command executor with test safety - * Throws error if used in test environment to ensure proper mocking - */ -export function getDefaultCommandExecutor(): CommandExecutor { - if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { - if (_testCommandExecutorOverride) return _testCommandExecutorOverride; - throw new Error( - `🚨 REAL SYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` + - `This test is trying to use the default command executor instead of a mock.\n` + - `Fix: Pass createMockExecutor() as the commandExecutor parameter in your test.\n` + - `Example: await plugin.handler(args, createMockExecutor({success: true}), mockFileSystem)\n` + - `See docs/dev/TESTING.md for proper testing patterns.`, - ); - } +export function __getRealCommandExecutor(): CommandExecutor { return defaultExecutor; } -/** - * Get default file system executor with test safety - * Throws error if used in test environment to ensure proper mocking - */ -export function getDefaultFileSystemExecutor(): FileSystemExecutor { - if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { - if (_testFileSystemExecutorOverride) return _testFileSystemExecutorOverride; - throw new Error( - `🚨 REAL FILESYSTEM EXECUTOR DETECTED IN TEST! 🚨\n` + - `This test is trying to use the default filesystem executor instead of a mock.\n` + - `Fix: Pass createMockFileSystemExecutor() as the fileSystemExecutor parameter in your test.\n` + - `Example: await plugin.handler(args, mockCmd, createMockFileSystemExecutor())\n` + - `See docs/dev/TESTING.md for proper testing patterns.`, - ); - } +export function __getRealFileSystemExecutor(): FileSystemExecutor { return defaultFileSystemExecutor; } + +export function getDefaultCommandExecutor(): CommandExecutor { + return _testCommandExecutorOverride ?? defaultExecutor; +} + +export function getDefaultFileSystemExecutor(): FileSystemExecutor { + return _testFileSystemExecutorOverride ?? defaultFileSystemExecutor; +} diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 8af6226e..b1986b63 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -1,29 +1,12 @@ -/** - * Environment Detection Utilities - * - * Provides abstraction for environment detection to enable testability - * while maintaining production functionality. - */ - import { execSync } from 'child_process'; import { log } from './logger.ts'; import { getConfig } from './config-store.ts'; import type { UiDebuggerGuardMode } from './runtime-config-types.ts'; -/** - * Interface for environment detection abstraction - */ export interface EnvironmentDetector { - /** - * Detects if the MCP server is running under Claude Code - * @returns true if Claude Code is detected, false otherwise - */ isRunningUnderClaudeCode(): boolean; } -/** - * Production implementation of environment detection - */ export class ProductionEnvironmentDetector implements EnvironmentDetector { private cachedResult: boolean | undefined; @@ -32,19 +15,16 @@ export class ProductionEnvironmentDetector implements EnvironmentDetector { return this.cachedResult; } - // Disable Claude Code detection during tests for environment-agnostic testing if (process.env.NODE_ENV === 'test' || process.env.VITEST === 'true') { this.cachedResult = false; return false; } - // Method 1: Check for Claude Code environment variables if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT === 'cli') { this.cachedResult = true; return true; } - // Method 2: Check parent process name try { const parentPid = process.ppid; if (parentPid) { @@ -58,7 +38,6 @@ export class ProductionEnvironmentDetector implements EnvironmentDetector { } } } catch (error) { - // If process detection fails, fall back to environment variables only log('debug', `Failed to detect parent process: ${error}`); } @@ -67,22 +46,12 @@ export class ProductionEnvironmentDetector implements EnvironmentDetector { } } -/** - * Default environment detector instance for production use - */ -export const defaultEnvironmentDetector = new ProductionEnvironmentDetector(); +const defaultEnvironmentDetector = new ProductionEnvironmentDetector(); -/** - * Gets the default environment detector for production use - */ export function getDefaultEnvironmentDetector(): EnvironmentDetector { return defaultEnvironmentDetector; } -/** - * Global opt-out for session defaults in MCP tool schemas. - * When enabled, tools re-expose all parameters instead of hiding session-managed fields. - */ export function isSessionDefaultsOptOutEnabled(): boolean { return getConfig().disableSessionDefaults; } @@ -91,38 +60,29 @@ export function getUiDebuggerGuardMode(): UiDebuggerGuardMode { return getConfig().uiDebuggerGuardMode; } -/** - * Normalizes a set of user-provided environment variables by ensuring they are - * prefixed with TEST_RUNNER_. Variables already prefixed are preserved. - * - * Example: - * normalizeTestRunnerEnv({ FOO: '1', TEST_RUNNER_BAR: '2' }) - * => { TEST_RUNNER_FOO: '1', TEST_RUNNER_BAR: '2' } - */ -export function normalizeTestRunnerEnv(vars: Record): Record { +function normalizeEnvWithPrefix( + prefix: string, + vars: Record, +): Record { const normalized: Record = {}; for (const [key, value] of Object.entries(vars ?? {})) { if (value == null) continue; - const prefixedKey = key.startsWith('TEST_RUNNER_') ? key : `TEST_RUNNER_${key}`; + const prefixedKey = key.startsWith(prefix) ? key : `${prefix}${key}`; normalized[prefixedKey] = value; } return normalized; } /** - * Normalizes a set of user-provided environment variables by ensuring they are - * prefixed with SIMCTL_CHILD_. Variables already prefixed are preserved. - * - * Example: - * normalizeSimctlChildEnv({ FOO: '1', SIMCTL_CHILD_BAR: '2' }) - * => { SIMCTL_CHILD_FOO: '1', SIMCTL_CHILD_BAR: '2' } + * Normalizes environment variables by ensuring they are prefixed with TEST_RUNNER_. + */ +export function normalizeTestRunnerEnv(vars: Record): Record { + return normalizeEnvWithPrefix('TEST_RUNNER_', vars); +} + +/** + * Normalizes environment variables by ensuring they are prefixed with SIMCTL_CHILD_. */ export function normalizeSimctlChildEnv(vars: Record): Record { - const normalized: Record = {}; - for (const [key, value] of Object.entries(vars ?? {})) { - if (value == null) continue; - const prefixedKey = key.startsWith('SIMCTL_CHILD_') ? key : `SIMCTL_CHILD_${key}`; - normalized[prefixedKey] = value; - } - return normalized; + return normalizeEnvWithPrefix('SIMCTL_CHILD_', vars); } diff --git a/src/utils/execution/index.ts b/src/utils/execution/index.ts index 4ca96841..0d71b66a 100644 --- a/src/utils/execution/index.ts +++ b/src/utils/execution/index.ts @@ -9,7 +9,11 @@ export { __setTestFileSystemExecutorOverride, __clearTestExecutorOverrides, } from '../command.ts'; -export { getDefaultInteractiveSpawner } from './interactive-process.ts'; +export { + getDefaultInteractiveSpawner, + __setTestInteractiveSpawnerOverride, + __clearTestInteractiveSpawnerOverride, +} from './interactive-process.ts'; // Types export type { CommandExecutor, CommandResponse, CommandExecOptions } from '../CommandExecutor.ts'; diff --git a/src/utils/execution/interactive-process.ts b/src/utils/execution/interactive-process.ts index 888d551b..ac5cab79 100644 --- a/src/utils/execution/interactive-process.ts +++ b/src/utils/execution/interactive-process.ts @@ -70,12 +70,16 @@ function createInteractiveProcess( return new DefaultInteractiveProcess(childProcess); } -export function getDefaultInteractiveSpawner(): InteractiveSpawner { - if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') { - throw new Error( - 'Interactive process spawn blocked in tests. Inject a mock InteractiveSpawner.', - ); - } +let _testInteractiveSpawnerOverride: InteractiveSpawner | null = null; + +export function __setTestInteractiveSpawnerOverride(spawner: InteractiveSpawner | null): void { + _testInteractiveSpawnerOverride = spawner; +} - return createInteractiveProcess; +export function __clearTestInteractiveSpawnerOverride(): void { + _testInteractiveSpawnerOverride = null; +} + +export function getDefaultInteractiveSpawner(): InteractiveSpawner { + return _testInteractiveSpawnerOverride ?? createInteractiveProcess; } diff --git a/src/utils/log-capture/device-log-sessions.ts b/src/utils/log-capture/device-log-sessions.ts index a5457641..dc3c4638 100644 --- a/src/utils/log-capture/device-log-sessions.ts +++ b/src/utils/log-capture/device-log-sessions.ts @@ -1,5 +1,5 @@ -import type { ChildProcess } from 'child_process'; -import type * as fs from 'fs'; +import type { ChildProcess } from 'node:child_process'; +import type * as fs from 'node:fs'; import { log } from '../logger.ts'; import { getDefaultFileSystemExecutor } from '../command.ts'; import type { FileSystemExecutor } from '../FileSystemExecutor.ts'; @@ -67,7 +67,7 @@ export async function stopDeviceLogSessionById( logSessionId: string, fileSystemExecutor: FileSystemExecutor, options: { timeoutMs?: number; readLogContent?: boolean } = {}, -): Promise<{ logContent: string; error?: string }> { +): Promise<{ logContent: string; logFilePath?: string; error?: string }> { const session = activeDeviceLogSessions.get(logSessionId); if (!session) { return { logContent: '', error: `Device log capture session not found: ${logSessionId}` }; @@ -93,7 +93,7 @@ export async function stopDeviceLogSessionById( return { logContent: '', error: `Log file not found: ${session.logFilePath}` }; } const fileContent = await fileSystemExecutor.readFile(session.logFilePath, 'utf-8'); - return { logContent: fileContent }; + return { logContent: fileContent, logFilePath: session.logFilePath }; } return { logContent: '' }; diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index d8e279d3..972d5339 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -1,7 +1,7 @@ -import * as path from 'path'; -import type { ChildProcess } from 'child_process'; -import type { Writable } from 'stream'; -import { finished } from 'stream/promises'; +import * as path from 'node:path'; +import type { ChildProcess } from 'node:child_process'; +import type { Writable } from 'node:stream'; +import { finished } from 'node:stream/promises'; import { v4 as uuidv4 } from 'uuid'; import { log } from '../utils/logger.ts'; import type { CommandExecutor } from './command.ts'; @@ -9,6 +9,7 @@ import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from './comma import { normalizeSimctlChildEnv } from './environment.ts'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import { acquireDaemonActivity } from '../daemon/activity-registry.ts'; +import { LOG_DIR as APP_LOG_DIR } from './log-paths.ts'; /** * Log file retention policy: @@ -16,7 +17,6 @@ import { acquireDaemonActivity } from '../daemon/activity-registry.ts'; * - Cleanup runs on every new log capture start */ const LOG_RETENTION_DAYS = 3; -const LOG_FILE_PREFIX = 'xcodemcp_sim_log_'; export interface LogSession { processes: ChildProcess[]; @@ -41,7 +41,6 @@ export type SubsystemFilter = 'app' | 'all' | 'swiftui' | string[]; */ function buildLogPredicate(bundleId: string, subsystemFilter: SubsystemFilter): string | null { if (subsystemFilter === 'all') { - // No filtering - capture everything from this process return null; } @@ -50,11 +49,9 @@ function buildLogPredicate(bundleId: string, subsystemFilter: SubsystemFilter): } if (subsystemFilter === 'swiftui') { - // Include both app logs and SwiftUI logs (for Self._printChanges()) return `subsystem == "${bundleId}" OR subsystem == "com.apple.SwiftUI"`; } - // Custom array of subsystems - always include the app's bundle ID const subsystems = new Set([bundleId, ...subsystemFilter]); const predicates = Array.from(subsystems).map((s) => `subsystem == "${s}"`); return predicates.join(' OR '); @@ -90,8 +87,10 @@ export async function startLogCapture( subsystemFilter = 'app', } = params; const logSessionId = uuidv4(); - const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`; - const logFilePath = path.join(fileSystem.tmpdir(), logFileName); + const ts = new Date().toISOString().replace(/:/g, '-').replace('.', '-').slice(0, -1) + 'Z'; + const logFileName = `${bundleId}_${ts}_pid${process.pid}.log`; + const logsDir = APP_LOG_DIR; + const logFilePath = path.join(logsDir, logFileName); let logStream: Writable | null = null; const processes: ChildProcess[] = []; @@ -127,10 +126,10 @@ export async function startLogCapture( }; try { - await fileSystem.mkdir(fileSystem.tmpdir(), { recursive: true }); + await fileSystem.mkdir(logsDir, { recursive: true }); await fileSystem.writeFile(logFilePath, ''); logStream = fileSystem.createWriteStream(logFilePath, { flags: 'a' }); - logStream.write('\n--- Log capture for bundle ID: ' + bundleId + ' ---\n'); + logStream.write(`\n--- Log capture for bundle ID: ${bundleId} ---\n`); if (captureConsole) { const launchCommand = [ @@ -150,9 +149,9 @@ export async function startLogCapture( const stdoutLogResult = await executor( launchCommand, 'Console Log Capture', - false, // useShell - launchOpts, // env - true, // detached - don't wait for this streaming process to complete + false, + launchOpts, + true, ); if (!stdoutLogResult.success) { @@ -170,7 +169,6 @@ export async function startLogCapture( processes.push(stdoutLogResult.process); } - // Build the log stream command based on subsystem filter const logPredicate = buildLogPredicate(bundleId, subsystemFilter); const osLogCommand = [ 'xcrun', @@ -182,18 +180,11 @@ export async function startLogCapture( '--level=debug', ]; - // Only add predicate if filtering is needed if (logPredicate) { osLogCommand.push('--predicate', logPredicate); } - const osLogResult = await executor( - osLogCommand, - 'OS Log Capture', - false, // useShell - undefined, // env - true, // detached - don't wait for this streaming process to complete - ); + const osLogResult = await executor(osLogCommand, 'OS Log Capture', false, undefined, true); if (!osLogResult.success) { await closeFailedCapture(); @@ -213,6 +204,9 @@ export async function startLogCapture( process.on('close', (code) => { log('info', `A log capture process for session ${logSessionId} exited with code ${code}.`); }); + process.unref?.(); + (process.stdout as any)?.unref?.(); + (process.stderr as any)?.unref?.(); } const releaseActivity = acquireDaemonActivity('logging.simulator'); @@ -243,6 +237,7 @@ interface StopLogSessionOptions { interface StopLogSessionResult { logContent: string; + logFilePath?: string; error?: string; } @@ -300,7 +295,7 @@ async function stopLogSession( return { logContent: '', error: `Log file not found: ${session.logFilePath}` }; } const fileContent = await options.fileSystem.readFile(session.logFilePath, 'utf-8'); - return { logContent: fileContent }; + return { logContent: fileContent, logFilePath: session.logFilePath }; } return { logContent: '' }; @@ -318,7 +313,7 @@ async function stopLogSession( export async function stopLogCapture( logSessionId: string, fileSystem: FileSystemExecutor = getDefaultFileSystemExecutor(), -): Promise<{ logContent: string; error?: string }> { +): Promise<{ logContent: string; logFilePath?: string; error?: string }> { const result = await stopLogSession(logSessionId, { fileSystem, readLogContent: true, @@ -364,15 +359,11 @@ export async function stopAllLogCaptures(timeoutMs = 1000): Promise<{ * Runs quietly; errors are logged but do not throw. */ async function cleanOldLogs(fileSystem: FileSystemExecutor): Promise { - const tempDir = fileSystem.tmpdir(); + const logsDir = APP_LOG_DIR; let files: unknown[]; try { - files = await fileSystem.readdir(tempDir); - } catch (err) { - log( - 'warn', - `Could not read temp dir for log cleanup: ${err instanceof Error ? err.message : String(err)}`, - ); + files = await fileSystem.readdir(logsDir); + } catch { return; } const now = Date.now(); @@ -381,9 +372,9 @@ async function cleanOldLogs(fileSystem: FileSystemExecutor): Promise { await Promise.all( fileNames - .filter((f) => f.startsWith(LOG_FILE_PREFIX) && f.endsWith('.log')) + .filter((f) => f.endsWith('.log')) .map(async (f) => { - const filePath = path.join(tempDir, f); + const filePath = path.join(logsDir, f); try { const stat = await fileSystem.stat(filePath); if (now - stat.mtimeMs > retentionMs) { diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index 8a09f693..da9f2632 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -3,16 +3,41 @@ import { server } from '../server/server-state.ts'; import type { ToolResponse } from '../types/common.ts'; import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts'; import { log } from './logger.ts'; -import { processToolResponse } from './responses/index.ts'; import { loadManifest, type ResolvedManifest } from '../core/manifest/load-manifest.ts'; import { importToolModule } from '../core/manifest/import-tool-module.ts'; import { getEffectiveCliName, type WorkflowManifestEntry } from '../core/manifest/schema.ts'; import { createToolCatalog } from '../runtime/tool-catalog.ts'; -import { postProcessToolResponse } from '../runtime/tool-invoker.ts'; +import { postProcessSession } from '../runtime/tool-invoker.ts'; import type { PredicateContext } from '../visibility/predicate-types.ts'; import { selectWorkflowsForMcp, isToolExposedForRuntime } from '../visibility/exposure.ts'; import { getConfig } from './config-store.ts'; import { recordInternalErrorMetric, recordToolInvocationMetric } from './sentry.ts'; +import type { ToolHandlerContext } from '../rendering/types.ts'; +import { createRenderSession } from '../rendering/render.ts'; + +function sessionToToolResponse(session: ReturnType): ToolResponse { + const text = session.finalize(); + const attachments = session.getAttachments(); + const events = [...session.getEvents()]; + + const content: ToolResponse['content'] = []; + if (text) { + content.push({ type: 'text' as const, text }); + } + for (const attachment of attachments) { + content.push({ + type: 'image' as const, + data: attachment.data, + mimeType: attachment.mimeType, + }); + } + + return { + content, + isError: session.isError() || undefined, + ...(events.length > 0 ? { _meta: { events } } : {}), + }; +} export interface RuntimeToolInfo { enabledWorkflows: string[]; @@ -22,9 +47,7 @@ export interface RuntimeToolInfo { const registryState: { tools: Map; enabledWorkflows: Set; - /** Current MCP predicate context (stored for use by manage_workflows) */ currentContext: PredicateContext | null; - /** Catalog of currently registered MCP tools for next-step template resolution */ catalog: ToolCatalog | null; } = { tools: new Map(), @@ -151,18 +174,10 @@ function defaultPredicateContext(): PredicateContext { }; } -/** - * Get the current MCP predicate context. - * Returns the context used for the most recent workflow registration, - * or a default context if not yet initialized. - */ export function getMcpPredicateContext(): PredicateContext { return registryState.currentContext ?? defaultPredicateContext(); } -/** - * Apply workflow selection using the manifest system. - */ export async function applyWorkflowSelectionFromManifest( requestedWorkflows: string[] | undefined, ctx: PredicateContext, @@ -171,7 +186,6 @@ export async function applyWorkflowSelectionFromManifest( throw new Error('Tool registry has not been initialized.'); } - // Store the context for later use (e.g., by manage_workflows) registryState.currentContext = ctx; const manifest = loadManifest(); @@ -182,12 +196,10 @@ export async function applyWorkflowSelectionFromManifest( } const allWorkflows = [...manifest.workflows.values(), ...customSelection.workflows]; - // Normalize requested workflows for consistent matching const normalizedRequestedWorkflows = requestedWorkflows ?.map(normalizeName) .filter((name) => name.length > 0); - // Select workflows using manifest-driven rules const selectedWorkflows = selectWorkflowsForMcp(allWorkflows, normalizedRequestedWorkflows, ctx); const knownWorkflowIds = new Set(allWorkflows.map((workflow) => workflow.id)); const unknownRequestedWorkflows = (normalizedRequestedWorkflows ?? []).filter( @@ -214,7 +226,6 @@ export async function applyWorkflowSelectionFromManifest( const toolManifest = manifest.tools.get(toolId); if (!toolManifest) continue; - // Check tool visibility using predicates if (!isToolExposedForRuntime(toolManifest, ctx)) { continue; } @@ -258,18 +269,30 @@ export async function applyWorkflowSelectionFromManifest( async (args: unknown): Promise => { const startedAt = Date.now(); try { - const response = await toolModule.handler(args as Record); + const session = createRenderSession('text'); + const ctx: ToolHandlerContext = { + emit: (event) => { + session.emit(event); + }, + attach: (image) => { + session.attach(image); + }, + }; + await toolModule.handler(args as Record, ctx); + const catalog = registryState.catalog; const catalogTool = catalog?.getByMcpName(toolName); - const postProcessedResponse = - catalog && catalogTool - ? postProcessToolResponse({ - tool: catalogTool, - response: response as ToolResponse, - catalog, - runtime: 'mcp', - }) - : (response as ToolResponse); + if (catalog && catalogTool) { + postProcessSession({ + tool: catalogTool, + session, + ctx, + catalog, + runtime: 'mcp', + }); + } + + const response = sessionToToolResponse(session); recordToolInvocationMetric({ toolName, @@ -279,7 +302,7 @@ export async function applyWorkflowSelectionFromManifest( durationMs: Date.now() - startedAt, }); - return processToolResponse(postProcessedResponse, 'mcp', 'normal'); + return response; } catch (error) { recordInternalErrorMetric({ component: 'mcp-tool-registry', @@ -304,7 +327,6 @@ export async function applyWorkflowSelectionFromManifest( registryState.catalog = createToolCatalog(catalogTools); - // Unregister tools no longer in selection for (const [toolName, registeredTool] of registryState.tools.entries()) { if (!desiredToolNames.has(toolName)) { registeredTool.remove(); @@ -323,9 +345,6 @@ export async function applyWorkflowSelectionFromManifest( }; } -/** - * Register workflows using manifest system. - */ export async function registerWorkflowsFromManifest( workflowNames?: string[], ctx?: PredicateContext, @@ -333,16 +352,6 @@ export async function registerWorkflowsFromManifest( await applyWorkflowSelectionFromManifest(workflowNames, ctx ?? defaultPredicateContext()); } -/** - * Update workflows using manifest system. - */ -export async function updateWorkflowsFromManifest( - workflowNames?: string[], - ctx?: PredicateContext, -): Promise { - await registerWorkflowsFromManifest(workflowNames, ctx); -} - export function __resetToolRegistryForTests(): void { for (const tool of registryState.tools.values()) { try { diff --git a/src/utils/video-capture/index.ts b/src/utils/video-capture/index.ts index 1d3f5f2f..3880d260 100644 --- a/src/utils/video-capture/index.ts +++ b/src/utils/video-capture/index.ts @@ -2,4 +2,6 @@ export { startSimulatorVideoCapture, stopSimulatorVideoCapture, type AxeHelpers, + type StartVideoCaptureResult, + type StopVideoCaptureResult, } from '../video_capture.ts'; diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts index 1cd5c5a4..30208c07 100644 --- a/src/utils/video_capture.ts +++ b/src/utils/video_capture.ts @@ -28,6 +28,14 @@ export interface AxeHelpers { getBundledAxeEnvironment: () => Record; } +export type StartVideoCaptureResult = + | { started: true; sessionId: string; warning?: string } + | { started: false; error: string }; + +export type StopVideoCaptureResult = + | { stopped: true; sessionId: string; stdout?: string; parsedPath?: string } + | { stopped: false; error: string }; + function createTimeoutPromise(timeoutMs: number): Promise<'timed_out'> { return new Promise((resolve) => { const timer = setTimeout(() => resolve('timed_out'), timeoutMs); @@ -72,10 +80,14 @@ async function waitForChildToStop(session: Session, timeoutMs: number): Promise< } } +type StopSessionSuccess = { sessionId: string; stdout: string; parsedPath?: string }; +type StopSessionError = { error: string }; +type StopSessionResult = StopSessionSuccess | StopSessionError; + async function stopSession( simulatorUuid: string, options: { timeoutMs?: number } = {}, -): Promise<{ sessionId?: string; stdout?: string; parsedPath?: string; error?: string }> { +): Promise { const session = sessions.get(simulatorUuid); if (!session) { return { error: 'No active video recording session for this simulator' }; @@ -147,7 +159,7 @@ export async function startSimulatorVideoCapture( params: { simulatorUuid: string; fps?: number }, executor: CommandExecutor, axeHelpers?: AxeHelpers, -): Promise<{ started: boolean; sessionId?: string; warning?: string; error?: string }> { +): Promise { const simulatorUuid = params.simulatorUuid; if (!simulatorUuid) { return { started: false, error: 'simulatorUuid is required' }; @@ -230,13 +242,7 @@ export async function startSimulatorVideoCapture( export async function stopSimulatorVideoCapture( params: { simulatorUuid: string }, executor: CommandExecutor, -): Promise<{ - stopped: boolean; - sessionId?: string; - stdout?: string; - parsedPath?: string; - error?: string; -}> { +): Promise { void executor; const simulatorUuid = params.simulatorUuid; @@ -245,7 +251,7 @@ export async function stopSimulatorVideoCapture( } const result = await stopSession(simulatorUuid, { timeoutMs: 5000 }); - if (result.error) { + if ('error' in result) { return { stopped: false, error: result.error }; } @@ -272,7 +278,7 @@ export async function stopAllVideoCaptureSessions(timeoutMs = 1000): Promise<{ for (const simulatorUuid of simulatorIds) { const result = await stopSession(simulatorUuid, { timeoutMs }); - if (result.error) { + if ('error' in result) { errors.push(`${simulatorUuid}: ${result.error}`); } } diff --git a/src/utils/xcode-state-reader.ts b/src/utils/xcode-state-reader.ts index 16405e81..75b92595 100644 --- a/src/utils/xcode-state-reader.ts +++ b/src/utils/xcode-state-reader.ts @@ -1,13 +1,3 @@ -/** - * Xcode IDE State Reader - * - * Reads Xcode's UserInterfaceState.xcuserstate file to extract the currently - * selected scheme and run destination (simulator/device). - * - * This enables XcodeBuildMCP to auto-sync with Xcode's IDE selection when - * running under Xcode's coding agent. - */ - import { dirname, resolve, sep } from 'node:path'; import { log } from './logger.ts'; import { parseXcuserstate } from './nskeyedarchiver-parser.ts'; @@ -23,27 +13,11 @@ export interface XcodeStateResult { export interface XcodeStateReaderContext { executor: CommandExecutor; cwd: string; - /** Optional boundary for parent-directory fallback search (typically workspace root) */ searchRoot?: string; - /** Optional pre-configured workspace path to use directly */ workspacePath?: string; - /** Optional pre-configured project path to use directly */ projectPath?: string; } -/** - * Finds the UserInterfaceState.xcuserstate file for the workspace/project. - * - * Search order: - * 1. Use configured workspacePath/projectPath if provided - * 2. Search for .xcworkspace/.xcodeproj under cwd - * 3. If none (or to broaden candidates), search direct children of parent directories - * up to searchRoot (workspace boundary) - * - * For each found project: - * - .xcworkspace: /xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate - * - .xcodeproj: /project.xcworkspace/xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate - */ function buildFindProjectsCommand(root: string, maxDepth: number): string[] { return [ 'find', @@ -110,7 +84,6 @@ export async function findXcodeStateFile( ): Promise { const { executor, cwd, searchRoot, workspacePath, projectPath } = ctx; - // Get current username const userResult = await executor(['whoami'], 'Get username', false); if (!userResult.success) { log('warn', `[xcode-state] Failed to get username: ${userResult.error}`); @@ -118,7 +91,6 @@ export async function findXcodeStateFile( } const username = userResult.output.trim(); - // If workspacePath or projectPath is configured, use it directly if (workspacePath || projectPath) { const basePath = workspacePath ?? projectPath; const xcuserstatePath = buildXcuserstatePath(basePath!, username); @@ -136,7 +108,6 @@ export async function findXcodeStateFile( const discoveredPaths = new Set(); - // Search descendants from cwd with increased depth (projects can be nested deeper). const descendantsResult = await executor( buildFindProjectsCommand(cwd, 6), 'Find Xcode project/workspace in cwd descendants', @@ -148,9 +119,6 @@ export async function findXcodeStateFile( } } - // Also search direct children of parent directories to support nested cwd usage. - // Example: cwd=/repo/feature/subdir, project=/repo/App.xcodeproj - // Parent traversal stops at searchRoot (workspace boundary). const parentSearchBoundary = searchRoot ?? cwd; for (const parentDir of listParentDirectories(cwd, parentSearchBoundary)) { const parentResult = await executor( @@ -176,11 +144,9 @@ export async function findXcodeStateFile( const paths = [...discoveredPaths]; - // Filter out nested workspaces inside xcodeproj and sort const filteredPaths = paths .filter((p) => !p.includes('.xcodeproj/project.xcworkspace')) .sort((a, b) => { - // Prefer .xcworkspace over .xcodeproj const aIsWorkspace = a.endsWith('.xcworkspace'); const bIsWorkspace = b.endsWith('.xcworkspace'); if (aIsWorkspace && !bIsWorkspace) return -1; @@ -188,13 +154,10 @@ export async function findXcodeStateFile( return 0; }); - // Collect all candidate xcuserstate files with their mtimes const candidates: Array<{ path: string; mtime: number }> = []; for (const projectPath of filteredPaths) { const xcuserstatePath = buildXcuserstatePath(projectPath, username); - - // Check if file exists and get mtime const statResult = await executor( ['stat', '-f', '%m', xcuserstatePath], 'Get xcuserstate mtime', @@ -212,7 +175,6 @@ export async function findXcodeStateFile( return undefined; } - // If multiple candidates, pick the one with the newest mtime (most recently active) if (candidates.length > 1) { candidates.sort((a, b) => b.mtime - a.mtime); log( @@ -225,21 +187,13 @@ export async function findXcodeStateFile( return candidates[0].path; } -/** - * Builds the path to the xcuserstate file for a given project/workspace path. - */ function buildXcuserstatePath(projectPath: string, username: string): string { - if (projectPath.endsWith('.xcworkspace')) { - return `${projectPath}/xcuserdata/${username}.xcuserdatad/UserInterfaceState.xcuserstate`; - } else { - // .xcodeproj - look in embedded workspace - return `${projectPath}/project.xcworkspace/xcuserdata/${username}.xcuserdatad/UserInterfaceState.xcuserstate`; - } + const base = projectPath.endsWith('.xcworkspace') + ? projectPath + : `${projectPath}/project.xcworkspace`; + return `${base}/xcuserdata/${username}.xcuserdatad/UserInterfaceState.xcuserstate`; } -/** - * Looks up a simulator name by its UUID. - */ export async function lookupSimulatorName( ctx: XcodeStateReaderContext, simulatorId: string, @@ -276,26 +230,13 @@ export async function lookupSimulatorName( return undefined; } -/** - * Reads Xcode's IDE state and extracts the active scheme and simulator. - * - * Uses bplist-parser for robust binary plist parsing of the xcuserstate file, - * navigating the NSKeyedArchiver object graph to extract: - * - ActiveScheme -> IDENameString (scheme name) - * - ActiveRunDestination -> targetDeviceLocation (simulator/device UUID) - * - * @param ctx Context with command executor and working directory - * @returns The extracted Xcode state or an error - */ export async function readXcodeIdeState(ctx: XcodeStateReaderContext): Promise { try { - // Find the xcuserstate file const xcuserstatePath = await findXcodeStateFile(ctx); if (!xcuserstatePath) { return { error: 'No Xcode project/workspace found in working directory' }; } - // Parse the state file using bplist-parser const state = parseXcuserstate(xcuserstatePath); const result: XcodeStateResult = {}; @@ -308,7 +249,6 @@ export async function readXcodeIdeState(ctx: XcodeStateReaderContext): Promise match[1]?.trim()) + .filter((value): value is string => Boolean(value) && value !== 'NO'); - return undefined; + const preferredMatch = matches.find((value) => value.includes('.')); + return preferredMatch ?? matches[0]; } /** diff --git a/src/utils/xcode.ts b/src/utils/xcode.ts index e5d10165..511402c2 100644 --- a/src/utils/xcode.ts +++ b/src/utils/xcode.ts @@ -1,32 +1,8 @@ -/** - * Xcode Utilities - Core infrastructure for interacting with Xcode tools - * - * This utility module provides the foundation for all Xcode interactions across the codebase. - * It offers platform-specific utilities, and common functionality that can be used by any module - * requiring Xcode tool integration. - * - * Responsibilities: - * - Constructing platform-specific destination strings (constructDestinationString) - * - * This file serves as the foundation layer for more specialized utilities like build-utils.ts, - * which build upon these core functions to provide higher-level abstractions. - */ - import { log } from './logger.ts'; import { XcodePlatform } from '../types/common.ts'; -// Re-export XcodePlatform for use in other modules export { XcodePlatform }; -/** - * Constructs a destination string for xcodebuild from platform and simulator parameters - * @param platform The target platform - * @param simulatorName Optional simulator name - * @param simulatorId Optional simulator UUID - * @param useLatest Whether to use the latest simulator version (primarily for named simulators) - * @param arch Optional architecture for macOS builds (arm64 or x86_64) - * @returns Properly formatted destination string for xcodebuild - */ export function constructDestinationString( platform: XcodePlatform, simulatorName?: string, @@ -41,29 +17,22 @@ export function constructDestinationString( XcodePlatform.visionOSSimulator, ].includes(platform); - // If ID is provided for a simulator, it takes precedence and uniquely identifies it. if (isSimulatorPlatform && simulatorId) { return `platform=${platform},id=${simulatorId}`; } - // If name is provided for a simulator if (isSimulatorPlatform && simulatorName) { return `platform=${platform},name=${simulatorName}${useLatest ? ',OS=latest' : ''}`; } - // If it's a simulator platform but neither ID nor name is provided (should be prevented by callers now) - if (isSimulatorPlatform && !simulatorId && !simulatorName) { - // Throw error as specific simulator is needed unless it's a generic build action - // Allow fallback for generic simulator builds if needed, but generally require specifics for build/run + if (isSimulatorPlatform) { log( 'warn', `Constructing generic destination for ${platform} without name or ID. This might not be specific enough.`, ); - // Example: return `platform=${platform},name=Any ${platform} Device`; // Or similar generic target throw new Error(`Simulator name or ID is required for specific ${platform} operations`); } - // Handle non-simulator platforms switch (platform) { case XcodePlatform.macOS: return arch ? `platform=macOS,arch=${arch}` : 'platform=macOS'; @@ -75,11 +44,8 @@ export function constructDestinationString( return 'generic/platform=tvOS'; case XcodePlatform.visionOS: return 'generic/platform=visionOS'; - // No default needed as enum covers all cases unless extended - // default: - // throw new Error(`Unsupported platform for destination string: ${platform}`); } - // Fallback just in case (shouldn't be reached with enum) + log('error', `Reached unexpected point in constructDestinationString for platform: ${platform}`); return `platform=${platform}`; } diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 0cb03eff..cb0c22cb 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -1,18 +1,3 @@ -/** - * xcodemake Utilities - Support for using xcodemake as an alternative build strategy - * - * This utility module provides functions for using xcodemake (https://github.com/johnno1962/xcodemake) - * as an alternative build strategy for Xcode projects. xcodemake logs xcodebuild output to generate - * a Makefile for an Xcode project, allowing for faster incremental builds using the "make" command. - * - * Responsibilities: - * - Checking if xcodemake is enabled via environment variable - * - Executing xcodemake commands with proper argument handling - * - Converting xcodebuild arguments to xcodemake arguments - * - Handling xcodemake-specific output and error reporting - * - Auto-downloading xcodemake if enabled but not found - */ - import { log } from './logger.ts'; import type { CommandResponse } from './command.ts'; import { getDefaultCommandExecutor } from './command.ts'; @@ -22,24 +7,12 @@ import * as os from 'os'; import * as fs from 'fs/promises'; import { getConfig } from './config-store.ts'; -// Environment variable to control xcodemake usage -export const XCODEMAKE_ENV_VAR = 'INCREMENTAL_BUILDS_ENABLED'; - -// Store the overridden path for xcodemake if needed let overriddenXcodemakePath: string | null = null; -/** - * Check if xcodemake is enabled via environment variable - * @returns boolean indicating if xcodemake should be used - */ export function isXcodemakeEnabled(): boolean { return getConfig().incrementalBuildsEnabled; } -/** - * Get the xcodemake command to use - * @returns The command string for xcodemake - */ function getXcodemakeCommand(): string { return overriddenXcodemakePath ?? 'xcodemake'; } @@ -53,10 +26,6 @@ function isExecutable(path: string): boolean { } } -/** - * Check whether xcodemake binary is currently resolvable without side effects. - * This does not attempt download or installation. - */ export function isXcodemakeBinaryAvailable(): boolean { if (overriddenXcodemakePath && isExecutable(overriddenXcodemakePath)) { return true; @@ -74,19 +43,11 @@ export function isXcodemakeBinaryAvailable(): boolean { return false; } -/** - * Override the xcodemake command path - * @param path Path to the xcodemake executable - */ function overrideXcodemakeCommand(path: string): void { overriddenXcodemakePath = path; log('info', `Using overridden xcodemake path: ${path}`); } -/** - * Install xcodemake by downloading it from GitHub - * @returns Promise resolving to boolean indicating if installation was successful - */ async function installXcodemake(): Promise { const tempDir = os.tmpdir(); const xcodemakeDir = path.join(tempDir, 'xcodebuildmcp'); @@ -95,10 +56,8 @@ async function installXcodemake(): Promise { log('info', `Attempting to install xcodemake to ${xcodemakePath}`); try { - // Create directory if it doesn't exist await fs.mkdir(xcodemakeDir, { recursive: true }); - // Download the script log('info', 'Downloading xcodemake from GitHub...'); const response = await fetch( 'https://raw.githubusercontent.com/cameroncooke/xcodemake/main/xcodemake', @@ -111,11 +70,9 @@ async function installXcodemake(): Promise { const scriptContent = await response.text(); await fs.writeFile(xcodemakePath, scriptContent, 'utf8'); - // Make executable await fs.chmod(xcodemakePath, 0o755); log('info', 'Made xcodemake executable'); - // Override the command to use the direct path overrideXcodemakeCommand(xcodemakePath); return true; @@ -128,25 +85,18 @@ async function installXcodemake(): Promise { } } -/** - * Check if xcodemake is installed and available. If enabled but not available, attempts to download it. - * @returns Promise resolving to boolean indicating if xcodemake is available - */ export async function isXcodemakeAvailable(): Promise { - // First check if xcodemake is enabled, if not, no need to check or install if (!isXcodemakeEnabled()) { log('debug', 'xcodemake is not enabled, skipping availability check'); return false; } try { - // Check if we already have an overridden path if (overriddenXcodemakePath && existsSync(overriddenXcodemakePath)) { log('debug', `xcodemake found at overridden path: ${overriddenXcodemakePath}`); return true; } - // Check if xcodemake is available in PATH const result = await getDefaultCommandExecutor()(['which', 'xcodemake']); if (result.success) { log('debug', 'xcodemake found in PATH'); @@ -171,28 +121,16 @@ export async function isXcodemakeAvailable(): Promise { } } -/** - * Check if a Makefile exists in the current directory - * @returns boolean indicating if a Makefile exists - */ export function doesMakefileExist(projectDir: string): boolean { return existsSync(`${projectDir}/Makefile`); } -/** - * Check if a Makefile log exists in the current directory - * @param projectDir Directory containing the Makefile - * @param command Command array to check for log file - * @returns boolean indicating if a Makefile log exists - */ export function doesMakeLogFileExist(projectDir: string, command: string[]): boolean { - // Change to the project directory as xcodemake requires being in the project dir const originalDir = process.cwd(); try { process.chdir(projectDir); - // Construct the expected log filename const xcodemakeCommand = ['xcodemake', ...command.slice(1)]; const escapedCommand = xcodemakeCommand.map((arg) => { // Remove projectDir from arguments if present at the start @@ -206,38 +144,27 @@ export function doesMakeLogFileExist(projectDir: string, command: string[]): boo const logFileName = `${commandString}.log`; log('debug', `Checking for Makefile log: ${logFileName} in directory: ${process.cwd()}`); - // Read directory contents and check if the file exists const files = readdirSync('.'); const exists = files.includes(logFileName); log('debug', `Makefile log ${exists ? 'exists' : 'does not exist'}: ${logFileName}`); return exists; } catch (error) { - // Log potential errors like directory not found, permissions issues, etc. log( 'error', `Error checking for Makefile log: ${error instanceof Error ? error.message : String(error)}`, ); return false; } finally { - // Always restore the original directory process.chdir(originalDir); } } -/** - * Execute an xcodemake command to generate a Makefile - * @param buildArgs Build arguments to pass to xcodemake (without the 'xcodebuild' command) - * @param logPrefix Prefix for logging - * @returns Promise resolving to command response - */ export async function executeXcodemakeCommand( projectDir: string, buildArgs: string[], logPrefix: string, ): Promise { const xcodemakeCommand = [getXcodemakeCommand(), ...buildArgs]; - - // Remove projectDir from arguments if present at the start const prefix = projectDir + '/'; const command = xcodemakeCommand.map((arg) => { if (arg.startsWith(prefix)) { @@ -249,12 +176,6 @@ export async function executeXcodemakeCommand( return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir }); } -/** - * Execute a make command for incremental builds - * @param projectDir Directory containing the Makefile - * @param logPrefix Prefix for logging - * @returns Promise resolving to command response - */ export async function executeMakeCommand( projectDir: string, logPrefix: string, diff --git a/src/visibility/exposure.ts b/src/visibility/exposure.ts index 89fc6cea..0875dae2 100644 --- a/src/visibility/exposure.ts +++ b/src/visibility/exposure.ts @@ -4,7 +4,11 @@ * availability flags and predicate evaluation. */ -import type { ToolManifestEntry, WorkflowManifestEntry } from '../core/manifest/schema.ts'; +import type { + ToolManifestEntry, + WorkflowManifestEntry, + ResourceManifestEntry, +} from '../core/manifest/schema.ts'; import type { PredicateContext, RuntimeKind } from './predicate-types.ts'; import { evalPredicates } from './predicate-registry.ts'; @@ -123,6 +127,43 @@ export function getAutoIncludeWorkflows( ); } +/** + * Check if a resource is available for the current runtime. + */ +export function isResourceAvailableForRuntime( + resource: ResourceManifestEntry, + runtime: RuntimeKind, +): boolean { + if (runtime !== 'mcp') { + return false; + } + return resource.availability.mcp; +} + +/** + * Check if a resource is exposed (visible) for the current runtime context. + * Checks both availability flag and all predicates. + */ +export function isResourceExposedForRuntime( + resource: ResourceManifestEntry, + ctx: PredicateContext, +): boolean { + if (!isResourceAvailableForRuntime(resource, ctx.runtime)) { + return false; + } + return evalPredicates(resource.predicates, ctx); +} + +/** + * Filter resources based on exposure rules. + */ +export function filterExposedResources( + resources: ResourceManifestEntry[], + ctx: PredicateContext, +): ResourceManifestEntry[] { + return resources.filter((resource) => isResourceExposedForRuntime(resource, ctx)); +} + /** * Select workflows for MCP runtime according to the manifest-driven selection rules. *