From df6904848efa189131def04342001bc65aa59b2f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 4 Feb 2026 13:13:12 +0000 Subject: [PATCH 01/23] Experimental support for Xcode Tools MCP --- CHANGELOG.md | 8 + docs/CONFIGURATION.md | 2 + docs/TOOLS-CLI.md | 19 +- docs/TOOLS.md | 19 +- docs/XCODE_IDE_MCPBRIDGE.md | 56 ++++ docs/dev/XCODE_IDE_MCPBRIDGE_PLAN.md | 239 ++++++++++++++++++ scripts/probe-xcode-mcpbridge.ts | 84 ++++++ src/cli.ts | 7 + src/cli/register-tool-commands.ts | 26 +- src/cli/yargs-app.ts | 2 + src/core/generated-plugins.ts | 23 ++ .../fixtures/fake-xcode-tools-server.mjs | 106 ++++++++ .../__tests__/jsonschema-to-zod.test.ts | 72 ++++++ .../__tests__/registry.integration.test.ts | 117 +++++++++ src/integrations/xcode-tools-bridge/client.ts | 202 +++++++++++++++ src/integrations/xcode-tools-bridge/index.ts | 16 ++ .../xcode-tools-bridge/jsonschema-to-zod.ts | 104 ++++++++ .../xcode-tools-bridge/manager.ts | 196 ++++++++++++++ .../xcode-tools-bridge/registry.ts | 177 +++++++++++++ src/mcp/tools/doctor/doctor.ts | 71 ++++++ src/mcp/tools/xcode-ide/index.ts | 5 + .../xcode_tools_bridge_disconnect.ts | 27 ++ .../xcode-ide/xcode_tools_bridge_status.ts | 27 ++ .../xcode-ide/xcode_tools_bridge_sync.ts | 27 ++ src/runtime/tool-catalog.ts | 4 + src/server/bootstrap.ts | 15 ++ src/server/start-mcp-server.ts | 3 + src/utils/tool-registry.ts | 4 + src/utils/tool-visibility.ts | 14 + 29 files changed, 1660 insertions(+), 12 deletions(-) create mode 100644 docs/XCODE_IDE_MCPBRIDGE.md create mode 100644 docs/dev/XCODE_IDE_MCPBRIDGE_PLAN.md create mode 100644 scripts/probe-xcode-mcpbridge.ts create mode 100644 src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs create mode 100644 src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts create mode 100644 src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts create mode 100644 src/integrations/xcode-tools-bridge/client.ts create mode 100644 src/integrations/xcode-tools-bridge/index.ts create mode 100644 src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts create mode 100644 src/integrations/xcode-tools-bridge/manager.ts create mode 100644 src/integrations/xcode-tools-bridge/registry.ts create mode 100644 src/mcp/tools/xcode-ide/index.ts create mode 100644 src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts create mode 100644 src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts create mode 100644 src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts create mode 100644 src/utils/tool-visibility.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4890529a..b803f7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +### Added +- Added optional `xcode-ide` workflow to proxy Xcode IDE MCP tools via `xcrun mcpbridge`. + +### Changed +- Hide `xcode_tools_bridge_{status,sync,disconnect}` unless `debug: true` is enabled (these are troubleshooting tools). + ## [2.0.0] - 2026-02-02 ### Breaking diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 28fd2abc..7629e7fa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -147,6 +147,8 @@ enabledWorkflows: ["simulator", "ui-automation", "debugging"] See [TOOLS.md](TOOLS.md) for available workflows and their tools. +To proxy Xcode IDE tools (Xcode 26+ `xcrun mcpbridge`), enable `xcode-ide`. See [XCODE_IDE_MCPBRIDGE.md](XCODE_IDE_MCPBRIDGE.md). + ### Experimental workflow discovery Enables a `manage-workflows` tool that agents can use to add/remove workflows at runtime. diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 5ebee8f2..8ef54bcd 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -2,7 +2,7 @@ This document lists CLI tool names as exposed by `xcodebuildmcp `. -XcodeBuildMCP provides 68 canonical tools organized into 12 workflow groups. +XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. ## Workflow Groups @@ -169,12 +169,21 @@ XcodeBuildMCP provides 68 canonical tools organized into 12 workflow groups. +### Xcode IDE (mcpbridge) (`xcode-ide`) +**Purpose**: Proxy Xcode's built-in 'Xcode Tools' MCP service via `xcrun mcpbridge`. Registers dynamic `xcode_tools_*` tools when available. Bridge debug tools are only registered when `debug: true`. (3 tools) + +- `xcode-tools-bridge-disconnect` - Disconnect bridge and unregister proxied `xcode_tools_*` tools. +- `xcode-tools-bridge-status` - Show xcrun mcpbridge availability and proxy tool sync status. +- `xcode-tools-bridge-sync` - One-shot connect + tools/list sync (manual retry; avoids background prompt spam). + + + ## Summary Statistics -- **Canonical Tools**: 68 -- **Total Tools**: 91 -- **Workflow Groups**: 12 +- **Canonical Tools**: 71 +- **Total Tools**: 94 +- **Workflow Groups**: 13 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-03T12:43:04.479Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-04T09:25:59.573Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e979dc14..4696d347 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP MCP Tools Reference -This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 72 canonical tools organized into 14 workflow groups for comprehensive Apple development workflows. +This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 75 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -183,12 +183,21 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov +### Xcode IDE (mcpbridge) (`xcode-ide`) +**Purpose**: Proxy Xcode's built-in 'Xcode Tools' MCP service via `xcrun mcpbridge`. Registers dynamic `xcode_tools_*` tools when available. Bridge debug tools are only registered when `debug: true`. (3 tools) + +- `xcode_tools_bridge_disconnect` - Disconnect bridge and unregister proxied `xcode_tools_*` tools. +- `xcode_tools_bridge_status` - Show xcrun mcpbridge availability and proxy tool sync status. +- `xcode_tools_bridge_sync` - One-shot connect + tools/list sync (manual retry; avoids background prompt spam). + + + ## Summary Statistics -- **Canonical Tools**: 72 -- **Total Tools**: 95 -- **Workflow Groups**: 14 +- **Canonical Tools**: 75 +- **Total Tools**: 98 +- **Workflow Groups**: 15 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-03T12:43:04.479Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-04T09:25:59.573Z UTC* diff --git a/docs/XCODE_IDE_MCPBRIDGE.md b/docs/XCODE_IDE_MCPBRIDGE.md new file mode 100644 index 00000000..49c132bf --- /dev/null +++ b/docs/XCODE_IDE_MCPBRIDGE.md @@ -0,0 +1,56 @@ +# Xcode IDE MCP Bridge (`xcrun mcpbridge`) + +XcodeBuildMCP can optionally proxy Xcode’s built-in “Xcode Tools” MCP service via Xcode 26’s `xcrun mcpbridge`. + +This enables IDE-only tools (e.g. Preview rendering, Issue Navigator queries, documentation search, project navigator operations) that are not available via `xcodebuild`. + +## Enable + +Add `xcode-ide` to `enabledWorkflows` in `.xcodebuildmcp/config.yaml`: + +```yaml +schemaVersion: 1 +enabledWorkflows: ["simulator", "debugging", "xcode-ide"] +``` + +If the workflow is not enabled, XcodeBuildMCP does not start the bridge. + +## Tool naming + +Proxied tools are registered dynamically, based on whatever Xcode advertises via `tools/list`: + +- Remote: `XcodeListWindows` +- Local proxy: `xcode_tools_XcodeListWindows` + +## Bridge debug tools + +These tools are stable and do not depend on Xcode’s tool catalog, but they are intentionally hidden unless you enable debugging (`debug: true`) because they are primarily for troubleshooting: + +- `xcode_tools_bridge_status`: Shows `mcpbridge` availability, connection state, last error, and proxied tool count. +- `xcode_tools_bridge_sync`: One-shot connect + re-sync (use this if Xcode prompts blocked startup sync). +- `xcode_tools_bridge_disconnect`: Disconnect and unregister proxied `xcode_tools_*` tools. + +## Trust prompts / troubleshooting + +Xcode may show trust/allow prompts when the bridge connects and/or when tools are invoked. + +Recommended flow: + +1. Launch Xcode. +2. Start XcodeBuildMCP with `xcode-ide` enabled. +3. If you don’t see any `xcode_tools_*` tools, temporarily set `debug: true` and call `xcode_tools_bridge_sync` after approving any prompts. + +## Targeting a specific Xcode instance (optional) + +If you need to scope the bridge to a specific Xcode instance, XcodeBuildMCP forwards these environment variables to the bridge: + +- `XCODEBUILDMCP_XCODE_PID` → `MCP_XCODE_PID` +- `XCODEBUILDMCP_XCODE_SESSION_ID` → `MCP_XCODE_SESSION_ID` + +## Dev probe script + +For manual verification against a real Xcode install: + +```bash +npx tsx scripts/probe-xcode-mcpbridge.ts +``` diff --git a/docs/dev/XCODE_IDE_MCPBRIDGE_PLAN.md b/docs/dev/XCODE_IDE_MCPBRIDGE_PLAN.md new file mode 100644 index 00000000..67c27e38 --- /dev/null +++ b/docs/dev/XCODE_IDE_MCPBRIDGE_PLAN.md @@ -0,0 +1,239 @@ +# Xcode IDE MCP Bridge Plan (Xcode 26.x `xcrun mcpbridge`) + +This document is an implementation plan to extend XcodeBuildMCP by optionally proxying Apple Xcode’s “Xcode Tools” MCP service through `xcrun mcpbridge`. + +## Goal + +Add an **optional** workflow to XcodeBuildMCP that: + +- Detects whether `xcrun mcpbridge` is available. +- If available and enabled, starts a long-lived `mcpbridge` child process. +- Connects to it as an **MCP client** over stdio. +- Discovers Xcode’s advertised tools (`tools/list`) and listens for tool list changes (`tools/listChanged`). +- Dynamically registers proxied tools in XcodeBuildMCP and forwards calls to Xcode through the bridge. +- Handles tool catalog changes over time without breaking XcodeBuildMCP. + +Non-goals: + +- Reverse engineering Xcode’s internal XPC transport (we rely on Apple’s `mcpbridge` for that). +- Keeping a stable, curated wrapper API for Xcode tools (we proxy dynamically; stability is best-effort). + +## Why This Is Worth Doing (Capabilities We Gain) + +XcodeBuildMCP is currently strongest at `xcodebuild` + simulator/device workflows. The Xcode IDE MCP service provides **IDE-only** capabilities that `xcodebuild` cannot: + +- SwiftUI Preview rendering to an image (`RenderPreview`). +- Issue Navigator issues (including workspace/package resolution problems) (`XcodeListNavigatorIssues`). +- “Refresh code issues for file” style diagnostics (`XcodeRefreshCodeIssuesInFile`). +- Apple Developer Documentation semantic search (`DocumentationSearch`). +- Execute code snippets in file context (`ExecuteSnippet`). +- Project navigator operations on **Xcode project structure**, not raw filesystem (`XcodeLS`, `XcodeGlob`, `XcodeGrep`, `XcodeRead`, `XcodeWrite`, `XcodeUpdate`, `XcodeMV`, `XcodeRM`, `XcodeMakeDir`). +- IDE-scoped build/test orchestration and richer result structures (`BuildProject`, `GetBuildLog`, `GetTestList`, `RunAllTests`, `RunSomeTests`). +- Xcode window/tab discovery to obtain `tabIdentifier` (`XcodeListWindows`). + +This complements XcodeBuildMCP rather than replacing it. + +## Observed Bridge Behavior (Local Probing) + +- `xcrun --find mcpbridge` resolves to Xcode’s bundled binary: + - `/Applications/Xcode.app/Contents/Developer/usr/bin/mcpbridge` +- The bridge is a stdio JSON-RPC server that speaks MCP. +- Initialization works with MCP protocol version `2024-11-05` and server reports: + - `serverInfo.name = "xcode-tools"` + - `capabilities.tools.listChanged = true` +- `tools/call` works (e.g. calling `XcodeListWindows` returned a `tabIdentifier` and workspace path). +- Xcode may show “trust/allow client prompts” when the bridge connects and/or when tools are invoked. This plan avoids noisy retry loops to reduce prompt spam. + +## Proposed Architecture + +```mermaid +flowchart LR + A["MCP client (Codex/Claude/etc)"] -->|tools/call| B["XcodeBuildMCP server"] + B -->|stdio MCP client| C["xcrun mcpbridge"] + C -->|private endpoint injection + XPC| D["Xcode IDE tool service"] +``` + +Key choice: **XcodeBuildMCP remains the server**. When the optional workflow is enabled, it embeds an internal MCP client to Xcode’s tool service via `mcpbridge`. + +## Tool Naming / Collision Strategy + +Expose proxied tools with a clear prefix: + +- Local tool name: `xcode_tools_` +- Example: `xcode_tools_XcodeListWindows` + +Rationale: + +- Avoid collisions with existing XcodeBuildMCP tools. +- Make provenance explicit for agents. + +## Safety Policy + +Proxy **all tools** that Xcode advertises (including mutating tools like `XcodeWrite`, `XcodeUpdate`, `XcodeRM`), as requested. + +Mitigations: + +- Keep the prefix (`xcode_tools_`) so call sites can easily reason about what they’re invoking. +- Add an `annotations.readOnlyHint` best-effort heuristic (mutators get `false`). +- Add clear documentation that these are IDE-scoped filesystem/project mutations. + +## Workflow / Enablement + +Add a new workflow group: + +- Directory: `src/mcp/tools/xcode-ide/` +- Workflow name: `xcode-ide` + +Enablement is via existing workflow selection (`enabledWorkflows`), e.g.: + +```yaml +schemaVersion: 1 +enabledWorkflows: ["simulator", "debugging", "xcode-ide"] +``` + +Behavior: + +- If `xcode-ide` workflow is not enabled, no bridge code runs. +- If enabled but `mcpbridge` is missing/unusable, we register **only** small “bridge management” tools (status + manual sync) and no proxied tools. +- If enabled and bridge is healthy, we dynamically register proxied tools based on `tools/list`. + +## Internal Modules To Add + +New directory: + +- `src/integrations/xcode-tools-bridge/` + +### `client.ts` + +Responsibilities: + +- Spawn `xcrun mcpbridge` using `StdioClientTransport` (`@modelcontextprotocol/sdk` client). +- Provide `connectOnce()`, `disconnect()`, `listTools()`, and `callTool(name,args)`. +- Apply request timeouts (avoid hanging forever when Xcode prompts). +- Listen for `tools/listChanged` notifications and trigger a re-sync. + +Environment plumbing: + +- Optional `XCODEBUILDMCP_XCODE_PID` -> `MCP_XCODE_PID` +- Optional `XCODEBUILDMCP_XCODE_SESSION_ID` -> `MCP_XCODE_SESSION_ID` + +### `jsonschema-to-zod.ts` + +Convert Xcode tool JSON Schemas to Zod shapes for XcodeBuildMCP’s `server.registerTool`. + +Support a pragmatic subset (covers the schema you provided): + +- `type: object` with `properties`, `required` +- primitives: `string`, `integer`, `number`, `boolean` +- `enum` +- `type: array` with `items` + +Forward-compat strategy: + +- Unknown constructs fall back to `z.any()` for that property. +- Top-level object schemas are `passthrough()` to tolerate added fields without breaking. + +### `registry.ts` + +Responsibilities: + +- Maintain a mapping of remote tool -> registered local tool. +- Given latest `tools/list`: + - Register new tools. + - Update tools when schema/metadata changes (remove + re-register). + - Remove tools no longer present. +- Proxy handler behavior: + - `xcode_tools_` simply forwards to `tools/call` on the bridge client. + +## MCP Tooling Added by XcodeBuildMCP (Bridge Management) + +These are non-proxied, always-stable tools in the `xcode-ide` workflow: + +- `xcode_tools_bridge_status` + - Returns availability/health: + - whether `xcrun` can find `mcpbridge` + - whether Xcode is running (optional hint) + - connection status, last error, number of proxied tools currently registered + - current pid/session id used (if any) +- `xcode_tools_bridge_sync` + - One-shot connect + tool sync attempt (manual retry; avoids background prompt spam) +- Optional: `xcode_tools_bridge_disconnect` + - Stop bridge and unregister proxied tools + +## Bootstrap / Lifecycle Integration + +Entry point: + +- `src/server/bootstrap.ts` after `registerWorkflows(enabledWorkflows)`: + - If `xcode-ide` is enabled, start bridge `connectOnce()` and attempt tool sync. + +Shutdown: + +- `src/server/start-mcp-server.ts` SIGINT/SIGTERM handlers: + - Disconnect bridge cleanly before closing server. + +Failure modes: + +- Bridge process exits unexpectedly: + - Unregister proxied tools. + - Status tool reports last error. + - Manual sync tool can be used to retry. + +## Handling Tool Catalog Changes Over Time + +- Use `tools/list` as source of truth on connect. +- Subscribe to list-changed (`tools.listChanged = true` is advertised by server) and re-sync on change. +- Do not assume tool names are stable; tool proxy registry is dynamic. +- Zod schema conversion is best-effort and permissive (`passthrough`, `z.any()` fallback) to avoid breaking when Apple extends schemas. + +## Testing Strategy + +### Unit tests (Vitest) + +- JSON Schema -> Zod conversion: + - object/required/enum/array nesting cases based on the provided schema. + - unknown schema constructs degrade to `z.any()`. + +### Integration tests (no Xcode required) + +- Fake MCP server over stdio that: + - supports `initialize`, `tools/list`, `tools/call` + - emits a `tools/listChanged` notification +- Validate: + - proxied tools register with correct `xcode_tools_` prefix + - tool calls forward correctly + - list-changed causes add/remove/re-register + +### Manual “real Xcode” probe (repeatable) + +Add a script: + +- `scripts/probe-xcode-mcpbridge.ts` + - connects to `xcrun mcpbridge` + - prints server info + tool count + first N tool names + - calls `XcodeListWindows` and prints discovered `tabIdentifier`s + +This should be used during dev because Xcode prompts are human-mediated and not CI-friendly. + +## Documentation Updates + +- New doc: + - `docs/XCODE_IDE_MCPBRIDGE.md` (user-facing-ish) + - how to enable `xcode-ide` + - how `tabIdentifier` works + - how to troubleshoot trust prompts and missing tools +- Update: + - `docs/TOOLS.md` to include `xcode-ide` workflow and stable bridge tools + - Document that proxied tools are dynamic and appear as `xcode_tools_*`. + +## Acceptance Criteria + +- With `xcode-ide` enabled and Xcode running: + - XcodeBuildMCP registers `xcode_tools_` for all tools returned by `tools/list`. + - Calling a proxied tool forwards to Xcode and returns the result content unchanged. +- With `xcode-ide` enabled and no bridge available: + - Only `xcode_tools_bridge_status` and `xcode_tools_bridge_sync` are present. + - Server startup remains healthy. +- If Xcode updates the tool catalog while running: + - proxied tool set updates without server restart. + diff --git a/scripts/probe-xcode-mcpbridge.ts b/scripts/probe-xcode-mcpbridge.ts new file mode 100644 index 00000000..c7d04212 --- /dev/null +++ b/scripts/probe-xcode-mcpbridge.ts @@ -0,0 +1,84 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import process from 'node:process'; + +function parseArgs(argv: string[]): { limit: number; callWindows: boolean } { + let limit = 20; + let callWindows = true; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--limit' && argv[i + 1]) { + limit = Number(argv[i + 1]); + i += 1; + continue; + } + if (arg === '--no-windows') { + callWindows = false; + continue; + } + } + return { limit: Number.isFinite(limit) ? limit : 20, callWindows }; +} + +function mapXcodeEnvForMcpBridge(env: NodeJS.ProcessEnv): Record { + const mapped: Record = {}; + + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + mapped[key] = value; + } + } + + if (typeof env.XCODEBUILDMCP_XCODE_PID === 'string' && mapped.MCP_XCODE_PID === undefined) { + mapped.MCP_XCODE_PID = env.XCODEBUILDMCP_XCODE_PID; + } + if ( + typeof env.XCODEBUILDMCP_XCODE_SESSION_ID === 'string' && + mapped.MCP_XCODE_SESSION_ID === undefined + ) { + mapped.MCP_XCODE_SESSION_ID = env.XCODEBUILDMCP_XCODE_SESSION_ID; + } + + return mapped; +} + +async function main(): Promise { + const { limit, callWindows } = parseArgs(process.argv); + + const transport = new StdioClientTransport({ + command: 'xcrun', + args: ['mcpbridge'], + stderr: 'inherit', + env: mapXcodeEnvForMcpBridge(process.env), + }); + + const client = new Client({ name: 'xcodebuildmcp-probe', version: '0.0.0' }); + await client.connect(transport, { timeout: 15_000 }); + + const serverInfo = client.getServerVersion(); + const capabilities = client.getServerCapabilities(); + + console.log('serverInfo:', serverInfo); + console.log('capabilities.tools.listChanged:', capabilities?.tools?.listChanged ?? false); + + const toolsResult = await client.listTools(undefined, { timeout: 15_000 }); + console.log(`tools: ${toolsResult.tools.length}`); + console.log('first tools:', toolsResult.tools.slice(0, limit).map((t) => t.name)); + + if (callWindows) { + const windows = await client.request( + { method: 'tools/call', params: { name: 'XcodeListWindows', arguments: {} } }, + CompatibilityCallToolResultSchema, + { timeout: 15_000 }, + ); + console.log('XcodeListWindows:', windows); + } + + await client.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/cli.ts b/src/cli.ts index cd7355e9..c159fa82 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts'; import { buildYargsApp } from './cli/yargs-app.ts'; import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts'; import { startMcpServer } from './server/start-mcp-server.ts'; +import { WORKFLOW_METADATA } from './core/generated-plugins.ts'; async function main(): Promise { if (process.argv.includes('mcp')) { @@ -39,6 +40,11 @@ async function main(): Promise { projectConfigPath: result.configPath, }); + const CLI_EXCLUDED_WORKFLOWS = new Set(['session-management', 'workflow-discovery']); + const workflowNames = Object.keys(WORKFLOW_METADATA).filter( + (name) => !CLI_EXCLUDED_WORKFLOWS.has(name), + ); + const enabledWorkflows = [...new Set(catalog.tools.map((tool) => tool.workflow))]; const yargsApp = buildYargsApp({ @@ -47,6 +53,7 @@ async function main(): Promise { defaultSocketPath, workspaceRoot, workspaceKey, + workflowNames, enabledWorkflows, }); diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 4252232f..2e39472a 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -11,6 +11,8 @@ import { WORKFLOW_METADATA, type WorkflowName } from '../core/generated-plugins. export interface RegisterToolCommandsOptions { workspaceRoot: string; enabledWorkflows?: string[]; + /** Workflows to register as command groups (even if currently empty) */ + workflowNames?: string[]; } /** @@ -24,8 +26,10 @@ export function registerToolCommands( const invoker = new DefaultToolInvoker(catalog); const toolsByWorkflow = groupToolsByWorkflow(catalog); const enabledWorkflows = opts.enabledWorkflows ?? [...toolsByWorkflow.keys()]; + const workflowNames = opts.workflowNames ?? [...toolsByWorkflow.keys()]; - for (const [workflowName, tools] of toolsByWorkflow) { + for (const workflowName of workflowNames) { + const tools = toolsByWorkflow.get(workflowName) ?? []; const workflowMeta = WORKFLOW_METADATA[workflowName as WorkflowName]; const workflowDescription = workflowMeta?.name ?? workflowName; @@ -44,10 +48,28 @@ export function registerToolCommands( registerToolSubcommand(yargs, tool, invoker, opts, enabledWorkflows); } + if (tools.length === 0) { + const hint = + workflowName === 'xcode-ide' + ? `No CLI commands are currently exposed for '${workflowName}'.\n` + + `Bridge debug tools are hidden unless XcodeBuildMCP debug mode is enabled.\n` + + `Set XCODEBUILDMCP_DEBUG=true to expose xcode_tools_bridge_{status,sync,disconnect}.` + : `No CLI commands are currently exposed for '${workflowName}'.`; + + yargs.epilogue(hint); + return yargs.help(); + } + return yargs.demandCommand(1, '').help(); }, () => { - // No-op handler - subcommands handle execution + if (tools.length === 0) { + console.error( + workflowName === 'xcode-ide' + ? `No CLI commands are currently exposed for '${workflowName}'. Set XCODEBUILDMCP_DEBUG=true to expose bridge debug commands.` + : `No CLI commands are currently exposed for '${workflowName}'.`, + ); + } }, ); } diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 1fbe6753..69d07b48 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -15,6 +15,7 @@ export interface YargsAppOptions { defaultSocketPath: string; workspaceRoot: string; workspaceKey: string; + workflowNames: string[]; enabledWorkflows: string[]; } @@ -89,6 +90,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, enabledWorkflows: opts.enabledWorkflows, + workflowNames: opts.workflowNames, }); return app; diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts index 236a035d..9a54f1e7 100644 --- a/src/core/generated-plugins.ts +++ b/src/core/generated-plugins.ts @@ -375,6 +375,25 @@ export const WORKFLOW_LOADERS = { manage_workflows: tool_0, }; }, + 'xcode-ide': async () => { + const { workflow } = await import('../mcp/tools/xcode-ide/index.ts'); + const tool_0 = await import('../mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts').then( + (m) => m.default, + ); + const tool_1 = await import('../mcp/tools/xcode-ide/xcode_tools_bridge_status.ts').then( + (m) => m.default, + ); + const tool_2 = await import('../mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts').then( + (m) => m.default, + ); + + return { + workflow, + xcode_tools_bridge_disconnect: tool_0, + xcode_tools_bridge_status: tool_1, + xcode_tools_bridge_sync: tool_2, + }; + }, }; export type WorkflowName = keyof typeof WORKFLOW_LOADERS; @@ -450,4 +469,8 @@ export const WORKFLOW_METADATA = { name: 'Workflow Discovery', description: 'Manage the workflows that are enabled and disabled.', }, + 'xcode-ide': { + name: 'Xcode IDE (mcpbridge)', + description: 'Proxy Xcode', + }, }; diff --git a/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs b/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs new file mode 100644 index 00000000..44267aa6 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs @@ -0,0 +1,106 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import * as z from 'zod'; + +const server = new McpServer( + { name: 'fake-xcode-tools', version: '0.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + }, + }, +); + +let alphaRegistered; +let betaRegistered; +let gammaRegistered; + +function registerInitialTools() { + alphaRegistered = server.registerTool( + 'Alpha', + { + description: 'Alpha tool', + inputSchema: z + .object({ + value: z.string(), + }) + .passthrough(), + annotations: { readOnlyHint: true, title: 'Alpha' }, + }, + async (args) => ({ + content: [{ type: 'text', text: `Alpha:${args.value}` }], + isError: false, + }), + ); + + betaRegistered = server.registerTool( + 'Beta', + { + description: 'Beta tool', + inputSchema: z + .object({ + n: z.number().int(), + }) + .passthrough(), + }, + async (args) => ({ + content: [{ type: 'text', text: `Beta:${args.n}` }], + isError: false, + }), + ); + + server.registerTool( + 'TriggerChange', + { + description: 'Mutate tool catalog and emit list_changed', + inputSchema: z.object({}).passthrough(), + }, + async () => { + applyCatalogChange(); + return { content: [{ type: 'text', text: 'changed' }], isError: false }; + }, + ); +} + +function applyCatalogChange() { + betaRegistered?.remove(); + betaRegistered = undefined; + + alphaRegistered?.remove(); + alphaRegistered = server.registerTool( + 'Alpha', + { + description: 'Alpha tool (changed schema)', + inputSchema: z + .object({ + value: z.string(), + extra: z.string().optional(), + }) + .passthrough(), + }, + async (args) => ({ + content: [{ type: 'text', text: `Alpha2:${args.value}:${args.extra ?? ''}` }], + isError: false, + }), + ); + + gammaRegistered?.remove(); + gammaRegistered = server.registerTool( + 'Gamma', + { + description: 'Gamma tool', + inputSchema: z.object({ ok: z.boolean() }).passthrough(), + }, + async (args) => ({ + content: [{ type: 'text', text: `Gamma:${args.ok}` }], + isError: false, + }), + ); + + server.sendToolListChanged(); +} + +registerInitialTools(); + +await server.connect(new StdioServerTransport()); + diff --git a/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts b/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts new file mode 100644 index 00000000..5e1c5cf9 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { jsonSchemaToZod } from '../jsonschema-to-zod.ts'; + +describe('jsonSchemaToZod', () => { + it('converts object properties + required correctly', () => { + const schema = { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'integer' }, + }, + required: ['a'], + }; + + const zod = jsonSchemaToZod(schema); + + expect(zod.safeParse({ a: 'x' }).success).toBe(true); + expect(zod.safeParse({}).success).toBe(false); + expect(zod.safeParse({ a: 'x', b: 1 }).success).toBe(true); + expect(zod.safeParse({ a: 'x', b: 1.5 }).success).toBe(false); + }); + + it('supports enums (mixed types)', () => { + const schema = { + enum: ['a', 1, true], + description: 'mixed enum', + }; + + const zod = jsonSchemaToZod(schema); + + expect(zod.safeParse('a').success).toBe(true); + expect(zod.safeParse(1).success).toBe(true); + expect(zod.safeParse(true).success).toBe(true); + expect(zod.safeParse('b').success).toBe(false); + }); + + it('supports arrays with items', () => { + const schema = { type: 'array', items: { type: 'number' } }; + const zod = jsonSchemaToZod(schema); + + expect(zod.safeParse([1, 2, 3]).success).toBe(true); + expect(zod.safeParse([1, 'x']).success).toBe(false); + }); + + it('is permissive for unknown constructs', () => { + const schema: unknown = { + type: 'object', + properties: { + x: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + }, + required: ['x'], + }; + + const zod = jsonSchemaToZod(schema); + expect(zod.safeParse({ x: 'hello' }).success).toBe(true); + expect(zod.safeParse({ x: 123 }).success).toBe(true); + }); + + it('does not reject unknown fields on objects (passthrough)', () => { + const schema = { + type: 'object', + properties: { + a: { type: 'string' }, + }, + required: ['a'], + }; + + const zod = jsonSchemaToZod(schema); + const parsed = zod.parse({ a: 'x', extra: 1 }) as Record; + expect(parsed.extra).toBe(1); + }); +}); diff --git a/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts b/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts new file mode 100644 index 00000000..27d9c1cb --- /dev/null +++ b/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { XcodeToolsBridgeClient } from '../client.ts'; +import { XcodeToolsProxyRegistry } from '../registry.ts'; + +function fixturePath(rel: string): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.join(here, 'fixtures', rel); +} + +async function waitFor(fn: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (true) { + if (fn()) return; + if (Date.now() - start > timeoutMs) { + throw new Error('Timed out waiting for condition'); + } + await new Promise((r) => setTimeout(r, 25)); + } +} + +describe('XcodeToolsProxyRegistry (stdio integration)', () => { + let localServer: McpServer; + let localClient: Client; + let bridgeClient: XcodeToolsBridgeClient; + let registry: XcodeToolsProxyRegistry; + + const doSync = async (): Promise => { + const tools = await bridgeClient.listTools(); + registry.sync(tools, async (remoteName, args) => bridgeClient.callTool(remoteName, args)); + if (localServer.isConnected()) { + localServer.sendToolListChanged(); + } + }; + + beforeAll(async () => { + localServer = new McpServer( + { name: 'local-test-server', version: '0.0.0' }, + { capabilities: { tools: { listChanged: true } } }, + ); + + registry = new XcodeToolsProxyRegistry(localServer); + + const fakeServerScript = fixturePath('fake-xcode-tools-server.mjs'); + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') { + env[key] = value; + } + } + bridgeClient = new XcodeToolsBridgeClient({ + serverParams: { + command: process.execPath, + args: [fakeServerScript], + stderr: 'pipe', + env, + }, + onToolsListChanged: () => { + void doSync(); + }, + }); + + await bridgeClient.connectOnce(); + await doSync(); + + // Connect after initial tool registration so MCP server can register capabilities before connect. + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await localServer.connect(serverTransport); + + localClient = new Client({ name: 'local-test-client', version: '0.0.0' }); + await localClient.connect(clientTransport); + }); + + afterAll(async () => { + await bridgeClient.disconnect(); + await localClient.close(); + await localServer.close(); + }); + + it('registers proxied tools and forwards calls', async () => { + const tools = await localClient.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).toContain('xcode_tools_Alpha'); + expect(names).toContain('xcode_tools_Beta'); + expect(names).toContain('xcode_tools_TriggerChange'); + + const res = (await localClient.callTool({ + name: 'xcode_tools_Alpha', + arguments: { value: 'hi' }, + })) as CallToolResult; + expect(res.isError).not.toBe(true); + expect(res.content[0]).toMatchObject({ type: 'text', text: 'Alpha:hi' }); + }); + + it('updates registered tools on remote list change', async () => { + await localClient.callTool({ name: 'xcode_tools_TriggerChange', arguments: {} }); + + await waitFor(() => registry.getRegisteredToolNames().includes('xcode_tools_Gamma')); + + const tools = await localClient.listTools(); + const names = tools.tools.map((t) => t.name); + expect(names).toContain('xcode_tools_Alpha'); + expect(names).not.toContain('xcode_tools_Beta'); + expect(names).toContain('xcode_tools_Gamma'); + + const res = (await localClient.callTool({ + name: 'xcode_tools_Alpha', + arguments: { value: 'hi', extra: 'e' }, + })) as CallToolResult; + expect(res.content[0]).toMatchObject({ type: 'text', text: 'Alpha2:hi:e' }); + }); +}); diff --git a/src/integrations/xcode-tools-bridge/client.ts b/src/integrations/xcode-tools-bridge/client.ts new file mode 100644 index 00000000..f3e42669 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/client.ts @@ -0,0 +1,202 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + StdioClientTransport, + type StdioServerParameters, +} from '@modelcontextprotocol/sdk/client/stdio.js'; +import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import process from 'node:process'; + +export interface XcodeToolsBridgeClientStatus { + connected: boolean; + bridgePid: number | null; + lastError: string | null; +} + +export interface XcodeToolsBridgeClientOptions { + serverParams?: StdioServerParameters; + connectTimeoutMs?: number; + listToolsTimeoutMs?: number; + callToolTimeoutMs?: number; + onToolsListChanged?: () => void; + onBridgeClosed?: () => void; +} + +export class XcodeToolsBridgeClient { + private readonly options: Required< + Pick< + XcodeToolsBridgeClientOptions, + 'connectTimeoutMs' | 'listToolsTimeoutMs' | 'callToolTimeoutMs' + > + > & + Omit< + XcodeToolsBridgeClientOptions, + 'connectTimeoutMs' | 'listToolsTimeoutMs' | 'callToolTimeoutMs' + >; + + private transport: StdioClientTransport | null = null; + private client: Client | null = null; + private connectPromise: Promise | null = null; + private lastError: string | null = null; + + constructor(options: XcodeToolsBridgeClientOptions = {}) { + this.options = { + connectTimeoutMs: options.connectTimeoutMs ?? 15_000, + listToolsTimeoutMs: options.listToolsTimeoutMs ?? 15_000, + callToolTimeoutMs: options.callToolTimeoutMs ?? 60_000, + ...options, + }; + } + + getStatus(): XcodeToolsBridgeClientStatus { + return { + connected: this.client !== null, + bridgePid: this.transport?.pid ?? null, + lastError: this.lastError, + }; + } + + async connectOnce(): Promise { + if (this.client) return; + if (this.connectPromise) return this.connectPromise; + + this.connectPromise = (async (): Promise => { + try { + const serverParams = + this.options.serverParams ?? + ({ + command: 'xcrun', + args: ['mcpbridge'], + stderr: 'pipe', + env: mapXcodeEnvForMcpBridge(process.env), + } satisfies StdioServerParameters); + + const transport = new StdioClientTransport(serverParams); + transport.onclose = (): void => { + this.client = null; + this.transport = null; + this.connectPromise = null; + this.options.onBridgeClosed?.(); + }; + + const client = new Client( + { name: 'xcodebuildmcp-xcode-tools-bridge', version: '0.0.0' }, + { + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 250, + onChanged: (): void => { + this.options.onToolsListChanged?.(); + }, + }, + }, + }, + ); + + await client.connect(transport, { timeout: this.options.connectTimeoutMs }); + + this.transport = transport; + this.client = client; + this.lastError = null; + } catch (error) { + this.lastError = error instanceof Error ? error.message : String(error); + await this.disconnect(); + throw error; + } finally { + this.connectPromise = null; + } + })(); + + return this.connectPromise; + } + + async disconnect(): Promise { + const client = this.client; + const transport = this.transport; + this.client = null; + this.transport = null; + this.connectPromise = null; + + try { + await client?.close(); + } finally { + try { + await transport?.close?.(); + } catch { + // ignore + } + } + } + + async listTools(): Promise { + if (!this.client) { + throw new Error('Bridge client is not connected'); + } + const result = await this.client.listTools(undefined, { + timeout: this.options.listToolsTimeoutMs, + }); + return result.tools; + } + + async callTool(name: string, args: Record): Promise { + if (!this.client) { + throw new Error('Bridge client is not connected'); + } + const result: unknown = await this.client.request( + { method: 'tools/call', params: { name, arguments: args } }, + CompatibilityCallToolResultSchema, + { + timeout: this.options.callToolTimeoutMs, + resetTimeoutOnProgress: true, + }, + ); + + if (isCallToolResult(result)) { + return result; + } + if (result && typeof result === 'object' && 'toolResult' in result) { + const toolResult = (result as { toolResult: unknown }).toolResult; + if (isCallToolResult(toolResult)) { + return toolResult; + } + } + + // If this is a task result, we don't support it today. + if (result && typeof result === 'object' && 'task' in result) { + throw new Error( + `Tool "${name}" returned a task result; task-based tools are not supported by the bridge proxy`, + ); + } + + throw new Error(`Tool "${name}" returned an unexpected result shape`); + } +} + +function isCallToolResult(result: unknown): result is CallToolResult { + if (!result || typeof result !== 'object') return false; + const record = result as Record; + return Array.isArray(record.content); +} + +function mapXcodeEnvForMcpBridge(env: NodeJS.ProcessEnv): Record { + const mapped: Record = {}; + + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + mapped[key] = value; + } + } + + if (typeof env.XCODEBUILDMCP_XCODE_PID === 'string' && mapped.MCP_XCODE_PID === undefined) { + mapped.MCP_XCODE_PID = env.XCODEBUILDMCP_XCODE_PID; + } + if ( + typeof env.XCODEBUILDMCP_XCODE_SESSION_ID === 'string' && + mapped.MCP_XCODE_SESSION_ID === undefined + ) { + mapped.MCP_XCODE_SESSION_ID = env.XCODEBUILDMCP_XCODE_SESSION_ID; + } + + return mapped; +} diff --git a/src/integrations/xcode-tools-bridge/index.ts b/src/integrations/xcode-tools-bridge/index.ts new file mode 100644 index 00000000..7844d336 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/index.ts @@ -0,0 +1,16 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { XcodeToolsBridgeManager } from './manager.ts'; + +let manager: XcodeToolsBridgeManager | null = null; + +export function getXcodeToolsBridgeManager(server?: McpServer): XcodeToolsBridgeManager | null { + if (manager) return manager; + if (!server) return null; + manager = new XcodeToolsBridgeManager(server); + return manager; +} + +export async function shutdownXcodeToolsBridge(): Promise { + await manager?.shutdown(); + manager = null; +} diff --git a/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts b/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts new file mode 100644 index 00000000..b1bb9744 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts @@ -0,0 +1,104 @@ +import * as z from 'zod'; + +type JsonSchemaEnumValue = string | number | boolean | null; + +type JsonSchema = { + type?: string | string[]; + description?: string; + enum?: unknown[]; + items?: JsonSchema; + properties?: Record; + required?: string[]; +}; + +function applyDescription(schema: T, description?: string): T { + if (!description) return schema; + return schema.describe(description) as T; +} + +function isObjectSchema(schema: JsonSchema): boolean { + const types = + schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]; + return types.includes('object') || schema.properties !== undefined; +} + +function isEnumValue(value: unknown): value is JsonSchemaEnumValue { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +export function jsonSchemaToZod(schema: JsonSchema | unknown): z.ZodTypeAny { + if (!schema || typeof schema !== 'object') { + return z.any(); + } + + const s = schema as JsonSchema; + + if (Array.isArray(s.enum)) { + const enumValues = s.enum.filter(isEnumValue); + if (enumValues.length === 0) { + return applyDescription(z.any(), s.description); + } + const allStrings = enumValues.every((v) => typeof v === 'string'); + if (allStrings) { + const stringValues = enumValues as string[]; + if (stringValues.length === 1) { + return applyDescription(z.literal(stringValues[0]), s.description); + } + return applyDescription(z.enum(stringValues as [string, ...string[]]), s.description); + } + + // z.enum only supports string unions; use z.literal union for mixed enums. + const literals = enumValues.map((v) => z.literal(v)) as z.ZodLiteral[]; + if (literals.length === 1) { + return applyDescription(literals[0], s.description); + } + return applyDescription( + z.union( + literals as [ + z.ZodLiteral, + z.ZodLiteral, + ...z.ZodLiteral[], + ], + ), + s.description, + ); + } + + const types = s.type === undefined ? [] : Array.isArray(s.type) ? s.type : [s.type]; + const primaryType = types[0]; + + switch (primaryType) { + case 'string': + return applyDescription(z.string(), s.description); + case 'integer': + return applyDescription(z.number().int(), s.description); + case 'number': + return applyDescription(z.number(), s.description); + case 'boolean': + return applyDescription(z.boolean(), s.description); + case 'array': { + const itemSchema = jsonSchemaToZod(s.items ?? {}); + return applyDescription(z.array(itemSchema), s.description); + } + case 'object': + default: { + if (!isObjectSchema(s)) { + return applyDescription(z.any(), s.description); + } + const required = new Set(s.required ?? []); + const props = s.properties ?? {}; + const shape: Record = {}; + for (const [key, value] of Object.entries(props)) { + const propSchema = jsonSchemaToZod(value); + shape[key] = required.has(key) ? propSchema : propSchema.optional(); + } + // Use passthrough to avoid breaking when Apple adds new fields. + return applyDescription(z.object(shape).passthrough(), s.description); + } + } +} diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts new file mode 100644 index 00000000..fe91bf7d --- /dev/null +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -0,0 +1,196 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { execFile } from 'node:child_process'; +import process from 'node:process'; +import { promisify } from 'node:util'; +import { log } from '../../utils/logger.ts'; +import { + createErrorResponse, + createTextResponse, + type ToolResponse, +} from '../../utils/responses/index.ts'; +import { XcodeToolsBridgeClient } from './client.ts'; +import { XcodeToolsProxyRegistry, type ProxySyncResult } from './registry.ts'; + +const execFileAsync = promisify(execFile); + +export type XcodeToolsBridgeStatus = { + workflowEnabled: boolean; + bridgeAvailable: boolean; + bridgePath: string | null; + xcodeRunning: boolean | null; + connected: boolean; + bridgePid: number | null; + proxiedToolCount: number; + lastError: string | null; + xcodePid: string | null; + xcodeSessionId: string | null; +}; + +export class XcodeToolsBridgeManager { + private readonly server: McpServer; + private readonly client: XcodeToolsBridgeClient; + private readonly registry: XcodeToolsProxyRegistry; + + private workflowEnabled = false; + private lastError: string | null = null; + private syncInFlight: Promise | null = null; + + constructor(server: McpServer) { + this.server = server; + this.registry = new XcodeToolsProxyRegistry(server); + this.client = new XcodeToolsBridgeClient({ + onToolsListChanged: (): void => { + void this.syncTools({ reason: 'listChanged' }); + }, + onBridgeClosed: (): void => { + this.registry.clear(); + this.lastError = this.client.getStatus().lastError ?? this.lastError; + }, + }); + } + + setWorkflowEnabled(enabled: boolean): void { + this.workflowEnabled = enabled; + } + + async shutdown(): Promise { + this.registry.clear(); + await this.client.disconnect(); + } + + async getStatus(): Promise { + const bridge = await findMcpBridge(); + const xcodeRunning = await isXcodeRunning(); + const clientStatus = this.client.getStatus(); + + return { + workflowEnabled: this.workflowEnabled, + bridgeAvailable: bridge.available, + bridgePath: bridge.path, + xcodeRunning, + connected: clientStatus.connected, + bridgePid: clientStatus.bridgePid, + proxiedToolCount: this.registry.getRegisteredCount(), + lastError: this.lastError ?? clientStatus.lastError, + xcodePid: process.env.XCODEBUILDMCP_XCODE_PID ?? process.env.MCP_XCODE_PID ?? null, + xcodeSessionId: + process.env.XCODEBUILDMCP_XCODE_SESSION_ID ?? process.env.MCP_XCODE_SESSION_ID ?? null, + }; + } + + async syncTools(opts: { + reason: 'startup' | 'manual' | 'listChanged'; + }): Promise { + if (!this.workflowEnabled) { + throw new Error('xcode-ide workflow is not enabled'); + } + + if (this.syncInFlight) return this.syncInFlight; + + this.syncInFlight = (async (): Promise => { + const bridge = await findMcpBridge(); + if (!bridge.available) { + this.lastError = 'mcpbridge not available (xcrun --find mcpbridge failed)'; + const existingCount = this.registry.getRegisteredCount(); + this.registry.clear(); + this.server.sendToolListChanged(); + return { added: 0, updated: 0, removed: existingCount, total: 0 }; + } + + try { + await this.client.connectOnce(); + const remoteTools = await this.client.listTools(); + + const sync = this.registry.sync(remoteTools, async (remoteName, args) => { + return this.client.callTool(remoteName, args); + }); + + if (opts.reason !== 'listChanged') { + log( + 'info', + `[xcode-ide] Synced proxied tools (added=${sync.added}, updated=${sync.updated}, removed=${sync.removed}, total=${sync.total})`, + ); + } + + this.lastError = null; + // Notify clients that our own tool list changed. + this.server.sendToolListChanged(); + + return sync; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.lastError = message; + log('warn', `[xcode-ide] Tool sync failed: ${message}`); + this.registry.clear(); + this.server.sendToolListChanged(); + return { added: 0, updated: 0, removed: 0, total: 0 }; + } finally { + this.syncInFlight = null; + } + })(); + + return this.syncInFlight; + } + + async disconnect(): Promise { + this.registry.clear(); + this.server.sendToolListChanged(); + await this.client.disconnect(); + } + + async statusTool(): Promise { + const status = await this.getStatus(); + return createTextResponse(JSON.stringify(status, null, 2)); + } + + async syncTool(): Promise { + try { + const sync = await this.syncTools({ reason: 'manual' }); + const status = await this.getStatus(); + return createTextResponse( + JSON.stringify( + { + sync, + status, + }, + null, + 2, + ), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Bridge sync failed', message); + } + } + + async disconnectTool(): Promise { + try { + await this.disconnect(); + const status = await this.getStatus(); + return createTextResponse(JSON.stringify(status, null, 2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createErrorResponse('Bridge disconnect failed', message); + } + } +} + +async function findMcpBridge(): Promise<{ available: boolean; path: string | null }> { + try { + const res = await execFileAsync('xcrun', ['--find', 'mcpbridge'], { timeout: 2000 }); + const out = (res.stdout ?? '').toString().trim(); + return out ? { available: true, path: out } : { available: false, path: null }; + } catch { + return { available: false, path: null }; + } +} + +async function isXcodeRunning(): Promise { + try { + const res = await execFileAsync('pgrep', ['-x', 'Xcode'], { timeout: 1000 }); + const out = (res.stdout ?? '').toString().trim(); + return out.length > 0; + } catch { + return null; + } +} diff --git a/src/integrations/xcode-tools-bridge/registry.ts b/src/integrations/xcode-tools-bridge/registry.ts new file mode 100644 index 00000000..40bbce1b --- /dev/null +++ b/src/integrations/xcode-tools-bridge/registry.ts @@ -0,0 +1,177 @@ +import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult, Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import * as z from 'zod'; +import { jsonSchemaToZod } from './jsonschema-to-zod.ts'; + +export type CallRemoteTool = ( + remoteToolName: string, + args: Record, +) => Promise; + +type Entry = { + remoteName: string; + localName: string; + fingerprint: string; + registered: RegisteredTool; +}; + +export type ProxySyncResult = { + added: number; + updated: number; + removed: number; + total: number; +}; + +export class XcodeToolsProxyRegistry { + private readonly server: McpServer; + private readonly tools: Map = new Map(); + + constructor(server: McpServer) { + this.server = server; + } + + getRegisteredToolNames(): string[] { + return [...this.tools.values()].map((t) => t.localName).sort(); + } + + getRegisteredCount(): number { + return this.tools.size; + } + + clear(): void { + for (const entry of this.tools.values()) { + entry.registered.remove(); + } + this.tools.clear(); + } + + sync(remoteTools: Tool[], callRemoteTool: CallRemoteTool): ProxySyncResult { + const desiredRemoteNames = new Set(remoteTools.map((t) => t.name)); + let added = 0; + let updated = 0; + let removed = 0; + + for (const remoteTool of remoteTools) { + const remoteName = remoteTool.name; + const localName = toLocalToolName(remoteName); + const fingerprint = stableFingerprint(remoteTool); + const existing = this.tools.get(remoteName); + + if (!existing) { + this.tools.set(remoteName, { + remoteName, + localName, + fingerprint, + registered: this.registerProxyTool(remoteTool, localName, callRemoteTool), + }); + added += 1; + continue; + } + + if (existing.fingerprint !== fingerprint) { + existing.registered.remove(); + this.tools.set(remoteName, { + remoteName, + localName, + fingerprint, + registered: this.registerProxyTool(remoteTool, localName, callRemoteTool), + }); + updated += 1; + } + } + + for (const [remoteName, entry] of this.tools.entries()) { + if (!desiredRemoteNames.has(remoteName)) { + entry.registered.remove(); + this.tools.delete(remoteName); + removed += 1; + } + } + + return { added, updated, removed, total: this.tools.size }; + } + + private registerProxyTool( + tool: Tool, + localName: string, + callRemoteTool: CallRemoteTool, + ): RegisteredTool { + const inputSchema = buildBestEffortInputSchema(tool); + const annotations = buildBestEffortAnnotations(tool, localName); + + return this.server.registerTool( + localName, + { + description: tool.description ?? '', + inputSchema, + annotations, + _meta: { + xcodeToolsBridge: { + remoteTool: tool.name, + source: 'xcrun mcpbridge', + }, + }, + }, + async (args: unknown) => { + const params = (args ?? {}) as Record; + return callRemoteTool(tool.name, params); + }, + ); + } +} + +export function toLocalToolName(remoteToolName: string): string { + return `xcode_tools_${remoteToolName}`; +} + +function stableFingerprint(tool: Tool): string { + return JSON.stringify({ + name: tool.name, + description: tool.description ?? null, + inputSchema: tool.inputSchema ?? null, + outputSchema: tool.outputSchema ?? null, + annotations: tool.annotations ?? null, + execution: tool.execution ?? null, + }); +} + +function buildBestEffortInputSchema(tool: Tool): z.ZodTypeAny { + if (!tool.inputSchema) { + return z.object({}).passthrough(); + } + const zod = jsonSchemaToZod(tool.inputSchema); + return zod; +} + +function buildBestEffortAnnotations(tool: Tool, localName: string): ToolAnnotations { + const existing = (tool.annotations ?? {}) as ToolAnnotations; + + if (existing.readOnlyHint !== undefined) { + return existing; + } + + return { + ...existing, + readOnlyHint: inferReadOnlyHint(localName), + }; +} + +function inferReadOnlyHint(localToolName: string): boolean { + // Default to conservative: most IDE tools can mutate project state. + const name = localToolName.toLowerCase(); + + const definitelyReadOnlyPrefixes = [ + 'xcode_tools_xcodelist', + 'xcode_tools_xcodeglob', + 'xcode_tools_xcodegrep', + 'xcode_tools_xcoderead', + 'xcode_tools_xcoderefreshcodeissuesinfile', + 'xcode_tools_documentationsearch', + 'xcode_tools_getbuildlog', + 'xcode_tools_gettestlist', + ]; + + if (definitelyReadOnlyPrefixes.some((p) => name.startsWith(p))) return true; + + return false; +} diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 84d81c96..25638a96 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -13,6 +13,8 @@ import { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getConfig } from '../../../utils/config-store.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; +import { getServer } from '../../../server/server-state.ts'; +import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; // Constants const LOG_PREFIX = '[Doctor]'; @@ -34,6 +36,60 @@ async function checkLldbDapAvailability(executor: CommandExecutor): Promise { + try { + const server = getServer(); + if (server) { + const manager = getXcodeToolsBridgeManager(server); + if (manager) { + const status = await manager.getStatus(); + return { + available: true, + workflowEnabled: status.workflowEnabled, + bridgePath: status.bridgePath, + xcodeRunning: status.xcodeRunning, + connected: status.connected, + bridgePid: status.bridgePid, + proxiedToolCount: status.proxiedToolCount, + lastError: status.lastError, + }; + } + } + + const config = getConfig(); + const bridgePathResult = await executor(['xcrun', '--find', 'mcpbridge'], 'Check mcpbridge'); + const bridgePath = bridgePathResult.success ? bridgePathResult.output.trim() : ''; + return { + available: true, + workflowEnabled: config.enabledWorkflows.includes('xcode-ide'), + bridgePath: bridgePath.length > 0 ? bridgePath : null, + xcodeRunning: null, + connected: false, + bridgePid: null, + proxiedToolCount: 0, + lastError: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { available: false, reason: message }; + } +} + /** * Run the doctor tool and return the results */ @@ -70,6 +126,7 @@ export async function runDoctor( const lldbDapAvailable = await checkLldbDapAvailability(deps.commandExecutor); const selectedDebuggerBackend = getConfig().debuggerBackend; const dapSelected = selectedDebuggerBackend === 'dap'; + const xcodeToolsBridge = await getXcodeToolsBridgeDoctorInfo(deps.commandExecutor); const doctorInfo = { serverVersion: version, @@ -247,6 +304,20 @@ export async function runDoctor( ? [`- Workflows: ${runtimeRegistration.enabledWorkflows.join(', ')}`] : []), + `\n### Xcode IDE Bridge (mcpbridge)`, + ...(xcodeToolsBridge.available + ? [ + `- Workflow enabled: ${xcodeToolsBridge.workflowEnabled ? '✅ Yes' : '❌ No'}`, + `- mcpbridge path: ${xcodeToolsBridge.bridgePath ?? '(not found)'}`, + `- Xcode running: ${xcodeToolsBridge.xcodeRunning ?? '(unknown)'}`, + `- Connected: ${xcodeToolsBridge.connected ? '✅ Yes' : '❌ No'}`, + `- Bridge PID: ${xcodeToolsBridge.bridgePid ?? '(none)'}`, + `- Proxied tools: ${xcodeToolsBridge.proxiedToolCount}`, + `- Last error: ${xcodeToolsBridge.lastError ?? '(none)'}`, + `- Note: Bridge debug tools (status/sync/disconnect) are only registered when debug: true`, + ] + : [`- Unavailable: ${xcodeToolsBridge.reason}`]), + `\n## Tool Availability Summary`, `- Build Tools: ${!('error' in doctorInfo.xcode) ? '\u2705 Available' : '\u274c Not available'}`, `- UI Automation Tools: ${doctorInfo.features.axe.uiAutomationSupported ? '\u2705 Available' : '\u274c Not available'}`, diff --git a/src/mcp/tools/xcode-ide/index.ts b/src/mcp/tools/xcode-ide/index.ts new file mode 100644 index 00000000..be744665 --- /dev/null +++ b/src/mcp/tools/xcode-ide/index.ts @@ -0,0 +1,5 @@ +export const workflow = { + name: 'Xcode IDE (mcpbridge)', + description: + "Proxy Xcode's built-in 'Xcode Tools' MCP service via `xcrun mcpbridge`. Registers dynamic `xcode_tools_*` tools when available. Bridge debug tools are only registered when `debug: true`.", +}; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts new file mode 100644 index 00000000..5389226a --- /dev/null +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts @@ -0,0 +1,27 @@ +import type { ToolResponse } from '../../../types/common.ts'; +import { getServer } from '../../../server/server-state.ts'; +import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; + +export default { + name: 'xcode_tools_bridge_disconnect', + description: 'Disconnect bridge and unregister proxied `xcode_tools_*` tools.', + schema: {}, + annotations: { + title: 'Disconnect Xcode Tools Bridge', + readOnlyHint: false, + }, + handler: async (): Promise => { + const server = getServer(); + if (!server) { + return createErrorResponse('Server not initialized', 'Unable to access server instance'); + } + + const manager = getXcodeToolsBridgeManager(server); + if (!manager) { + return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + } + + return manager.disconnectTool(); + }, +}; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts new file mode 100644 index 00000000..5dbf31db --- /dev/null +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts @@ -0,0 +1,27 @@ +import type { ToolResponse } from '../../../types/common.ts'; +import { getServer } from '../../../server/server-state.ts'; +import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; + +export default { + name: 'xcode_tools_bridge_status', + description: 'Show xcrun mcpbridge availability and proxy tool sync status.', + schema: {}, + annotations: { + title: 'Xcode Tools Bridge Status', + readOnlyHint: true, + }, + handler: async (): Promise => { + const server = getServer(); + if (!server) { + return createErrorResponse('Server not initialized', 'Unable to access server instance'); + } + + const manager = getXcodeToolsBridgeManager(server); + if (!manager) { + return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + } + + return manager.statusTool(); + }, +}; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts new file mode 100644 index 00000000..7f6419aa --- /dev/null +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts @@ -0,0 +1,27 @@ +import type { ToolResponse } from '../../../types/common.ts'; +import { getServer } from '../../../server/server-state.ts'; +import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { createErrorResponse } from '../../../utils/responses/index.ts'; + +export default { + name: 'xcode_tools_bridge_sync', + description: 'One-shot connect + tools/list sync (manual retry; avoids background prompt spam).', + schema: {}, + annotations: { + title: 'Sync Xcode Tools Bridge', + readOnlyHint: false, + }, + handler: async (): Promise => { + const server = getServer(); + if (!server) { + return createErrorResponse('Server not initialized', 'Unable to access server instance'); + } + + const manager = getXcodeToolsBridgeManager(server); + if (!manager) { + return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + } + + return manager.syncTool(); + }, +}; diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index fc9dc643..aa4b6314 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -1,5 +1,6 @@ import { loadWorkflowGroups } from '../core/plugin-registry.ts'; import { resolveSelectedWorkflows } from '../utils/workflow-selection.ts'; +import { shouldExposeTool } from '../utils/tool-visibility.ts'; import type { ToolCatalog, ToolDefinition, ToolResolution } from './types.ts'; import { toKebabCase, disambiguateCliNames } from './naming.ts'; @@ -18,6 +19,9 @@ export async function buildToolCatalog(opts: { continue; } for (const tool of wf.tools) { + if (!shouldExposeTool(wf.directoryName, tool.name)) { + continue; + } const baseCliName = tool.cli?.name ?? toKebabCase(tool.name); tools.push({ cliName: baseCliName, // Will be disambiguated below diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 441dabd4..927f7afc 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -6,6 +6,7 @@ import { log, setLogLevel, type LogLevel } from '../utils/logger.ts'; import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; import { registerWorkflows } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; +import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; export interface BootstrapOptions { enabledWorkflows?: string[]; @@ -55,5 +56,19 @@ export async function bootstrapServer( log('info', `🚀 Initializing server...`); await registerWorkflows(enabledWorkflows); + const xcodeIdeEnabled = enabledWorkflows.includes('xcode-ide'); + const xcodeToolsBridge = getXcodeToolsBridgeManager(server); + xcodeToolsBridge?.setWorkflowEnabled(xcodeIdeEnabled); + if (xcodeIdeEnabled && xcodeToolsBridge) { + try { + await xcodeToolsBridge.syncTools({ reason: 'startup' }); + } catch (error) { + log( + 'warn', + `[xcode-ide] Startup sync failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + await registerResources(server); } diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index b88afdfb..05b3ca7f 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -14,6 +14,7 @@ import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; import { version } from '../version.ts'; import process from 'node:process'; import { bootstrapServer } from './bootstrap.ts'; +import { shutdownXcodeToolsBridge } from '../integrations/xcode-tools-bridge/index.ts'; /** * Start the MCP server. @@ -31,12 +32,14 @@ export async function startMcpServer(): Promise { await startServer(server); process.on('SIGTERM', async () => { + await shutdownXcodeToolsBridge(); await getDefaultDebuggerManager().disposeAll(); await server.close(); process.exit(0); }); process.on('SIGINT', async () => { + await shutdownXcodeToolsBridge(); await getDefaultDebuggerManager().disposeAll(); await server.close(); process.exit(0); diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index 5995e016..c2d5f1d5 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -5,6 +5,7 @@ import { log } from './logger.ts'; import { loadWorkflowGroups } from '../core/plugin-registry.ts'; import { resolveSelectedWorkflows } from './workflow-selection.ts'; import { processToolResponse } from './responses/index.ts'; +import { shouldExposeTool } from './tool-visibility.ts'; export interface RuntimeToolInfo { enabledWorkflows: string[]; @@ -43,6 +44,9 @@ export async function applyWorkflowSelection(workflowNames: string[]): Promise Date: Wed, 4 Feb 2026 17:06:52 +0000 Subject: [PATCH 02/23] Add Xcode agent detection --- src/mcp/tools/doctor/doctor.ts | 17 ++++ src/utils/__tests__/process-tree.test.ts | 93 ++++++++++++++++++++++ src/utils/__tests__/xcode-process.test.ts | 94 +++++++++++++++++++++++ src/utils/process-tree.ts | 90 ++++++++++++++++++++++ src/utils/xcode-process.ts | 31 ++++++++ 5 files changed, 325 insertions(+) create mode 100644 src/utils/__tests__/process-tree.test.ts create mode 100644 src/utils/__tests__/xcode-process.test.ts create mode 100644 src/utils/process-tree.ts create mode 100644 src/utils/xcode-process.ts diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 25638a96..2fc96f79 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -12,6 +12,7 @@ import { version } from '../../../utils/version/index.ts'; import { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getConfig } from '../../../utils/config-store.ts'; +import { detectXcodeRuntime } from '../../../utils/xcode-process.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; import { getServer } from '../../../server/server-state.ts'; import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; @@ -112,6 +113,7 @@ export async function runDoctor( const envVars = deps.env.getEnvironmentVariables(); const systemInfo = deps.env.getSystemInfo(); const nodeInfo = deps.env.getNodeInfo(); + const xcodeRuntime = await detectXcodeRuntime(deps.commandExecutor); const axeAvailable = deps.features.areAxeToolsAvailable(); const pluginSystemInfo = await deps.plugins.getPluginSystemInfo(); const runtimeInfo = await deps.runtime.getRuntimeToolInfo(); @@ -133,6 +135,9 @@ export async function runDoctor( timestamp: new Date().toISOString(), system: systemInfo, node: nodeInfo, + processTree: xcodeRuntime.processTree, + processTreeError: xcodeRuntime.error, + runningUnderXcode: xcodeRuntime.runningUnderXcode, xcode: xcodeInfo, dependencies: binaryStatus, environmentVariables: envVars, @@ -242,6 +247,18 @@ export async function runDoctor( `\n## Node.js Information`, ...Object.entries(doctorInfo.node).map(([key, value]) => `- ${key}: ${value}`), + `\n## Process Tree`, + `- Running under Xcode: ${doctorInfo.runningUnderXcode ? '✅ Yes' : '❌ No'}`, + ...(doctorInfo.processTree.length > 0 + ? doctorInfo.processTree.map( + (entry) => + `- ${entry.pid} (ppid ${entry.ppid}): ${entry.name}${ + entry.command ? ` — ${entry.command}` : '' + }`, + ) + : ['- (unavailable)']), + ...(doctorInfo.processTreeError ? [`- Error: ${doctorInfo.processTreeError}`] : []), + `\n## Xcode Information`, ...('error' in doctorInfo.xcode ? [`- Error: ${doctorInfo.xcode.error}`] diff --git a/src/utils/__tests__/process-tree.test.ts b/src/utils/__tests__/process-tree.test.ts new file mode 100644 index 00000000..7d7dff7a --- /dev/null +++ b/src/utils/__tests__/process-tree.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { getProcessTree } from '../process-tree.ts'; +import { createCommandMatchingMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('getProcessTree', () => { + it('parses pid, ppid, name, and command', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + output: '123 1 Xcode /Applications/Xcode.app/Contents/MacOS/Xcode', + }, + '/bin/ps -o pid=,ppid=,comm=,args= -p 1': { + output: '1 0 launchd /sbin/launchd', + }, + }); + + const result = await getProcessTree(executor, '123'); + expect(result.error).toBeUndefined(); + expect(result.entries).toEqual([ + { + pid: '123', + ppid: '1', + name: 'Xcode', + command: '/Applications/Xcode.app/Contents/MacOS/Xcode', + }, + { + pid: '1', + ppid: '0', + name: 'launchd', + command: '/sbin/launchd', + }, + ]); + }); + + it('handles lines without command args', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + output: '123 1 Xcode', + }, + '/bin/ps -o pid=,ppid=,comm=,args= -p 1': { + output: '1 0 launchd', + }, + }); + + const result = await getProcessTree(executor, '123'); + expect(result.error).toBeUndefined(); + expect(result.entries).toEqual([ + { + pid: '123', + ppid: '1', + name: 'Xcode', + command: '', + }, + { + pid: '1', + ppid: '0', + name: 'launchd', + command: '', + }, + ]); + }); + + it('returns error when ps output is empty', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + output: '', + }, + 'ps -o pid=,ppid=,comm=,args= -p 123': { + output: '', + }, + }); + + const result = await getProcessTree(executor, '123'); + expect(result.entries).toEqual([]); + expect(result.error).toContain('ps returned no output for pid 123'); + }); + + it('returns error when ps exits unsuccessfully', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + success: false, + error: 'ps failed', + }, + 'ps -o pid=,ppid=,comm=,args= -p 123': { + success: false, + error: 'ps failed', + }, + }); + + const result = await getProcessTree(executor, '123'); + expect(result.entries).toEqual([]); + expect(result.error).toContain('ps failed'); + }); +}); diff --git a/src/utils/__tests__/xcode-process.test.ts b/src/utils/__tests__/xcode-process.test.ts new file mode 100644 index 00000000..8f363c22 --- /dev/null +++ b/src/utils/__tests__/xcode-process.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { detectXcodeRuntime, isRunningUnderXcode } from '../xcode-process.ts'; +import { createCommandMatchingMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('isRunningUnderXcode', () => { + it('detects Xcode by name', () => { + expect( + isRunningUnderXcode([ + { + pid: '1', + ppid: '0', + name: 'Xcode', + command: '/Applications/Xcode.app/Contents/MacOS/Xcode', + }, + ]), + ).toBe(true); + }); + + it('detects Xcode by command suffix', () => { + expect( + isRunningUnderXcode([ + { + pid: '1', + ppid: '0', + name: 'xcode', + command: '/Volumes/Dev/Xcode-26.3.app/Contents/MacOS/Xcode', + }, + ]), + ).toBe(true); + }); + + it('returns false when no match exists', () => { + expect( + isRunningUnderXcode([ + { + pid: '1', + ppid: '0', + name: 'launchd', + command: '/sbin/launchd', + }, + ]), + ).toBe(false); + }); +}); + +describe('detectXcodeRuntime', () => { + it('returns true when the process tree contains Xcode', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + output: '123 1 Xcode /Applications/Xcode.app/Contents/MacOS/Xcode', + }, + '/bin/ps -o pid=,ppid=,comm=,args= -p 1': { + output: '1 0 launchd /sbin/launchd', + }, + }); + + const result = await detectXcodeRuntime(executor, '123'); + expect(result.error).toBeUndefined(); + expect(result.runningUnderXcode).toBe(true); + }); + + it('returns false when the process tree has no Xcode match', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + output: '123 1 node node /tmp/server.js', + }, + '/bin/ps -o pid=,ppid=,comm=,args= -p 1': { + output: '1 0 launchd /sbin/launchd', + }, + }); + + const result = await detectXcodeRuntime(executor, '123'); + expect(result.error).toBeUndefined(); + expect(result.runningUnderXcode).toBe(false); + }); + + it('returns error when process tree collection fails', async () => { + const executor = createCommandMatchingMockExecutor({ + '/bin/ps -o pid=,ppid=,comm=,args= -p 123': { + success: false, + error: 'ps failed', + }, + 'ps -o pid=,ppid=,comm=,args= -p 123': { + success: false, + error: 'ps failed', + }, + }); + + const result = await detectXcodeRuntime(executor, '123'); + expect(result.processTree).toEqual([]); + expect(result.runningUnderXcode).toBe(false); + expect(result.error).toContain('ps failed'); + }); +}); diff --git a/src/utils/process-tree.ts b/src/utils/process-tree.ts new file mode 100644 index 00000000..c53f812a --- /dev/null +++ b/src/utils/process-tree.ts @@ -0,0 +1,90 @@ +import type { CommandExecutor } from './execution/index.ts'; + +export type ProcessTreeEntry = { + pid: string; + ppid: string; + name: string; + command: string; +}; + +export type ProcessTreeResult = { + entries: ProcessTreeEntry[]; + error?: string; +}; + +export async function getProcessTree( + executor: CommandExecutor, + startPid = process.pid.toString(), +): Promise { + const results: ProcessTreeEntry[] = []; + const seen = new Set(); + let currentPid = startPid; + let lastError: string | undefined; + + const parseLine = (line: string): ProcessTreeEntry | null => { + const tokens = line.trim().split(/\s+/); + if (tokens.length < 3) { + return null; + } + const pid = tokens[0]; + const ppid = tokens[1]; + const name = tokens[2]; + if (!pid || !ppid || !name) { + return null; + } + const command = tokens.slice(3).join(' '); + return { pid, ppid, name, command }; + }; + + const fetchProcessInfo = async (pid: string): Promise => { + const command = ['-o', 'pid=,ppid=,comm=,args=', '-p', pid]; + const attempts = [ + { bin: '/bin/ps', label: 'Get process info (ps)' }, + { bin: 'ps', label: 'Get process info (ps fallback)' }, + ]; + + for (const attempt of attempts) { + try { + const res = await executor([attempt.bin, ...command], attempt.label); + if (!res.success) { + lastError = res.error ?? `ps returned non-zero exit code for pid ${pid}`; + continue; + } + const line = res.output.trim().split('\n')[0]?.trim(); + if (!line) { + lastError = `ps returned no output for pid ${pid}`; + continue; + } + const parsed = parseLine(line); + if (!parsed) { + lastError = `ps output was not parseable for pid ${pid}`; + continue; + } + return parsed; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return null; + }; + + while (currentPid && currentPid !== '0' && !seen.has(currentPid)) { + seen.add(currentPid); + const entry = await fetchProcessInfo(currentPid); + if (!entry) { + break; + } + + results.push(entry); + if (entry.ppid === entry.pid || entry.ppid === '0') { + break; + } + currentPid = entry.ppid; + } + + return { + entries: results, + error: results.length === 0 ? lastError : undefined, + }; +} diff --git a/src/utils/xcode-process.ts b/src/utils/xcode-process.ts new file mode 100644 index 00000000..496f9b9d --- /dev/null +++ b/src/utils/xcode-process.ts @@ -0,0 +1,31 @@ +import type { CommandExecutor } from './execution/index.ts'; +import { getProcessTree, type ProcessTreeEntry } from './process-tree.ts'; + +export type { ProcessTreeEntry }; + +export type XcodeRuntimeDetection = { + runningUnderXcode: boolean; + processTree: ProcessTreeEntry[]; + error?: string; +}; + +export function isRunningUnderXcode(entries: ProcessTreeEntry[]): boolean { + return entries.some( + (entry) => + entry.name === 'Xcode' || + entry.command.includes('Contents/MacOS/Xcode') || + entry.command.includes('com.apple.dt.Xcode'), + ); +} + +export async function detectXcodeRuntime( + executor: CommandExecutor, + startPid?: string, +): Promise { + const processTreeResult = await getProcessTree(executor, startPid); + return { + runningUnderXcode: isRunningUnderXcode(processTreeResult.entries), + processTree: processTreeResult.entries, + error: processTreeResult.error, + }; +} From 907cfe3cfe9173f4d370b05cbcf92e3d1e6c87ec Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 4 Feb 2026 23:32:26 +0000 Subject: [PATCH 03/23] Refactor tool/workflow system to use YAML manifests as single source of truth - Add YAML manifest system for tools and workflows in manifests/ directory - Implement manifest loader with schema validation (Zod) - Add visibility system with predicates for runtime filtering - Remove legacy codegen (generate-tools-manifest.ts, generate-loaders.ts) - Remove legacy index.ts workflow exports and re-export tool files - Add PredicateContext for runtime-aware tool filtering - Support hideWhenXcodeAgentMode predicate for Xcode IDE conflicts - Fix CLI next steps to use canonical names from manifests - Default MCP server to info log level - Remove fallback in next-steps-renderer (require cliTool from enrichment) - Update entry points from index.js to cli.js in docs and configs --- .claude/agents/xcodebuild-mcp-qa-tester.md | 10 +- .vscode/launch.json | 3 +- .vscode/mcp.json | 3 +- build-plugins/plugin-discovery.js | 283 ------ build-plugins/plugin-discovery.ts | 284 ------ build-plugins/tsconfig.json | 13 - docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md | 2 +- docs/dev/ARCHITECTURE.md | 2 +- docs/dev/CLI_CONVERSION_PLAN.md | 2 +- docs/dev/CONTRIBUTING.md | 22 +- docs/dev/MANIFEST_FORMAT.md | 465 +++++++++ docs/dev/MANUAL_TESTING.md | 86 +- docs/dev/RELOADEROO.md | 86 +- docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md | 124 +-- docs/dev/TESTING.md | 88 +- docs/dev/TOOL_DISCOVERY_LOGIC.md | 137 +++ docs/dev/TOOL_REGISTRY_REFACTOR.md | 940 ++++++++++++++++++ docs/dev/XCODE_IDE_TOOL_CONFLICTS.md | 328 ++++++ docs/dev/oracle-prompt-workspace-daemon.md | 2 +- docs/dev/session_management_plan.md | 16 +- .../INVESTIGATION_TOOL_DISCOVERY.md | 54 + docs/investigations/launch-app-logs-sim.md | 2 +- manifests/tools/boot_sim.yaml | 13 + manifests/tools/build_device.yaml | 14 + manifests/tools/build_macos.yaml | 14 + manifests/tools/build_run_macos.yaml | 14 + manifests/tools/build_run_sim.yaml | 14 + manifests/tools/build_sim.yaml | 14 + manifests/tools/button.yaml | 13 + manifests/tools/clean.yaml | 14 + manifests/tools/debug_attach_sim.yaml | 13 + manifests/tools/debug_breakpoint_add.yaml | 13 + manifests/tools/debug_breakpoint_remove.yaml | 13 + manifests/tools/debug_continue.yaml | 13 + manifests/tools/debug_detach.yaml | 13 + manifests/tools/debug_lldb_command.yaml | 13 + manifests/tools/debug_stack.yaml | 13 + manifests/tools/debug_variables.yaml | 13 + manifests/tools/discover_projs.yaml | 14 + manifests/tools/doctor.yaml | 11 + manifests/tools/erase_sims.yaml | 13 + manifests/tools/gesture.yaml | 13 + manifests/tools/get_app_bundle_id.yaml | 13 + manifests/tools/get_device_app_path.yaml | 13 + manifests/tools/get_mac_app_path.yaml | 13 + manifests/tools/get_mac_bundle_id.yaml | 13 + manifests/tools/get_sim_app_path.yaml | 13 + manifests/tools/install_app_device.yaml | 13 + manifests/tools/install_app_sim.yaml | 13 + manifests/tools/key_press.yaml | 13 + manifests/tools/key_sequence.yaml | 13 + manifests/tools/launch_app_device.yaml | 13 + manifests/tools/launch_app_logs_sim.yaml | 13 + manifests/tools/launch_app_sim.yaml | 13 + manifests/tools/launch_mac_app.yaml | 13 + manifests/tools/list_devices.yaml | 13 + manifests/tools/list_schemes.yaml | 14 + manifests/tools/list_sims.yaml | 13 + manifests/tools/long_press.yaml | 13 + manifests/tools/manage_workflows.yaml | 11 + manifests/tools/open_sim.yaml | 13 + manifests/tools/record_sim_video.yaml | 13 + manifests/tools/reset_sim_location.yaml | 13 + manifests/tools/scaffold_ios_project.yaml | 14 + manifests/tools/scaffold_macos_project.yaml | 14 + manifests/tools/screenshot.yaml | 13 + manifests/tools/session_clear_defaults.yaml | 10 + manifests/tools/session_set_defaults.yaml | 10 + manifests/tools/session_show_defaults.yaml | 10 + manifests/tools/set_sim_appearance.yaml | 13 + manifests/tools/set_sim_location.yaml | 13 + manifests/tools/show_build_settings.yaml | 14 + manifests/tools/sim_statusbar.yaml | 13 + manifests/tools/snapshot_ui.yaml | 13 + manifests/tools/start_device_log_cap.yaml | 13 + manifests/tools/start_sim_log_cap.yaml | 13 + manifests/tools/stop_app_device.yaml | 13 + manifests/tools/stop_app_sim.yaml | 13 + manifests/tools/stop_device_log_cap.yaml | 13 + manifests/tools/stop_mac_app.yaml | 13 + manifests/tools/stop_sim_log_cap.yaml | 13 + manifests/tools/swift_package_build.yaml | 14 + manifests/tools/swift_package_clean.yaml | 13 + manifests/tools/swift_package_list.yaml | 13 + manifests/tools/swift_package_run.yaml | 13 + manifests/tools/swift_package_stop.yaml | 13 + manifests/tools/swift_package_test.yaml | 14 + manifests/tools/swipe.yaml | 13 + manifests/tools/tap.yaml | 13 + manifests/tools/test_device.yaml | 14 + manifests/tools/test_macos.yaml | 14 + manifests/tools/test_sim.yaml | 14 + manifests/tools/touch.yaml | 13 + manifests/tools/type_text.yaml | 13 + .../tools/xcode_tools_bridge_disconnect.yaml | 11 + .../tools/xcode_tools_bridge_status.yaml | 11 + manifests/tools/xcode_tools_bridge_sync.yaml | 11 + manifests/workflows/debugging.yaml | 22 + manifests/workflows/device.yaml | 28 + manifests/workflows/doctor.yaml | 16 + manifests/workflows/logging.yaml | 18 + manifests/workflows/macos.yaml | 25 + manifests/workflows/project-discovery.yaml | 19 + manifests/workflows/project-scaffolding.yaml | 16 + manifests/workflows/session-management.yaml | 17 + manifests/workflows/simulator-management.yaml | 22 + manifests/workflows/simulator.yaml | 35 + manifests/workflows/swift-package.yaml | 20 + manifests/workflows/ui-automation.yaml | 25 + manifests/workflows/utilities.yaml | 15 + manifests/workflows/workflow-discovery.yaml | 16 + manifests/workflows/xcode-ide.yaml | 17 + package-lock.json | 136 +-- package.json | 19 +- scripts/copy-build-assets.js | 37 + scripts/generate-loaders.ts | 11 - scripts/generate-tools-manifest.ts | 109 -- server.json | 3 +- src/cli.ts | 5 +- src/cli/cli-tool-catalog.ts | 12 +- src/cli/commands/tools.ts | 135 ++- src/cli/register-tool-commands.ts | 5 +- src/core/generated-plugins.ts | 476 --------- src/core/generated-resources.ts | 23 - .../manifest/__tests__/load-manifest.test.ts | 141 +++ src/core/manifest/__tests__/schema.test.ts | 220 ++++ src/core/manifest/import-tool-module.ts | 110 ++ src/core/manifest/index.ts | 7 + src/core/manifest/load-manifest.ts | 322 ++++++ src/core/manifest/schema.ts | 150 +++ src/core/plugin-registry.ts | 113 --- src/core/plugin-types.ts | 4 +- src/core/resources.ts | 57 +- src/daemon.ts | 20 +- src/mcp/resources/doctor.ts | 3 +- src/mcp/tools/debugging/debug_attach_sim.ts | 2 +- .../tools/debugging/debug_breakpoint_add.ts | 2 +- .../debugging/debug_breakpoint_remove.ts | 2 +- src/mcp/tools/debugging/debug_continue.ts | 2 +- src/mcp/tools/debugging/debug_detach.ts | 2 +- src/mcp/tools/debugging/debug_lldb_command.ts | 2 +- src/mcp/tools/debugging/debug_stack.ts | 2 +- src/mcp/tools/debugging/debug_variables.ts | 2 +- src/mcp/tools/debugging/index.ts | 5 - src/mcp/tools/device/__tests__/index.test.ts | 33 - src/mcp/tools/device/build_device.ts | 3 +- src/mcp/tools/device/clean.ts | 2 - src/mcp/tools/device/discover_projs.ts | 2 - src/mcp/tools/device/get_app_bundle_id.ts | 2 - src/mcp/tools/device/get_device_app_path.ts | 3 +- src/mcp/tools/device/index.ts | 5 - src/mcp/tools/device/install_app_device.ts | 2 +- src/mcp/tools/device/launch_app_device.ts | 2 +- src/mcp/tools/device/list_schemes.ts | 2 - src/mcp/tools/device/show_build_settings.ts | 2 - src/mcp/tools/device/start_device_log_cap.ts | 2 - src/mcp/tools/device/stop_app_device.ts | 2 +- src/mcp/tools/device/stop_device_log_cap.ts | 2 - src/mcp/tools/device/test_device.ts | 3 +- src/mcp/tools/doctor/__tests__/index.test.ts | 33 - src/mcp/tools/doctor/doctor.ts | 2 +- src/mcp/tools/doctor/index.ts | 5 - src/mcp/tools/doctor/lib/doctor.deps.ts | 18 +- src/mcp/tools/logging/__tests__/index.test.ts | 33 - src/mcp/tools/logging/index.ts | 5 - src/mcp/tools/logging/start_device_log_cap.ts | 2 +- src/mcp/tools/logging/start_sim_log_cap.ts | 6 +- src/mcp/tools/logging/stop_device_log_cap.ts | 4 +- src/mcp/tools/logging/stop_sim_log_cap.ts | 3 +- src/mcp/tools/macos/__tests__/index.test.ts | 33 - src/mcp/tools/macos/build_macos.ts | 3 +- src/mcp/tools/macos/build_run_macos.ts | 3 +- src/mcp/tools/macos/clean.ts | 2 - src/mcp/tools/macos/discover_projs.ts | 2 - src/mcp/tools/macos/get_mac_app_path.ts | 2 +- src/mcp/tools/macos/get_mac_bundle_id.ts | 2 - src/mcp/tools/macos/index.ts | 5 - src/mcp/tools/macos/launch_mac_app.ts | 2 +- src/mcp/tools/macos/list_schemes.ts | 2 - src/mcp/tools/macos/show_build_settings.ts | 2 - src/mcp/tools/macos/stop_mac_app.ts | 2 +- src/mcp/tools/macos/test_macos.ts | 3 +- .../project-discovery/__tests__/index.test.ts | 33 - .../tools/project-discovery/discover_projs.ts | 5 +- .../project-discovery/get_app_bundle_id.ts | 11 +- .../project-discovery/get_mac_bundle_id.ts | 11 +- src/mcp/tools/project-discovery/index.ts | 5 - .../tools/project-discovery/list_schemes.ts | 2 +- .../project-discovery/show_build_settings.ts | 2 +- .../__tests__/index.test.ts | 33 - src/mcp/tools/project-scaffolding/index.ts | 13 - .../scaffold_ios_project.ts | 2 +- .../scaffold_macos_project.ts | 11 +- .../__tests__/index.test.ts | 33 - src/mcp/tools/session-management/index.ts | 5 - .../__tests__/index.test.ts | 24 - .../tools/simulator-management/boot_sim.ts | 2 - .../tools/simulator-management/erase_sims.ts | 5 +- src/mcp/tools/simulator-management/index.ts | 13 - .../tools/simulator-management/list_sims.ts | 2 - .../tools/simulator-management/open_sim.ts | 2 - .../reset_sim_location.ts | 5 +- .../set_sim_appearance.ts | 5 +- .../simulator-management/set_sim_location.ts | 5 +- .../simulator-management/sim_statusbar.ts | 5 +- .../tools/simulator/__tests__/index.test.ts | 33 - src/mcp/tools/simulator/boot_sim.ts | 2 +- src/mcp/tools/simulator/build_run_sim.ts | 3 +- src/mcp/tools/simulator/build_sim.ts | 3 +- src/mcp/tools/simulator/clean.ts | 2 - src/mcp/tools/simulator/discover_projs.ts | 2 - src/mcp/tools/simulator/get_app_bundle_id.ts | 2 - src/mcp/tools/simulator/get_sim_app_path.ts | 2 +- src/mcp/tools/simulator/index.ts | 5 - src/mcp/tools/simulator/install_app_sim.ts | 2 +- .../tools/simulator/launch_app_logs_sim.ts | 3 +- src/mcp/tools/simulator/launch_app_sim.ts | 2 +- src/mcp/tools/simulator/list_schemes.ts | 2 - src/mcp/tools/simulator/open_sim.ts | 2 +- src/mcp/tools/simulator/screenshot.ts | 2 - .../tools/simulator/show_build_settings.ts | 2 - src/mcp/tools/simulator/snapshot_ui.ts | 2 - src/mcp/tools/simulator/stop_app_sim.ts | 2 +- src/mcp/tools/simulator/stop_sim_log_cap.ts | 2 - src/mcp/tools/simulator/test_sim.ts | 2 +- .../swift-package/__tests__/index.test.ts | 33 - src/mcp/tools/swift-package/index.ts | 5 - .../swift-package/swift_package_build.ts | 2 +- .../swift-package/swift_package_clean.ts | 2 +- .../tools/swift-package/swift_package_list.ts | 3 +- .../tools/swift-package/swift_package_run.ts | 3 +- .../tools/swift-package/swift_package_stop.ts | 2 +- .../tools/swift-package/swift_package_test.ts | 2 +- .../ui-automation/__tests__/index.test.ts | 33 - src/mcp/tools/ui-automation/gesture.ts | 2 +- src/mcp/tools/ui-automation/index.ts | 5 - src/mcp/tools/ui-automation/key_press.ts | 2 +- src/mcp/tools/ui-automation/key_sequence.ts | 2 +- src/mcp/tools/ui-automation/long_press.ts | 2 +- src/mcp/tools/ui-automation/screenshot.ts | 3 +- src/mcp/tools/ui-automation/snapshot_ui.ts | 2 +- src/mcp/tools/ui-automation/swipe.ts | 2 +- src/mcp/tools/ui-automation/touch.ts | 2 +- src/mcp/tools/ui-automation/type_text.ts | 2 +- .../tools/utilities/__tests__/index.test.ts | 33 - src/mcp/tools/utilities/clean.ts | 3 +- src/mcp/tools/utilities/index.ts | 5 - .../__tests__/manage_workflows.test.ts | 48 +- src/mcp/tools/workflow-discovery/index.ts | 4 - .../workflow-discovery/manage_workflows.ts | 19 +- src/mcp/tools/xcode-ide/index.ts | 5 - src/runtime/naming.ts | 27 - src/runtime/tool-catalog.ts | 187 +++- src/runtime/tool-invoker.ts | 2 +- src/server/bootstrap.ts | 33 +- src/server/start-mcp-server.ts | 6 +- src/test-utils/mock-executors.ts | 4 +- src/utils/CommandExecutor.ts | 3 + src/utils/FileSystemExecutor.ts | 3 + src/utils/axe-helpers.ts | 2 +- src/utils/build-utils.ts | 4 +- src/utils/command.ts | 8 +- src/utils/errors.ts | 2 +- src/utils/log_capture.ts | 9 +- src/utils/plugin-registry/index.ts | 5 - .../__tests__/next-steps-renderer.test.ts | 48 +- src/utils/responses/next-steps-renderer.ts | 19 +- src/utils/simulator-utils.ts | 2 +- src/utils/template-manager.ts | 4 +- src/utils/test-common.ts | 7 +- src/utils/tool-registry.ts | 133 ++- src/utils/typed-tool-factory.ts | 2 +- src/utils/validation.ts | 6 +- src/utils/xcodemake.ts | 3 +- src/visibility/__tests__/exposure.test.ts | 333 +++++++ .../__tests__/predicate-registry.test.ts | 152 +++ src/visibility/exposure.ts | 170 ++++ src/visibility/index.ts | 7 + src/visibility/predicate-registry.ts | 84 ++ src/visibility/predicate-types.ts | 35 + tsconfig.build.json | 23 + tsconfig.json | 1 + tsup.config.ts | 55 +- 283 files changed, 6000 insertions(+), 2531 deletions(-) delete mode 100644 build-plugins/plugin-discovery.js delete mode 100644 build-plugins/plugin-discovery.ts delete mode 100644 build-plugins/tsconfig.json create mode 100644 docs/dev/MANIFEST_FORMAT.md create mode 100644 docs/dev/TOOL_DISCOVERY_LOGIC.md create mode 100644 docs/dev/TOOL_REGISTRY_REFACTOR.md create mode 100644 docs/dev/XCODE_IDE_TOOL_CONFLICTS.md create mode 100644 docs/investigations/INVESTIGATION_TOOL_DISCOVERY.md create mode 100644 manifests/tools/boot_sim.yaml create mode 100644 manifests/tools/build_device.yaml create mode 100644 manifests/tools/build_macos.yaml create mode 100644 manifests/tools/build_run_macos.yaml create mode 100644 manifests/tools/build_run_sim.yaml create mode 100644 manifests/tools/build_sim.yaml create mode 100644 manifests/tools/button.yaml create mode 100644 manifests/tools/clean.yaml create mode 100644 manifests/tools/debug_attach_sim.yaml create mode 100644 manifests/tools/debug_breakpoint_add.yaml create mode 100644 manifests/tools/debug_breakpoint_remove.yaml create mode 100644 manifests/tools/debug_continue.yaml create mode 100644 manifests/tools/debug_detach.yaml create mode 100644 manifests/tools/debug_lldb_command.yaml create mode 100644 manifests/tools/debug_stack.yaml create mode 100644 manifests/tools/debug_variables.yaml create mode 100644 manifests/tools/discover_projs.yaml create mode 100644 manifests/tools/doctor.yaml create mode 100644 manifests/tools/erase_sims.yaml create mode 100644 manifests/tools/gesture.yaml create mode 100644 manifests/tools/get_app_bundle_id.yaml create mode 100644 manifests/tools/get_device_app_path.yaml create mode 100644 manifests/tools/get_mac_app_path.yaml create mode 100644 manifests/tools/get_mac_bundle_id.yaml create mode 100644 manifests/tools/get_sim_app_path.yaml create mode 100644 manifests/tools/install_app_device.yaml create mode 100644 manifests/tools/install_app_sim.yaml create mode 100644 manifests/tools/key_press.yaml create mode 100644 manifests/tools/key_sequence.yaml create mode 100644 manifests/tools/launch_app_device.yaml create mode 100644 manifests/tools/launch_app_logs_sim.yaml create mode 100644 manifests/tools/launch_app_sim.yaml create mode 100644 manifests/tools/launch_mac_app.yaml create mode 100644 manifests/tools/list_devices.yaml create mode 100644 manifests/tools/list_schemes.yaml create mode 100644 manifests/tools/list_sims.yaml create mode 100644 manifests/tools/long_press.yaml create mode 100644 manifests/tools/manage_workflows.yaml create mode 100644 manifests/tools/open_sim.yaml create mode 100644 manifests/tools/record_sim_video.yaml create mode 100644 manifests/tools/reset_sim_location.yaml create mode 100644 manifests/tools/scaffold_ios_project.yaml create mode 100644 manifests/tools/scaffold_macos_project.yaml create mode 100644 manifests/tools/screenshot.yaml create mode 100644 manifests/tools/session_clear_defaults.yaml create mode 100644 manifests/tools/session_set_defaults.yaml create mode 100644 manifests/tools/session_show_defaults.yaml create mode 100644 manifests/tools/set_sim_appearance.yaml create mode 100644 manifests/tools/set_sim_location.yaml create mode 100644 manifests/tools/show_build_settings.yaml create mode 100644 manifests/tools/sim_statusbar.yaml create mode 100644 manifests/tools/snapshot_ui.yaml create mode 100644 manifests/tools/start_device_log_cap.yaml create mode 100644 manifests/tools/start_sim_log_cap.yaml create mode 100644 manifests/tools/stop_app_device.yaml create mode 100644 manifests/tools/stop_app_sim.yaml create mode 100644 manifests/tools/stop_device_log_cap.yaml create mode 100644 manifests/tools/stop_mac_app.yaml create mode 100644 manifests/tools/stop_sim_log_cap.yaml create mode 100644 manifests/tools/swift_package_build.yaml create mode 100644 manifests/tools/swift_package_clean.yaml create mode 100644 manifests/tools/swift_package_list.yaml create mode 100644 manifests/tools/swift_package_run.yaml create mode 100644 manifests/tools/swift_package_stop.yaml create mode 100644 manifests/tools/swift_package_test.yaml create mode 100644 manifests/tools/swipe.yaml create mode 100644 manifests/tools/tap.yaml create mode 100644 manifests/tools/test_device.yaml create mode 100644 manifests/tools/test_macos.yaml create mode 100644 manifests/tools/test_sim.yaml create mode 100644 manifests/tools/touch.yaml create mode 100644 manifests/tools/type_text.yaml create mode 100644 manifests/tools/xcode_tools_bridge_disconnect.yaml create mode 100644 manifests/tools/xcode_tools_bridge_status.yaml create mode 100644 manifests/tools/xcode_tools_bridge_sync.yaml create mode 100644 manifests/workflows/debugging.yaml create mode 100644 manifests/workflows/device.yaml create mode 100644 manifests/workflows/doctor.yaml create mode 100644 manifests/workflows/logging.yaml create mode 100644 manifests/workflows/macos.yaml create mode 100644 manifests/workflows/project-discovery.yaml create mode 100644 manifests/workflows/project-scaffolding.yaml create mode 100644 manifests/workflows/session-management.yaml create mode 100644 manifests/workflows/simulator-management.yaml create mode 100644 manifests/workflows/simulator.yaml create mode 100644 manifests/workflows/swift-package.yaml create mode 100644 manifests/workflows/ui-automation.yaml create mode 100644 manifests/workflows/utilities.yaml create mode 100644 manifests/workflows/workflow-discovery.yaml create mode 100644 manifests/workflows/xcode-ide.yaml create mode 100644 scripts/copy-build-assets.js delete mode 100644 scripts/generate-loaders.ts delete mode 100644 scripts/generate-tools-manifest.ts delete mode 100644 src/core/generated-plugins.ts delete mode 100644 src/core/generated-resources.ts create mode 100644 src/core/manifest/__tests__/load-manifest.test.ts create mode 100644 src/core/manifest/__tests__/schema.test.ts create mode 100644 src/core/manifest/import-tool-module.ts create mode 100644 src/core/manifest/index.ts create mode 100644 src/core/manifest/load-manifest.ts create mode 100644 src/core/manifest/schema.ts delete mode 100644 src/core/plugin-registry.ts delete mode 100644 src/mcp/tools/debugging/index.ts delete mode 100644 src/mcp/tools/device/__tests__/index.test.ts delete mode 100644 src/mcp/tools/device/clean.ts delete mode 100644 src/mcp/tools/device/discover_projs.ts delete mode 100644 src/mcp/tools/device/get_app_bundle_id.ts delete mode 100644 src/mcp/tools/device/index.ts delete mode 100644 src/mcp/tools/device/list_schemes.ts delete mode 100644 src/mcp/tools/device/show_build_settings.ts delete mode 100644 src/mcp/tools/device/start_device_log_cap.ts delete mode 100644 src/mcp/tools/device/stop_device_log_cap.ts delete mode 100644 src/mcp/tools/doctor/__tests__/index.test.ts delete mode 100644 src/mcp/tools/doctor/index.ts delete mode 100644 src/mcp/tools/logging/__tests__/index.test.ts delete mode 100644 src/mcp/tools/logging/index.ts delete mode 100644 src/mcp/tools/macos/__tests__/index.test.ts delete mode 100644 src/mcp/tools/macos/clean.ts delete mode 100644 src/mcp/tools/macos/discover_projs.ts delete mode 100644 src/mcp/tools/macos/get_mac_bundle_id.ts delete mode 100644 src/mcp/tools/macos/index.ts delete mode 100644 src/mcp/tools/macos/list_schemes.ts delete mode 100644 src/mcp/tools/macos/show_build_settings.ts delete mode 100644 src/mcp/tools/project-discovery/__tests__/index.test.ts delete mode 100644 src/mcp/tools/project-discovery/index.ts delete mode 100644 src/mcp/tools/project-scaffolding/__tests__/index.test.ts delete mode 100644 src/mcp/tools/project-scaffolding/index.ts delete mode 100644 src/mcp/tools/session-management/__tests__/index.test.ts delete mode 100644 src/mcp/tools/session-management/index.ts delete mode 100644 src/mcp/tools/simulator-management/__tests__/index.test.ts delete mode 100644 src/mcp/tools/simulator-management/boot_sim.ts delete mode 100644 src/mcp/tools/simulator-management/index.ts delete mode 100644 src/mcp/tools/simulator-management/list_sims.ts delete mode 100644 src/mcp/tools/simulator-management/open_sim.ts delete mode 100644 src/mcp/tools/simulator/__tests__/index.test.ts delete mode 100644 src/mcp/tools/simulator/clean.ts delete mode 100644 src/mcp/tools/simulator/discover_projs.ts delete mode 100644 src/mcp/tools/simulator/get_app_bundle_id.ts delete mode 100644 src/mcp/tools/simulator/index.ts delete mode 100644 src/mcp/tools/simulator/list_schemes.ts delete mode 100644 src/mcp/tools/simulator/screenshot.ts delete mode 100644 src/mcp/tools/simulator/show_build_settings.ts delete mode 100644 src/mcp/tools/simulator/snapshot_ui.ts delete mode 100644 src/mcp/tools/simulator/stop_sim_log_cap.ts delete mode 100644 src/mcp/tools/swift-package/__tests__/index.test.ts delete mode 100644 src/mcp/tools/swift-package/index.ts delete mode 100644 src/mcp/tools/ui-automation/__tests__/index.test.ts delete mode 100644 src/mcp/tools/ui-automation/index.ts delete mode 100644 src/mcp/tools/utilities/__tests__/index.test.ts delete mode 100644 src/mcp/tools/utilities/index.ts delete mode 100644 src/mcp/tools/workflow-discovery/index.ts delete mode 100644 src/mcp/tools/xcode-ide/index.ts delete mode 100644 src/utils/plugin-registry/index.ts create mode 100644 src/visibility/__tests__/exposure.test.ts create mode 100644 src/visibility/__tests__/predicate-registry.test.ts create mode 100644 src/visibility/exposure.ts create mode 100644 src/visibility/index.ts create mode 100644 src/visibility/predicate-registry.ts create mode 100644 src/visibility/predicate-types.ts create mode 100644 tsconfig.build.json diff --git a/.claude/agents/xcodebuild-mcp-qa-tester.md b/.claude/agents/xcodebuild-mcp-qa-tester.md index a055b237..6e3afdfc 100644 --- a/.claude/agents/xcodebuild-mcp-qa-tester.md +++ b/.claude/agents/xcodebuild-mcp-qa-tester.md @@ -76,7 +76,7 @@ After testing `list_sims` tool, update the report: ## Detailed Test Results ### Tool: list_sims ✅ PASSED -**Command:** `npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js` +**Command:** `npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/cli.js` **Verification:** Command returned JSON array with 6 simulator objects **Validation Summary:** Successfully discovered 6 available simulators with UUIDs, names, and boot status **Timestamp:** 2025-01-29 14:30:15 @@ -87,8 +87,8 @@ After testing `list_sims` tool, update the report: ### Pre-Testing Setup - Always start by building the project: `npm run build` - Verify Reloaderoo is available: `npx reloaderoo@latest --help` -- Check server connectivity: `npx reloaderoo@latest inspect ping -- node build/index.js` -- Get server information: `npx reloaderoo@latest inspect server-info -- node build/index.js` +- Check server connectivity: `npx reloaderoo@latest inspect ping -- node build/cli.js` +- Get server information: `npx reloaderoo@latest inspect server-info -- node build/cli.js` ### Systematic Testing Workflow 1. **Create Initial Report**: Generate test report with all checkboxes unchecked @@ -108,7 +108,7 @@ After testing `list_sims` tool, update the report: ### Tool Testing Process For each tool: -1. Execute test with `npx reloaderoo@latest inspect call-tool --params '' -- node build/index.js` +1. Execute test with `npx reloaderoo@latest inspect call-tool --params '' -- node build/cli.js` 2. Verify response format and content 3. **IMMEDIATELY** update test report with result 4. Check the box and add detailed verification summary @@ -116,7 +116,7 @@ For each tool: ### Resource Testing Process For each resource: -1. Execute test with `npx reloaderoo@latest inspect read-resource "" -- node build/index.js` +1. Execute test with `npx reloaderoo@latest inspect read-resource "" -- node build/cli.js` 2. Verify resource accessibility and content format 3. **IMMEDIATELY** update test report with result 4. Check the box and add detailed verification summary diff --git a/.vscode/launch.json b/.vscode/launch.json index fb633852..b0dcd07c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,7 +27,8 @@ "type": "node", "request": "launch", "name": "Launch MCP Server Dev", - "program": "${workspaceFolder}/build/index.js", + "program": "${workspaceFolder}/build/cli.js", + "args": ["mcp"], "cwd": "${workspaceFolder}", "runtimeArgs": [ "--inspect=9999" diff --git a/.vscode/mcp.json b/.vscode/mcp.json index de9ab67b..ac5ac9c3 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -20,7 +20,8 @@ "args": [ "--inspect=9999", "--trace-warnings", - "${workspaceFolder}/build/index.js" + "${workspaceFolder}/build/cli.js", + "mcp" ], "env": { "XCODEBUILDMCP_DEBUG": "true", diff --git a/build-plugins/plugin-discovery.js b/build-plugins/plugin-discovery.js deleted file mode 100644 index 4f5f1a5d..00000000 --- a/build-plugins/plugin-discovery.js +++ /dev/null @@ -1,283 +0,0 @@ -import { readdirSync, readFileSync, existsSync } from 'fs'; -import { join } from 'path'; -import path from 'path'; - -export function createPluginDiscoveryPlugin() { - return { - name: 'plugin-discovery', - setup(build) { - // Generate the workflow loaders file before build starts - build.onStart(async () => { - try { - await generateWorkflowLoaders(); - await generateResourceLoaders(); - } catch (error) { - console.error('Failed to generate loaders:', error); - throw error; - } - }); - }, - }; -} - -async function generateWorkflowLoaders() { - const pluginsDir = path.resolve(process.cwd(), 'src/mcp/tools'); - - if (!existsSync(pluginsDir)) { - throw new Error(`Plugins directory not found: ${pluginsDir}`); - } - - // Scan for workflow directories - const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name); - - const workflowLoaders = {}; - const workflowMetadata = {}; - - for (const dirName of workflowDirs) { - const dirPath = join(pluginsDir, dirName); - const indexPath = join(dirPath, 'index.ts'); - - // Check if workflow has index.ts file - if (!existsSync(indexPath)) { - console.warn(`Skipping ${dirName}: no index.ts file found`); - continue; - } - - // Try to extract workflow metadata from index.ts - try { - const indexContent = readFileSync(indexPath, 'utf8'); - const metadata = extractWorkflowMetadata(indexContent); - - if (metadata) { - // Find all tool files in this workflow directory - const toolFiles = readdirSync(dirPath, { withFileTypes: true }) - .filter((dirent) => dirent.isFile()) - .map((dirent) => dirent.name) - .filter( - (name) => - (name.endsWith('.ts') || name.endsWith('.js')) && - name !== 'index.ts' && - name !== 'index.js' && - !name.endsWith('.test.ts') && - !name.endsWith('.test.js') && - name !== 'active-processes.ts', // Special exclusion for swift-package - ); - - // Generate dynamic loader function that loads workflow and all its tools - workflowLoaders[dirName] = generateWorkflowLoader(dirName, toolFiles); - workflowMetadata[dirName] = metadata; - - console.log( - `✅ Discovered workflow: ${dirName} - ${metadata.name} (${toolFiles.length} tools)`, - ); - } else { - console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`); - } - } catch (error) { - console.warn(`⚠️ Error processing ${dirName}:`, error); - } - } - - // Generate the content for generated-plugins.ts - const generatedContent = await generatePluginsFileContent(workflowLoaders, workflowMetadata); - - // Write to the generated file - const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts'); - - const fs = await import('fs'); - await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); - - console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`); -} - -function generateWorkflowLoader(workflowName, toolFiles) { - const toolImports = toolFiles - .map((file, index) => { - const toolName = file.replace(/\.(ts|js)$/, ''); - return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.ts').then(m => m.default)`; - }) - .join(';\n '); - - const toolExports = toolFiles - .map((file, index) => { - const toolName = file.replace(/\.(ts|js)$/, ''); - return `'${toolName}': tool_${index}`; - }) - .join(',\n '); - - return `async () => { - const { workflow } = await import('../mcp/tools/${workflowName}/index.ts'); - ${toolImports ? toolImports + ';\n ' : ''} - return { - workflow, - ${toolExports ? toolExports : ''} - }; - }`; -} - -function extractWorkflowMetadata(content) { - try { - // Simple regex to extract workflow export object - const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/); - - if (!workflowMatch) { - return null; - } - - const workflowObj = workflowMatch[1]; - - // Extract name - const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/); - if (!nameMatch) return null; - - // Extract description - const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/); - if (!descMatch) return null; - - const result = { - name: nameMatch[1], - description: descMatch[1], - }; - - return result; - } catch (error) { - console.warn('Failed to extract workflow metadata:', error); - return null; - } -} - -async function generatePluginsFileContent(workflowLoaders, workflowMetadata) { - const loaderEntries = Object.entries(workflowLoaders) - .map(([key, loader]) => { - // Indent the loader function properly - const indentedLoader = loader - .split('\n') - .map((line, index) => (index === 0 ? ` '${key}': ${line}` : ` ${line}`)) - .join('\n'); - return indentedLoader; - }) - .join(',\n'); - - const metadataEntries = Object.entries(workflowMetadata) - .map(([key, metadata]) => { - const metadataJson = JSON.stringify(metadata, null, 4) - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); - return ` '${key}': ${metadataJson.trim()}`; - }) - .join(',\n'); - - const content = `// AUTO-GENERATED - DO NOT EDIT -// This file is generated by the plugin discovery esbuild plugin - -// Generated based on filesystem scan -export const WORKFLOW_LOADERS = { -${loaderEntries} -}; - -export type WorkflowName = keyof typeof WORKFLOW_LOADERS; - -// Optional: Export workflow metadata for quick access -export const WORKFLOW_METADATA = { -${metadataEntries} -}; -`; - return formatGenerated(content); -} - -async function generateResourceLoaders() { - const resourcesDir = path.resolve(process.cwd(), 'src/mcp/resources'); - - if (!existsSync(resourcesDir)) { - console.log('Resources directory not found, skipping resource generation'); - return; - } - - // Scan for resource files - const resourceFiles = readdirSync(resourcesDir, { withFileTypes: true }) - .filter((dirent) => dirent.isFile()) - .map((dirent) => dirent.name) - .filter( - (name) => - (name.endsWith('.ts') || name.endsWith('.js')) && - !name.endsWith('.test.ts') && - !name.endsWith('.test.js') && - !name.startsWith('__'), // Exclude test directories - ); - - const resourceLoaders = {}; - - for (const fileName of resourceFiles) { - const resourceName = fileName.replace(/\.(ts|js)$/, ''); - - // Generate dynamic loader for this resource - resourceLoaders[resourceName] = `async () => { - const module = await import('../mcp/resources/${resourceName}.ts'); - return module.default; - }`; - - console.log(`✅ Discovered resource: ${resourceName}`); - } - - // Generate the content for generated-resources.ts - const generatedContent = await generateResourcesFileContent(resourceLoaders); - - // Write to the generated file - const outputPath = path.resolve(process.cwd(), 'src/core/generated-resources.ts'); - - const fs = await import('fs'); - await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); - - console.log(`🔧 Generated resource loaders for ${Object.keys(resourceLoaders).length} resources`); -} - -async function generateResourcesFileContent(resourceLoaders) { - const loaderEntries = Object.entries(resourceLoaders) - .map(([key, loader]) => ` '${key}': ${loader}`) - .join(',\n'); - - const content = `// AUTO-GENERATED - DO NOT EDIT -// This file is generated by the plugin discovery esbuild plugin - -export const RESOURCE_LOADERS = { -${loaderEntries} -}; - -export type ResourceName = keyof typeof RESOURCE_LOADERS; -`; - return formatGenerated(content); -} - -async function formatGenerated(content) { - try { - const { resolve } = await import('node:path'); - const { pathToFileURL } = await import('node:url'); - const prettier = await import('prettier'); - let config = (await prettier.resolveConfig(process.cwd())) ?? null; - if (!config) { - try { - const configUrl = pathToFileURL(resolve(process.cwd(), '.prettierrc.js')).href; - const configModule = await import(configUrl); - config = configModule.default ?? configModule; - } catch { - config = null; - } - } - const options = { - semi: true, - trailingComma: 'all', - singleQuote: true, - printWidth: 100, - tabWidth: 2, - endOfLine: 'auto', - ...config, - parser: 'typescript', - }; - return prettier.format(content, options); - } catch { - return content; - } -} diff --git a/build-plugins/plugin-discovery.ts b/build-plugins/plugin-discovery.ts deleted file mode 100644 index d2a1d971..00000000 --- a/build-plugins/plugin-discovery.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { Plugin } from 'esbuild'; -import { readdirSync, readFileSync, existsSync } from 'fs'; -import { join } from 'path'; -import path from 'path'; - -export interface WorkflowMetadata { - name: string; - description: string; -} - -export function createPluginDiscoveryPlugin(): Plugin { - return { - name: 'plugin-discovery', - setup(build) { - // Generate the workflow loaders file before build starts - build.onStart(async () => { - try { - await generateWorkflowLoaders(); - await generateResourceLoaders(); - } catch (error) { - console.error('Failed to generate loaders:', error); - throw error; - } - }); - }, - }; -} - -export async function generateWorkflowLoaders(): Promise { - const pluginsDir = path.resolve(process.cwd(), 'src/mcp/tools'); - - if (!existsSync(pluginsDir)) { - throw new Error(`Plugins directory not found: ${pluginsDir}`); - } - - // Scan for workflow directories - const workflowDirs = readdirSync(pluginsDir, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name); - - const workflowLoaders: Record = {}; - const workflowMetadata: Record = {}; - - for (const dirName of workflowDirs) { - const dirPath = join(pluginsDir, dirName); - const indexPath = join(dirPath, 'index.ts'); - - // Check if workflow has index.ts file - if (!existsSync(indexPath)) { - console.warn(`Skipping ${dirName}: no index.ts file found`); - continue; - } - - // Try to extract workflow metadata from index.ts - try { - const indexContent = readFileSync(indexPath, 'utf8'); - const metadata = extractWorkflowMetadata(indexContent); - - if (metadata) { - // Find all tool files in this workflow directory - const toolFiles = readdirSync(dirPath, { withFileTypes: true }) - .filter((dirent) => dirent.isFile()) - .map((dirent) => dirent.name) - .filter( - (name) => - (name.endsWith('.ts') || name.endsWith('.js')) && - name !== 'index.ts' && - name !== 'index.js' && - !name.endsWith('.test.ts') && - !name.endsWith('.test.js') && - name !== 'active-processes.ts', - ); - - workflowLoaders[dirName] = generateWorkflowLoader(dirName, toolFiles); - workflowMetadata[dirName] = metadata; - - console.log( - `✅ Discovered workflow: ${dirName} - ${metadata.name} (${toolFiles.length} tools)`, - ); - } else { - console.warn(`⚠️ Skipping ${dirName}: invalid workflow metadata`); - } - } catch (error) { - console.warn(`⚠️ Error processing ${dirName}:`, error); - } - } - - // Generate the content for generated-plugins.ts - const generatedContent = await generatePluginsFileContent(workflowLoaders, workflowMetadata); - - // Write to the generated file - const outputPath = path.resolve(process.cwd(), 'src/core/generated-plugins.ts'); - - const fs = await import('fs'); - await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); - - console.log(`🔧 Generated workflow loaders for ${Object.keys(workflowLoaders).length} workflows`); -} - -function generateWorkflowLoader(workflowName: string, toolFiles: string[]): string { - const toolImports = toolFiles - .map((file, index) => { - const toolName = file.replace(/\.(ts|js)$/, ''); - return `const tool_${index} = await import('../mcp/tools/${workflowName}/${toolName}.ts').then(m => m.default)`; - }) - .join(';\n '); - - const toolExports = toolFiles - .map((file, index) => { - const toolName = file.replace(/\.(ts|js)$/, ''); - return `'${toolName}': tool_${index}`; - }) - .join(',\n '); - - return `async () => { - const { workflow } = await import('../mcp/tools/${workflowName}/index.ts'); - ${toolImports ? toolImports + ';\n ' : ''} - return { - workflow, - ${toolExports ? toolExports : ''} - }; - }`; -} - -function extractWorkflowMetadata(content: string): WorkflowMetadata | null { - try { - // Simple regex to extract workflow export object - const workflowMatch = content.match(/export\s+const\s+workflow\s*=\s*({[\s\S]*?});/); - - if (!workflowMatch) { - return null; - } - - const workflowObj = workflowMatch[1]; - - // Extract name - const nameMatch = workflowObj.match(/name\s*:\s*['"`]([^'"`]+)['"`]/); - if (!nameMatch) return null; - - // Extract description - const descMatch = workflowObj.match(/description\s*:\s*['"`]([\s\S]*?)['"`]/); - if (!descMatch) return null; - - return { - name: nameMatch[1], - description: descMatch[1], - }; - } catch (error) { - console.warn('Failed to extract workflow metadata:', error); - return null; - } -} - -async function generatePluginsFileContent( - workflowLoaders: Record, - workflowMetadata: Record, -): Promise { - const loaderEntries = Object.entries(workflowLoaders) - .map(([key, loader]) => { - const indentedLoader = loader - .split('\n') - .map((line, index) => (index === 0 ? ` '${key}': ${line}` : ` ${line}`)) - .join('\n'); - return indentedLoader; - }) - .join(',\n'); - - const metadataEntries = Object.entries(workflowMetadata) - .map(([key, metadata]) => { - const metadataJson = JSON.stringify(metadata, null, 4) - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); - return ` '${key}': ${metadataJson.trim()}`; - }) - .join(',\n'); - - const content = `// AUTO-GENERATED - DO NOT EDIT -// This file is generated by the plugin discovery esbuild plugin - -// Generated based on filesystem scan -export const WORKFLOW_LOADERS = { -${loaderEntries} -}; - -export type WorkflowName = keyof typeof WORKFLOW_LOADERS; - -// Optional: Export workflow metadata for quick access -export const WORKFLOW_METADATA = { -${metadataEntries} -}; -`; - return formatGenerated(content); -} - -export async function generateResourceLoaders(): Promise { - const resourcesDir = path.resolve(process.cwd(), 'src/mcp/resources'); - - if (!existsSync(resourcesDir)) { - console.log('Resources directory not found, skipping resource generation'); - return; - } - - const resourceFiles = readdirSync(resourcesDir, { withFileTypes: true }) - .filter((dirent) => dirent.isFile()) - .map((dirent) => dirent.name) - .filter( - (name) => - (name.endsWith('.ts') || name.endsWith('.js')) && - !name.endsWith('.test.ts') && - !name.endsWith('.test.js') && - !name.startsWith('__'), - ); - - const resourceLoaders: Record = {}; - - for (const fileName of resourceFiles) { - const resourceName = fileName.replace(/\.(ts|js)$/, ''); - resourceLoaders[resourceName] = `async () => { - const module = await import('../mcp/resources/${resourceName}.ts'); - return module.default; - }`; - - console.log(`✅ Discovered resource: ${resourceName}`); - } - - const generatedContent = await generateResourcesFileContent(resourceLoaders); - const outputPath = path.resolve(process.cwd(), 'src/core/generated-resources.ts'); - - const fs = await import('fs'); - await fs.promises.writeFile(outputPath, generatedContent, 'utf8'); - - console.log(`🔧 Generated resource loaders for ${Object.keys(resourceLoaders).length} resources`); -} - -async function generateResourcesFileContent( - resourceLoaders: Record, -): Promise { - const loaderEntries = Object.entries(resourceLoaders) - .map(([key, loader]) => ` '${key}': ${loader}`) - .join(',\n'); - - const content = `// AUTO-GENERATED - DO NOT EDIT -// This file is generated by the plugin discovery esbuild plugin - -export const RESOURCE_LOADERS = { -${loaderEntries} -}; - -export type ResourceName = keyof typeof RESOURCE_LOADERS; -`; - return formatGenerated(content); -} - -async function formatGenerated(content: string): Promise { - try { - const { resolve } = await import('node:path'); - const { pathToFileURL } = await import('node:url'); - const prettier = await import('prettier'); - let config = (await prettier.resolveConfig(process.cwd())) ?? null; - if (!config) { - try { - const configUrl = pathToFileURL(resolve(process.cwd(), '.prettierrc.js')).href; - const configModule = await import(configUrl); - config = (configModule as { default?: unknown }).default ?? configModule; - } catch { - config = null; - } - } - const options = { - semi: true, - trailingComma: 'all' as const, - singleQuote: true, - printWidth: 100, - tabWidth: 2, - endOfLine: 'auto' as const, - ...(config as Record | null), - parser: 'typescript', - }; - return prettier.format(content, options); - } catch { - return content; - } -} diff --git a/build-plugins/tsconfig.json b/build-plugins/tsconfig.json deleted file mode 100644 index c7c2ce0a..00000000 --- a/build-plugins/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "module": "ESNext", - "target": "ES2022", - "outDir": "../build-plugins-dist", - "rootDir": ".", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "dist"] -} \ No newline at end of file diff --git a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md index 9dce4c50..75b10f82 100644 --- a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md +++ b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md @@ -532,7 +532,7 @@ Add a section “DAP Backend (lldb-dap)”: 1. Ensure `lldb-dap` is discoverable: - `xcrun --find lldb-dap` 2. Run server with DAP enabled: - - `XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/index.js mcp` + - `XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/cli.js mcp` 3. Use existing MCP tool flow: - `debug_attach_sim` (attach by PID or bundleId) - `debug_breakpoint_add` (with condition) diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index f9c43010..6a0d3f01 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -30,7 +30,7 @@ XcodeBuildMCP is a Model Context Protocol (MCP) server that exposes Xcode operat ### Runtime Flow 1. **Initialization** - - The `xcodebuildmcp` executable, as defined in `package.json`, points to the compiled `build/index.js` (CLI entrypoint from `src/cli.ts`); the MCP server starts via the `mcp` subcommand which invokes `src/index.ts`. + - The `xcodebuildmcp` executable, as defined in `package.json`, points to the compiled `build/cli.js` (CLI entrypoint from `src/cli.ts`); the MCP server starts via the `mcp` subcommand which invokes `src/index.ts`. - Sentry initialized for error tracking (optional) - Version information loaded from `package.json` diff --git a/docs/dev/CLI_CONVERSION_PLAN.md b/docs/dev/CLI_CONVERSION_PLAN.md index 77f97d14..a32a51ea 100644 --- a/docs/dev/CLI_CONVERSION_PLAN.md +++ b/docs/dev/CLI_CONVERSION_PLAN.md @@ -795,7 +795,7 @@ export default defineConfig({ ```json { "bin": { - "xcodebuildmcp": "build/index.js", + "xcodebuildmcp": "build/cli.js", "xcodebuildmcp-doctor": "build/doctor-cli.js", "xcodebuildcli": "build/cli.js" }, diff --git a/docs/dev/CONTRIBUTING.md b/docs/dev/CONTRIBUTING.md index 5c801c4b..2d27564b 100644 --- a/docs/dev/CONTRIBUTING.md +++ b/docs/dev/CONTRIBUTING.md @@ -64,7 +64,7 @@ brew install axe ``` 4. Start the server: ``` - node build/index.js mcp + node build/cli.js mcp ``` ### Configure your MCP client @@ -77,7 +77,7 @@ Most MCP clients (Cursor, VS Code, Windsurf, Claude Desktop etc) have standardis "XcodeBuildMCP": { "command": "node", "args": [ - "/path_to/XcodeBuildMCP/build/index.js", + "/path_to/XcodeBuildMCP/build/cli.js", "mcp" ] } @@ -110,7 +110,7 @@ npm run inspect or if you prefer the explicit command: ```bash -npx @modelcontextprotocol/inspector node build/index.js mcp +npx @modelcontextprotocol/inspector node build/cli.js mcp ``` #### Reloaderoo (Advanced Debugging) - **RECOMMENDED** @@ -127,7 +127,7 @@ Provides transparent hot-reloading without disconnecting your MCP client: npm install -g reloaderoo # Start XcodeBuildMCP through reloaderoo proxy -reloaderoo -- node build/index.js mcp +reloaderoo -- node build/cli.js mcp ``` **Benefits**: @@ -140,7 +140,7 @@ reloaderoo -- node build/index.js mcp ```json "XcodeBuildMCP": { "command": "reloaderoo", - "args": ["--", "node", "/path/to/XcodeBuildMCP/build/index.js", "mcp"], + "args": ["--", "node", "/path/to/XcodeBuildMCP/build/cli.js", "mcp"], "env": { "XCODEBUILDMCP_DEBUG": "true" } @@ -152,7 +152,7 @@ Exposes debug tools for making raw MCP protocol calls and inspecting server resp ```bash # Start reloaderoo in inspection mode -reloaderoo inspect mcp -- node build/index.js mcp +reloaderoo inspect mcp -- node build/cli.js mcp ``` **Available Debug Tools**: @@ -174,7 +174,7 @@ reloaderoo inspect mcp -- node build/index.js mcp "inspect", "mcp", "--working-dir", "/path/to/XcodeBuildMCP", "--", - "node", "/path/to/XcodeBuildMCP/build/index.js", "mcp" + "node", "/path/to/XcodeBuildMCP/build/cli.js", "mcp" ], "env": { "XCODEBUILDMCP_DEBUG": "true" @@ -188,10 +188,10 @@ Test full vs. selective workflow registration during development: ```bash # Test full tool registration (default) -reloaderoo inspect mcp -- node build/index.js mcp +reloaderoo inspect mcp -- node build/cli.js mcp # Test selective workflow registration -XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device reloaderoo inspect mcp -- node build/index.js mcp +XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device reloaderoo inspect mcp -- node build/cli.js mcp ``` **Key Differences to Test**: - **Full Registration**: All tools are available immediately via `list_tools` @@ -212,7 +212,7 @@ Running the XcodeBuildMCP server with the environmental variable `XCODEBUILDMCP_ 1. **Start Development Session**: ```bash # Terminal 1: Start in hot-reload mode - reloaderoo -- node build/index.js mcp + reloaderoo -- node build/cli.js mcp # Terminal 2: Start build watcher npm run build:watch @@ -338,7 +338,7 @@ When developing or testing changes to the templates: ```json "XcodeBuildMCP": { "command": "node", - "args": ["/path_to/XcodeBuildMCP/build/index.js", "mcp"], + "args": ["/path_to/XcodeBuildMCP/build/cli.js", "mcp"], "env": { "XCODEBUILDMCP_IOS_TEMPLATE_PATH": "/path/to/XcodeBuildMCP-iOS-Template", "XCODEBUILDMCP_MACOS_TEMPLATE_PATH": "/path/to/XcodeBuildMCP-macOS-Template" diff --git a/docs/dev/MANIFEST_FORMAT.md b/docs/dev/MANIFEST_FORMAT.md new file mode 100644 index 00000000..47a91d7f --- /dev/null +++ b/docs/dev/MANIFEST_FORMAT.md @@ -0,0 +1,465 @@ +# Manifest Format Reference + +This document describes the YAML manifest format used to define tools and workflows in XcodeBuildMCP. Manifests are the single source of truth for tool/workflow metadata, visibility rules, and runtime behavior. + +## Overview + +Manifests are stored in the `manifests/` directory: + +``` +manifests/ +├── tools/ # Tool manifest files +│ ├── build_sim.yaml +│ ├── list_sims.yaml +│ └── ... +└── workflows/ # Workflow manifest files + ├── simulator.yaml + ├── device.yaml + └── ... +``` + +Each tool and workflow has its own YAML file. The manifest loader reads all files at startup and validates them against the schema. + +## Directory Structure + +Tool implementations live in `src/mcp/tools//`: + +``` +src/mcp/tools/ +├── simulator/ +│ ├── build_sim.ts # Tool implementation +│ ├── build_run_sim.ts +│ ├── list_sims.ts +│ └── ... +├── device/ +│ ├── build_device.ts +│ └── ... +└── ... +``` + +## Tool Manifest Format + +Tool manifests define individual tools and their metadata. + +### Schema + +```yaml +# Required fields +id: string # Unique tool identifier (must match filename without .yaml) +module: string # Module path (see Module Path section) +names: + mcp: string # MCP tool name (globally unique, used in MCP protocol) + cli: string # CLI command name (optional, derived from mcp if omitted) + +# Optional fields +description: string # Tool description (shown in tool listings) +availability: # Per-runtime availability flags + mcp: boolean # Available via MCP server (default: true) + cli: boolean # Available via CLI (default: true) + daemon: boolean # Available via daemon (default: true) +predicates: string[] # Predicate names for visibility filtering (default: []) +routing: # Daemon routing hints + stateful: boolean # Tool maintains state (default: false) + daemonAffinity: enum # 'preferred' or 'required' (optional) +``` + +### Example: Basic Tool + +```yaml +id: list_sims +module: mcp/tools/simulator/list_sims +names: + mcp: list_sims +description: "List available iOS simulators." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred +``` + +### Example: Tool with Predicates + +```yaml +id: build_sim +module: mcp/tools/simulator/build_sim +names: + mcp: build_sim +description: "Build for iOS sim." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode # Hidden when Xcode provides equivalent tool +routing: + stateful: false + daemonAffinity: preferred +``` + +### Example: MCP-Only Tool + +```yaml +id: manage_workflows +module: mcp/tools/workflow-discovery/manage_workflows +names: + mcp: manage-workflows # Note: MCP name uses hyphens +description: "Manage enabled workflows at runtime." +availability: + mcp: true + cli: false # Not available in CLI + daemon: false # Not available via daemon +predicates: + - experimentalWorkflowDiscoveryEnabled +``` + +## Workflow Manifest Format + +Workflow manifests define groups of related tools. + +### Schema + +```yaml +# Required fields +id: string # Unique workflow identifier (must match filename without .yaml) +title: string # Display title +description: string # Workflow description +tools: string[] # Array of tool IDs belonging to this workflow + +# Optional fields +availability: # Per-runtime availability flags + mcp: boolean # Available via MCP server (default: true) + cli: boolean # Available via CLI (default: true) + daemon: boolean # Available via daemon (default: true) +selection: # MCP selection rules + mcp: + mandatory: boolean # Always included, cannot be disabled (default: false) + defaultEnabled: boolean # Enabled when config.enabledWorkflows is empty (default: false) + autoInclude: boolean # Include when predicates pass, even if not requested (default: false) +predicates: string[] # Predicate names for visibility filtering (default: []) +``` + +### Example: Default-Enabled Workflow + +```yaml +id: simulator +title: "iOS Simulator Development" +description: "Complete iOS development workflow for simulators." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: true # Enabled by default + autoInclude: false +predicates: [] +tools: + - list_sims + - boot_sim + - build_sim + - build_run_sim + - test_sim + # ... more tools +``` + +### Example: Auto-Include Workflow + +```yaml +id: doctor +title: "MCP Doctor" +description: "Diagnostic tool for the MCP server environment." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: true # Auto-included when predicates pass +predicates: + - debugEnabled # Only shown in debug mode +tools: + - doctor +``` + +### Example: Conditional Workflow + +```yaml +id: workflow-discovery +title: "Workflow Discovery" +description: "Manage enabled workflows at runtime." +availability: + mcp: true + cli: false + daemon: false +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: true +predicates: + - experimentalWorkflowDiscoveryEnabled # Feature flag +tools: + - manage_workflows +``` + +## Field Reference + +### Tool Fields + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `id` | string | Yes | - | Unique identifier, must match filename | +| `module` | string | Yes | - | Module path relative to `src/` (extensionless) | +| `names.mcp` | string | Yes | - | MCP protocol tool name | +| `names.cli` | string | No | Derived from MCP name | CLI command name | +| `description` | string | No | - | Tool description | +| `availability.mcp` | boolean | No | `true` | Available via MCP | +| `availability.cli` | boolean | No | `true` | Available via CLI | +| `availability.daemon` | boolean | No | `true` | Available via daemon | +| `predicates` | string[] | No | `[]` | Visibility predicates (all must pass) | +| `routing.stateful` | boolean | No | `false` | Tool maintains state | +| `routing.daemonAffinity` | enum | No | - | `'preferred'` or `'required'` | + +### Workflow Fields + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `id` | string | Yes | - | Unique identifier, must match filename | +| `title` | string | Yes | - | Display title | +| `description` | string | Yes | - | Workflow description | +| `tools` | string[] | Yes | - | Tool IDs in this workflow | +| `availability.mcp` | boolean | No | `true` | Available via MCP | +| `availability.cli` | boolean | No | `true` | Available via CLI | +| `availability.daemon` | boolean | No | `true` | Available via daemon | +| `selection.mcp.mandatory` | boolean | No | `false` | Cannot be disabled | +| `selection.mcp.defaultEnabled` | boolean | No | `false` | Enabled when no workflows configured | +| `selection.mcp.autoInclude` | boolean | No | `false` | Auto-include when predicates pass | +| `predicates` | string[] | No | `[]` | Visibility predicates (all must pass) | + +## Module Path + +The `module` field specifies where to find the tool implementation. It uses a package-relative path without file extension: + +``` +mcp/tools// +``` + +At runtime, this resolves to: +``` +build/mcp/tools//.js +``` + +The module must export either: +1. **Named exports** (preferred): `{ schema, handler, annotations? }` +2. **Legacy default export**: `export default { name, schema, handler, annotations? }` + +Example module structure: +```typescript +// src/mcp/tools/simulator/build_sim.ts +import { z } from 'zod'; + +export const schema = z.object({ + projectPath: z.string().describe('Path to project'), + // ... +}); + +export async function handler(params: z.infer) { + // Implementation +} + +export const annotations = { + title: 'Build for Simulator', + // ... +}; +``` + +## Naming Conventions + +### Tool ID +- Use `snake_case`: `build_sim`, `list_devices` +- Must match the YAML filename (without `.yaml`) +- Must be unique across all tools + +### MCP Name (`names.mcp`) +- Use `snake_case` or `kebab-case` consistently +- Must be globally unique across all tools +- This is what LLMs see and call + +### CLI Name (`names.cli`) +- Optional; if omitted, derived from MCP name +- Derivation: `snake_case` → `kebab-case` (`build_sim` → `build-sim`) +- Use `kebab-case` for explicit names + +### Workflow ID +- Use `kebab-case`: `simulator`, `swift-package`, `ui-automation` +- Must match the YAML filename (without `.yaml`) + +## Predicates + +Predicates control visibility based on runtime context. All predicates in the array must pass (AND logic) for the tool/workflow to be visible. + +### Available Predicates + +| Predicate | Description | +|-----------|-------------| +| `debugEnabled` | Show only when `config.debug` is `true` | +| `experimentalWorkflowDiscoveryEnabled` | Show only when experimental workflow discovery is enabled | +| `hideWhenXcodeAgentMode` | Hide when running under Xcode agent AND Xcode Tools bridge is active | +| `always` | Always visible (explicit documentation) | +| `never` | Never visible (temporarily disable) | + +### Predicate Context + +Predicates receive a context object: + +```typescript +interface PredicateContext { + runtime: 'cli' | 'mcp' | 'daemon'; + config: ResolvedRuntimeConfig; + runningUnderXcode: boolean; + xcodeToolsActive: boolean; +} +``` + +### Adding New Predicates + +To add a new predicate, edit `src/visibility/predicate-registry.ts`: + +```typescript +export const PREDICATES: Record = { + // Existing predicates... + + myNewPredicate: (ctx: PredicateContext): boolean => { + return ctx.config.someFlag === true; + }, +}; +``` + +## Workflow Selection Rules + +For MCP runtime, workflows are selected based on these rules (in order): + +1. **Mandatory workflows** (`mandatory: true`) are always included +2. **Explicitly requested workflows** from `config.enabledWorkflows` +3. **Default workflows** (`defaultEnabled: true`) when `config.enabledWorkflows` is empty +4. **Auto-include workflows** (`autoInclude: true`) when their predicates pass + +### Selection Examples + +```yaml +# Always included regardless of config +selection: + mcp: + mandatory: true + +# Enabled by default, can be disabled +selection: + mcp: + defaultEnabled: true + +# Auto-included when predicates pass (e.g., debug mode) +selection: + mcp: + autoInclude: true +predicates: + - debugEnabled +``` + +## Tool Re-export + +A single tool can belong to multiple workflows. This is useful for shared utilities: + +```yaml +# manifests/workflows/simulator.yaml +tools: + - clean # Shared tool + - discover_projs # Shared tool + - build_sim + +# manifests/workflows/device.yaml +tools: + - clean # Same tool, different workflow + - discover_projs # Same tool, different workflow + - build_device +``` + +The tool is defined once in `manifests/tools/clean.yaml` but referenced by both workflows. + +## Daemon Routing + +The `routing` field provides hints for daemon-based execution: + +- **`stateful: true`**: Tool maintains state across calls (e.g., debug sessions) +- **`daemonAffinity: 'preferred'`**: Prefer daemon execution but fall back to direct +- **`daemonAffinity: 'required'`**: Must run via daemon (fails if daemon unavailable) + +## Validation + +Manifests are validated at load time against Zod schemas. Invalid manifests cause startup failures with descriptive error messages. + +The schema definitions are in `src/core/manifest/schema.ts`. + +## Runtime Tool Registration + +At startup, tools are registered dynamically from manifests: + +``` +1. loadManifest() + └── Reads all YAML files from manifests/tools/ and manifests/workflows/ + └── Validates against Zod schemas + └── Returns { tools: Map, workflows: Map } + +2. selectWorkflowsForMcp(workflows, requestedWorkflows, ctx) + └── Filters workflows by availability (mcp: true) + └── Applies selection rules (mandatory, defaultEnabled, autoInclude) + └── Evaluates predicates against context + +3. For each selected workflow: + └── For each tool ID in workflow.tools: + └── Look up tool manifest by ID + └── Check tool availability and predicates + └── importToolModule(module) → { schema, handler, annotations } + └── server.registerTool(mcpName, schema, handler) +``` + +Key files: +- `src/core/manifest/load-manifest.ts` - Manifest loading and caching +- `src/core/manifest/import-tool-module.ts` - Dynamic module imports +- `src/utils/tool-registry.ts` - MCP server tool registration +- `src/runtime/tool-catalog.ts` - CLI/daemon tool catalog building +- `src/visibility/exposure.ts` - Workflow/tool visibility filtering + +## Creating a New Tool + +1. **Create the tool module** in `src/mcp/tools//.ts` +2. **Create the manifest** in `manifests/tools/.yaml` +3. **Add to workflow(s)** in `manifests/workflows/.yaml` +4. **Run tests** to validate + +Example checklist: +- [ ] Tool ID matches filename +- [ ] Module path is correct +- [ ] MCP name is unique +- [ ] Tool is added to at least one workflow +- [ ] Predicates reference valid predicate names +- [ ] Availability flags match intended runtimes + +## Creating a New Workflow + +1. **Create the manifest** in `manifests/workflows/.yaml` +2. **Add tool references** (tools must already exist) +3. **Configure selection rules** for MCP behavior +4. **Run tests** to validate + +Example checklist: +- [ ] Workflow ID matches filename +- [ ] All referenced tool IDs exist +- [ ] Selection rules are appropriate +- [ ] Predicates reference valid predicate names diff --git a/docs/dev/MANUAL_TESTING.md b/docs/dev/MANUAL_TESTING.md index d2ae93a2..b4049e56 100644 --- a/docs/dev/MANUAL_TESTING.md +++ b/docs/dev/MANUAL_TESTING.md @@ -60,11 +60,11 @@ Black Box Testing means testing ONLY through external interfaces without any kno **ABSOLUTE TESTING RULES - NO EXCEPTIONS:** 1. **✅ ONLY ALLOWED: Reloaderoo Inspect Commands** - - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp` - - `npx reloaderoo@latest inspect list-tools -- node build/index.js mcp` - - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js mcp` - - `npx reloaderoo@latest inspect server-info -- node build/index.js mcp` - - `npx reloaderoo@latest inspect ping -- node build/index.js mcp` + - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect read-resource "URI" -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect server-info -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect ping -- node build/cli.js mcp` 2. **❌ COMPLETELY FORBIDDEN ACTIONS:** - **NEVER** call `mcp__XcodeBuildMCP__tool_name()` functions directly @@ -86,8 +86,8 @@ Black Box Testing means testing ONLY through external interfaces without any kno const result = await doctor(); // ✅ CORRECT - Only through Reloaderoo inspect - npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp - npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp + npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp ``` **WHY RELOADEROO INSPECT IS MANDATORY:** @@ -160,7 +160,7 @@ grep "^ • " /tmp/tools_detailed.txt | sed 's/^ • //' > /tmp/tool_names.t For EVERY tool in the list: ```bash # Test each tool individually - NO BATCHING -npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/cli.js mcp # Mark tool as completed in TodoWrite IMMEDIATELY after testing # Record result (success/failure/blocked) for each tool @@ -414,7 +414,7 @@ echo "Tool schema reference created at /tmp/tool_schemas.md" - Mark "completed" only after manual verification 2. **Test Each Tool Individually** - - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp` + - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/cli.js mcp` - Wait for complete response before proceeding to next tool - Read and verify each tool's output manually - Record key outputs (UUIDs, paths, schemes) for dependent tools @@ -438,16 +438,16 @@ echo "Tool schema reference created at /tmp/tool_schemas.md" ```bash # Test server connectivity -npx reloaderoo@latest inspect ping -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp # Get server information -npx reloaderoo@latest inspect server-info -- node build/index.js mcp +npx reloaderoo@latest inspect server-info -- node build/cli.js mcp # Verify tool count manually -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp 2>/dev/null | jq '.tools | length' +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp 2>/dev/null | jq '.tools | length' # Verify resource count manually -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/null | jq '.resources | length' +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp 2>/dev/null | jq '.resources | length' ``` #### Phase 2: Resource Testing @@ -456,7 +456,7 @@ npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/n # Test each resource systematically while IFS= read -r resource_uri; do echo "Testing resource: $resource_uri" - npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js mcp 2>/dev/null + npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/cli.js mcp 2>/dev/null echo "---" done < /tmp/resource_uris.txt ``` @@ -470,23 +470,23 @@ echo "=== FOUNDATION TOOL TESTING & DATA COLLECTION ===" # 1. Test doctor (no dependencies) echo "Testing doctor..." -npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp 2>/dev/null # 2. Collect device data echo "Collecting device UUIDs..." -npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/devices_output.json +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp 2>/dev/null > /tmp/devices_output.json DEVICE_UUIDS=$(jq -r '.content[0].text' /tmp/devices_output.json | grep -E "UDID: [A-F0-9-]+" | sed 's/.*UDID: //' | head -2) echo "Device UUIDs captured: $DEVICE_UUIDS" # 3. Collect simulator data echo "Collecting simulator UUIDs..." -npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/sims_output.json +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/cli.js mcp 2>/dev/null > /tmp/sims_output.json SIMULATOR_UUIDS=$(jq -r '.content[0].text' /tmp/sims_output.json | grep -E "\([A-F0-9-]+\)" | sed 's/.*(\([A-F0-9-]*\)).*/\1/' | head -3) echo "Simulator UUIDs captured: $SIMULATOR_UUIDS" # 4. Collect project data echo "Collecting project paths..." -npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js mcp 2>/dev/null > /tmp/projects_output.json +npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/cli.js mcp 2>/dev/null > /tmp/projects_output.json PROJECT_PATHS=$(jq -r '.content[1].text' /tmp/projects_output.json | grep -E "\.xcodeproj$" | sed 's/.*- //' | head -3) WORKSPACE_PATHS=$(jq -r '.content[2].text' /tmp/projects_output.json | grep -E "\.xcworkspace$" | sed 's/.*- //' | head -2) echo "Project paths captured: $PROJECT_PATHS" @@ -508,7 +508,7 @@ echo "=== DISCOVERY TOOL TESTING & METADATA COLLECTION ===" while IFS= read -r project_path; do if [ -n "$project_path" ]; then echo "Getting schemes for: $project_path" - npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/schemes_$$.json + npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/cli.js mcp 2>/dev/null > /tmp/schemes_$$.json SCHEMES=$(jq -r '.content[1].text' /tmp/schemes_$$.json 2>/dev/null || echo "NoScheme") echo "$project_path|$SCHEMES" >> /tmp/project_schemes.txt echo "Schemes captured for $project_path: $SCHEMES" @@ -519,7 +519,7 @@ done < /tmp/project_paths.txt while IFS= read -r workspace_path; do if [ -n "$workspace_path" ]; then echo "Getting schemes for: $workspace_path" - npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/ws_schemes_$$.json + npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/cli.js mcp 2>/dev/null > /tmp/ws_schemes_$$.json SCHEMES=$(jq -r '.content[1].text' /tmp/ws_schemes_$$.json 2>/dev/null || echo "NoScheme") echo "$workspace_path|$SCHEMES" >> /tmp/workspace_schemes.txt echo "Schemes captured for $workspace_path: $SCHEMES" @@ -544,29 +544,29 @@ done < /tmp/workspace_paths.txt ```bash # STEP 1: Test foundation tools (no parameters required) # Execute each command individually, wait for response, verify manually -npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp # [Wait for response, read output, mark tool complete in task list] -npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp # [Record device UUIDs from response for dependent tools] -npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/cli.js mcp # [Record simulator UUIDs from response for dependent tools] # STEP 2: Test project discovery (use discovered project paths) -npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/cli.js mcp # [Record scheme names from response for build tools] # STEP 3: Test workspace tools (use discovered workspace paths) -npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/cli.js mcp # [Record scheme names from response for build tools] # STEP 4: Test simulator tools (use captured simulator UUIDs from step 1) -npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/cli.js mcp # [Verify simulator boots successfully] # STEP 5: Test build tools (requires project + scheme + simulator from previous steps) -npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/cli.js mcp # [Verify build succeeds and record app bundle path] ``` @@ -592,7 +592,7 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP ```bash # ❌ IMMEDIATE TERMINATION - Using scripts to test tools for tool in $(cat tool_list.txt); do - npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js mcp + npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/cli.js mcp done ``` @@ -618,19 +618,19 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP ```bash # ✅ CORRECT - Step-by-step manual execution via Reloaderoo # Tool 1: Test doctor -npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp # [Read response, verify, mark complete in TodoWrite] # Tool 2: Test list_devices -npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp # [Read response, capture UUIDs, mark complete in TodoWrite] # Tool 3: Test list_sims -npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/cli.js mcp # [Read response, capture UUIDs, mark complete in TodoWrite] # Tool X: Test stateful tool (expected to fail) -npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/cli.js mcp # [Tool fails as expected - no in-memory state available] # [Mark as "false negative - stateful tool limitation" in TodoWrite] # [Continue to next tool without investigation] @@ -655,15 +655,15 @@ echo "=== Error Testing ===" # Test with invalid JSON parameters echo "Testing invalid parameter types..." -npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/cli.js mcp 2>/dev/null # Test with non-existent paths echo "Testing non-existent paths..." -npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/cli.js mcp 2>/dev/null # Test with invalid UUIDs echo "Testing invalid UUIDs..." -npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/cli.js mcp 2>/dev/null ``` ## Testing Report Generation @@ -699,12 +699,12 @@ echo "Testing session template created: TESTING_SESSION_$(date +%Y-%m-%d).md" ```bash # Essential testing commands -npx reloaderoo@latest inspect ping -- node build/index.js mcp -npx reloaderoo@latest inspect server-info -- node build/index.js mcp -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp | jq '.tools | length' -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp | jq '.resources | length' -npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js mcp -npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp +npx reloaderoo@latest inspect server-info -- node build/cli.js mcp +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp | jq '.tools | length' +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp | jq '.resources | length' +npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/cli.js mcp +npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/cli.js mcp # Schema extraction jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json @@ -720,7 +720,7 @@ jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tm **Cause**: Server startup issues or MCP protocol communication problems **Resolution**: - Verify server builds successfully: `npm run build` -- Test direct server startup: `node build/index.js mcp` +- Test direct server startup: `node build/cli.js mcp` - Check for TypeScript compilation errors #### 2. Tool Parameter Validation Errors @@ -735,7 +735,7 @@ jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tm **Symptoms**: Reloaderoo reports tool not found **Cause**: Tool name mismatch or server registration issues **Resolution**: -- Verify tool exists in list: `npx reloaderoo@latest inspect list-tools -- node build/index.js mcp | jq '.tools[].name'` +- Verify tool exists in list: `npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp | jq '.tools[].name'` - Check exact tool name spelling and case sensitivity - Ensure server built successfully diff --git a/docs/dev/RELOADEROO.md b/docs/dev/RELOADEROO.md index 3fc293db..8d656640 100644 --- a/docs/dev/RELOADEROO.md +++ b/docs/dev/RELOADEROO.md @@ -36,44 +36,44 @@ Direct command-line access to MCP servers without client setup - perfect for tes ```bash # List all available tools -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp # Call any tool with parameters -npx reloaderoo@latest inspect call-tool --params '' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool --params '' -- node build/cli.js mcp # Get server information -npx reloaderoo@latest inspect server-info -- node build/index.js mcp +npx reloaderoo@latest inspect server-info -- node build/cli.js mcp # List available resources -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp # Read a specific resource -npx reloaderoo@latest inspect read-resource "" -- node build/index.js mcp +npx reloaderoo@latest inspect read-resource "" -- node build/cli.js mcp # List available prompts -npx reloaderoo@latest inspect list-prompts -- node build/index.js mcp +npx reloaderoo@latest inspect list-prompts -- node build/cli.js mcp # Get a specific prompt -npx reloaderoo@latest inspect get-prompt --args '' -- node build/index.js mcp +npx reloaderoo@latest inspect get-prompt --args '' -- node build/cli.js mcp # Check server connectivity -npx reloaderoo@latest inspect ping -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp ``` **Example Tool Calls:** ```bash # List connected devices -npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/cli.js mcp # Get doctor information -npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/cli.js mcp # List iOS simulators -npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/cli.js mcp # Read devices resource -npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js mcp +npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/cli.js mcp ``` ### 🔄 **Proxy Mode** (Hot-Reload Development) @@ -91,10 +91,10 @@ Transparent MCP proxy server that enables seamless hot-reloading during developm ```bash # Start proxy mode (your AI client connects to this) -npx reloaderoo@latest proxy -- node build/index.js mcp +npx reloaderoo@latest proxy -- node build/cli.js mcp # With debug logging -npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp +npx reloaderoo@latest proxy --log-level debug -- node build/cli.js mcp # Then in your AI session, request: # "Please restart the MCP server to load my latest changes" @@ -108,7 +108,7 @@ Start CLI mode as a persistent MCP server for interactive debugging through MCP ```bash # Start reloaderoo in CLI mode as an MCP server -npx reloaderoo@latest inspect mcp -- node build/index.js mcp +npx reloaderoo@latest inspect mcp -- node build/cli.js mcp ``` This runs CLI mode as a persistent MCP server, exposing 8 debug tools through the MCP protocol: @@ -172,9 +172,9 @@ Options: --dry-run Validate configuration without starting proxy Examples: - npx reloaderoo proxy -- node build/index.js mcp - npx reloaderoo -- node build/index.js mcp # Same as above (proxy is default) - npx reloaderoo proxy --log-level debug -- node build/index.js mcp + npx reloaderoo proxy -- node build/cli.js mcp + npx reloaderoo -- node build/cli.js mcp # Same as above (proxy is default) + npx reloaderoo proxy --log-level debug -- node build/cli.js mcp ``` ### 🔍 **CLI Mode Commands** @@ -193,9 +193,9 @@ Subcommands: ping [options] Check server connectivity Examples: - npx reloaderoo@latest inspect list-tools -- node build/index.js mcp - npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp - npx reloaderoo@latest inspect server-info -- node build/index.js mcp + npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp + npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/cli.js mcp + npx reloaderoo@latest inspect server-info -- node build/cli.js mcp ``` ### **Info Command** @@ -260,14 +260,14 @@ Perfect for testing individual tools or debugging server issues without MCP clie npm run build # 2. Test your server quickly -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp # 3. Call specific tools to verify behavior -npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/cli.js mcp # 4. Check server health and resources -npx reloaderoo@latest inspect ping -- node build/index.js mcp -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp ``` ### 🔄 **Proxy Mode Workflow** (Hot-Reload Development) @@ -277,9 +277,9 @@ For full development sessions with AI clients that need persistent connections: #### 1. **Start Development Session** Configure your AI client to connect to reloaderoo proxy instead of your server directly: ```bash -npx reloaderoo@latest proxy -- node build/index.js mcp +npx reloaderoo@latest proxy -- node build/cli.js mcp # or with debug logging: -npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp +npx reloaderoo@latest proxy --log-level debug -- node build/cli.js mcp ``` #### 2. **Develop Your MCP Server** @@ -305,7 +305,7 @@ For interactive debugging through MCP clients: ```bash # Start reloaderoo CLI mode as an MCP server -npx reloaderoo@latest inspect mcp -- node build/index.js mcp +npx reloaderoo@latest inspect mcp -- node build/cli.js mcp # Then connect with an MCP client to access debug tools # Available tools: list_tools, call_tool, list_resources, etc. @@ -318,16 +318,16 @@ npx reloaderoo@latest inspect mcp -- node build/index.js mcp **Server won't start in proxy mode:** ```bash # Check if XcodeBuildMCP runs independently first -node build/index.js mcp +node build/cli.js mcp # Then try with reloaderoo proxy to validate configuration -npx reloaderoo@latest proxy -- node build/index.js mcp +npx reloaderoo@latest proxy -- node build/cli.js mcp ``` **Connection problems with MCP clients:** ```bash # Enable debug logging to see what's happening -npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp +npx reloaderoo@latest proxy --log-level debug -- node build/cli.js mcp # Check system info and configuration npx reloaderoo@latest info --verbose @@ -336,10 +336,10 @@ npx reloaderoo@latest info --verbose **Restart failures in proxy mode:** ```bash # Increase restart timeout -npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js mcp +npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/cli.js mcp # Check restart limits -npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js mcp +npx reloaderoo@latest proxy --max-restarts 5 -- node build/cli.js mcp ``` ### 🔍 **CLI Mode Issues** @@ -347,19 +347,19 @@ npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js mcp **CLI commands failing:** ```bash # Test basic connectivity first -npx reloaderoo@latest inspect ping -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp # Enable debug logging for CLI commands (via proxy debug mode) -npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp +npx reloaderoo@latest proxy --log-level debug -- node build/cli.js mcp ``` **JSON parsing errors:** ```bash # Check server information for troubleshooting -npx reloaderoo@latest inspect server-info -- node build/index.js mcp +npx reloaderoo@latest inspect server-info -- node build/cli.js mcp # Ensure your server outputs valid JSON -node build/index.js mcp | head -10 +node build/cli.js mcp | head -10 ``` ### **General Issues** @@ -376,15 +376,15 @@ npm install -g reloaderoo **Parameter validation:** ```bash # Ensure JSON parameters are properly quoted -npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/cli.js mcp ``` ### **General Debug Mode** ```bash # Get detailed information about what's happening -npx reloaderoo@latest proxy --debug -- node build/index.js mcp # For proxy mode -npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp # For detailed proxy logging +npx reloaderoo@latest proxy --debug -- node build/cli.js mcp # For proxy mode +npx reloaderoo@latest proxy --log-level debug -- node build/cli.js mcp # For detailed proxy logging # View system information npx reloaderoo@latest info --verbose @@ -421,14 +421,14 @@ export MCPDEV_PROXY_CWD=/path/to/directory # Default working directory ### Custom Working Directory ```bash -npx reloaderoo@latest proxy --working-dir /custom/path -- node build/index.js mcp -npx reloaderoo@latest inspect list-tools --working-dir /custom/path -- node build/index.js mcp +npx reloaderoo@latest proxy --working-dir /custom/path -- node build/cli.js mcp +npx reloaderoo@latest inspect list-tools --working-dir /custom/path -- node build/cli.js mcp ``` ### Timeout Configuration ```bash -npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js mcp +npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/cli.js mcp ``` ## Integration with XcodeBuildMCP diff --git a/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md b/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md index 49ad6d3b..a2aa7e2c 100644 --- a/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md +++ b/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md @@ -22,158 +22,158 @@ npx reloaderoo@latest --help - **`build_device`**: Builds an app for a physical device. ```bash - npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` - **`get_device_app_path`**: Gets the `.app` bundle path for a device build. ```bash - npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` - **`install_app_device`**: Installs an app on a physical device. ```bash - npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/cli.js mcp ``` - **`launch_app_device`**: Launches an app on a physical device. ```bash - npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/cli.js mcp ``` - **`list_devices`**: Lists connected physical devices. ```bash - npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/cli.js mcp ``` - **`stop_app_device`**: Stops an app on a physical device. ```bash - npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/cli.js mcp ``` - **`test_device`**: Runs tests on a physical device. ```bash - npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/cli.js mcp ``` ### iOS Simulator Development - **`boot_sim`**: Boots a simulator. ```bash - npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/cli.js mcp ``` - **`build_run_sim`**: Builds and runs an app on a simulator. ```bash - npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/cli.js mcp ``` - **`build_sim`**: Builds an app for a simulator. ```bash - npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/cli.js mcp ``` - **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build. ```bash - npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/cli.js mcp ``` - **`install_app_sim`**: Installs an app on a simulator. ```bash - npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/cli.js mcp ``` - **`launch_app_logs_sim`**: Launches an app on a simulator with log capture. ```bash - npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/cli.js mcp ``` - **`launch_app_sim`**: Launches an app on a simulator. ```bash - npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/cli.js mcp ``` - **`list_sims`**: Lists available simulators. ```bash - npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/cli.js mcp ``` - **`open_sim`**: Opens the Simulator application. ```bash - npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/cli.js mcp ``` - **`stop_app_sim`**: Stops an app on a simulator. ```bash - npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/cli.js mcp ``` - **`test_sim`**: Runs tests on a simulator. ```bash - npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/cli.js mcp ``` ### Log Capture & Management - **`start_device_log_cap`**: Starts log capture for a physical device. ```bash - npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/cli.js mcp ``` - **`start_sim_log_cap`**: Starts log capture for a simulator. ```bash - npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/cli.js mcp ``` - **`stop_device_log_cap`**: Stops log capture for a physical device. ```bash - npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/cli.js mcp ``` - **`stop_sim_log_cap`**: Stops log capture for a simulator. ```bash - npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/cli.js mcp ``` ### macOS Development - **`build_macos`**: Builds a macOS app. ```bash - npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` - **`build_run_macos`**: Builds and runs a macOS app. ```bash - npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` - **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build. ```bash - npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` - **`launch_mac_app`**: Launches a macOS app. ```bash - npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/cli.js mcp ``` - **`stop_mac_app`**: Stops a macOS app. ```bash - npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/cli.js mcp ``` - **`test_macos`**: Runs tests for a macOS project. ```bash - npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` ### Project Discovery - **`discover_projs`**: Discovers Xcode projects and workspaces. ```bash - npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/cli.js mcp ``` - **`get_app_bundle_id`**: Gets an app's bundle identifier. ```bash - npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/cli.js mcp ``` - **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier. ```bash - npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/cli.js mcp ``` - **`list_schemes`**: Lists schemes in a project or workspace. ```bash - npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/cli.js mcp ``` - **`show_build_settings`**: Shows build settings for a scheme. ```bash - npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` ### Project Scaffolding - **`scaffold_ios_project`**: Scaffolds a new iOS project. ```bash - npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/cli.js mcp ``` - **`scaffold_macos_project`**: Scaffolds a new macOS project. ```bash - npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/cli.js mcp ``` ### Project Utilities @@ -181,122 +181,122 @@ npx reloaderoo@latest --help - **`clean`**: Cleans build artifacts. ```bash # For a project - npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/cli.js mcp # For a workspace - npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/cli.js mcp ``` ### Simulator Management - **`reset_sim_location`**: Resets a simulator's location. ```bash - npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/cli.js mcp ``` - **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode). ```bash - npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/cli.js mcp ``` - **`set_sim_location`**: Sets a simulator's GPS location. ```bash - npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/cli.js mcp ``` - **`sim_statusbar`**: Overrides a simulator's status bar. ```bash - npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/cli.js mcp ``` ### Swift Package Manager - **`swift_package_build`**: Builds a Swift package. ```bash - npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/cli.js mcp ``` - **`swift_package_clean`**: Cleans a Swift package. ```bash - npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/cli.js mcp ``` - **`swift_package_list`**: Lists running Swift package processes. ```bash - npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/cli.js mcp ``` - **`swift_package_run`**: Runs a Swift package executable. ```bash - npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/cli.js mcp ``` - **`swift_package_stop`**: Stops a running Swift package process. ```bash - npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/cli.js mcp ``` - **`swift_package_test`**: Tests a Swift package. ```bash - npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/cli.js mcp ``` ### System Doctor - **`doctor`**: Runs system diagnostics. ```bash - npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/cli.js mcp ``` ### UI Testing & Automation - **`button`**: Simulates a hardware button press. ```bash - npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/cli.js mcp ``` - **`snapshot_ui`**: Gets the UI hierarchy of the current screen. ```bash - npx reloaderoo@latest inspect call-tool snapshot_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool snapshot_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/cli.js mcp ``` - **`gesture`**: Performs a pre-defined gesture. ```bash - npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/cli.js mcp ``` - **`key_press`**: Simulates a key press. ```bash - npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/cli.js mcp ``` - **`key_sequence`**: Simulates a sequence of key presses. ```bash - npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/cli.js mcp ``` - **`long_press`**: Performs a long press at coordinates. ```bash - npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/cli.js mcp ``` - **`screenshot`**: Takes a screenshot. ```bash - npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/cli.js mcp ``` - **`swipe`**: Performs a swipe gesture. ```bash - npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/cli.js mcp ``` - **`tap`**: Performs a tap at coordinates. ```bash - npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/cli.js mcp ``` - **`touch`**: Simulates a touch down or up event. ```bash - npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/cli.js mcp ``` - **`type_text`**: Types text into the focused element. ```bash - npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/cli.js mcp ``` ### Resources - **Read devices resource**: ```bash - npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js mcp + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/cli.js mcp ``` - **Read simulators resource**: ```bash - npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/index.js mcp + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/cli.js mcp ``` - **Read doctor resource**: ```bash - npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/index.js mcp + npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/cli.js mcp ``` diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md index 5495f4ae..2e217ff4 100644 --- a/docs/dev/TESTING.md +++ b/docs/dev/TESTING.md @@ -555,11 +555,11 @@ Black Box Testing means testing ONLY through external interfaces without any kno ### ABSOLUTE TESTING RULES - NO EXCEPTIONS 1. **✅ ONLY ALLOWED: Reloaderoo Inspect Commands** - - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp` - - `npx reloaderoo@latest inspect list-tools -- node build/index.js mcp` - - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js mcp` - - `npx reloaderoo@latest inspect server-info -- node build/index.js mcp` - - `npx reloaderoo@latest inspect ping -- node build/index.js mcp` + - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect read-resource "URI" -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect server-info -- node build/cli.js mcp` + - `npx reloaderoo@latest inspect ping -- node build/cli.js mcp` 2. **❌ COMPLETELY FORBIDDEN ACTIONS:** - **NEVER** call `mcp__XcodeBuildMCP__tool_name()` functions directly @@ -581,8 +581,8 @@ Black Box Testing means testing ONLY through external interfaces without any kno const result = await doctor(); // ✅ CORRECT - Only through Reloaderoo inspect - npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp - npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp + npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp + npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp ``` ### WHY RELOADEROO INSPECT IS MANDATORY @@ -631,7 +631,7 @@ Some tools rely on in-memory state within the MCP server and will fail when test #### Step 1: Create Complete Tool Inventory ```bash # Generate complete list of all tools -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp > /tmp/all_tools.json +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp > /tmp/all_tools.json TOTAL_TOOLS=$(jq '.tools | length' /tmp/all_tools.json) echo "TOTAL TOOLS TO TEST: $TOTAL_TOOLS" @@ -653,7 +653,7 @@ jq -r '.tools[].name' /tmp/all_tools.json > /tmp/tool_names.txt For EVERY tool in the list: ```bash # Test each tool individually - NO BATCHING -npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/cli.js mcp # Mark tool as completed in TodoWrite IMMEDIATELY after testing # Record result (success/failure/blocked) for each tool @@ -777,7 +777,7 @@ Must capture and document these values for dependent tools: ```bash # Generate complete tool list with accurate count -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp 2>/dev/null > /tmp/tools.json +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp 2>/dev/null > /tmp/tools.json # Get accurate tool count TOOL_COUNT=$(jq '.tools | length' /tmp/tools.json) @@ -792,7 +792,7 @@ echo "Tool names saved to /tmp/tool_names.txt" ```bash # Generate complete resource list -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/null > /tmp/resources.json +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp 2>/dev/null > /tmp/resources.json # Get accurate resource count RESOURCE_COUNT=$(jq '.resources | length' /tmp/resources.json) @@ -905,7 +905,7 @@ echo "Tool schema reference created at /tmp/tool_schemas.md" - Mark "completed" only after manual verification 2. **Test Each Tool Individually** - - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp` + - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/cli.js mcp` - Wait for complete response before proceeding to next tool - Read and verify each tool's output manually - Record key outputs (UUIDs, paths, schemes) for dependent tools @@ -929,16 +929,16 @@ echo "Tool schema reference created at /tmp/tool_schemas.md" ```bash # Test server connectivity -npx reloaderoo@latest inspect ping -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp # Get server information -npx reloaderoo@latest inspect server-info -- node build/index.js mcp +npx reloaderoo@latest inspect server-info -- node build/cli.js mcp # Verify tool count manually -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp 2>/dev/null | jq '.tools | length' +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp 2>/dev/null | jq '.tools | length' # Verify resource count manually -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/null | jq '.resources | length' +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp 2>/dev/null | jq '.resources | length' ``` #### Phase 2: Resource Testing @@ -947,7 +947,7 @@ npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/n # Test each resource systematically while IFS= read -r resource_uri; do echo "Testing resource: $resource_uri" - npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js mcp 2>/dev/null + npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/cli.js mcp 2>/dev/null echo "---" done < /tmp/resource_uris.txt ``` @@ -961,23 +961,23 @@ echo "=== FOUNDATION TOOL TESTING & DATA COLLECTION ===" # 1. Test doctor (no dependencies) echo "Testing doctor..." -npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp 2>/dev/null # 2. Collect device data echo "Collecting device UUIDs..." -npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/devices_output.json +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp 2>/dev/null > /tmp/devices_output.json DEVICE_UUIDS=$(jq -r '.content[0].text' /tmp/devices_output.json | grep -E "UDID: [A-F0-9-]+" | sed 's/.*UDID: //' | head -2) echo "Device UUIDs captured: $DEVICE_UUIDS" # 3. Collect simulator data echo "Collecting simulator UUIDs..." -npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/sims_output.json +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/cli.js mcp 2>/dev/null > /tmp/sims_output.json SIMULATOR_UUIDS=$(jq -r '.content[0].text' /tmp/sims_output.json | grep -E "\([A-F0-9-]+\)" | sed 's/.*(\([A-F0-9-]*\)).*/\1/' | head -3) echo "Simulator UUIDs captured: $SIMULATOR_UUIDS" # 4. Collect project data echo "Collecting project paths..." -npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js mcp 2>/dev/null > /tmp/projects_output.json +npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/cli.js mcp 2>/dev/null > /tmp/projects_output.json PROJECT_PATHS=$(jq -r '.content[1].text' /tmp/projects_output.json | grep -E "\.xcodeproj$" | sed 's/.*- //' | head -3) WORKSPACE_PATHS=$(jq -r '.content[2].text' /tmp/projects_output.json | grep -E "\.xcworkspace$" | sed 's/.*- //' | head -2) echo "Project paths captured: $PROJECT_PATHS" @@ -999,7 +999,7 @@ echo "=== DISCOVERY TOOL TESTING & METADATA COLLECTION ===" while IFS= read -r project_path; do if [ -n "$project_path" ]; then echo "Getting schemes for: $project_path" - npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/schemes_$$.json + npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/cli.js mcp 2>/dev/null > /tmp/schemes_$$.json SCHEMES=$(jq -r '.content[1].text' /tmp/schemes_$$.json 2>/dev/null || echo "NoScheme") echo "$project_path|$SCHEMES" >> /tmp/project_schemes.txt echo "Schemes captured for $project_path: $SCHEMES" @@ -1010,7 +1010,7 @@ done < /tmp/project_paths.txt while IFS= read -r workspace_path; do if [ -n "$workspace_path" ]; then echo "Getting schemes for: $workspace_path" - npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/ws_schemes_$$.json + npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/cli.js mcp 2>/dev/null > /tmp/ws_schemes_$$.json SCHEMES=$(jq -r '.content[1].text' /tmp/ws_schemes_$$.json 2>/dev/null || echo "NoScheme") echo "$workspace_path|$SCHEMES" >> /tmp/workspace_schemes.txt echo "Schemes captured for $workspace_path: $SCHEMES" @@ -1035,29 +1035,29 @@ done < /tmp/workspace_paths.txt ```bash # STEP 1: Test foundation tools (no parameters required) # Execute each command individually, wait for response, verify manually -npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp # [Wait for response, read output, mark tool complete in task list] -npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp # [Record device UUIDs from response for dependent tools] -npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/cli.js mcp # [Record simulator UUIDs from response for dependent tools] # STEP 2: Test project discovery (use discovered project paths) -npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/cli.js mcp # [Record scheme names from response for build tools] # STEP 3: Test workspace tools (use discovered workspace paths) -npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/cli.js mcp # [Record scheme names from response for build tools] # STEP 4: Test simulator tools (use captured simulator UUIDs from step 1) -npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/cli.js mcp # [Verify simulator boots successfully] # STEP 5: Test build tools (requires project + scheme + simulator from previous steps) -npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/cli.js mcp # [Verify build succeeds and record app bundle path] ``` @@ -1083,7 +1083,7 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP ```bash # ❌ IMMEDIATE TERMINATION - Using scripts to test tools for tool in $(cat tool_list.txt); do - npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js mcp + npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/cli.js mcp done ``` @@ -1109,19 +1109,19 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP ```bash # ✅ CORRECT - Step-by-step manual execution via Reloaderoo # Tool 1: Test doctor -npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/cli.js mcp # [Read response, verify, mark complete in TodoWrite] # Tool 2: Test list_devices -npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/cli.js mcp # [Read response, capture UUIDs, mark complete in TodoWrite] # Tool 3: Test list_sims -npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/cli.js mcp # [Read response, capture UUIDs, mark complete in TodoWrite] # Tool X: Test stateful tool (expected to fail) -npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js mcp +npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/cli.js mcp # [Tool fails as expected - no in-memory state available] # [Mark as "false negative - stateful tool limitation" in TodoWrite] # [Continue to next tool without investigation] @@ -1146,15 +1146,15 @@ echo "=== Error Testing ===" # Test with invalid JSON parameters echo "Testing invalid parameter types..." -npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/cli.js mcp 2>/dev/null # Test with non-existent paths echo "Testing non-existent paths..." -npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/cli.js mcp 2>/dev/null # Test with invalid UUIDs echo "Testing invalid UUIDs..." -npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js mcp 2>/dev/null +npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/cli.js mcp 2>/dev/null ``` ### Step 5: Generate Testing Report @@ -1190,12 +1190,12 @@ echo "Testing session template created: TESTING_SESSION_$(date +%Y-%m-%d).md" ```bash # Essential testing commands -npx reloaderoo@latest inspect ping -- node build/index.js mcp -npx reloaderoo@latest inspect server-info -- node build/index.js mcp -npx reloaderoo@latest inspect list-tools -- node build/index.js mcp | jq '.tools | length' -npx reloaderoo@latest inspect list-resources -- node build/index.js mcp | jq '.resources | length' -npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js mcp -npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js mcp +npx reloaderoo@latest inspect ping -- node build/cli.js mcp +npx reloaderoo@latest inspect server-info -- node build/cli.js mcp +npx reloaderoo@latest inspect list-tools -- node build/cli.js mcp | jq '.tools | length' +npx reloaderoo@latest inspect list-resources -- node build/cli.js mcp | jq '.resources | length' +npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/cli.js mcp +npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/cli.js mcp # Schema extraction jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json diff --git a/docs/dev/TOOL_DISCOVERY_LOGIC.md b/docs/dev/TOOL_DISCOVERY_LOGIC.md new file mode 100644 index 00000000..8ddf3711 --- /dev/null +++ b/docs/dev/TOOL_DISCOVERY_LOGIC.md @@ -0,0 +1,137 @@ +# Tool Discovery & Visibility Logic (MCP server + CLI) + +This document describes how XcodeBuildMCP discovers workflows/tools and decides which ones are visible in: + +- the **MCP server** (`xcodebuildmcp mcp`), and +- the **CLI** (`node build/cli.js` / `xcodebuildmcp ...`). + +It also documents the current and intended **visibility filtering** behavior (post workflow-selection filtering). + +## Terminology + +- **Workflow**: a directory under `src/mcp/tools//` containing an `index.ts` with workflow metadata and tool modules. +- **Tool**: a `PluginMeta` exported from a workflow module with `name`, `schema`, and `handler`. +- **Workflow selection**: picking which workflows are active (coarse-grained inclusion). +- **Visibility filtering**: hiding specific tools even if their workflow is enabled (fine-grained exclusion). +- **Dynamic tools**: tools registered at runtime that do not come from static workflows (e.g. proxied Xcode Tools). + +## Where workflows/tools come from (source of truth) + +Workflows are discovered via generated loaders in `src/core/generated-plugins.ts` (the `WORKFLOW_LOADERS` map). At runtime, `loadWorkflowGroups()` imports each workflow module via these loaders and collects tools from it (`src/core/plugin-registry.ts`). + +Key properties of this design: + +- Workflows are “discoverable” by enumerating `Object.keys(WORKFLOW_LOADERS)`. +- Tools within a workflow are whatever `index.ts` exports (excluding `workflow` itself). +- A single tool name can appear in multiple workflows (re-exports). This matters for workflow management and hiding. + +## MCP server: registration pipeline + +At MCP server startup: + +1) Runtime config is loaded (`bootstrapRuntime` → `initConfigStore`) from: + - config file (project config), + - env (`XCODEBUILDMCP_*`), + - explicit overrides. + +2) Enabled workflows are taken from config (`enabledWorkflows`). + +3) `registerWorkflows(enabledWorkflows)` runs, which calls `applyWorkflowSelection(...)` (`src/utils/tool-registry.ts`). + +4) Workflow selection uses `resolveSelectedWorkflows(...)` (`src/utils/workflow-selection.ts`) which: + - always includes `session-management`, + - optionally includes `doctor` when `debug: true`, + - optionally includes `workflow-discovery` when `experimentalWorkflowDiscovery: true`, + - defaults to `simulator` when `enabledWorkflows` is empty. + +5) For each selected workflow, each tool is considered for registration, then filtered by `shouldExposeTool(workflowName, toolName)` (`src/utils/tool-registry.ts`). + +6) If visible, the tool is registered via `server.registerTool(...)`. + +### Runtime workflow management (MCP) + +The `manage-workflows` tool updates the enabled workflow list at runtime and re-applies workflow selection (`src/mcp/tools/workflow-discovery/manage_workflows.ts` → `applyWorkflowSelection(...)`). + +Important nuance: + +- Because tools can be re-exported across workflows, disabling one workflow may not remove a tool if another enabled workflow still provides that same tool name. +- Visibility filtering operates on the tool name (and workflow name) at registration time; it is layered after workflow selection. + +## CLI: discovery and help output + +The CLI has two related but distinct concepts: + +1) **Command registration / `--help` tree** (yargs commands) +2) **Tool listing** (`xcodebuildmcp tools`) which is driven by a manifest + +### CLI command registration (yargs) + +CLI mode builds a tool catalog using `buildCliToolCatalog()` which enables **all workflows** returned by `listWorkflowDirectoryNames()` except `session-management` and `workflow-discovery` (`src/cli/cli-tool-catalog.ts`). + +The catalog is built via `buildToolCatalog(...)` (`src/runtime/tool-catalog.ts`), which: + +- loads workflow groups via `loadWorkflowGroups()`, +- resolves selected workflows (as above), +- applies `shouldExposeTool(...)` as a per-tool visibility filter, +- generates CLI names and disambiguates collisions. + +Yargs then registers workflow command groups and per-tool subcommands from the catalog (`src/cli/register-tool-commands.ts`). + +Additionally, the CLI registers workflow command groups from workflow metadata even if there are currently zero visible tools in that workflow (so the workflow still appears in `--help` output). + +### CLI `tools` command (manifest-driven) + +`xcodebuildmcp tools` reads `build/tools-manifest.json` (`src/cli/commands/tools.ts`) which is generated by `npm run generate:tools-manifest` (invoked by `npm run build:tsup`). + +Key implications: + +- The manifest is a **static analysis** snapshot (canonical source for docs/CLI listing). +- Dynamic runtime tools (see below) are **not** represented in the manifest. + +## Dynamic tools: Xcode Tools via `xcrun mcpbridge` + +When the `xcode-ide` workflow is enabled, XcodeBuildMCP can proxy Xcode’s IDE tool service through `xcrun mcpbridge` and register proxied tools at runtime. + +Properties: + +- Proxied tools are registered dynamically and prefixed as `xcode_tools_` (see `src/integrations/xcode-tools-bridge/registry.ts`). +- Proxied tools are not part of any static workflow and are not present in `build/tools-manifest.json`. +- Proxied tool registration can change over time if Xcode’s tool catalog changes (the bridge supports `tools/listChanged`). + +## Visibility filtering (post-selection hiding) + +### Current behavior (implemented) + +`shouldExposeTool(...)` is the single, shared visibility gate for: + +- MCP server tool registration (`src/utils/tool-registry.ts`) +- CLI catalog building (`src/runtime/tool-catalog.ts`) + +Currently it is used to hide Xcode IDE bridge **debug** tools unless debugging is enabled: + +- `xcode_tools_bridge_status` +- `xcode_tools_bridge_sync` +- `xcode_tools_bridge_disconnect` + +These tools are gated behind `debug: true` / `XCODEBUILDMCP_DEBUG=true` (`src/utils/tool-visibility.ts`). + +### Intended behavior (documented policy) + +We also want a broader “Xcode agent mode” visibility filter where specific XcodeBuildMCP tools are hidden when: + +- `runningUnderXcode === true`, AND +- Xcode Tools MCP is enabled/available + +This is documented in `docs/dev/XCODE_IDE_TOOL_CONFLICTS.md`. + +Design principle: + +- Workflow selection stays the **primary** “what areas are enabled” control. +- Visibility filtering is a **secondary** control to reduce confusion/duplication and align behavior with the environment (e.g. “inside Xcode, builds/tests should run inside Xcode”). + +## Layering summary (what wins) + +1) **Workflow selection** decides which workflows are in play. +2) **Visibility filtering** can hide individual tools even within enabled workflows. +3) **Dynamic tools** may appear even if there are no static tools in a workflow (e.g. proxied `xcode_tools_*`). + diff --git a/docs/dev/TOOL_REGISTRY_REFACTOR.md b/docs/dev/TOOL_REGISTRY_REFACTOR.md new file mode 100644 index 00000000..96fada97 --- /dev/null +++ b/docs/dev/TOOL_REGISTRY_REFACTOR.md @@ -0,0 +1,940 @@ +# Tool Registry Refactor: Manifest-Driven Workflows & Tools (No Codegen) + +Status: Proposed (canonical target design) +Last updated: 2026-02-04 + +## Executive summary + +XcodeBuildMCP currently discovers workflows/tools using build-time filesystem scanning and code generation (`generated-plugins.ts`) and separately generates a static `tools-manifest.json` for CLI listing. This has created drift: + +- CLI `xcodebuildmcp tools` is sourced from a JSON manifest and can disagree with what the runtime CLI actually exposes (runtime visibility filtering differs). +- Tool identity can drift between filename-based naming (analysis scripts) and `export default { name: ... }` naming at runtime. + +This refactor replaces the “clever” discovery system with a **human-managed YAML manifest registry** as the **single source of truth** for: + +- Workflows and their metadata (title, description) +- Tools and their metadata (names for MCP and CLI, descriptions, routing) +- Per-runtime availability (MCP vs CLI vs daemon) +- Runtime visibility rules via **named predicates** selected in YAML + +The runtime uses the manifest to: +- Load tool implementations via dynamic import from the package `build/**` tree +- Build the CLI command tree and the `xcodebuildmcp tools` listing from the same catalog +- Register MCP tools consistently with the same filtering rules +- Support dynamic Xcode IDE tool proxying (mcpbridge) and hide conflicting XcodeBuildMCP tools when running under Xcode agent mode, per `XCODE_IDE_TOOL_CONFLICTS.md` + +Key design constraints: +- **No build-time plugin discovery/codegen** +- **Multiple YAML manifests** to avoid monolith file size +- **Predicate registry only** (YAML references predicate names; logic is coded in TS) +- Safe for `npm`/`npx` installs by resolving everything from **package root** (not `cwd`) +- Tool module metadata moves out of tool modules as part of this plan + +--- + +## Goals + +1. **Single source of truth**: one canonical registry for workflow/tool metadata and exposure rules. +2. **Consistency**: CLI `tools` output, CLI subcommands, and MCP server tool list use the same data and filtering logic. +3. **Explicit MCP vs CLI naming**: + - MCP tool name is stable and explicit. + - CLI tool name is explicit or derived from MCP name. +4. **Explicit per-runtime availability**: + - Workflows/tools can be enabled/disabled independently for MCP, CLI, and daemon. +5. **Runtime visibility is configurable and auditable**: + - Predicates are named, versioned functions in code and referenced in YAML. +6. **Dynamic Xcode IDE tools supported**: + - Proxied `xcode_tools_*` tools remain dynamic. + - When running under Xcode and Xcode Tools are active, hide conflicting XcodeBuildMCP tools as documented. + +--- + +## Non-goals + +- Rewriting tool implementations. +- Creating an expression language in YAML (predicates are registry-only). +- Supporting bundling that eliminates individual tool modules on disk. (We require that tool modules exist in the shipped package.) + +--- + +## Why a manifest registry? + +### Problems with the current approach +- Build-time codegen (`plugin-discovery.ts`) produces `generated-plugins.ts`. Runtime behavior depends on generated code correctness and build pipeline ordering. +- CLI `xcodebuildmcp tools` uses `tools-manifest.json` which bypasses runtime filtering and naming rules. +- Static analysis uses filenames for tool identity, while runtime uses exported `name`, causing mismatches. + +### Manifest registry benefits +- Human-managed, easy to reason about, explicit. +- One place to edit availability and names. +- Eliminates codegen and AST parsing complexity. +- Enables data-driven conflict filtering with Xcode IDE tools. + +--- + +## Repository layout (new) + +The registry is stored in multiple small YAML files: + +``` +manifests/ + workflows/ + simulator.yaml + device.yaml + doctor.yaml + ... + tools/ + build_sim.yaml + discover_projs.yaml + clean.yaml + ... +``` + +Rules: +- Each YAML file defines exactly one object with a unique `id`. +- Duplicate `id`s are an error. +- Workflows reference tools by tool `id`. +- Tools are defined once (no “re-export” duplication); multiple workflows can reference the same tool. + +--- + +## Canonical data model + +### Tool manifest entry + +A tool manifest entry describes: +- Where the implementation lives (module path) +- Names for MCP and CLI +- Description(s) +- Availability by runtime +- Predicates for visibility filtering +- Routing hints for daemon affinity + +Example: `manifests/tools/discover_projs.yaml` + +```yaml +id: discover_projs + +# Module identifier (extensionless). Resolved to build/.js at runtime. +# Must be package-root relative (not cwd-relative). +module: mcp/tools/project-discovery/discover_projs + +names: + # MCP name is required and must be globally unique. + mcp: discover_projs + + # CLI name optional. If omitted, derived as kebab-case of MCP name + # (e.g. discover_projs -> discover-projs) + cli: discover-projs + +description: "Discover Xcode projects/workspaces in a directory." + +availability: + mcp: true + cli: true + daemon: true + +# Visibility rules: predicate names, all must pass to expose tool. +predicates: + - hideWhenXcodeAgentMode + +routing: + stateful: false + daemonAffinity: preferred # preferred | required +``` + +### Workflow manifest entry + +Example: `manifests/workflows/simulator.yaml` + +```yaml +id: simulator +title: "iOS Simulator Development" +description: "Complete iOS development workflow targeting simulators." + +availability: + mcp: true + cli: true + daemon: true + +# Optional workflow selection rules, primarily for MCP selection defaults. +selection: + mcp: + # Mandatory workflows are always included in MCP selection. + mandatory: false + + # defaultEnabled is used when config.enabledWorkflows is empty. + defaultEnabled: true + + # autoInclude means “include when predicates pass even if not requested”. + autoInclude: false + +# Workflow-level predicates: if any fail, workflow is hidden for that runtime. +predicates: [] + +tools: + - boot_sim + - build_sim + - build_run_sim + - test_sim + - discover_projs + - clean + - list_sims +``` + +### Mandatory workflow example (session-management) + +`manifests/workflows/session-management.yaml` + +```yaml +id: session-management +title: "Session Management" +description: "Manage session defaults for project/workspace paths, scheme, simulator/device defaults." + +availability: + mcp: true + cli: false + daemon: false + +selection: + mcp: + mandatory: true + defaultEnabled: true + autoInclude: true + +tools: + - session_show_defaults + - session_set_defaults + - session_clear_defaults +``` + +### Workflow auto-inclusion example (doctor, workflow-discovery) + +`manifests/workflows/doctor.yaml` + +```yaml +id: doctor +title: "System Doctor" +description: "Diagnostics and environment checks." + +availability: { mcp: true, cli: true, daemon: true } + +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: true + +predicates: + - debugEnabled + +tools: + - doctor +``` + +`manifests/workflows/workflow-discovery.yaml` + +```yaml +id: workflow-discovery +title: "Workflow Discovery" +description: "Manage enabled workflows at runtime." + +availability: { mcp: true, cli: false, daemon: false } + +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: true + +predicates: + - experimentalWorkflowDiscoveryEnabled + +tools: + - manage_workflows +``` + +--- + +## Names and uniqueness rules + +### MCP name +- `names.mcp` is required and is the canonical identity for MCP registration. +- Must be unique across all tools. + +### CLI name +- `names.cli` optional; if omitted it is derived from MCP name: + - `_` → `-` + - camelCase → kebab-case +- Must be unique across all tools after derivation. +- If a collision occurs, the registry must explicitly set `names.cli` for one of the tools (we fail fast instead of auto-disambiguating). + +### Why this design? +- Avoids runtime ambiguity and “clever” automatic disambiguation. +- Encourages stable CLI UX. + +--- + +## Availability rules (CLI vs MCP vs daemon) + +Availability can be set at both workflow and tool level: +- Workflow availability gate applies first. +- Tool availability gate applies second. + +A tool is exposed only if: +- workflow availability for runtime is true +- tool availability for runtime is true +- all predicates (workflow + tool) pass for the current runtime context + +--- + +## Predicate registry (registry-only; YAML references predicate names) + +Predicates are named functions in code. YAML includes predicate names; there is no expression language. + +### Context passed to predicates + +```ts +type PredicateContext = { + runtime: 'cli' | 'mcp' | 'daemon'; + config: ResolvedRuntimeConfig; + + // environment-derived + runningUnderXcode: boolean; + + // dynamic bridge-derived (MCP only; false otherwise) + xcodeToolsActive: boolean; +}; +``` + +### Built-in predicates (initial set) + +- `debugEnabled`: true if config debug mode is enabled +- `experimentalWorkflowDiscoveryEnabled`: true if experimental workflow discovery is enabled +- `hideWhenXcodeAgentMode`: hides tool/workflow when: + - running under Xcode agent, AND + - Xcode Tools bridge is active (proxied tools are available) + +This predicate powers the policy described in `XCODE_IDE_TOOL_CONFLICTS.md`. + +### Applying Xcode IDE conflict policy + +Tools that conflict with Xcode IDE tools are tagged in YAML with: + +```yaml +predicates: + - hideWhenXcodeAgentMode +``` + +This keeps the conflict policy: +- human-managed +- auditable +- easy to update without code changes + +--- + +## Dynamic Xcode IDE tools (mcpbridge) + +### What is dynamic? +When `xcode-ide` is enabled, XcodeBuildMCP proxies Xcode tools via `xcrun mcpbridge` and registers tools dynamically with names: +- `xcode_tools_` + +These tools: +- are not listed in the YAML registry (their set changes at runtime) +- are always registered under the `xcode_tools_` prefix to avoid collisions +- can trigger `tools/listChanged` updates + +### How dynamic tools influence static tool visibility +When dynamic tools are active (`xcodeToolsActive`), conflict-tagged XcodeBuildMCP tools are hidden via `hideWhenXcodeAgentMode`. + +This behavior is: +- scoped to MCP runtime +- driven by bridge status +- re-applied whenever bridge status changes + +--- + +## Runtime logic flows + +### CLI runtime +1. Load manifest registry from package root. +2. Build CLI exposure context: + - runtime: `cli` + - `runningUnderXcode: false` (CLI does not use Xcode IDE bridge) + - `xcodeToolsActive: false` +3. Build `ToolCatalog` from manifest (no codegen, no AST). +4. Register yargs commands from catalog. +5. `xcodebuildmcp tools` lists from the same catalog. + +### MCP runtime +1. Load manifest registry from package root. +2. Load runtime config. +3. Build exposure context: + - runtime: `mcp` + - `runningUnderXcode` from environment detection +4. Select workflows: + - include mandatory workflows + - include requested workflows + - if none requested: include defaultEnabled workflows + - include autoInclude workflows whose predicates pass +5. Initialize Xcode bridge if `xcode-ide` workflow is enabled. +6. Compute `xcodeToolsActive` from bridge status. +7. Register static tools from manifest applying availability + predicates. +8. Register dynamic `xcode_tools_*` tools from bridge. +9. On bridge status changes, recompute `xcodeToolsActive` and re-apply static tool registration. + +### Daemon runtime +1. Load manifest registry. +2. Build daemon exposure context: + - runtime: `daemon` + - runningUnderXcode=false + - xcodeToolsActive=false +3. Build daemon `ToolCatalog` from manifest and expose over daemon protocol. + +--- + +## Build and packaging requirements (npm/npx safe) + +This refactor requires that tool modules exist on disk at runtime for dynamic imports. + +### Key requirements +1. Keep a single entrypoint for users: + - CLI bin remains `build/cli.js` (or equivalent) + - MCP starts from a single JS entrypoint +2. Emit an **unbundled** build tree: + - `build/**` contains tool modules and their dependencies +3. Ship manifests: + - `manifests/**` must be included in published package +4. Resolve everything from **package root**, not `process.cwd()`: + - npx installs packages into a temporary directory; cwd can be arbitrary + - dynamic imports must use absolute paths derived from package root + +### Module resolution convention +Manifest uses extensionless module IDs: +- `module: mcp/tools/simulator/boot_sim` + +At runtime the loader imports: +- `${packageRoot}/build/${module}.js` (packaged) +- optionally `${packageRoot}/src/${module}.ts` (dev fallback) + +--- + +## Implementation plan (phased migration) + +### Phase 1: introduce manifest system + predicate registry +- Add `manifests/workflows/*.yaml` and `manifests/tools/*.yaml` +- Add manifest loader, schemas, predicate registry, exposure evaluator +- Add package-root resolver utilities +- Add tool module importer with backward-compatible adapter + +### Phase 2: fix CLI `tools` drift immediately +- Change `xcodebuildmcp tools` to list from the runtime `ToolCatalog` (manifest-based) +- Remove dependence on `tools-manifest.json` + +### Phase 3: migrate CLI catalog build to manifest-driven +- Build CLI ToolCatalog from manifest + imported tool implementations +- Enforce unique CLI names in manifest (fail fast) + +### Phase 4: migrate MCP registration to manifest-driven +- Replace generated loader usage with manifest selection + import-based registration +- Wire in Xcode bridge status → `xcodeToolsActive` updates +- Apply conflict filtering via `hideWhenXcodeAgentMode` + +### Phase 5: migrate daemon to manifest-driven +- Daemon builds catalog from manifest +- CLI invocation routing unchanged, but tool list and names now consistent everywhere + +### Phase 6: move tool metadata out of tool modules +- Update tools to export only implementation (schema, handler, annotations) +- Manifest becomes authoritative for names, descriptions, routing, availability, predicates +- Remove legacy `PluginMeta`-style defaults once all tools migrated + +### Phase 7: delete legacy code paths +- Remove build-time plugin discovery + generated loaders +- Remove static AST tools analysis scripts +- Remove old manifest JSON generator +- Update docs generation to read YAML registry + +--- + +## Worked example: tagging conflict tools (Xcode agent mode) + +Per `XCODE_IDE_TOOL_CONFLICTS.md`, hide `build_sim` inside Xcode agent mode when Xcode Tools are active: + +`manifests/tools/build_sim.yaml` + +```yaml +id: build_sim +module: mcp/tools/simulator/build_sim +names: + mcp: build_sim + cli: build-sim +description: "Build an iOS app for a simulator target." +availability: { mcp: true, cli: true, daemon: true } +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred +``` + +When: +- runningUnderXcode=true AND xcodeToolsActive=true +then `hideWhenXcodeAgentMode` fails and tool is not registered/listed in MCP (but remains in CLI outside Xcode). + +--- + +## FAQ + +### Why not keep codegen? +Because it is a second “source of truth” and forces build pipeline coupling. The manifest removes that complexity and makes the system human-managed and predictable. + +### Will unbundled output break npx? +No, as long as: +- you ship the emitted modules and manifests +- your loader resolves from package root using `import.meta.url` and file URLs +- you do not depend on `cwd` relative paths + +### How do we prevent runtime failures due to missing modules? +Manifest loader performs startup validation: +- verifies each `module` path exists as `build/.js` in packaged mode +- fails fast with a clear error indicating which manifest entry is broken + +--- + +## Appendix: current modules to be deprecated + +- build-plugins/plugin-discovery.ts +- src/core/generated-plugins.ts +- scripts/analysis/tools-analysis.ts +- scripts/generate-tools-manifest.ts +- CLI tools listing dependence on tools-manifest.json + +These are removed once the manifest registry is the only path. + +# Plan + + + +## Proposed direction: **Yes — replace codegen + AST manifest with human-managed YAML manifests** + +Your goals map cleanly to a **data-driven manifest** architecture: + +- **One source of truth** for workflow/tool metadata: YAML. +- **No build-time plugin discovery**, no generated loaders, no AST scanning. +- **Separate MCP vs CLI names** (with sane defaults `_ ↔ -` when one is omitted). +- **Per-runtime availability** (`mcp`, `cli`, `daemon`) at both workflow and tool level. +- **Runtime visibility rules are coded predicates** from a registry and **opted-in** via manifest flags. +- **Dynamic Xcode Tools (mcpbridge) stay dynamic**, but their presence drives predicates (per `XCODE_IDE_TOOL_CONFLICTS.md`). + +--- + +## 1) Manifest system design (supports multiple manifests) + +### 1.1 Layout: many small YAML files (avoids a giant 70+ tool file) + +Use a directory with two subdirectories; each workflow/tool can be edited independently. + +``` +manifests/ + workflows/ + simulator.yaml + device.yaml + xcode-ide.yaml + ... + tools/ + build_sim.yaml + discover_projs.yaml + clean.yaml + ... +``` + +**Merge rule (simple, not clever):** +- Each file defines exactly **one object** with a unique `id`. +- Duplicate IDs across files are a **hard error** at startup / docs generation. +- No implicit overrides (keeps it easy to reason about). + +### 1.2 Canonical model: tools are defined once, workflows reference them +This avoids duplication for “re-exported” tools (e.g. `discover_projs` appearing in multiple workflows today). + +#### `manifests/tools/discover_projs.yaml` +```yaml +id: discover_projs +module: mcp/tools/project-discovery/discover_projs # extensionless +names: + mcp: discover_projs + cli: discover-projs +description: "Discover Xcode projects/workspaces in a directory." +availability: { mcp: true, cli: true, daemon: true } +predicates: + - hideWhenXcodeAgentMode # from predicate registry +routing: + stateful: false + daemonAffinity: preferred +``` + +#### `manifests/workflows/simulator.yaml` +```yaml +id: simulator +title: "iOS Simulator Development" +description: "Complete iOS development workflow targeting simulators." +availability: { mcp: true, cli: true, daemon: true } + +selection: + mcp: + mandatory: false + defaultEnabled: true # replaces hard-coded DEFAULT_WORKFLOW=simulator + autoInclude: false + +predicates: [] + +tools: + - boot_sim + - build_sim + - build_run_sim + - test_sim + - discover_projs + - clean +``` + +#### `manifests/workflows/session-management.yaml` +```yaml +id: session-management +title: "Session Management" +description: "Manage session defaults." + +availability: { mcp: true, cli: false, daemon: false } +selection: + mcp: + mandatory: true # replaces REQUIRED_WORKFLOW=session-management + defaultEnabled: true + autoInclude: true + +tools: + - session_show_defaults + - session_set_defaults + - session_clear_defaults +``` + +### 1.3 Naming rules (simple defaults) +- **MCP name**: required (`names.mcp`), stable, underscore style. +- **CLI name**: optional (`names.cli`) + - if absent: derive via `toKebabCase(names.mcp)` (underscore → hyphen) +- **Validation**: + - MCP names must be globally unique + - CLI names must be globally unique *after derivation* + - If a collision happens, manifest must specify an explicit CLI name for one of them (no automatic disambiguation). + +--- + +## 2) Predicate registry design (registry-only, referenced by name in YAML) + +### 2.1 Context object +Predicates get a runtime context; YAML only references predicate names. + +```ts +// src/visibility/predicate-types.ts +export type RuntimeKind = 'cli' | 'mcp' | 'daemon'; + +export type PredicateContext = { + runtime: RuntimeKind; + config: import('../utils/config-store.ts').ResolvedRuntimeConfig; + + // environment-derived + runningUnderXcode: boolean; + + // dynamic bridge-derived (MCP only; false elsewhere) + xcodeToolsActive: boolean; +}; +``` + +### 2.2 Registry + evaluator +```ts +// src/visibility/predicate-registry.ts +export type PredicateFn = (ctx: PredicateContext) => boolean; + +export const PREDICATES: Record = { + debugEnabled: (ctx) => ctx.config.debug, + experimentalWorkflowDiscoveryEnabled: (ctx) => ctx.config.experimentalWorkflowDiscovery, + + // Key for XCODE_IDE_TOOL_CONFLICTS.md + hideWhenXcodeAgentMode: (ctx) => !(ctx.runningUnderXcode && ctx.xcodeToolsActive), +}; + +export function evalPredicates(names: string[] | undefined, ctx: PredicateContext): boolean { + for (const name of names ?? []) { + const fn = PREDICATES[name]; + if (!fn) throw new Error(`Unknown predicate '${name}'`); + if (!fn(ctx)) return false; + } + return true; +} +``` + +### 2.3 Applying `XCODE_IDE_TOOL_CONFLICTS.md` +Instead of hardcoding conflict tool lists in TS, you mark the conflicting tools in YAML with: + +```yaml +predicates: + - hideWhenXcodeAgentMode +``` + +That is the clean “single source of truth” you’re looking for. + +--- + +## 3) Runtime loaders (no codegen): manifest → tool modules → catalogs/registration + +### 3.1 Manifest loader (multi-file) +New module reads all YAML files, validates structure, and produces an in-memory `ResolvedManifest`. + +```ts +// src/core/manifest/load-manifest.ts +export type ToolManifestEntry = { + id: string; + module: string; // extensionless module path + names: { mcp: string; cli?: string }; + description?: string; + availability: { mcp: boolean; cli: boolean; daemon: boolean }; + predicates?: string[]; + routing?: { stateful?: boolean; daemonAffinity?: 'preferred' | 'required' }; +}; + +export type WorkflowManifestEntry = { + id: string; + title: string; + description: string; + availability: { mcp: boolean; cli: boolean; daemon: boolean }; + selection?: { mcp?: { mandatory?: boolean; defaultEnabled?: boolean; autoInclude?: boolean } }; + predicates?: string[]; + tools: string[]; // tool IDs +}; + +export type ResolvedManifest = { + tools: Map; + workflows: Map; +}; +``` + +**Key design choice:** module path is **extensionless** and package-relative (e.g. `mcp/tools/simulator/boot_sim`). Loader resolves `.js` in built output (and optionally `.ts` in dev). + +```ts +export async function importToolModule(moduleId: string): Promise<{ + schema: import('../plugin-types.ts').ToolSchemaShape; + handler: import('../plugin-types.ts').PluginMeta['handler']; + annotations?: import('@modelcontextprotocol/sdk/types.js').ToolAnnotations; +}> { /* adapter supports default export or named exports */ } +``` + +--- + +## 4) Unified filtering/exposure rules (MCP + CLI + daemon) + +### 4.1 Single filter function (manifest + predicates + runtime) +```ts +// src/visibility/exposure.ts +export function isWorkflowEnabledForRuntime( + wf: WorkflowManifestEntry, + ctx: PredicateContext, +): boolean; + +export function isToolExposedForRuntime( + tool: ToolManifestEntry, + wf: WorkflowManifestEntry, + ctx: PredicateContext, +): boolean; +``` + +Logic: +- availability gate: `wf.availability[ctx.runtime]` and `tool.availability[ctx.runtime]` +- predicate gate: `evalPredicates(wf.predicates, ctx)` and `evalPredicates(tool.predicates, ctx)` + +This replaces: +- `src/utils/tool-visibility.ts` (hardcoded xcode-ide debug tools) +- the CLI manifest JSON listing divergence +- the plugin discovery / generated loaders path + +--- + +## 5) MCP workflow selection becomes data-driven + +Replace the hard-coded constants in `src/utils/workflow-selection.ts` with manifest `selection.mcp`. + +**Selection rule (simple and matches current behavior):** +1. Start with `mandatory: true` +2. Add `autoInclude: true` if its predicates pass (covers `doctor` when debug enabled, `workflow-discovery` when experimental enabled) +3. If user config `enabledWorkflows` is empty: + - include workflows with `defaultEnabled: true` +4. Else include requested workflows +5. Finally filter by availability + predicates + +This gives you the same behavior you currently have, but **declared in YAML**. + +--- + +## 6) Dynamic Xcode Tools (mcpbridge) integration (kept, but influences predicates) + +### 6.1 Determine `xcodeToolsActive` +In MCP bootstrap, set `ctx.xcodeToolsActive` based on bridge status: + +- `workflowEnabled && bridgeAvailable && connected && proxiedToolCount > 0` + +### 6.2 Re-apply static tool registration when bridge becomes active/inactive +When the bridge syncs tools / disconnects, `xcodeToolsActive` can flip. If it flips, you must re-run the static registration pass so `hideWhenXcodeAgentMode` takes effect immediately. + +This requires a small wiring change: +- add an event/callback in `XcodeToolsBridgeManager` (or expose status polling after sync) +- call `updateWorkflows(...)` (or a new `applyManifestSelection(...)`) when status changes + +--- + +# Implementation plan (concrete file-by-file changes) + +## Phase 1 — Add manifest system + predicate registry (no behavior change yet) + +### New files +- `manifests/workflows/*.yaml` +- `manifests/tools/*.yaml` +- `src/core/manifest/schema.ts` (zod schemas for both yaml types) +- `src/core/manifest/load-manifest.ts` (loads and merges files) +- `src/core/manifest/import-tool-module.ts` (extension resolution + adapter) +- `src/visibility/predicate-types.ts` +- `src/visibility/predicate-registry.ts` +- `src/visibility/exposure.ts` + +### Build/config update +You’ll need to ensure `manifests/**` is included in the published/built artifact. +- Likely update `package.json` `files` field and/or build copy step. +- If you’re currently bundling, switch to emitting a directory structure (so dynamic imports work). + +**Side effects:** none yet; just plumbing. + +--- + +## Phase 2 — Make CLI `tools` list come from runtime catalog (eliminate JSON manifest path) + +### Modify +- `src/cli/commands/tools.ts` + - Change `registerToolsCommand(app)` → `registerToolsCommand(app, catalog)` + - Remove all `tools-manifest.json` reading logic + - List from `catalog.tools` + +- `src/cli/yargs-app.ts` + - `registerToolsCommand(app);` → `registerToolsCommand(app, opts.catalog);` + +**Result:** CLI `tools` can no longer drift from actual CLI-exposed tools. + +--- + +## Phase 3 — Replace runtime `ToolCatalog` builder with manifest-driven builder + +### Modify / replace +- `src/runtime/tool-catalog.ts` + - New entrypoint: + ```ts + export async function buildToolCatalogFromManifest(opts: { + runtime: 'cli' | 'daemon'; + manifest: ResolvedManifest; + ctx: PredicateContext; + includeWorkflows?: string[]; + excludeWorkflows?: string[]; + }): Promise + ``` + - Build `ToolDefinition` from manifest tool entries + imported module `{schema, handler, annotations}` + - Apply exposure via `isToolExposedForRuntime(...)` + - Validate CLI/MCP name uniqueness at construction time (hard error) + +### Modify +- `src/cli/cli-tool-catalog.ts` + - Replace `listWorkflowDirectoryNames()` + `buildToolCatalog(...)` with: + - `loadManifest()` + - build context for CLI (`runningUnderXcode=false`, `xcodeToolsActive=false`) + - `buildToolCatalogFromManifest({ runtime: 'cli', ... })` + +### Modify +- `src/daemon.ts` + - Build catalog using manifest with `runtime: 'daemon'` + +**Side effect:** tool exposure now fully controlled by YAML + predicate registry in CLI/daemon. + +--- + +## Phase 4 — MCP registration becomes manifest-driven + +### Modify +- `src/utils/tool-registry.ts` + - Stop calling `loadWorkflowGroups()` (plugin registry) + - Instead: + - load manifest + - compute selected workflows using new manifest-driven workflow selection + - for each exposed tool, import module + `server.registerTool(mcpName, ...)` + +### Modify +- `src/server/bootstrap.ts` + - Build `PredicateContext` for MCP: + - `runningUnderXcode = detectRunningUnderXcode()` + - `xcodeToolsActive = computed from bridge status` + - Ensure bridge is initialized *before* final static tool registration if you want conflict hiding to apply on first list. + +### Modify +- `src/integrations/xcode-tools-bridge/manager.ts` + - Add status-change callback/event so bootstrap can re-apply registration when bridge status changes. + +--- + +## Phase 5 — Remove codegen + generated-plugin pipeline + +### Remove/stop using +- `build-plugins/plugin-discovery.ts` +- `src/core/generated-plugins.ts` +- `src/core/plugin-registry.ts` (either delete or repurpose to be a manifest registry) +- scripts: + - `scripts/analysis/tools-analysis.ts` + - `scripts/generate-tools-manifest.ts` + +### Update any imports +- Replace `WORKFLOW_METADATA` uses (CLI help) with manifest workflow titles/descriptions. + +--- + +## Phase 6 — Move tool metadata out of tool modules (part of this plan) + +### Target tool module shape +Instead of `export default { name, description, schema, handler, cli... }`, tools should export **only implementation**: + +```ts +// src/mcp/tools/simulator/boot_sim.ts +export const schema = baseSchemaObject.shape; +export const annotations = { /* optional */ }; +export async function handler(params: Record) { ... } +``` + +Your manifest provides: +- names (mcp/cli) +- descriptions (cli/mcp if you want separate) +- routing (stateful/daemonAffinity) +- availability +- predicates + +### Adapter during migration +`import-tool-module.ts` should support both forms initially: +- If `default` export exists and looks like old `PluginMeta`, take `schema/handler/annotations` +- Else use named exports + +Once migrated, you can delete `PluginMeta`-style exports and simplify types. + +**Side effects / important note:** any tool code that emits `nextSteps` referencing tool names must match manifest MCP names. This is easiest if MCP names remain the underscore-stable names you already use (recommended). + +--- + +## Notes + +### (1) Multiple manifests? +Yes—designed in. Use `manifests/tools/*.yaml` and `manifests/workflows/*.yaml`. The loader merges them with duplicate-ID errors. This keeps each file small and easy to manage. + +### (2) Move meta out of tool modules as part of the plan? +Included as **Phase 6** with a supported migration adapter so you can convert incrementally but still land “manifest is authoritative” early. + +### (3) Predicates registry only? +Yes—YAML only contains predicate names. Unknown predicate names are a startup error. + diff --git a/docs/dev/XCODE_IDE_TOOL_CONFLICTS.md b/docs/dev/XCODE_IDE_TOOL_CONFLICTS.md new file mode 100644 index 00000000..8ffbd8d9 --- /dev/null +++ b/docs/dev/XCODE_IDE_TOOL_CONFLICTS.md @@ -0,0 +1,328 @@ +# Tool Conflicts: XcodeBuildMCP vs Xcode Tools MCP (running inside Xcode agent) + +Date: 2026-02-04 + +This report is scoped to the “user is running inside Xcode’s integrated coding agent” scenario (Codex/Claude-in-Xcode), where the user expectation is that **build/test operations happen inside the Xcode IDE**, not via external `xcodebuild` invocations. + +The goal is to identify which existing XcodeBuildMCP tools become redundant or counterproductive once we also expose Xcode’s own MCP toolset via `xcrun mcpbridge` (proxied as `xcode_tools_*`). + +## Inputs / assumptions + +- XcodeBuildMCP can detect it is running under Xcode via process-tree detection (`detectXcodeRuntime` / `runningUnderXcode`). +- When `xcode-ide` workflow is enabled and the bridge is healthy, Xcode’s “Xcode Tools” MCP server tools are proxied and exposed as `xcode_tools_` (e.g. `xcode_tools_BuildProject`). +- The Xcode Tools MCP service includes IDE-scoped tools such as: + - `BuildProject`, `GetBuildLog` + - `GetTestList`, `RunAllTests`, `RunSomeTests` + - `XcodeListWindows` (to obtain `tabIdentifier`) + - IDE diagnostics + previews (`XcodeListNavigatorIssues`, `XcodeRefreshCodeIssuesInFile`, `RenderPreview`) + - Project navigator operations (`XcodeLS`, `XcodeRead`, `XcodeWrite`, `XcodeUpdate`, `XcodeMV`, `XcodeRM`, `XcodeMakeDir`, `XcodeGlob`, `XcodeGrep`) +- This report intentionally keeps the policy simple: tools marked “Yes” in the table are hidden whenever **(1)** we are running under Xcode and **(2)** Xcode Tools MCP is enabled/available. We do not try to detect project type, and we do not gate on the presence of individual remote tool names. + +This report focuses on **behavioral conflicts** (user intent + Xcode expectation), not literal name collisions (we prefix proxied tools so there are no name collisions). + +## What “conflict” means in the Xcode agent context + +When inside Xcode: + +- Running `xcodebuild` externally is surprising: users expect Xcode’s scheme/configuration, IDE build settings, and build system state to be authoritative. +- External builds/tests risk divergence from the IDE state (indexing, derived data location, active scheme/config, workspace selection, build logs, test results). +- The Xcode Tools MCP tools are **IDE-scoped**: they naturally align with “build/test in the IDE”. + +Therefore, any XcodeBuildMCP tool that primarily exists to build/test a workspace/project should be considered a “conflict” when Xcode Tools MCP is available. + +## Recommended mapping (Xcode Tools MCP → XcodeBuildMCP tools to hide) + +This table is the actionable “what to hide” list for the Xcode agent scenario. + +| User intent inside Xcode | Prefer Xcode Tools MCP | Hide XcodeBuildMCP tools | Notes | +| --- | --- | --- | --- | +| Build (iOS simulator) | `xcode_tools_BuildProject` | `simulator/build_sim`, `simulator/build_run_sim` | Avoid external `xcodebuild` builds competing with the IDE. | +| Build (iOS device) | `xcode_tools_BuildProject` | `device/build_device` | Same reasoning as simulator; keep device install/launch tools. | +| Build (macOS) | `xcode_tools_BuildProject` | `macos/build_macos`, `macos/build_run_macos` | Keep `launch_mac_app`/`stop_mac_app` if they remain useful. | +| Run tests | `xcode_tools_RunAllTests` / `xcode_tools_RunSomeTests` | `simulator/test_sim`, `device/test_device`, `macos/test_macos` | Use `xcode_tools_GetTestList` for discovery. | +| Select active workspace/tab | `xcode_tools_XcodeListWindows` | `*/discover_projs` | When inside Xcode, prefer tab-scoped operations over filesystem scanning. | +| SwiftPM build/test (inside Xcode) | `xcode_tools_BuildProject` / `xcode_tools_RunAllTests` | `swift-package/swift_package_build`, `swift-package/swift_package_test` | We likely cannot reliably detect “SPM-only vs Xcode-opened”; treat “inside Xcode” as sufficient. | +| Clean build artifacts | (TBD: if Xcode exposes clean) | `*/clean` | External cleans are surprising in IDE mode; hide unless we explicitly want them as an escape hatch. | +| Scaffold new projects | (none) | `project-scaffolding/*` | Inside Xcode agent mode the user already has an open workspace; scaffolding is noise by default. | +| Inspect build/test results | `xcode_tools_GetBuildLog` | (none) | We don’t have an equivalent; this is additive. | +| Inspect IDE issues/diagnostics | `xcode_tools_XcodeListNavigatorIssues`, `xcode_tools_XcodeRefreshCodeIssuesInFile` | (none) | Additive; these are uniquely IDE-scoped. | +| Render SwiftUI previews | `xcode_tools_RenderPreview` | (none) | Additive; we don’t have a preview renderer. | + +## High-confidence replacements (hide XcodeBuildMCP tool in favor of Xcode Tools MCP) + +These are the tools where the user expectation (“do it inside Xcode”) matches an Xcode Tools MCP tool, and using our external implementation is likely worse. + +### Builds + +Preferred (inside Xcode): `xcode_tools_BuildProject` (+ `xcode_tools_GetBuildLog` to read the result). + +Hide these XcodeBuildMCP tools when running under Xcode **and** Xcode Tools MCP is available: + +- `simulator/build_sim` +- `simulator/build_run_sim` +- `device/build_device` +- `macos/build_macos` +- `macos/build_run_macos` + +Rationale: +- These tools are conceptually “build in Xcode”; inside Xcode, the IDE’s own build orchestration should win. +- Even when they work, external builds can produce confusing logs/results compared to Xcode’s build log UI. + +Decision: +- Hide these tools in Xcode agent mode when Xcode Tools MCP is available. + +### Tests + +Preferred (inside Xcode): `xcode_tools_RunAllTests` / `xcode_tools_RunSomeTests` (+ `xcode_tools_GetTestList` for discovery). + +Hide these XcodeBuildMCP tools when running under Xcode **and** Xcode Tools MCP is available: + +- `simulator/test_sim` +- `device/test_device` +- `macos/test_macos` + +Optional (see “Medium-confidence” below): +- `swift-package/swift_package_test` + +Rationale: +- Same expectation mismatch as builds: inside Xcode, “run tests” should be IDE-driven for correct scheme/test plan selection and to keep results in the IDE’s world. + +Decision: +- Hide these tools in Xcode agent mode when Xcode Tools MCP is available. + +## Medium-confidence replacements (likely hide, but verify ergonomics first) + +These are tools where Xcode Tools MCP likely provides a better alternative, but the mapping isn’t 1:1 or may depend on project type. + +### Project/workspace discovery + +Candidate: +- `*/discover_projs` (appears in `simulator`, `device`, `macos`, `project-discovery`) + +Potential replacement (inside Xcode): +- `xcode_tools_XcodeListWindows` (gives you the active workspace + `tabIdentifier`) + +Recommendation: +- Hide `discover_projs` inside Xcode agent mode. +- Prefer tab-scoped context (`tabIdentifier`) over filesystem scanning to ensure builds/tests target the active Xcode workspace. + +Decision: +- Hide `discover_projs` in Xcode agent mode. +- Do not add a separate “window picker” tool; `XcodeListWindows` already provides the list and the agent can select the relevant `tabIdentifier`. + +### Swift Package builds/tests + +Potentially hide when inside Xcode and the package is opened in Xcode: + +- `swift-package/swift_package_build` +- `swift-package/swift_package_test` + +Preferred (inside Xcode): `xcode_tools_BuildProject` / `xcode_tools_RunAllTests`. + +Why it’s medium confidence: +- Our SwiftPM tools are valuable when working outside Xcode or for pure SwiftPM workflows. +- In Xcode agent mode, users may still want a “swift package” workflow if the package is not opened as an Xcode project/tab. + +Decision: +- Hide `swift_package_build` and `swift_package_test` in Xcode agent mode (regardless of project type detection), but keep them outside Xcode agent mode. + +### Build settings / scheme listing + +Candidates: +- `*/list_schemes` +- `*/show_build_settings` + +Xcode Tools MCP does not obviously provide a direct “list schemes” or “show build settings” equivalent; but the IDE *knows* these things and may surface them indirectly via build/test APIs (or via project navigator + metadata). + +Recommendation: +- Treat these as supporting tools for external `xcodebuild`-driven workflows. If we hide the external build/test workflows in Xcode agent mode, these are usually noise. + +Decision: +- Hide `*/list_schemes` and `*/show_build_settings` in Xcode agent mode if (after audit) no remaining exposed tool depends on them. +- If we still have any XcodeBuildMCP tool that requires scheme/config/build settings inside Xcode agent mode, keep these until that dependency is removed or replaced. + +## Low-confidence replacements (do not hide; keep XcodeBuildMCP) + +These remain useful inside Xcode because Xcode Tools MCP does not replace them, or because they operate on domains outside the IDE tool service. + +- Simulator/device control + UI automation: + - `simulator-management/*`, `simulator/boot_sim`, `simulator/open_sim`, `ui-automation/*` +- App install/launch/stop/log capture: + - `simulator/install_app_sim`, `simulator/launch_app_sim`, `simulator/stop_app_sim` + - `device/install_app_device`, `device/launch_app_device`, `device/stop_app_device` + - `logging/*` (device/sim log capture) +- Debugger workflows: + - `debugging/*` (LLDB/DAP/stack/breakpoints) + +Reasoning: +- Xcode Tools MCP is IDE-scoped and does not appear to cover simulator/device lifecycle automation at the level XcodeBuildMCP provides. +- Even if Xcode can run/debug, the agent often needs precise control (boot, tap, capture logs, etc.) that is outside the IDE tool service. + +Decision: +- Keep the simulator/device/debugging/logging automation tools. +- Hide `project-scaffolding/*` in Xcode agent mode by default (keep outside Xcode agent mode). + +## Concrete “hide” policy proposal (for implementation) + +When **all** conditions are true: + +1) `runningUnderXcode === true` (process-tree detection), and +2) Xcode Tools bridge is enabled/available (i.e. Xcode Tools MCP is in play), + +…then hide (do not register / do not list) the following XcodeBuildMCP tools: + +- Builds: `simulator/build_sim`, `simulator/build_run_sim`, `device/build_device`, `macos/build_macos`, `macos/build_run_macos` +- Tests: `simulator/test_sim`, `device/test_device`, `macos/test_macos` +- Discovery: `*/discover_projs` +- SwiftPM: `swift-package/swift_package_build`, `swift-package/swift_package_test` +- Scaffolding: `project-scaffolding/scaffold_ios_project`, `project-scaffolding/scaffold_macos_project` +- Clean: `*/clean` (unless we explicitly keep it as an escape hatch) + +Implementation note: +- “`*/clean`” maps to multiple workflows (`device/clean`, `macos/clean`, `simulator/clean`, `utilities/clean`). +- “`*/discover_projs`” maps to multiple workflows (`device/discover_projs`, `macos/discover_projs`, `simulator/discover_projs`, `project-discovery/discover_projs`). + +Notes: +- This should be “hide”, not “delete”: keep the tools as fallback when not running under Xcode, or when the bridge is unavailable/untrusted. +- Keep a config escape hatch (e.g. `preferXcodeToolsInXcodeAgent: true/false`) if you want to allow power users to force external builds/tests. + +## Interplay with workflow selection (`enabledWorkflows`) and workflow management + +There are two separate “filters” that decide what tools an agent sees: + +1) **Workflow selection** (coarse inclusion) + - The server registers tools from the workflows listed in config `enabledWorkflows` (plus mandatory workflows). + - If `enabledWorkflows` is empty, workflow selection defaults to the `simulator` workflow. + - The `manage-workflows` tool adjusts the *enabled workflow list* at runtime by adding/removing workflow names, then re-applying selection. + - Some workflows are effectively mandatory (e.g. `session-management`; `doctor` and `workflow-discovery` are auto-added based on config flags). + +2) **Xcode-agent-mode visibility filtering** (fine-grained hiding) + - This report proposes an additional post-selection rule that hides specific tools when running inside Xcode and Xcode Tools MCP is enabled/available. + - This is intentionally layered **after** workflow selection: a tool must be included by workflow selection *first* before it can be hidden by visibility filtering. + - Effect: enabling a workflow does not guarantee every tool in that workflow is visible in Xcode agent mode (by design). + +Important nuance: some tools are **re-exported** across workflows (same MCP tool name appears in multiple workflows). Disabling one workflow might not remove the tool if another enabled workflow still provides it; the visibility filter applies to the tool name regardless of where it came from. + +## Why this improves the Xcode agent UX + +- Matches the user’s mental model: “I’m in Xcode; builds/tests happen in Xcode”. +- Makes tool choice less noisy: the agent isn’t offered two competing ways to “build” or “run tests”. +- Reduces risk of confusing state divergence (derived data, schemes, logs, test results). + +## Follow-up validation tasks (before hard-hiding) + +These are quick checks to run once the Xcode Tools bridge is stable in the Xcode agent environment: + +- Confirm `BuildProject` supports the projects you care about (workspace vs project, SPM package, build configurations). +- Confirm test tools (`RunAllTests` / `RunSomeTests`) map cleanly to scheme/test plan selection and produce useful results. +- Confirm `GetBuildLog` provides enough structure for agents (errors/warnings + file locations). +- Confirm whether Xcode exposes “clean” semantics via `BuildProject` (or some other tool). If it does, `*/clean` may become a hide candidate too. +- Audit remaining exposed XcodeBuildMCP tools in Xcode agent mode to ensure none require schemes/build settings (otherwise keep `*/list_schemes` and `*/show_build_settings`). + +## Full tool catalog decisions (Xcode agent mode) + +The table below is the complete XcodeBuildMCP tool catalog (from `build/tools-manifest.json`) with the yes/no decision for whether to hide the tool when: + +- Running under Xcode is `true`, AND +- Xcode Tools MCP is enabled/available + +| Workflow | Tool | Hide In Xcode Agent Mode? | Notes | +| --- | --- | --- | --- | +| debugging | debug_attach_sim | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_breakpoint_add | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_breakpoint_remove | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_continue | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_detach | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_lldb_command | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_stack | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| debugging | debug_variables | No | No Xcode Tools equivalent; simulator debugging via LLDB/DAP. | +| device | build_device | Yes | Prefer `xcode_tools_BuildProject` (IDE build). | +| device | clean | Yes | External clean is surprising in IDE mode; keep as non-Xcode fallback only. Re-export of utilities. | +| device | discover_projs | Yes | Prefer `xcode_tools_XcodeListWindows` + `tabIdentifier` over filesystem scanning. Re-export of project-discovery. | +| device | get_app_bundle_id | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. Re-export of project-discovery. | +| device | get_device_app_path | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. | +| device | install_app_device | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. | +| device | launch_app_device | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. | +| device | list_devices | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. | +| device | list_schemes | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. Re-export of project-discovery. | +| device | show_build_settings | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. Re-export of project-discovery. | +| device | start_device_log_cap | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. Re-export of logging. | +| device | stop_app_device | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. | +| device | stop_device_log_cap | No | Runtime device workflow (install/launch/logs/etc) not covered by Xcode Tools MCP. Re-export of logging. | +| device | test_device | Yes | Prefer `xcode_tools_RunAllTests`/`xcode_tools_RunSomeTests` (IDE tests). | +| doctor | doctor | No | Diagnostics; still needed. | +| logging | start_device_log_cap | No | No Xcode Tools equivalent; log capture (sim/device). | +| logging | start_sim_log_cap | No | No Xcode Tools equivalent; log capture (sim/device). | +| logging | stop_device_log_cap | No | No Xcode Tools equivalent; log capture (sim/device). | +| logging | stop_sim_log_cap | No | No Xcode Tools equivalent; log capture (sim/device). | +| macos | build_macos | Yes | Prefer `xcode_tools_BuildProject` (IDE build). | +| macos | build_run_macos | Yes | Prefer `xcode_tools_BuildProject` (IDE build). | +| macos | clean | Yes | External clean is surprising in IDE mode; keep as non-Xcode fallback only. Re-export of utilities. | +| macos | discover_projs | Yes | Prefer `xcode_tools_XcodeListWindows` + `tabIdentifier` over filesystem scanning. Re-export of project-discovery. | +| macos | get_mac_app_path | No | Runtime macOS app lifecycle not covered by Xcode Tools MCP. | +| macos | get_mac_bundle_id | No | Runtime macOS app lifecycle not covered by Xcode Tools MCP. Re-export of project-discovery. | +| macos | launch_mac_app | No | Runtime macOS app lifecycle not covered by Xcode Tools MCP. | +| macos | list_schemes | No | Runtime macOS app lifecycle not covered by Xcode Tools MCP. Re-export of project-discovery. | +| macos | show_build_settings | No | Runtime macOS app lifecycle not covered by Xcode Tools MCP. Re-export of project-discovery. | +| macos | stop_mac_app | No | Runtime macOS app lifecycle not covered by Xcode Tools MCP. | +| macos | test_macos | Yes | Prefer `xcode_tools_RunAllTests`/`xcode_tools_RunSomeTests` (IDE tests). | +| project-discovery | discover_projs | Yes | Prefer `xcode_tools_XcodeListWindows` + `tabIdentifier` over filesystem scanning. | +| project-discovery | get_app_bundle_id | No | Useful for non-IDE flows and for remaining runtime tools. | +| project-discovery | get_mac_bundle_id | No | Useful for non-IDE flows and for remaining runtime tools. | +| project-discovery | list_schemes | No | Useful for non-IDE flows and for remaining runtime tools. | +| project-discovery | show_build_settings | No | Useful for non-IDE flows and for remaining runtime tools. | +| project-scaffolding | scaffold_ios_project | Yes | Not expected inside Xcode agent mode (project already open). | +| project-scaffolding | scaffold_macos_project | Yes | Not expected inside Xcode agent mode (project already open). | +| session-management | session_clear_defaults | No | Session defaults plumbing; still needed. | +| session-management | session_set_defaults | No | Session defaults plumbing; still needed. | +| session-management | session_show_defaults | No | Session defaults plumbing; still needed. | +| simulator | boot_sim | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | build_run_sim | Yes | Prefer `xcode_tools_BuildProject` (IDE build). | +| simulator | build_sim | Yes | Prefer `xcode_tools_BuildProject` (IDE build). | +| simulator | clean | Yes | External clean is surprising in IDE mode; keep as non-Xcode fallback only. Re-export of utilities. | +| simulator | discover_projs | Yes | Prefer `xcode_tools_XcodeListWindows` + `tabIdentifier` over filesystem scanning. Re-export of project-discovery. | +| simulator | get_app_bundle_id | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. Re-export of project-discovery. | +| simulator | get_sim_app_path | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | install_app_sim | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | launch_app_logs_sim | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | launch_app_sim | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | list_schemes | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. Re-export of project-discovery. | +| simulator | list_sims | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | open_sim | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | record_sim_video | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | screenshot | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. Re-export of ui-automation. | +| simulator | show_build_settings | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. Re-export of project-discovery. | +| simulator | snapshot_ui | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. Re-export of ui-automation. | +| simulator | stop_app_sim | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. | +| simulator | stop_sim_log_cap | No | Runtime simulator workflow (install/launch/UI/etc) not covered by Xcode Tools MCP. Re-export of logging. | +| simulator | test_sim | Yes | Prefer `xcode_tools_RunAllTests`/`xcode_tools_RunSomeTests` (IDE tests). | +| simulator-management | boot_sim | No | Simulator fleet management not covered by Xcode Tools MCP. Re-export of simulator. | +| simulator-management | erase_sims | No | Simulator fleet management not covered by Xcode Tools MCP. | +| simulator-management | list_sims | No | Simulator fleet management not covered by Xcode Tools MCP. Re-export of simulator. | +| simulator-management | open_sim | No | Simulator fleet management not covered by Xcode Tools MCP. Re-export of simulator. | +| simulator-management | reset_sim_location | No | Simulator fleet management not covered by Xcode Tools MCP. | +| simulator-management | set_sim_appearance | No | Simulator fleet management not covered by Xcode Tools MCP. | +| simulator-management | set_sim_location | No | Simulator fleet management not covered by Xcode Tools MCP. | +| simulator-management | sim_statusbar | No | Simulator fleet management not covered by Xcode Tools MCP. | +| swift-package | swift_package_build | Yes | Build/test should run via Xcode IDE in agent mode. | +| swift-package | swift_package_clean | No | Keep (no clear IDE-scoped replacement). | +| swift-package | swift_package_list | No | Keep (no clear IDE-scoped replacement). | +| swift-package | swift_package_run | No | Keep (no clear IDE-scoped replacement). | +| swift-package | swift_package_stop | No | Keep (no clear IDE-scoped replacement). | +| swift-package | swift_package_test | Yes | Build/test should run via Xcode IDE in agent mode. | +| ui-automation | button | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | gesture | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | key_press | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | key_sequence | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | long_press | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | screenshot | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | snapshot_ui | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | swipe | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | tap | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | touch | No | No Xcode Tools equivalent; simulator UI automation. | +| ui-automation | type_text | No | No Xcode Tools equivalent; simulator UI automation. | +| utilities | clean | Yes | External clean is surprising in IDE mode; keep as non-Xcode fallback only. | +| workflow-discovery | manage_workflows | No | Workflow toggling; still needed. | +| xcode-ide | xcode_tools_bridge_disconnect | No | Bridge debug-only tools (also gated by `debug: true`). | +| xcode-ide | xcode_tools_bridge_status | No | Bridge debug-only tools (also gated by `debug: true`). | +| xcode-ide | xcode_tools_bridge_sync | No | Bridge debug-only tools (also gated by `debug: true`). | diff --git a/docs/dev/oracle-prompt-workspace-daemon.md b/docs/dev/oracle-prompt-workspace-daemon.md index c409ff91..9341d0d1 100644 --- a/docs/dev/oracle-prompt-workspace-daemon.md +++ b/docs/dev/oracle-prompt-workspace-daemon.md @@ -1300,7 +1300,7 @@ export default defineConfig({ ```json { "bin": { - "xcodebuildmcp": "build/index.js", + "xcodebuildmcp": "build/cli.js", "xcodebuildmcp-doctor": "build/doctor-cli.js", "xcodebuildcli": "build/cli.js" }, diff --git a/docs/dev/session_management_plan.md b/docs/dev/session_management_plan.md index dd0ccdf5..9c539e7c 100644 --- a/docs/dev/session_management_plan.md +++ b/docs/dev/session_management_plan.md @@ -436,7 +436,7 @@ npm run build 2) Discover a scheme (optional helper): ```bash -mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js mcp +mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/cli.js mcp ``` 3) Set the session defaults (project/workspace, scheme, and simulator): @@ -446,30 +446,30 @@ mcpli --raw session-set-defaults \ --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \ --scheme MCPTest \ --simulatorName "iPhone 16" \ - -- node build/index.js mcp + -- node build/cli.js mcp ``` 4) Verify defaults are stored: ```bash -mcpli --raw session-show-defaults -- node build/index.js mcp +mcpli --raw session-show-defaults -- node build/cli.js mcp ``` 5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically): ```bash # Optionally provide a scratch derived data path and a short timeout -mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js mcp +mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/cli.js mcp ``` Troubleshooting: - If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys. - If you see connect ECONNREFUSED or the daemon appears flaky: - - Check logs: `mcpli daemon log --since=10m -- node build/index.js mcp` - - Restart daemon: `mcpli daemon restart -- node build/index.js mcp` - - Clean daemon state: `mcpli daemon clean -- node build/index.js mcp` then `mcpli daemon start -- node build/index.js mcp` - - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js mcp` + - Check logs: `mcpli daemon log --since=10m -- node build/cli.js mcp` + - Restart daemon: `mcpli daemon restart -- node build/cli.js mcp` + - Clean daemon state: `mcpli daemon clean -- node build/cli.js mcp` then `mcpli daemon start -- node build/cli.js mcp` + - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/cli.js mcp` Notes: diff --git a/docs/investigations/INVESTIGATION_TOOL_DISCOVERY.md b/docs/investigations/INVESTIGATION_TOOL_DISCOVERY.md new file mode 100644 index 00000000..bf3154de --- /dev/null +++ b/docs/investigations/INVESTIGATION_TOOL_DISCOVERY.md @@ -0,0 +1,54 @@ +# Investigation: Workflow/Tool Discovery, Registration, and Visibility (CLI vs MCP) + +## Summary +Discovery is build-time (filesystem -> generated loaders), with runtime selection/visibility filtering applied in CLI/MCP/daemon catalogs. CLI `tools` listing diverges because it reads a static manifest that bypasses runtime visibility and naming rules. + +## Symptoms +- CLI `tools` output can list tools that runtime CLI commands do not expose (debug-gated tools). +- Tool names in the manifest can differ from actual MCP tool names when export `name` differs from filename. + +## Investigation Log + +### 2026-02-04 - Build-time discovery and codegen +**Hypothesis:** Tools/workflows are discovered at build-time from filesystem and compiled into generated loaders. +**Findings:** Discovery scans `src/mcp/tools/*` and generates loaders/metadata in `src/core/generated-plugins.ts`, which runtime uses for plugin loading. +**Evidence:** `build-plugins/plugin-discovery.ts`, `src/core/generated-plugins.ts`, `src/core/plugin-registry.ts`. +**Conclusion:** Confirmed. Runtime does not live-scan the filesystem; it depends on generated loaders. + +### 2026-02-04 - Runtime catalog and visibility filtering +**Hypothesis:** Runtime catalogs apply workflow selection and tool visibility filters. +**Findings:** `buildToolCatalog()` filters tools through `shouldExposeTool()` and resolves workflow selection, then CLI registers tool commands from the catalog. +**Evidence:** `src/runtime/tool-catalog.ts`, `src/utils/tool-visibility.ts`, `src/utils/workflow-selection.ts`, `src/cli/cli-tool-catalog.ts`, `src/cli/register-tool-commands.ts`. +**Conclusion:** Confirmed. Runtime catalog is the source for actual CLI commands, not the manifest. + +### 2026-02-04 - CLI `tools` list divergence +**Hypothesis:** `xcodebuildmcp tools` uses a static manifest and ignores runtime visibility gates. +**Findings:** `tools` command loads `tools-manifest.json` and filters only by workflow exclusion/CLI flags; it does not call `buildToolCatalog()` or `shouldExposeTool()`. +**Evidence:** `src/cli/commands/tools.ts`, `scripts/generate-tools-manifest.ts`, `scripts/analysis/tools-analysis.ts`. +**Conclusion:** Confirmed. CLI `tools` can disagree with runtime command availability, especially for debug-gated tools. + +### 2026-02-04 - Tool name mismatch (manifest vs runtime) +**Hypothesis:** Manifest uses filename as tool name while runtime uses export `name`, causing mismatches. +**Findings:** Manifest derives `ToolInfo.name` from file basename and writes `mcpName` from that; runtime uses `export default { name: ... }` as tool identity. +**Evidence:** `scripts/analysis/tools-analysis.ts`, `scripts/generate-tools-manifest.ts`, `src/core/plugin-registry.ts`, `src/mcp/tools/workflow-discovery/manage_workflows.ts`. +**Conclusion:** Confirmed. Example mismatch: `manage_workflows.ts` exports `name: 'manage-workflows'` but manifest reports `manage_workflows`. + +### 2026-02-04 - Recent history review +**Hypothesis:** Recent commits may have introduced or modified these discovery/visibility flows. +**Findings:** Recent commits include MCP tool support and tool analysis updates around 2026-02-04. +**Evidence:** `git log -n 20` (commit `df690484` and `f4604d65`). +**Conclusion:** The MCP additions and tool analysis updates are recent; they likely interact with the manifest/runtime divergence. + +## Root Cause +1) **CLI `tools` listing bypasses runtime catalog and visibility gates.** It reads a static manifest file and does not consult `shouldExposeTool()` or disambiguated CLI names, so it can show tools not available at runtime. +2) **Manifest naming uses filename as canonical tool identity.** Runtime identity is the exported `name` field, so any filename/name mismatch causes the manifest (and CLI listing/docs) to diverge from actual MCP tool names. + +## Recommendations +1. Align `xcodebuildmcp tools` with the runtime catalog output (or apply the same visibility gates and naming rules in manifest generation). +2. Decide on a single source-of-truth for tool names: + - Enforce `filename === export name`, or + - Parse `name` from the default export during manifest generation. + +## Preventive Measures +- Add a build-time validation step that fails when tool filenames and exported names diverge. +- Add a test that compares manifest output with runtime catalog to catch visibility/name drift. diff --git a/docs/investigations/launch-app-logs-sim.md b/docs/investigations/launch-app-logs-sim.md index 792836cd..9becfed2 100644 --- a/docs/investigations/launch-app-logs-sim.md +++ b/docs/investigations/launch-app-logs-sim.md @@ -4,7 +4,7 @@ The CLI remains alive because `launch_app_logs_sim` starts long-running log capture processes and keeps open streams in the same Node process, and the tool is not marked `cli.stateful` so it does not route through the daemon. ## Symptoms -- `node build/index.js simulator launch-app-logs-sim --simulator-id B38FE93D-578B-454B-BE9A-C6FA0CE5F096 --bundle-id com.example.calculatorapp` keeps the CLI process running while the app is running. +- `node build/cli.js simulator launch-app-logs-sim --simulator-id B38FE93D-578B-454B-BE9A-C6FA0CE5F096 --bundle-id com.example.calculatorapp` keeps the CLI process running while the app is running. ## Investigation Log diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml new file mode 100644 index 00000000..d10a392d --- /dev/null +++ b/manifests/tools/boot_sim.yaml @@ -0,0 +1,13 @@ +id: boot_sim +module: mcp/tools/simulator/boot_sim +names: + mcp: boot_sim +description: "Boot iOS simulator." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/build_device.yaml b/manifests/tools/build_device.yaml new file mode 100644 index 00000000..f2a1e6d6 --- /dev/null +++ b/manifests/tools/build_device.yaml @@ -0,0 +1,14 @@ +id: build_device +module: mcp/tools/device/build_device +names: + mcp: build_device +description: "Build for device." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/build_macos.yaml b/manifests/tools/build_macos.yaml new file mode 100644 index 00000000..2f887d04 --- /dev/null +++ b/manifests/tools/build_macos.yaml @@ -0,0 +1,14 @@ +id: build_macos +module: mcp/tools/macos/build_macos +names: + mcp: build_macos +description: "Build macOS app." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/build_run_macos.yaml b/manifests/tools/build_run_macos.yaml new file mode 100644 index 00000000..700aa843 --- /dev/null +++ b/manifests/tools/build_run_macos.yaml @@ -0,0 +1,14 @@ +id: build_run_macos +module: mcp/tools/macos/build_run_macos +names: + mcp: build_run_macos +description: "Build and run macOS app." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml new file mode 100644 index 00000000..a4dfb615 --- /dev/null +++ b/manifests/tools/build_run_sim.yaml @@ -0,0 +1,14 @@ +id: build_run_sim +module: mcp/tools/simulator/build_run_sim +names: + mcp: build_run_sim +description: "Build and run iOS sim." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml new file mode 100644 index 00000000..c6949f5d --- /dev/null +++ b/manifests/tools/build_sim.yaml @@ -0,0 +1,14 @@ +id: build_sim +module: mcp/tools/simulator/build_sim +names: + mcp: build_sim +description: "Build for iOS sim." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/button.yaml b/manifests/tools/button.yaml new file mode 100644 index 00000000..c09af445 --- /dev/null +++ b/manifests/tools/button.yaml @@ -0,0 +1,13 @@ +id: button +module: mcp/tools/ui-automation/button +names: + mcp: button +description: "Press simulator hardware button." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/clean.yaml b/manifests/tools/clean.yaml new file mode 100644 index 00000000..532bdcf9 --- /dev/null +++ b/manifests/tools/clean.yaml @@ -0,0 +1,14 @@ +id: clean +module: mcp/tools/utilities/clean +names: + mcp: clean +description: "Clean build products." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/debug_attach_sim.yaml b/manifests/tools/debug_attach_sim.yaml new file mode 100644 index 00000000..3f8bc840 --- /dev/null +++ b/manifests/tools/debug_attach_sim.yaml @@ -0,0 +1,13 @@ +id: debug_attach_sim +module: mcp/tools/debugging/debug_attach_sim +names: + mcp: debug_attach_sim +description: "Attach LLDB to sim app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_breakpoint_add.yaml b/manifests/tools/debug_breakpoint_add.yaml new file mode 100644 index 00000000..ff21c825 --- /dev/null +++ b/manifests/tools/debug_breakpoint_add.yaml @@ -0,0 +1,13 @@ +id: debug_breakpoint_add +module: mcp/tools/debugging/debug_breakpoint_add +names: + mcp: debug_breakpoint_add +description: "Add breakpoint." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_breakpoint_remove.yaml b/manifests/tools/debug_breakpoint_remove.yaml new file mode 100644 index 00000000..88683fd2 --- /dev/null +++ b/manifests/tools/debug_breakpoint_remove.yaml @@ -0,0 +1,13 @@ +id: debug_breakpoint_remove +module: mcp/tools/debugging/debug_breakpoint_remove +names: + mcp: debug_breakpoint_remove +description: "Remove breakpoint." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_continue.yaml b/manifests/tools/debug_continue.yaml new file mode 100644 index 00000000..90e5bedf --- /dev/null +++ b/manifests/tools/debug_continue.yaml @@ -0,0 +1,13 @@ +id: debug_continue +module: mcp/tools/debugging/debug_continue +names: + mcp: debug_continue +description: "Continue debug session." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_detach.yaml b/manifests/tools/debug_detach.yaml new file mode 100644 index 00000000..143781ea --- /dev/null +++ b/manifests/tools/debug_detach.yaml @@ -0,0 +1,13 @@ +id: debug_detach +module: mcp/tools/debugging/debug_detach +names: + mcp: debug_detach +description: "Detach debugger." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_lldb_command.yaml b/manifests/tools/debug_lldb_command.yaml new file mode 100644 index 00000000..c3b199da --- /dev/null +++ b/manifests/tools/debug_lldb_command.yaml @@ -0,0 +1,13 @@ +id: debug_lldb_command +module: mcp/tools/debugging/debug_lldb_command +names: + mcp: debug_lldb_command +description: "Run LLDB command." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_stack.yaml b/manifests/tools/debug_stack.yaml new file mode 100644 index 00000000..8c676aba --- /dev/null +++ b/manifests/tools/debug_stack.yaml @@ -0,0 +1,13 @@ +id: debug_stack +module: mcp/tools/debugging/debug_stack +names: + mcp: debug_stack +description: "Get backtrace." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/debug_variables.yaml b/manifests/tools/debug_variables.yaml new file mode 100644 index 00000000..21600d6e --- /dev/null +++ b/manifests/tools/debug_variables.yaml @@ -0,0 +1,13 @@ +id: debug_variables +module: mcp/tools/debugging/debug_variables +names: + mcp: debug_variables +description: "Get frame variables." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/discover_projs.yaml b/manifests/tools/discover_projs.yaml new file mode 100644 index 00000000..2feb803c --- /dev/null +++ b/manifests/tools/discover_projs.yaml @@ -0,0 +1,14 @@ +id: discover_projs +module: mcp/tools/project-discovery/discover_projs +names: + mcp: discover_projs +description: "Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/doctor.yaml b/manifests/tools/doctor.yaml new file mode 100644 index 00000000..be2f7545 --- /dev/null +++ b/manifests/tools/doctor.yaml @@ -0,0 +1,11 @@ +id: doctor +module: mcp/tools/doctor/doctor +names: + mcp: doctor +description: "MCP environment info." +availability: + mcp: true + cli: true + daemon: true +predicates: + - debugEnabled diff --git a/manifests/tools/erase_sims.yaml b/manifests/tools/erase_sims.yaml new file mode 100644 index 00000000..59b3d26e --- /dev/null +++ b/manifests/tools/erase_sims.yaml @@ -0,0 +1,13 @@ +id: erase_sims +module: mcp/tools/simulator-management/erase_sims +names: + mcp: erase_sims +description: "Erase simulator." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/gesture.yaml b/manifests/tools/gesture.yaml new file mode 100644 index 00000000..ff347464 --- /dev/null +++ b/manifests/tools/gesture.yaml @@ -0,0 +1,13 @@ +id: gesture +module: mcp/tools/ui-automation/gesture +names: + mcp: gesture +description: "Simulator gesture preset." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/get_app_bundle_id.yaml b/manifests/tools/get_app_bundle_id.yaml new file mode 100644 index 00000000..4c38f4dc --- /dev/null +++ b/manifests/tools/get_app_bundle_id.yaml @@ -0,0 +1,13 @@ +id: get_app_bundle_id +module: mcp/tools/project-discovery/get_app_bundle_id +names: + mcp: get_app_bundle_id +description: "Extract bundle id from .app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/get_device_app_path.yaml b/manifests/tools/get_device_app_path.yaml new file mode 100644 index 00000000..f17a386e --- /dev/null +++ b/manifests/tools/get_device_app_path.yaml @@ -0,0 +1,13 @@ +id: get_device_app_path +module: mcp/tools/device/get_device_app_path +names: + mcp: get_device_app_path +description: "Get device built app path." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/get_mac_app_path.yaml b/manifests/tools/get_mac_app_path.yaml new file mode 100644 index 00000000..ae871032 --- /dev/null +++ b/manifests/tools/get_mac_app_path.yaml @@ -0,0 +1,13 @@ +id: get_mac_app_path +module: mcp/tools/macos/get_mac_app_path +names: + mcp: get_mac_app_path +description: "Get macOS built app path." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/get_mac_bundle_id.yaml b/manifests/tools/get_mac_bundle_id.yaml new file mode 100644 index 00000000..642dec7c --- /dev/null +++ b/manifests/tools/get_mac_bundle_id.yaml @@ -0,0 +1,13 @@ +id: get_mac_bundle_id +module: mcp/tools/project-discovery/get_mac_bundle_id +names: + mcp: get_mac_bundle_id +description: "Extract bundle id from macOS .app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/get_sim_app_path.yaml b/manifests/tools/get_sim_app_path.yaml new file mode 100644 index 00000000..b367a0b5 --- /dev/null +++ b/manifests/tools/get_sim_app_path.yaml @@ -0,0 +1,13 @@ +id: get_sim_app_path +module: mcp/tools/simulator/get_sim_app_path +names: + mcp: get_sim_app_path +description: "Get sim built app path." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/install_app_device.yaml b/manifests/tools/install_app_device.yaml new file mode 100644 index 00000000..fd41cd57 --- /dev/null +++ b/manifests/tools/install_app_device.yaml @@ -0,0 +1,13 @@ +id: install_app_device +module: mcp/tools/device/install_app_device +names: + mcp: install_app_device +description: "Install app on device." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/install_app_sim.yaml b/manifests/tools/install_app_sim.yaml new file mode 100644 index 00000000..931a6a7f --- /dev/null +++ b/manifests/tools/install_app_sim.yaml @@ -0,0 +1,13 @@ +id: install_app_sim +module: mcp/tools/simulator/install_app_sim +names: + mcp: install_app_sim +description: "Install app on sim." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/key_press.yaml b/manifests/tools/key_press.yaml new file mode 100644 index 00000000..601e5282 --- /dev/null +++ b/manifests/tools/key_press.yaml @@ -0,0 +1,13 @@ +id: key_press +module: mcp/tools/ui-automation/key_press +names: + mcp: key_press +description: "Press key by keycode." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/key_sequence.yaml b/manifests/tools/key_sequence.yaml new file mode 100644 index 00000000..34b74a73 --- /dev/null +++ b/manifests/tools/key_sequence.yaml @@ -0,0 +1,13 @@ +id: key_sequence +module: mcp/tools/ui-automation/key_sequence +names: + mcp: key_sequence +description: "Press a sequence of keys by their keycodes." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/launch_app_device.yaml b/manifests/tools/launch_app_device.yaml new file mode 100644 index 00000000..84bf5539 --- /dev/null +++ b/manifests/tools/launch_app_device.yaml @@ -0,0 +1,13 @@ +id: launch_app_device +module: mcp/tools/device/launch_app_device +names: + mcp: launch_app_device +description: "Launch app on device." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml new file mode 100644 index 00000000..7bb4047f --- /dev/null +++ b/manifests/tools/launch_app_logs_sim.yaml @@ -0,0 +1,13 @@ +id: launch_app_logs_sim +module: mcp/tools/simulator/launch_app_logs_sim +names: + mcp: launch_app_logs_sim +description: "Launch sim app with logs." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: preferred diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml new file mode 100644 index 00000000..4d7aafb6 --- /dev/null +++ b/manifests/tools/launch_app_sim.yaml @@ -0,0 +1,13 @@ +id: launch_app_sim +module: mcp/tools/simulator/launch_app_sim +names: + mcp: launch_app_sim +description: "Launch app on simulator." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/launch_mac_app.yaml b/manifests/tools/launch_mac_app.yaml new file mode 100644 index 00000000..b7ffc026 --- /dev/null +++ b/manifests/tools/launch_mac_app.yaml @@ -0,0 +1,13 @@ +id: launch_mac_app +module: mcp/tools/macos/launch_mac_app +names: + mcp: launch_mac_app +description: "Launch macOS app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/list_devices.yaml b/manifests/tools/list_devices.yaml new file mode 100644 index 00000000..c315a743 --- /dev/null +++ b/manifests/tools/list_devices.yaml @@ -0,0 +1,13 @@ +id: list_devices +module: mcp/tools/device/list_devices +names: + mcp: list_devices +description: "List connected devices." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml new file mode 100644 index 00000000..e754bb58 --- /dev/null +++ b/manifests/tools/list_schemes.yaml @@ -0,0 +1,14 @@ +id: list_schemes +module: mcp/tools/project-discovery/list_schemes +names: + mcp: list_schemes +description: "List Xcode schemes." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/list_sims.yaml b/manifests/tools/list_sims.yaml new file mode 100644 index 00000000..b7af7179 --- /dev/null +++ b/manifests/tools/list_sims.yaml @@ -0,0 +1,13 @@ +id: list_sims +module: mcp/tools/simulator/list_sims +names: + mcp: list_sims +description: "List iOS simulators." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/long_press.yaml b/manifests/tools/long_press.yaml new file mode 100644 index 00000000..8abffb16 --- /dev/null +++ b/manifests/tools/long_press.yaml @@ -0,0 +1,13 @@ +id: long_press +module: mcp/tools/ui-automation/long_press +names: + mcp: long_press +description: "Long press at coords." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/manage_workflows.yaml b/manifests/tools/manage_workflows.yaml new file mode 100644 index 00000000..34c91d0f --- /dev/null +++ b/manifests/tools/manage_workflows.yaml @@ -0,0 +1,11 @@ +id: manage_workflows +module: mcp/tools/workflow-discovery/manage_workflows +names: + mcp: manage-workflows +description: "Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled." +availability: + mcp: true + cli: false + daemon: false +predicates: + - experimentalWorkflowDiscoveryEnabled diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml new file mode 100644 index 00000000..78395e38 --- /dev/null +++ b/manifests/tools/open_sim.yaml @@ -0,0 +1,13 @@ +id: open_sim +module: mcp/tools/simulator/open_sim +names: + mcp: open_sim +description: "Open Simulator app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/record_sim_video.yaml b/manifests/tools/record_sim_video.yaml new file mode 100644 index 00000000..75b8a9f9 --- /dev/null +++ b/manifests/tools/record_sim_video.yaml @@ -0,0 +1,13 @@ +id: record_sim_video +module: mcp/tools/simulator/record_sim_video +names: + mcp: record_sim_video +description: "Record sim video." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: preferred diff --git a/manifests/tools/reset_sim_location.yaml b/manifests/tools/reset_sim_location.yaml new file mode 100644 index 00000000..297ba7e4 --- /dev/null +++ b/manifests/tools/reset_sim_location.yaml @@ -0,0 +1,13 @@ +id: reset_sim_location +module: mcp/tools/simulator-management/reset_sim_location +names: + mcp: reset_sim_location +description: "Reset sim location." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/scaffold_ios_project.yaml b/manifests/tools/scaffold_ios_project.yaml new file mode 100644 index 00000000..977a0978 --- /dev/null +++ b/manifests/tools/scaffold_ios_project.yaml @@ -0,0 +1,14 @@ +id: scaffold_ios_project +module: mcp/tools/project-scaffolding/scaffold_ios_project +names: + mcp: scaffold_ios_project +description: "Scaffold iOS project." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/scaffold_macos_project.yaml b/manifests/tools/scaffold_macos_project.yaml new file mode 100644 index 00000000..c0c17516 --- /dev/null +++ b/manifests/tools/scaffold_macos_project.yaml @@ -0,0 +1,14 @@ +id: scaffold_macos_project +module: mcp/tools/project-scaffolding/scaffold_macos_project +names: + mcp: scaffold_macos_project +description: "Scaffold macOS project." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/screenshot.yaml b/manifests/tools/screenshot.yaml new file mode 100644 index 00000000..00dd9c59 --- /dev/null +++ b/manifests/tools/screenshot.yaml @@ -0,0 +1,13 @@ +id: screenshot +module: mcp/tools/ui-automation/screenshot +names: + mcp: screenshot +description: "Capture screenshot." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/session_clear_defaults.yaml b/manifests/tools/session_clear_defaults.yaml new file mode 100644 index 00000000..63d7bc68 --- /dev/null +++ b/manifests/tools/session_clear_defaults.yaml @@ -0,0 +1,10 @@ +id: session_clear_defaults +module: mcp/tools/session-management/session_clear_defaults +names: + mcp: session_clear_defaults +description: "Clear session defaults." +availability: + mcp: true + cli: false + daemon: false +predicates: [] diff --git a/manifests/tools/session_set_defaults.yaml b/manifests/tools/session_set_defaults.yaml new file mode 100644 index 00000000..2014a4b6 --- /dev/null +++ b/manifests/tools/session_set_defaults.yaml @@ -0,0 +1,10 @@ +id: session_set_defaults +module: mcp/tools/session-management/session_set_defaults +names: + mcp: session_set_defaults +description: "Set the session defaults, should be called at least once to set tool defaults." +availability: + mcp: true + cli: false + daemon: false +predicates: [] diff --git a/manifests/tools/session_show_defaults.yaml b/manifests/tools/session_show_defaults.yaml new file mode 100644 index 00000000..bd8f8d6c --- /dev/null +++ b/manifests/tools/session_show_defaults.yaml @@ -0,0 +1,10 @@ +id: session_show_defaults +module: mcp/tools/session-management/session_show_defaults +names: + mcp: session_show_defaults +description: "Show session defaults." +availability: + mcp: true + cli: false + daemon: false +predicates: [] diff --git a/manifests/tools/set_sim_appearance.yaml b/manifests/tools/set_sim_appearance.yaml new file mode 100644 index 00000000..f60891d4 --- /dev/null +++ b/manifests/tools/set_sim_appearance.yaml @@ -0,0 +1,13 @@ +id: set_sim_appearance +module: mcp/tools/simulator-management/set_sim_appearance +names: + mcp: set_sim_appearance +description: "Set sim appearance." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/set_sim_location.yaml b/manifests/tools/set_sim_location.yaml new file mode 100644 index 00000000..f46acf6b --- /dev/null +++ b/manifests/tools/set_sim_location.yaml @@ -0,0 +1,13 @@ +id: set_sim_location +module: mcp/tools/simulator-management/set_sim_location +names: + mcp: set_sim_location +description: "Set sim location." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/show_build_settings.yaml b/manifests/tools/show_build_settings.yaml new file mode 100644 index 00000000..68d2121d --- /dev/null +++ b/manifests/tools/show_build_settings.yaml @@ -0,0 +1,14 @@ +id: show_build_settings +module: mcp/tools/project-discovery/show_build_settings +names: + mcp: show_build_settings +description: "Show build settings." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/sim_statusbar.yaml b/manifests/tools/sim_statusbar.yaml new file mode 100644 index 00000000..33160578 --- /dev/null +++ b/manifests/tools/sim_statusbar.yaml @@ -0,0 +1,13 @@ +id: sim_statusbar +module: mcp/tools/simulator-management/sim_statusbar +names: + mcp: sim_statusbar +description: "Set sim status bar network." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml new file mode 100644 index 00000000..b3ba2a8b --- /dev/null +++ b/manifests/tools/snapshot_ui.yaml @@ -0,0 +1,13 @@ +id: snapshot_ui +module: mcp/tools/ui-automation/snapshot_ui +names: + mcp: snapshot_ui +description: "Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml new file mode 100644 index 00000000..e0b79404 --- /dev/null +++ b/manifests/tools/start_device_log_cap.yaml @@ -0,0 +1,13 @@ +id: start_device_log_cap +module: mcp/tools/logging/start_device_log_cap +names: + mcp: start_device_log_cap +description: "Start device log capture." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml new file mode 100644 index 00000000..6540a9e8 --- /dev/null +++ b/manifests/tools/start_sim_log_cap.yaml @@ -0,0 +1,13 @@ +id: start_sim_log_cap +module: mcp/tools/logging/start_sim_log_cap +names: + mcp: start_sim_log_cap +description: "Start sim log capture." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/stop_app_device.yaml b/manifests/tools/stop_app_device.yaml new file mode 100644 index 00000000..19feb719 --- /dev/null +++ b/manifests/tools/stop_app_device.yaml @@ -0,0 +1,13 @@ +id: stop_app_device +module: mcp/tools/device/stop_app_device +names: + mcp: stop_app_device +description: "Stop device app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/stop_app_sim.yaml b/manifests/tools/stop_app_sim.yaml new file mode 100644 index 00000000..8f6a92b7 --- /dev/null +++ b/manifests/tools/stop_app_sim.yaml @@ -0,0 +1,13 @@ +id: stop_app_sim +module: mcp/tools/simulator/stop_app_sim +names: + mcp: stop_app_sim +description: "Stop sim app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/stop_device_log_cap.yaml b/manifests/tools/stop_device_log_cap.yaml new file mode 100644 index 00000000..65e23705 --- /dev/null +++ b/manifests/tools/stop_device_log_cap.yaml @@ -0,0 +1,13 @@ +id: stop_device_log_cap +module: mcp/tools/logging/stop_device_log_cap +names: + mcp: stop_device_log_cap +description: "Stop device app and return logs." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/stop_mac_app.yaml b/manifests/tools/stop_mac_app.yaml new file mode 100644 index 00000000..f617e231 --- /dev/null +++ b/manifests/tools/stop_mac_app.yaml @@ -0,0 +1,13 @@ +id: stop_mac_app +module: mcp/tools/macos/stop_mac_app +names: + mcp: stop_mac_app +description: "Stop macOS app." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/stop_sim_log_cap.yaml b/manifests/tools/stop_sim_log_cap.yaml new file mode 100644 index 00000000..cdd421c2 --- /dev/null +++ b/manifests/tools/stop_sim_log_cap.yaml @@ -0,0 +1,13 @@ +id: stop_sim_log_cap +module: mcp/tools/logging/stop_sim_log_cap +names: + mcp: stop_sim_log_cap +description: "Stop sim app and return logs." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/swift_package_build.yaml b/manifests/tools/swift_package_build.yaml new file mode 100644 index 00000000..90c93143 --- /dev/null +++ b/manifests/tools/swift_package_build.yaml @@ -0,0 +1,14 @@ +id: swift_package_build +module: mcp/tools/swift-package/swift_package_build +names: + mcp: swift_package_build +description: "swift package target build." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/swift_package_clean.yaml b/manifests/tools/swift_package_clean.yaml new file mode 100644 index 00000000..39894a7e --- /dev/null +++ b/manifests/tools/swift_package_clean.yaml @@ -0,0 +1,13 @@ +id: swift_package_clean +module: mcp/tools/swift-package/swift_package_clean +names: + mcp: swift_package_clean +description: "swift package clean." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/swift_package_list.yaml b/manifests/tools/swift_package_list.yaml new file mode 100644 index 00000000..42b33a5e --- /dev/null +++ b/manifests/tools/swift_package_list.yaml @@ -0,0 +1,13 @@ +id: swift_package_list +module: mcp/tools/swift-package/swift_package_list +names: + mcp: swift_package_list +description: "List SwiftPM processes." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/swift_package_run.yaml b/manifests/tools/swift_package_run.yaml new file mode 100644 index 00000000..96898b3d --- /dev/null +++ b/manifests/tools/swift_package_run.yaml @@ -0,0 +1,13 @@ +id: swift_package_run +module: mcp/tools/swift-package/swift_package_run +names: + mcp: swift_package_run +description: "swift package target run." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/swift_package_stop.yaml b/manifests/tools/swift_package_stop.yaml new file mode 100644 index 00000000..a0cf398f --- /dev/null +++ b/manifests/tools/swift_package_stop.yaml @@ -0,0 +1,13 @@ +id: swift_package_stop +module: mcp/tools/swift-package/swift_package_stop +names: + mcp: swift_package_stop +description: "Stop SwiftPM run." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: true + daemonAffinity: required diff --git a/manifests/tools/swift_package_test.yaml b/manifests/tools/swift_package_test.yaml new file mode 100644 index 00000000..5616dbe4 --- /dev/null +++ b/manifests/tools/swift_package_test.yaml @@ -0,0 +1,14 @@ +id: swift_package_test +module: mcp/tools/swift-package/swift_package_test +names: + mcp: swift_package_test +description: "Run swift package target tests." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/swipe.yaml b/manifests/tools/swipe.yaml new file mode 100644 index 00000000..c4c38e59 --- /dev/null +++ b/manifests/tools/swipe.yaml @@ -0,0 +1,13 @@ +id: swipe +module: mcp/tools/ui-automation/swipe +names: + mcp: swipe +description: "Swipe between points." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/tap.yaml b/manifests/tools/tap.yaml new file mode 100644 index 00000000..18a03c8d --- /dev/null +++ b/manifests/tools/tap.yaml @@ -0,0 +1,13 @@ +id: tap +module: mcp/tools/ui-automation/tap +names: + mcp: tap +description: "Tap coordinate or element." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/test_device.yaml b/manifests/tools/test_device.yaml new file mode 100644 index 00000000..19eb19c3 --- /dev/null +++ b/manifests/tools/test_device.yaml @@ -0,0 +1,14 @@ +id: test_device +module: mcp/tools/device/test_device +names: + mcp: test_device +description: "Test on device." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/test_macos.yaml b/manifests/tools/test_macos.yaml new file mode 100644 index 00000000..39c17d8d --- /dev/null +++ b/manifests/tools/test_macos.yaml @@ -0,0 +1,14 @@ +id: test_macos +module: mcp/tools/macos/test_macos +names: + mcp: test_macos +description: "Test macOS target." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/test_sim.yaml b/manifests/tools/test_sim.yaml new file mode 100644 index 00000000..0142bbb2 --- /dev/null +++ b/manifests/tools/test_sim.yaml @@ -0,0 +1,14 @@ +id: test_sim +module: mcp/tools/simulator/test_sim +names: + mcp: test_sim +description: "Test on iOS sim." +availability: + mcp: true + cli: true + daemon: true +predicates: + - hideWhenXcodeAgentMode +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/touch.yaml b/manifests/tools/touch.yaml new file mode 100644 index 00000000..ef3ff2b7 --- /dev/null +++ b/manifests/tools/touch.yaml @@ -0,0 +1,13 @@ +id: touch +module: mcp/tools/ui-automation/touch +names: + mcp: touch +description: "Touch down/up at coords." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/type_text.yaml b/manifests/tools/type_text.yaml new file mode 100644 index 00000000..30903e5f --- /dev/null +++ b/manifests/tools/type_text.yaml @@ -0,0 +1,13 @@ +id: type_text +module: mcp/tools/ui-automation/type_text +names: + mcp: type_text +description: "Type text." +availability: + mcp: true + cli: true + daemon: true +predicates: [] +routing: + stateful: false + daemonAffinity: preferred diff --git a/manifests/tools/xcode_tools_bridge_disconnect.yaml b/manifests/tools/xcode_tools_bridge_disconnect.yaml new file mode 100644 index 00000000..9b370eff --- /dev/null +++ b/manifests/tools/xcode_tools_bridge_disconnect.yaml @@ -0,0 +1,11 @@ +id: xcode_tools_bridge_disconnect +module: mcp/tools/xcode-ide/xcode_tools_bridge_disconnect +names: + mcp: xcode_tools_bridge_disconnect +description: "Disconnect bridge and unregister proxied `xcode_tools_*` tools." +availability: + mcp: true + cli: false + daemon: false +predicates: + - debugEnabled diff --git a/manifests/tools/xcode_tools_bridge_status.yaml b/manifests/tools/xcode_tools_bridge_status.yaml new file mode 100644 index 00000000..414773fe --- /dev/null +++ b/manifests/tools/xcode_tools_bridge_status.yaml @@ -0,0 +1,11 @@ +id: xcode_tools_bridge_status +module: mcp/tools/xcode-ide/xcode_tools_bridge_status +names: + mcp: xcode_tools_bridge_status +description: "Show xcrun mcpbridge availability and proxy tool sync status." +availability: + mcp: true + cli: false + daemon: false +predicates: + - debugEnabled diff --git a/manifests/tools/xcode_tools_bridge_sync.yaml b/manifests/tools/xcode_tools_bridge_sync.yaml new file mode 100644 index 00000000..ec3e0ce8 --- /dev/null +++ b/manifests/tools/xcode_tools_bridge_sync.yaml @@ -0,0 +1,11 @@ +id: xcode_tools_bridge_sync +module: mcp/tools/xcode-ide/xcode_tools_bridge_sync +names: + mcp: xcode_tools_bridge_sync +description: "One-shot connect + tools/list sync (manual retry; avoids background prompt spam)." +availability: + mcp: true + cli: false + daemon: false +predicates: + - debugEnabled diff --git a/manifests/workflows/debugging.yaml b/manifests/workflows/debugging.yaml new file mode 100644 index 00000000..246db98e --- /dev/null +++ b/manifests/workflows/debugging.yaml @@ -0,0 +1,22 @@ +id: debugging +title: "LLDB Debugging" +description: "Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - debug_attach_sim + - debug_breakpoint_add + - debug_breakpoint_remove + - debug_continue + - debug_detach + - debug_lldb_command + - debug_stack + - debug_variables diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml new file mode 100644 index 00000000..c71cdc2b --- /dev/null +++ b/manifests/workflows/device.yaml @@ -0,0 +1,28 @@ +id: device +title: "iOS Device Development" +description: "Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro)." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - build_device + - test_device + - list_devices + - install_app_device + - launch_app_device + - stop_app_device + - get_device_app_path + - clean + - discover_projs + - list_schemes + - show_build_settings + - get_app_bundle_id + - start_device_log_cap + - stop_device_log_cap diff --git a/manifests/workflows/doctor.yaml b/manifests/workflows/doctor.yaml new file mode 100644 index 00000000..818d1dd5 --- /dev/null +++ b/manifests/workflows/doctor.yaml @@ -0,0 +1,16 @@ +id: doctor +title: "MCP Doctor" +description: "Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: true +predicates: + - debugEnabled +tools: + - doctor diff --git a/manifests/workflows/logging.yaml b/manifests/workflows/logging.yaml new file mode 100644 index 00000000..e44a44c3 --- /dev/null +++ b/manifests/workflows/logging.yaml @@ -0,0 +1,18 @@ +id: logging +title: "Log Capture" +description: "Capture and retrieve logs from simulator and device apps." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - start_sim_log_cap + - stop_sim_log_cap + - start_device_log_cap + - stop_device_log_cap diff --git a/manifests/workflows/macos.yaml b/manifests/workflows/macos.yaml new file mode 100644 index 00000000..c7a1d62c --- /dev/null +++ b/manifests/workflows/macos.yaml @@ -0,0 +1,25 @@ +id: macos +title: "macOS Development" +description: "Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - build_macos + - build_run_macos + - test_macos + - launch_mac_app + - stop_mac_app + - get_mac_app_path + - get_mac_bundle_id + - clean + - discover_projs + - list_schemes + - show_build_settings diff --git a/manifests/workflows/project-discovery.yaml b/manifests/workflows/project-discovery.yaml new file mode 100644 index 00000000..ae0f6900 --- /dev/null +++ b/manifests/workflows/project-discovery.yaml @@ -0,0 +1,19 @@ +id: project-discovery +title: "Project Discovery" +description: "Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - discover_projs + - list_schemes + - show_build_settings + - get_app_bundle_id + - get_mac_bundle_id diff --git a/manifests/workflows/project-scaffolding.yaml b/manifests/workflows/project-scaffolding.yaml new file mode 100644 index 00000000..c7ad17c6 --- /dev/null +++ b/manifests/workflows/project-scaffolding.yaml @@ -0,0 +1,16 @@ +id: project-scaffolding +title: "Project Scaffolding" +description: "Scaffold new iOS and macOS projects from templates." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - scaffold_ios_project + - scaffold_macos_project diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml new file mode 100644 index 00000000..8505ae64 --- /dev/null +++ b/manifests/workflows/session-management.yaml @@ -0,0 +1,17 @@ +id: session-management +title: "Session Management" +description: "Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings." +availability: + mcp: true + cli: false + daemon: false +selection: + mcp: + mandatory: true + defaultEnabled: true + autoInclude: true +predicates: [] +tools: + - session_show_defaults + - session_set_defaults + - session_clear_defaults diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml new file mode 100644 index 00000000..764cd1e4 --- /dev/null +++ b/manifests/workflows/simulator-management.yaml @@ -0,0 +1,22 @@ +id: simulator-management +title: "Simulator Management" +description: "Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - boot_sim + - list_sims + - open_sim + - erase_sims + - set_sim_location + - reset_sim_location + - set_sim_appearance + - sim_statusbar diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml new file mode 100644 index 00000000..f2ac86f2 --- /dev/null +++ b/manifests/workflows/simulator.yaml @@ -0,0 +1,35 @@ +id: simulator +title: "iOS Simulator Development" +description: "Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: true + autoInclude: false +predicates: [] +tools: + - list_sims + - boot_sim + - open_sim + - build_sim + - build_run_sim + - test_sim + - get_sim_app_path + - install_app_sim + - launch_app_sim + - launch_app_logs_sim + - stop_app_sim + - record_sim_video + - clean + - discover_projs + - list_schemes + - show_build_settings + - get_app_bundle_id + - screenshot + - snapshot_ui + - stop_sim_log_cap + - start_sim_log_cap diff --git a/manifests/workflows/swift-package.yaml b/manifests/workflows/swift-package.yaml new file mode 100644 index 00000000..2929df15 --- /dev/null +++ b/manifests/workflows/swift-package.yaml @@ -0,0 +1,20 @@ +id: swift-package +title: "Swift Package Development" +description: "Build, test, run and manage Swift Package Manager projects." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: true + autoInclude: false +predicates: [] +tools: + - swift_package_build + - swift_package_test + - swift_package_clean + - swift_package_run + - swift_package_stop + - swift_package_list diff --git a/manifests/workflows/ui-automation.yaml b/manifests/workflows/ui-automation.yaml new file mode 100644 index 00000000..d6261b2e --- /dev/null +++ b/manifests/workflows/ui-automation.yaml @@ -0,0 +1,25 @@ +id: ui-automation +title: "UI Automation" +description: "UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - tap + - touch + - long_press + - swipe + - gesture + - button + - key_press + - key_sequence + - type_text + - screenshot + - snapshot_ui diff --git a/manifests/workflows/utilities.yaml b/manifests/workflows/utilities.yaml new file mode 100644 index 00000000..e404d13b --- /dev/null +++ b/manifests/workflows/utilities.yaml @@ -0,0 +1,15 @@ +id: utilities +title: "Build Utilities" +description: "Utility tools for cleaning build products and managing build artifacts." +availability: + mcp: true + cli: true + daemon: true +selection: + mcp: + mandatory: false + defaultEnabled: true + autoInclude: false +predicates: [] +tools: + - clean diff --git a/manifests/workflows/workflow-discovery.yaml b/manifests/workflows/workflow-discovery.yaml new file mode 100644 index 00000000..19176994 --- /dev/null +++ b/manifests/workflows/workflow-discovery.yaml @@ -0,0 +1,16 @@ +id: workflow-discovery +title: "Workflow Discovery" +description: "Manage enabled workflows at runtime." +availability: + mcp: true + cli: false + daemon: false +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: true +predicates: + - experimentalWorkflowDiscoveryEnabled +tools: + - manage_workflows diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml new file mode 100644 index 00000000..67cef62b --- /dev/null +++ b/manifests/workflows/xcode-ide.yaml @@ -0,0 +1,17 @@ +id: xcode-ide +title: "Xcode IDE Integration" +description: "Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality." +availability: + mcp: true + cli: false + daemon: false +selection: + mcp: + mandatory: false + defaultEnabled: false + autoInclude: false +predicates: [] +tools: + - xcode_tools_bridge_status + - xcode_tools_bridge_sync + - xcode_tools_bridge_disconnect diff --git a/package-lock.json b/package-lock.json index 8208ace5..149c02aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,15 +18,14 @@ "zod": "^4.0.0" }, "bin": { - "xcodebuildcli": "build/cli.js", - "xcodebuildmcp": "build/index.js", + "xcodebuildmcp": "build/cli.js", "xcodebuildmcp-doctor": "build/doctor-cli.js" }, "devDependencies": { "@bacons/xcode": "^1.0.0-alpha.24", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", - "@smithery/cli": "^3.4.0", + "@smithery/cli": "^3.7.0", "@types/node": "^22.13.6", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.28.0", @@ -914,9 +913,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1859,12 +1858,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", - "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1872,14 +1871,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -3401,9 +3401,9 @@ } }, "node_modules/@smithery/api": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@smithery/api/-/api-0.28.0.tgz", - "integrity": "sha512-zO6TXNbTne1N+IbMc6kx4PWhrg1kAS9WCfiNF9nqSTvye85iqslO3Wq4xKjvU6NcuUaMyABzK/ynvCF9wLpp4Q==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@smithery/api/-/api-0.37.0.tgz", + "integrity": "sha512-ilA4jF4O4/J5+ERSmAWFt4NWslJ79SJqBVojfIU1ETzoPbRFKeLrwBvpRW8Q0UKT1FOEoH9gX7iv0WpeYdAaSA==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -3416,26 +3416,27 @@ } }, "node_modules/@smithery/cli": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@smithery/cli/-/cli-3.4.1.tgz", - "integrity": "sha512-Lu7DtPRqItXrGcKwwxdzToBCXSBqjIgWHN/F+T8my/fF5pJNaidv+iLl8K0Dy0IQFEtaFJgW4/hykMYiqj7AkA==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@smithery/cli/-/cli-3.10.0.tgz", + "integrity": "sha512-vEKvcedSjI8IONTL0hKOrJ/k7h4n6zNwfMXdJ1KSknJUg0FtaJKRJy3qO0L6PAga2NNCOPflH3w096tM/40Bow==", "dev": true, "dependencies": { "@anthropic-ai/mcpb": "^1.1.1", - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.25.3", "@ngrok/ngrok": "^1.5.1", - "@smithery/api": "0.28.0", + "@smithery/api": "0.37.0", "chalk": "^4.1.2", "cli-spinners": "^3.3.0", "commander": "^14.0.0", "cors": "^2.8.5", - "cross-fetch": "^4.1.0", "cross-spawn": "^7.0.6", "esbuild": "^0.25.10", "express": "^5.1.0", "fast-glob": "^3.3.3", + "flexsearch": "^0.7.43", "inquirer": "^8.2.4", "inquirer-autocomplete-prompt": "^2.0.0", + "jsonc-parser": "^3.3.1", "lodash": "^4.17.21", "miniflare": "^4.20260103.0", "ora": "^8.2.0", @@ -4819,16 +4820,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-fetch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", - "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5549,10 +5540,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -5800,6 +5794,13 @@ "dev": true, "license": "ISC" }, + "node_modules/flexsearch": { + "version": "0.7.43", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.7.43.tgz", + "integrity": "sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/flora-colossus": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz", @@ -6148,7 +6149,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6478,6 +6478,15 @@ "node": ">= 0.10" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6729,6 +6738,13 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -7272,27 +7288,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-forge": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", @@ -9291,13 +9286,6 @@ "node": ">=6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9907,24 +9895,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index cb0fc662..aa39fac0 100644 --- a/package.json +++ b/package.json @@ -7,21 +7,19 @@ "type": "module", "module": "src/smithery.ts", "exports": { - ".": "./build/index.js", + ".": "./build/cli.js", "./package.json": "./package.json" }, "bin": { - "xcodebuildmcp": "build/index.js", + "xcodebuildmcp": "build/cli.js", "xcodebuildmcp-doctor": "build/doctor-cli.js" }, "scripts": { "build": "npm run build:tsup && npx smithery build --transport stdio", - "dev": "npm run generate:version && npm run generate:loaders && npx smithery dev", - "build:tsup": "npm run generate:version && npm run generate:loaders && tsup && npm run generate:tools-manifest", + "dev": "npm run generate:version && npx smithery dev", + "build:tsup": "npm run generate:version && tsup", "dev:tsup": "npm run build:tsup && tsup --watch", "generate:version": "npx tsx scripts/generate-version.ts", - "generate:loaders": "npx tsx scripts/generate-loaders.ts", - "generate:tools-manifest": "npx tsx scripts/generate-tools-manifest.ts", "bundle:axe": "scripts/bundle-axe.sh", "lint": "eslint 'src/**/*.{js,ts}'", "lint:fix": "eslint 'src/**/*.{js,ts}' --fix", @@ -30,7 +28,7 @@ "typecheck": "npx tsc --noEmit && npx tsc -p tsconfig.test.json", "typecheck:tests": "npx tsc -p tsconfig.test.json", "verify:smithery-bundle": "bash scripts/verify-smithery-bundle.sh", - "inspect": "npx @modelcontextprotocol/inspector node build/index.js mcp", + "inspect": "npx @modelcontextprotocol/inspector node build/cli.js mcp", "doctor": "node build/doctor-cli.js", "tools": "npx tsx scripts/tools-cli.ts", "tools:list": "npx tsx scripts/tools-cli.ts list", @@ -47,7 +45,8 @@ "files": [ "build", "bundled", - "plugins" + "plugins", + "manifests" ], "keywords": [ "xcodebuild", @@ -82,7 +81,7 @@ "@bacons/xcode": "^1.0.0-alpha.24", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", - "@smithery/cli": "^3.4.0", + "@smithery/cli": "^3.7.0", "@types/node": "^22.13.6", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.28.0", @@ -102,4 +101,4 @@ "vitest": "^3.2.4", "xcode": "^3.0.1" } -} +} \ No newline at end of file diff --git a/scripts/copy-build-assets.js b/scripts/copy-build-assets.js new file mode 100644 index 00000000..0df07c6e --- /dev/null +++ b/scripts/copy-build-assets.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Post-build script to copy assets and set permissions. + * Called after tsc compilation to prepare the build directory. + */ + +import { chmodSync, existsSync, copyFileSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const projectRoot = join(__dirname, '..'); + +// Set executable permissions for entry points +const executables = [ + 'build/cli.js', + 'build/doctor-cli.js', + 'build/daemon.js' +]; + +for (const file of executables) { + const fullPath = join(projectRoot, file); + if (existsSync(fullPath)) { + chmodSync(fullPath, '755'); + console.log(` Set executable: ${file}`); + } +} + +// Copy tools-manifest.json to build directory (for backward compatibility) +// This can be removed once Phase 7 is complete +const toolsManifestSrc = join(projectRoot, 'build', 'tools-manifest.json'); +if (existsSync(toolsManifestSrc)) { + console.log(' tools-manifest.json already in build/'); +} + +console.log('✅ Build assets copied successfully'); diff --git a/scripts/generate-loaders.ts b/scripts/generate-loaders.ts deleted file mode 100644 index 9f36dc1c..00000000 --- a/scripts/generate-loaders.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { generateResourceLoaders, generateWorkflowLoaders } from '../build-plugins/plugin-discovery.ts'; - -async function main(): Promise { - await generateWorkflowLoaders(); - await generateResourceLoaders(); -} - -main().catch((error) => { - console.error('Failed to generate plugin/resource loaders:', error); - process.exit(1); -}); diff --git a/scripts/generate-tools-manifest.ts b/scripts/generate-tools-manifest.ts deleted file mode 100644 index e6f3e219..00000000 --- a/scripts/generate-tools-manifest.ts +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node - -/** - * XcodeBuildMCP Tools Manifest Generator - * - * Generates build/tools-manifest.json from static AST analysis. - * This is the canonical source of truth for docs and CLI tooling output. - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import { getStaticToolAnalysis } from './analysis/tools-analysis.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const projectRoot = path.resolve(__dirname, '..'); - -type ToolManifestEntry = { - name: string; - mcpName: string; - cliName: string; - workflow: string; - description: string; - originWorkflow?: string; - isCanonical: boolean; - stateful: boolean; -}; - -type WorkflowManifestEntry = { - name: string; - displayName: string; - description: string; - toolCount: number; - canonicalCount: number; - reExportCount: number; -}; - -type ToolsManifest = { - generatedAt: string; - stats: { - totalTools: number; - canonicalTools: number; - reExportTools: number; - workflowCount: number; - }; - workflows: WorkflowManifestEntry[]; - tools: ToolManifestEntry[]; -}; - -function toKebabCase(name: string): string { - return name - .trim() - .replace(/_/g, '-') - .replace(/([a-z])([A-Z])/g, '$1-$2') - .replace(/\s+/g, '-') - .toLowerCase() - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); -} - -async function main(): Promise { - const analysis = await getStaticToolAnalysis(); - - const tools: ToolManifestEntry[] = analysis.tools.map((tool) => ({ - name: tool.name, - mcpName: tool.name, - cliName: tool.cliName ?? toKebabCase(tool.name), - workflow: tool.workflow, - description: tool.description, - originWorkflow: tool.originWorkflow, - isCanonical: tool.isCanonical, - stateful: tool.stateful ?? false, - })); - - tools.sort( - (a, b) => a.workflow.localeCompare(b.workflow) || a.name.localeCompare(b.name), - ); - - const workflows: WorkflowManifestEntry[] = analysis.workflows.map((workflow) => ({ - name: workflow.name, - displayName: workflow.displayName, - description: workflow.description, - toolCount: workflow.toolCount, - canonicalCount: workflow.canonicalCount, - reExportCount: workflow.reExportCount, - })); - - const manifest: ToolsManifest = { - generatedAt: new Date().toISOString(), - stats: analysis.stats, - workflows, - tools, - }; - - const outputPath = path.join(projectRoot, 'build', 'tools-manifest.json'); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); - - process.stdout.write( - `✅ Generated tools manifest: ${path.relative(projectRoot, outputPath)}\n`, - ); -} - -main().catch((error) => { - process.stderr.write(`❌ Failed to generate tools manifest: ${String(error)}\n`); - process.exit(1); -}); - diff --git a/server.json b/server.json index 9b0c79ec..5df9b327 100644 --- a/server.json +++ b/server.json @@ -1,8 +1,7 @@ { - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json", + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "com.xcodebuildmcp/XcodeBuildMCP", "description": "XcodeBuildMCP provides tools for Xcode project management, simulator management, and app utilities.", - "status": "active", "repository": { "url": "https://github.com/cameroncooke/XcodeBuildMCP", "source": "github", diff --git a/src/cli.ts b/src/cli.ts index c159fa82..3cb61e6a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts'; import { buildYargsApp } from './cli/yargs-app.ts'; import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts'; import { startMcpServer } from './server/start-mcp-server.ts'; -import { WORKFLOW_METADATA } from './core/generated-plugins.ts'; +import { loadManifest } from './core/manifest/load-manifest.ts'; async function main(): Promise { if (process.argv.includes('mcp')) { @@ -41,7 +41,8 @@ async function main(): Promise { }); const CLI_EXCLUDED_WORKFLOWS = new Set(['session-management', 'workflow-discovery']); - const workflowNames = Object.keys(WORKFLOW_METADATA).filter( + const manifest = loadManifest(); + const workflowNames = Array.from(manifest.workflows.keys()).filter( (name) => !CLI_EXCLUDED_WORKFLOWS.has(name), ); diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts index c4f8fac2..ff03effa 100644 --- a/src/cli/cli-tool-catalog.ts +++ b/src/cli/cli-tool-catalog.ts @@ -1,18 +1,14 @@ -import { listWorkflowDirectoryNames } from '../core/plugin-registry.ts'; -import { buildToolCatalog } from '../runtime/tool-catalog.ts'; +import { buildCliToolCatalogFromManifest } from '../runtime/tool-catalog.ts'; import type { ToolCatalog } from '../runtime/types.ts'; const CLI_EXCLUDED_WORKFLOWS = ['session-management', 'workflow-discovery']; /** - * Build a tool catalog for CLI usage. - * CLI shows ALL workflows (not config-driven) except session-management. + * Build a tool catalog for CLI usage using the manifest system. + * CLI shows ALL workflows (not config-driven) except session-management and workflow-discovery. */ export async function buildCliToolCatalog(): Promise { - const allWorkflows = listWorkflowDirectoryNames(); - - return buildToolCatalog({ - enabledWorkflows: allWorkflows, + return buildCliToolCatalogFromManifest({ excludeWorkflows: CLI_EXCLUDED_WORKFLOWS, }); } diff --git a/src/cli/commands/tools.ts b/src/cli/commands/tools.ts index 2980f15a..6d16381e 100644 --- a/src/cli/commands/tools.ts +++ b/src/cli/commands/tools.ts @@ -1,41 +1,21 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; import type { Argv } from 'yargs'; import { formatToolList } from '../output.ts'; +import { + loadManifest, + type ResolvedManifest, + type ToolManifestEntry, +} from '../../core/manifest/load-manifest.ts'; +import { getEffectiveCliName } from '../../core/manifest/schema.ts'; +import { isWorkflowEnabledForRuntime, isToolExposedForRuntime } from '../../visibility/exposure.ts'; +import type { PredicateContext } from '../../visibility/predicate-types.ts'; +import { getConfig } from '../../utils/config-store.ts'; -type ToolsManifestEntry = { - name: string; - cliName: string; - workflow: string; - description: string; - originWorkflow?: string; - isCanonical: boolean; - stateful: boolean; -}; - -type ToolsManifest = { - tools: ToolsManifestEntry[]; -}; +const CLI_EXCLUDED_WORKFLOWS = new Set(['session-management', 'workflow-discovery']); function writeLine(text: string): void { process.stdout.write(`${text}\n`); } -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const manifestPath = path.resolve(__dirname, 'tools-manifest.json'); -const CLI_EXCLUDED_WORKFLOWS = new Set(['session-management', 'workflow-discovery']); - -function loadManifest(): ToolsManifest { - if (!fs.existsSync(manifestPath)) { - throw new Error(`Missing tools manifest at ${manifestPath}. Run "npm run build" first.`); - } - - const raw = fs.readFileSync(manifestPath, 'utf-8'); - return JSON.parse(raw) as ToolsManifest; -} - type ToolListItem = { cliName: string; command: string; @@ -96,6 +76,89 @@ function toGroupedJsonTool(tool: ToolListItem): JsonTool { return withCanonicalWorkflow(tool, toJsonToolBase(tool)); } +/** + * Build CLI predicate context. + * CLI is never running under Xcode and never has Xcode tools active. + */ +function buildCliPredicateContext(): PredicateContext { + return { + runtime: 'cli', + config: getConfig(), + runningUnderXcode: false, + xcodeToolsActive: false, + }; +} + +/** + * Build tool list from YAML manifest with predicate filtering. + */ +function buildToolList(manifest: ResolvedManifest): ToolListItem[] { + const tools: ToolListItem[] = []; + const seenToolIds = new Set(); + const ctx = buildCliPredicateContext(); + + // Get all CLI-available workflows that pass predicate checks + const cliWorkflows = Array.from(manifest.workflows.values()).filter( + (wf) => !CLI_EXCLUDED_WORKFLOWS.has(wf.id) && isWorkflowEnabledForRuntime(wf, ctx), + ); + + for (const workflow of cliWorkflows) { + for (const toolId of workflow.tools) { + const tool = manifest.tools.get(toolId); + if (!tool) continue; + + // Check tool availability and predicates for CLI + if (!isToolExposedForRuntime(tool, ctx)) continue; + + const cliName = getEffectiveCliName(tool); + + // Determine if this is a canonical tool or re-export + const isCanonical = isToolCanonicalInWorkflow(tool, workflow.id); + const originWorkflow = isCanonical ? undefined : getCanonicalWorkflow(tool); + + // Track seen tools to avoid duplicates + const toolKey = `${workflow.id}:${toolId}`; + if (seenToolIds.has(toolKey)) continue; + seenToolIds.add(toolKey); + + tools.push({ + cliName, + command: `${workflow.id} ${cliName}`, + workflow: workflow.id, + description: tool.description ?? '', + stateful: tool.routing?.stateful ?? false, + isCanonical, + originWorkflow, + }); + } + } + + return tools; +} + +/** + * Determine if a tool is canonical in a given workflow. + * A tool is canonical if its module path matches the workflow ID. + */ +function isToolCanonicalInWorkflow(tool: ToolManifestEntry, workflowId: string): boolean { + // Check if the module path contains the workflow ID + // e.g., "mcp/tools/simulator/build_sim" is canonical for "simulator" + const moduleParts = tool.module.split('/'); + const workflowPart = moduleParts[2]; // mcp/tools// + return workflowPart === workflowId; +} + +/** + * Get the canonical workflow for a tool based on its module path. + */ +function getCanonicalWorkflow(tool: ToolManifestEntry): string | undefined { + const moduleParts = tool.module.split('/'); + if (moduleParts.length >= 3) { + return moduleParts[2]; // mcp/tools// + } + return undefined; +} + /** * Register the 'tools' command for listing available tools. */ @@ -130,17 +193,7 @@ export function registerToolsCommand(app: Argv): void { }, (argv) => { const manifest = loadManifest(); - let tools: ToolListItem[] = manifest.tools - .filter((t) => !CLI_EXCLUDED_WORKFLOWS.has(t.workflow)) - .map((t) => ({ - cliName: t.cliName, - command: `${t.workflow} ${t.cliName}`, - workflow: t.workflow, - description: t.description, - stateful: t.stateful, - isCanonical: t.isCanonical, - originWorkflow: t.originWorkflow, - })); + let tools = buildToolList(manifest); // Filter by workflow if specified if (argv.workflow) { diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 2e39472a..adbdc160 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -6,7 +6,7 @@ import { schemaToYargsOptions, getUnsupportedSchemaKeys } from './schema-to-yarg import { convertArgvToToolParams } from '../runtime/naming.ts'; import { printToolResponse, type OutputFormat } from './output.ts'; import { groupToolsByWorkflow } from '../runtime/tool-catalog.ts'; -import { WORKFLOW_METADATA, type WorkflowName } from '../core/generated-plugins.ts'; +import { getWorkflowMetadataFromManifest } from '../core/manifest/load-manifest.ts'; export interface RegisterToolCommandsOptions { workspaceRoot: string; @@ -27,10 +27,11 @@ export function registerToolCommands( const toolsByWorkflow = groupToolsByWorkflow(catalog); const enabledWorkflows = opts.enabledWorkflows ?? [...toolsByWorkflow.keys()]; const workflowNames = opts.workflowNames ?? [...toolsByWorkflow.keys()]; + const workflowMetadata = getWorkflowMetadataFromManifest(); for (const workflowName of workflowNames) { const tools = toolsByWorkflow.get(workflowName) ?? []; - const workflowMeta = WORKFLOW_METADATA[workflowName as WorkflowName]; + const workflowMeta = workflowMetadata[workflowName]; const workflowDescription = workflowMeta?.name ?? workflowName; app.command( diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts deleted file mode 100644 index 9a54f1e7..00000000 --- a/src/core/generated-plugins.ts +++ /dev/null @@ -1,476 +0,0 @@ -// AUTO-GENERATED - DO NOT EDIT -// This file is generated by the plugin discovery esbuild plugin - -// Generated based on filesystem scan -export const WORKFLOW_LOADERS = { - debugging: async () => { - const { workflow } = await import('../mcp/tools/debugging/index.ts'); - const tool_0 = await import('../mcp/tools/debugging/debug_attach_sim.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/debugging/debug_breakpoint_add.ts').then( - (m) => m.default, - ); - const tool_2 = await import('../mcp/tools/debugging/debug_breakpoint_remove.ts').then( - (m) => m.default, - ); - const tool_3 = await import('../mcp/tools/debugging/debug_continue.ts').then((m) => m.default); - const tool_4 = await import('../mcp/tools/debugging/debug_detach.ts').then((m) => m.default); - const tool_5 = await import('../mcp/tools/debugging/debug_lldb_command.ts').then( - (m) => m.default, - ); - const tool_6 = await import('../mcp/tools/debugging/debug_stack.ts').then((m) => m.default); - const tool_7 = await import('../mcp/tools/debugging/debug_variables.ts').then((m) => m.default); - - return { - workflow, - debug_attach_sim: tool_0, - debug_breakpoint_add: tool_1, - debug_breakpoint_remove: tool_2, - debug_continue: tool_3, - debug_detach: tool_4, - debug_lldb_command: tool_5, - debug_stack: tool_6, - debug_variables: tool_7, - }; - }, - device: async () => { - const { workflow } = await import('../mcp/tools/device/index.ts'); - const tool_0 = await import('../mcp/tools/device/build_device.ts').then((m) => m.default); - const tool_1 = await import('../mcp/tools/device/clean.ts').then((m) => m.default); - const tool_2 = await import('../mcp/tools/device/discover_projs.ts').then((m) => m.default); - const tool_3 = await import('../mcp/tools/device/get_app_bundle_id.ts').then((m) => m.default); - const tool_4 = await import('../mcp/tools/device/get_device_app_path.ts').then( - (m) => m.default, - ); - const tool_5 = await import('../mcp/tools/device/install_app_device.ts').then((m) => m.default); - const tool_6 = await import('../mcp/tools/device/launch_app_device.ts').then((m) => m.default); - const tool_7 = await import('../mcp/tools/device/list_devices.ts').then((m) => m.default); - const tool_8 = await import('../mcp/tools/device/list_schemes.ts').then((m) => m.default); - const tool_9 = await import('../mcp/tools/device/show_build_settings.ts').then( - (m) => m.default, - ); - const tool_10 = await import('../mcp/tools/device/start_device_log_cap.ts').then( - (m) => m.default, - ); - const tool_11 = await import('../mcp/tools/device/stop_app_device.ts').then((m) => m.default); - const tool_12 = await import('../mcp/tools/device/stop_device_log_cap.ts').then( - (m) => m.default, - ); - const tool_13 = await import('../mcp/tools/device/test_device.ts').then((m) => m.default); - - return { - workflow, - build_device: tool_0, - clean: tool_1, - discover_projs: tool_2, - get_app_bundle_id: tool_3, - get_device_app_path: tool_4, - install_app_device: tool_5, - launch_app_device: tool_6, - list_devices: tool_7, - list_schemes: tool_8, - show_build_settings: tool_9, - start_device_log_cap: tool_10, - stop_app_device: tool_11, - stop_device_log_cap: tool_12, - test_device: tool_13, - }; - }, - doctor: async () => { - const { workflow } = await import('../mcp/tools/doctor/index.ts'); - const tool_0 = await import('../mcp/tools/doctor/doctor.ts').then((m) => m.default); - - return { - workflow, - doctor: tool_0, - }; - }, - logging: async () => { - const { workflow } = await import('../mcp/tools/logging/index.ts'); - const tool_0 = await import('../mcp/tools/logging/start_device_log_cap.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/logging/start_sim_log_cap.ts').then((m) => m.default); - const tool_2 = await import('../mcp/tools/logging/stop_device_log_cap.ts').then( - (m) => m.default, - ); - const tool_3 = await import('../mcp/tools/logging/stop_sim_log_cap.ts').then((m) => m.default); - - return { - workflow, - start_device_log_cap: tool_0, - start_sim_log_cap: tool_1, - stop_device_log_cap: tool_2, - stop_sim_log_cap: tool_3, - }; - }, - macos: async () => { - const { workflow } = await import('../mcp/tools/macos/index.ts'); - const tool_0 = await import('../mcp/tools/macos/build_macos.ts').then((m) => m.default); - const tool_1 = await import('../mcp/tools/macos/build_run_macos.ts').then((m) => m.default); - const tool_2 = await import('../mcp/tools/macos/clean.ts').then((m) => m.default); - const tool_3 = await import('../mcp/tools/macos/discover_projs.ts').then((m) => m.default); - const tool_4 = await import('../mcp/tools/macos/get_mac_app_path.ts').then((m) => m.default); - const tool_5 = await import('../mcp/tools/macos/get_mac_bundle_id.ts').then((m) => m.default); - const tool_6 = await import('../mcp/tools/macos/launch_mac_app.ts').then((m) => m.default); - const tool_7 = await import('../mcp/tools/macos/list_schemes.ts').then((m) => m.default); - const tool_8 = await import('../mcp/tools/macos/show_build_settings.ts').then((m) => m.default); - const tool_9 = await import('../mcp/tools/macos/stop_mac_app.ts').then((m) => m.default); - const tool_10 = await import('../mcp/tools/macos/test_macos.ts').then((m) => m.default); - - return { - workflow, - build_macos: tool_0, - build_run_macos: tool_1, - clean: tool_2, - discover_projs: tool_3, - get_mac_app_path: tool_4, - get_mac_bundle_id: tool_5, - launch_mac_app: tool_6, - list_schemes: tool_7, - show_build_settings: tool_8, - stop_mac_app: tool_9, - test_macos: tool_10, - }; - }, - 'project-discovery': async () => { - const { workflow } = await import('../mcp/tools/project-discovery/index.ts'); - const tool_0 = await import('../mcp/tools/project-discovery/discover_projs.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/project-discovery/get_app_bundle_id.ts').then( - (m) => m.default, - ); - const tool_2 = await import('../mcp/tools/project-discovery/get_mac_bundle_id.ts').then( - (m) => m.default, - ); - const tool_3 = await import('../mcp/tools/project-discovery/list_schemes.ts').then( - (m) => m.default, - ); - const tool_4 = await import('../mcp/tools/project-discovery/show_build_settings.ts').then( - (m) => m.default, - ); - - return { - workflow, - discover_projs: tool_0, - get_app_bundle_id: tool_1, - get_mac_bundle_id: tool_2, - list_schemes: tool_3, - show_build_settings: tool_4, - }; - }, - 'project-scaffolding': async () => { - const { workflow } = await import('../mcp/tools/project-scaffolding/index.ts'); - const tool_0 = await import('../mcp/tools/project-scaffolding/scaffold_ios_project.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/project-scaffolding/scaffold_macos_project.ts').then( - (m) => m.default, - ); - - return { - workflow, - scaffold_ios_project: tool_0, - scaffold_macos_project: tool_1, - }; - }, - 'session-management': async () => { - const { workflow } = await import('../mcp/tools/session-management/index.ts'); - const tool_0 = await import('../mcp/tools/session-management/session_clear_defaults.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/session-management/session_set_defaults.ts').then( - (m) => m.default, - ); - const tool_2 = await import('../mcp/tools/session-management/session_show_defaults.ts').then( - (m) => m.default, - ); - - return { - workflow, - session_clear_defaults: tool_0, - session_set_defaults: tool_1, - session_show_defaults: tool_2, - }; - }, - simulator: async () => { - const { workflow } = await import('../mcp/tools/simulator/index.ts'); - const tool_0 = await import('../mcp/tools/simulator/boot_sim.ts').then((m) => m.default); - const tool_1 = await import('../mcp/tools/simulator/build_run_sim.ts').then((m) => m.default); - const tool_2 = await import('../mcp/tools/simulator/build_sim.ts').then((m) => m.default); - const tool_3 = await import('../mcp/tools/simulator/clean.ts').then((m) => m.default); - const tool_4 = await import('../mcp/tools/simulator/discover_projs.ts').then((m) => m.default); - const tool_5 = await import('../mcp/tools/simulator/get_app_bundle_id.ts').then( - (m) => m.default, - ); - const tool_6 = await import('../mcp/tools/simulator/get_sim_app_path.ts').then( - (m) => m.default, - ); - const tool_7 = await import('../mcp/tools/simulator/install_app_sim.ts').then((m) => m.default); - const tool_8 = await import('../mcp/tools/simulator/launch_app_logs_sim.ts').then( - (m) => m.default, - ); - const tool_9 = await import('../mcp/tools/simulator/launch_app_sim.ts').then((m) => m.default); - const tool_10 = await import('../mcp/tools/simulator/list_schemes.ts').then((m) => m.default); - const tool_11 = await import('../mcp/tools/simulator/list_sims.ts').then((m) => m.default); - const tool_12 = await import('../mcp/tools/simulator/open_sim.ts').then((m) => m.default); - const tool_13 = await import('../mcp/tools/simulator/record_sim_video.ts').then( - (m) => m.default, - ); - const tool_14 = await import('../mcp/tools/simulator/screenshot.ts').then((m) => m.default); - const tool_15 = await import('../mcp/tools/simulator/show_build_settings.ts').then( - (m) => m.default, - ); - const tool_16 = await import('../mcp/tools/simulator/snapshot_ui.ts').then((m) => m.default); - const tool_17 = await import('../mcp/tools/simulator/stop_app_sim.ts').then((m) => m.default); - const tool_18 = await import('../mcp/tools/simulator/stop_sim_log_cap.ts').then( - (m) => m.default, - ); - const tool_19 = await import('../mcp/tools/simulator/test_sim.ts').then((m) => m.default); - - return { - workflow, - boot_sim: tool_0, - build_run_sim: tool_1, - build_sim: tool_2, - clean: tool_3, - discover_projs: tool_4, - get_app_bundle_id: tool_5, - get_sim_app_path: tool_6, - install_app_sim: tool_7, - launch_app_logs_sim: tool_8, - launch_app_sim: tool_9, - list_schemes: tool_10, - list_sims: tool_11, - open_sim: tool_12, - record_sim_video: tool_13, - screenshot: tool_14, - show_build_settings: tool_15, - snapshot_ui: tool_16, - stop_app_sim: tool_17, - stop_sim_log_cap: tool_18, - test_sim: tool_19, - }; - }, - 'simulator-management': async () => { - const { workflow } = await import('../mcp/tools/simulator-management/index.ts'); - const tool_0 = await import('../mcp/tools/simulator-management/boot_sim.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/simulator-management/erase_sims.ts').then( - (m) => m.default, - ); - const tool_2 = await import('../mcp/tools/simulator-management/list_sims.ts').then( - (m) => m.default, - ); - const tool_3 = await import('../mcp/tools/simulator-management/open_sim.ts').then( - (m) => m.default, - ); - const tool_4 = await import('../mcp/tools/simulator-management/reset_sim_location.ts').then( - (m) => m.default, - ); - const tool_5 = await import('../mcp/tools/simulator-management/set_sim_appearance.ts').then( - (m) => m.default, - ); - const tool_6 = await import('../mcp/tools/simulator-management/set_sim_location.ts').then( - (m) => m.default, - ); - const tool_7 = await import('../mcp/tools/simulator-management/sim_statusbar.ts').then( - (m) => m.default, - ); - - return { - workflow, - boot_sim: tool_0, - erase_sims: tool_1, - list_sims: tool_2, - open_sim: tool_3, - reset_sim_location: tool_4, - set_sim_appearance: tool_5, - set_sim_location: tool_6, - sim_statusbar: tool_7, - }; - }, - 'swift-package': async () => { - const { workflow } = await import('../mcp/tools/swift-package/index.ts'); - const tool_0 = await import('../mcp/tools/swift-package/swift_package_build.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/swift-package/swift_package_clean.ts').then( - (m) => m.default, - ); - const tool_2 = await import('../mcp/tools/swift-package/swift_package_list.ts').then( - (m) => m.default, - ); - const tool_3 = await import('../mcp/tools/swift-package/swift_package_run.ts').then( - (m) => m.default, - ); - const tool_4 = await import('../mcp/tools/swift-package/swift_package_stop.ts').then( - (m) => m.default, - ); - const tool_5 = await import('../mcp/tools/swift-package/swift_package_test.ts').then( - (m) => m.default, - ); - - return { - workflow, - swift_package_build: tool_0, - swift_package_clean: tool_1, - swift_package_list: tool_2, - swift_package_run: tool_3, - swift_package_stop: tool_4, - swift_package_test: tool_5, - }; - }, - 'ui-automation': async () => { - const { workflow } = await import('../mcp/tools/ui-automation/index.ts'); - const tool_0 = await import('../mcp/tools/ui-automation/button.ts').then((m) => m.default); - const tool_1 = await import('../mcp/tools/ui-automation/gesture.ts').then((m) => m.default); - const tool_2 = await import('../mcp/tools/ui-automation/key_press.ts').then((m) => m.default); - const tool_3 = await import('../mcp/tools/ui-automation/key_sequence.ts').then( - (m) => m.default, - ); - const tool_4 = await import('../mcp/tools/ui-automation/long_press.ts').then((m) => m.default); - const tool_5 = await import('../mcp/tools/ui-automation/screenshot.ts').then((m) => m.default); - const tool_6 = await import('../mcp/tools/ui-automation/snapshot_ui.ts').then((m) => m.default); - const tool_7 = await import('../mcp/tools/ui-automation/swipe.ts').then((m) => m.default); - const tool_8 = await import('../mcp/tools/ui-automation/tap.ts').then((m) => m.default); - const tool_9 = await import('../mcp/tools/ui-automation/touch.ts').then((m) => m.default); - const tool_10 = await import('../mcp/tools/ui-automation/type_text.ts').then((m) => m.default); - - return { - workflow, - button: tool_0, - gesture: tool_1, - key_press: tool_2, - key_sequence: tool_3, - long_press: tool_4, - screenshot: tool_5, - snapshot_ui: tool_6, - swipe: tool_7, - tap: tool_8, - touch: tool_9, - type_text: tool_10, - }; - }, - utilities: async () => { - const { workflow } = await import('../mcp/tools/utilities/index.ts'); - const tool_0 = await import('../mcp/tools/utilities/clean.ts').then((m) => m.default); - - return { - workflow, - clean: tool_0, - }; - }, - 'workflow-discovery': async () => { - const { workflow } = await import('../mcp/tools/workflow-discovery/index.ts'); - const tool_0 = await import('../mcp/tools/workflow-discovery/manage_workflows.ts').then( - (m) => m.default, - ); - - return { - workflow, - manage_workflows: tool_0, - }; - }, - 'xcode-ide': async () => { - const { workflow } = await import('../mcp/tools/xcode-ide/index.ts'); - const tool_0 = await import('../mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts').then( - (m) => m.default, - ); - const tool_1 = await import('../mcp/tools/xcode-ide/xcode_tools_bridge_status.ts').then( - (m) => m.default, - ); - const tool_2 = await import('../mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts').then( - (m) => m.default, - ); - - return { - workflow, - xcode_tools_bridge_disconnect: tool_0, - xcode_tools_bridge_status: tool_1, - xcode_tools_bridge_sync: tool_2, - }; - }, -}; - -export type WorkflowName = keyof typeof WORKFLOW_LOADERS; - -// Optional: Export workflow metadata for quick access -export const WORKFLOW_METADATA = { - debugging: { - name: 'Simulator Debugging', - description: - 'Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands.', - }, - device: { - name: 'iOS Device Development', - description: - 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware.', - }, - doctor: { - name: 'System Doctor', - description: - 'Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability.', - }, - logging: { - name: 'Log Capture & Management', - description: - 'Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing.', - }, - macos: { - name: 'macOS Development', - description: - 'Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications.', - }, - 'project-discovery': { - name: 'Project Discovery', - description: - 'Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information.', - }, - 'project-scaffolding': { - name: 'Project Scaffolding', - description: - 'Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures.', - }, - 'session-management': { - name: 'session-management', - description: - 'Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup.', - }, - simulator: { - name: 'iOS Simulator Development', - description: - 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators.', - }, - 'simulator-management': { - name: 'Simulator Management', - description: - 'Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance.', - }, - 'swift-package': { - name: 'Swift Package Manager', - description: - 'Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support.', - }, - 'ui-automation': { - name: 'UI Automation', - description: - 'UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows.', - }, - utilities: { - name: 'Project Utilities', - description: - 'Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files.', - }, - 'workflow-discovery': { - name: 'Workflow Discovery', - description: 'Manage the workflows that are enabled and disabled.', - }, - 'xcode-ide': { - name: 'Xcode IDE (mcpbridge)', - description: 'Proxy Xcode', - }, -}; diff --git a/src/core/generated-resources.ts b/src/core/generated-resources.ts deleted file mode 100644 index f479e102..00000000 --- a/src/core/generated-resources.ts +++ /dev/null @@ -1,23 +0,0 @@ -// AUTO-GENERATED - DO NOT EDIT -// This file is generated by the plugin discovery esbuild plugin - -export const RESOURCE_LOADERS = { - devices: async () => { - const module = await import('../mcp/resources/devices.ts'); - return module.default; - }, - doctor: async () => { - const module = await import('../mcp/resources/doctor.ts'); - return module.default; - }, - 'session-status': async () => { - const module = await import('../mcp/resources/session-status.ts'); - return module.default; - }, - simulators: async () => { - const module = await import('../mcp/resources/simulators.ts'); - return module.default; - }, -}; - -export type ResourceName = keyof typeof RESOURCE_LOADERS; diff --git a/src/core/manifest/__tests__/load-manifest.test.ts b/src/core/manifest/__tests__/load-manifest.test.ts new file mode 100644 index 00000000..6331113a --- /dev/null +++ b/src/core/manifest/__tests__/load-manifest.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + loadManifest, + getWorkflowTools, + getToolsForWorkflows, + ManifestValidationError, +} from '../load-manifest.ts'; + +describe('load-manifest', () => { + describe('loadManifest (integration with real manifests)', () => { + it('should load all manifests from the manifests directory', () => { + const manifest = loadManifest(); + + // Check that we have tools and workflows + expect(manifest.tools.size).toBeGreaterThan(0); + expect(manifest.workflows.size).toBeGreaterThan(0); + }); + + it('should have required workflows', () => { + const manifest = loadManifest(); + + expect(manifest.workflows.has('simulator')).toBe(true); + expect(manifest.workflows.has('device')).toBe(true); + expect(manifest.workflows.has('session-management')).toBe(true); + }); + + it('should have required tools', () => { + const manifest = loadManifest(); + + expect(manifest.tools.has('build_sim')).toBe(true); + expect(manifest.tools.has('discover_projs')).toBe(true); + expect(manifest.tools.has('session_show_defaults')).toBe(true); + }); + + it('should validate tool references in workflows', () => { + const manifest = loadManifest(); + + // Every tool referenced in a workflow should exist + for (const [workflowId, workflow] of manifest.workflows) { + for (const toolId of workflow.tools) { + expect( + manifest.tools.has(toolId), + `Workflow '${workflowId}' references unknown tool '${toolId}'`, + ).toBe(true); + } + } + }); + + it('should have unique MCP names across all tools', () => { + const manifest = loadManifest(); + const mcpNames = new Set(); + + for (const [, tool] of manifest.tools) { + expect(mcpNames.has(tool.names.mcp), `Duplicate MCP name '${tool.names.mcp}'`).toBe(false); + mcpNames.add(tool.names.mcp); + } + }); + + it('should have session-management as mandatory workflow', () => { + const manifest = loadManifest(); + const sessionMgmt = manifest.workflows.get('session-management'); + + expect(sessionMgmt).toBeDefined(); + expect(sessionMgmt?.selection?.mcp?.mandatory).toBe(true); + }); + + it('should have simulator as default-enabled workflow', () => { + const manifest = loadManifest(); + const simulator = manifest.workflows.get('simulator'); + + expect(simulator).toBeDefined(); + expect(simulator?.selection?.mcp?.defaultEnabled).toBe(true); + }); + + it('should have doctor workflow with debugEnabled predicate', () => { + const manifest = loadManifest(); + const doctor = manifest.workflows.get('doctor'); + + expect(doctor).toBeDefined(); + expect(doctor?.predicates).toContain('debugEnabled'); + expect(doctor?.selection?.mcp?.autoInclude).toBe(true); + }); + }); + + describe('getWorkflowTools', () => { + it('should return tools for a workflow', () => { + const manifest = loadManifest(); + const tools = getWorkflowTools(manifest, 'simulator'); + + expect(tools.length).toBeGreaterThan(0); + expect(tools.some((t) => t.id === 'build_sim')).toBe(true); + }); + + it('should return empty array for unknown workflow', () => { + const manifest = loadManifest(); + const tools = getWorkflowTools(manifest, 'nonexistent-workflow'); + + expect(tools).toEqual([]); + }); + }); + + describe('getToolsForWorkflows', () => { + it('should return unique tools across multiple workflows', () => { + const manifest = loadManifest(); + const tools = getToolsForWorkflows(manifest, ['simulator', 'device']); + + // Should have tools from both workflows + expect(tools.some((t) => t.id === 'build_sim')).toBe(true); + expect(tools.some((t) => t.id === 'build_device')).toBe(true); + + // Tools should be unique (discover_projs is in both) + const toolIds = tools.map((t) => t.id); + const uniqueIds = new Set(toolIds); + expect(toolIds.length).toBe(uniqueIds.size); + }); + + it('should return empty array for empty workflow list', () => { + const manifest = loadManifest(); + const tools = getToolsForWorkflows(manifest, []); + + expect(tools).toEqual([]); + }); + }); +}); + +describe('ManifestValidationError', () => { + it('should include source file in message', () => { + const error = new ManifestValidationError('Test error', 'test.yaml'); + expect(error.message).toBe('Test error (in test.yaml)'); + expect(error.sourceFile).toBe('test.yaml'); + }); + + it('should work without source file', () => { + const error = new ManifestValidationError('Test error'); + expect(error.message).toBe('Test error'); + expect(error.sourceFile).toBeUndefined(); + }); +}); diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts new file mode 100644 index 00000000..6cb4f7b2 --- /dev/null +++ b/src/core/manifest/__tests__/schema.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest'; +import { + toolManifestEntrySchema, + workflowManifestEntrySchema, + deriveCliName, + getEffectiveCliName, + type ToolManifestEntry, +} from '../schema.ts'; + +describe('schema', () => { + describe('toolManifestEntrySchema', () => { + it('should parse valid tool manifest', () => { + const input = { + id: 'build_sim', + module: 'mcp/tools/simulator/build_sim', + names: { mcp: 'build_sim' }, + description: 'Build iOS app for simulator', + availability: { mcp: true, cli: true, daemon: true }, + predicates: [], + routing: { stateful: false, daemonAffinity: 'preferred' }, + }; + + const result = toolManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe('build_sim'); + expect(result.data.names.mcp).toBe('build_sim'); + } + }); + + it('should apply default availability', () => { + const input = { + id: 'test_tool', + module: 'mcp/tools/test/test_tool', + names: { mcp: 'test_tool' }, + }; + + const result = toolManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.availability).toEqual({ mcp: true, cli: true, daemon: true }); + expect(result.data.predicates).toEqual([]); + } + }); + + it('should reject missing required fields', () => { + const input = { + id: 'test_tool', + // missing module and names + }; + + const result = toolManifestEntrySchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('should accept optional CLI name', () => { + const input = { + id: 'build_sim', + module: 'mcp/tools/simulator/build_sim', + names: { mcp: 'build_sim', cli: 'build-simulator' }, + }; + + const result = toolManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.names.cli).toBe('build-simulator'); + } + }); + + it('should accept daemonAffinity values', () => { + const inputPreferred = { + id: 'tool1', + module: 'mcp/tools/test/tool1', + names: { mcp: 'tool1' }, + routing: { stateful: false, daemonAffinity: 'preferred' }, + }; + + const inputRequired = { + id: 'tool2', + module: 'mcp/tools/test/tool2', + names: { mcp: 'tool2' }, + routing: { stateful: true, daemonAffinity: 'required' }, + }; + + expect(toolManifestEntrySchema.safeParse(inputPreferred).success).toBe(true); + expect(toolManifestEntrySchema.safeParse(inputRequired).success).toBe(true); + }); + }); + + describe('workflowManifestEntrySchema', () => { + it('should parse valid workflow manifest', () => { + const input = { + id: 'simulator', + title: 'iOS Simulator Development', + description: 'Build and test iOS apps on simulators', + availability: { mcp: true, cli: true, daemon: true }, + selection: { + mcp: { + mandatory: false, + defaultEnabled: true, + autoInclude: false, + }, + }, + predicates: [], + tools: ['build_sim', 'test_sim', 'boot_sim'], + }; + + const result = workflowManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe('simulator'); + expect(result.data.tools).toHaveLength(3); + expect(result.data.selection?.mcp?.defaultEnabled).toBe(true); + } + }); + + it('should apply default values', () => { + const input = { + id: 'test-workflow', + title: 'Test Workflow', + description: 'A test workflow', + tools: ['tool1'], + }; + + const result = workflowManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.availability).toEqual({ mcp: true, cli: true, daemon: true }); + expect(result.data.predicates).toEqual([]); + } + }); + + it('should reject empty tools array', () => { + const input = { + id: 'empty-workflow', + title: 'Empty Workflow', + description: 'A workflow with no tools', + tools: [], + }; + + // Empty tools array is technically valid per schema + const result = workflowManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it('should parse mandatory workflow', () => { + const input = { + id: 'session-management', + title: 'Session Management', + description: 'Manage session defaults', + availability: { mcp: true, cli: false, daemon: false }, + selection: { + mcp: { + mandatory: true, + defaultEnabled: true, + autoInclude: true, + }, + }, + tools: ['session_show_defaults'], + }; + + const result = workflowManifestEntrySchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.selection?.mcp?.mandatory).toBe(true); + expect(result.data.availability.cli).toBe(false); + } + }); + }); + + describe('deriveCliName', () => { + it('should convert underscores to hyphens', () => { + expect(deriveCliName('build_sim')).toBe('build-sim'); + expect(deriveCliName('get_app_bundle_id')).toBe('get-app-bundle-id'); + }); + + it('should convert camelCase to kebab-case', () => { + expect(deriveCliName('buildSim')).toBe('build-sim'); + expect(deriveCliName('getAppBundleId')).toBe('get-app-bundle-id'); + }); + + it('should handle mixed underscores and camelCase', () => { + expect(deriveCliName('build_simApp')).toBe('build-sim-app'); + }); + + it('should handle already kebab-case', () => { + expect(deriveCliName('build-sim')).toBe('build-sim'); + }); + + it('should lowercase the result', () => { + expect(deriveCliName('BUILD_SIM')).toBe('build-sim'); + }); + }); + + describe('getEffectiveCliName', () => { + it('should use explicit CLI name when provided', () => { + const tool: ToolManifestEntry = { + id: 'build_sim', + module: 'mcp/tools/simulator/build_sim', + names: { mcp: 'build_sim', cli: 'build-simulator' }, + availability: { mcp: true, cli: true, daemon: true }, + predicates: [], + }; + + expect(getEffectiveCliName(tool)).toBe('build-simulator'); + }); + + it('should derive CLI name when not provided', () => { + const tool: ToolManifestEntry = { + id: 'build_sim', + module: 'mcp/tools/simulator/build_sim', + names: { mcp: 'build_sim' }, + availability: { mcp: true, cli: true, daemon: true }, + predicates: [], + }; + + expect(getEffectiveCliName(tool)).toBe('build-sim'); + }); + }); +}); diff --git a/src/core/manifest/import-tool-module.ts b/src/core/manifest/import-tool-module.ts new file mode 100644 index 00000000..32a8444a --- /dev/null +++ b/src/core/manifest/import-tool-module.ts @@ -0,0 +1,110 @@ +/** + * Tool module importer with backward-compatible adapter. + * Dynamically imports tool modules and adapts both old (PluginMeta default export) + * and new (named exports) formats. + */ + +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { ToolSchemaShape } from '../plugin-types.ts'; +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import { getPackageRoot } from './load-manifest.ts'; + +/** + * Imported tool module interface. + * This is what we extract from each tool module for runtime use. + */ +export interface ImportedToolModule { + schema: ToolSchemaShape; + handler: (params: Record) => Promise; + annotations?: ToolAnnotations; +} + +/** + * Cache for imported modules. + */ +const moduleCache = new Map(); + +/** + * Import a tool module by its manifest module path. + * + * Supports two module formats: + * 1. Legacy: `export default { name, schema, handler, annotations?, ... }` + * 2. New: Named exports `{ schema, handler, annotations? }` + * + * @param moduleId - Extensionless module path (e.g., 'mcp/tools/simulator/build_sim') + * @returns Imported tool module with schema, handler, and optional annotations + */ +export async function importToolModule(moduleId: string): Promise { + // Check cache first + const cached = moduleCache.get(moduleId); + if (cached) { + return cached; + } + + const packageRoot = getPackageRoot(); + const modulePath = path.join(packageRoot, 'build', `${moduleId}.js`); + const moduleUrl = pathToFileURL(modulePath).href; + + let mod: Record; + try { + mod = (await import(moduleUrl)) as Record; + } catch (err) { + throw new Error(`Failed to import tool module '${moduleId}': ${err}`); + } + + const result = extractToolExports(mod, moduleId); + + // Cache the result + moduleCache.set(moduleId, result); + + return result; +} + +/** + * Extract tool exports from a module, supporting both legacy and new formats. + */ +function extractToolExports(mod: Record, moduleId: string): ImportedToolModule { + // Try legacy format first: default export with PluginMeta shape + if (mod.default && typeof mod.default === 'object') { + const defaultExport = mod.default as Record; + + // Check if it looks like a PluginMeta (has schema and handler) + if (defaultExport.schema && typeof defaultExport.handler === 'function') { + return { + schema: defaultExport.schema as ToolSchemaShape, + handler: defaultExport.handler as (params: Record) => Promise, + annotations: defaultExport.annotations as ToolAnnotations | undefined, + }; + } + } + + // Try new format: named exports + if (mod.schema && typeof mod.handler === 'function') { + return { + schema: mod.schema as ToolSchemaShape, + handler: mod.handler as (params: Record) => Promise, + annotations: mod.annotations as ToolAnnotations | undefined, + }; + } + + throw new Error( + `Tool module '${moduleId}' does not export the required shape. ` + + `Expected either a default export with { schema, handler } or named exports { schema, handler }.`, + ); +} + +/** + * Clear the module cache. + * Useful for testing or hot-reloading scenarios. + */ +export function clearModuleCache(): void { + moduleCache.clear(); +} + +/** + * Preload multiple tool modules in parallel. + */ +export async function preloadToolModules(moduleIds: string[]): Promise { + await Promise.all(moduleIds.map((id) => importToolModule(id))); +} diff --git a/src/core/manifest/index.ts b/src/core/manifest/index.ts new file mode 100644 index 00000000..002f0437 --- /dev/null +++ b/src/core/manifest/index.ts @@ -0,0 +1,7 @@ +/** + * Manifest system exports. + */ + +export * from './schema.ts'; +export * from './load-manifest.ts'; +export * from './import-tool-module.ts'; diff --git a/src/core/manifest/load-manifest.ts b/src/core/manifest/load-manifest.ts new file mode 100644 index 00000000..52a49acc --- /dev/null +++ b/src/core/manifest/load-manifest.ts @@ -0,0 +1,322 @@ +/** + * Manifest loader for YAML-based tool and workflow definitions. + * Loads and merges multiple YAML files into a resolved manifest. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; +import { + toolManifestEntrySchema, + workflowManifestEntrySchema, + type ToolManifestEntry, + type WorkflowManifestEntry, + type ResolvedManifest, + getEffectiveCliName, +} from './schema.ts'; + +// Re-export types for consumers +export type { ResolvedManifest, ToolManifestEntry, WorkflowManifestEntry }; +import { isValidPredicate } from '../../visibility/predicate-registry.ts'; + +// Capture import.meta.url at module load time (before any CJS bundling issues) +// This works because the value is captured when the module is first evaluated +const importMetaUrl: string | undefined = ((): string | undefined => { + try { + // This will be undefined in CJS bundles but valid in ESM + return import.meta.url; + } catch { + return undefined; + } +})(); + +/** + * Get the current file path, handling both ESM and CJS contexts. + * Smithery bundles to CJS where import.meta.url is undefined. + */ +function getCurrentFilePath(): string { + // ESM context - use captured import.meta.url + if (importMetaUrl) { + return fileURLToPath(importMetaUrl); + } + + // CJS context (Smithery bundle) - __filename is shimmed by esbuild + if (typeof __filename !== 'undefined' && __filename) { + return __filename as string; + } + + // Fallback: try to resolve from cwd + const cwd = process.cwd(); + const possiblePaths = [ + path.join(cwd, 'build', 'core', 'manifest', 'load-manifest.ts'), + path.join(cwd, 'src', 'core', 'manifest', 'load-manifest.ts'), + ]; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + throw new Error('Cannot determine current file path in this runtime context'); +} + +/** + * Get the package root directory. + * Works correctly for both development and npx/npm installs. + */ +export function getPackageRoot(): string { + // Start from this file's directory and go up to find package.json + const currentFile = getCurrentFilePath(); + let dir = path.dirname(currentFile); + + // Walk up until we find package.json + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + dir = path.dirname(dir); + } + + throw new Error('Could not find package root (no package.json found in parent directories)'); +} + +/** + * Get the manifests directory path. + */ +export function getManifestsDir(): string { + return path.join(getPackageRoot(), 'manifests'); +} + +/** + * Load all YAML files from a directory. + */ +function loadYamlFiles(dir: string): unknown[] { + if (!fs.existsSync(dir)) { + return []; + } + + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + const results: unknown[] = []; + + for (const file of files) { + const filePath = path.join(dir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + try { + const parsed = parseYaml(content) as Record | null; + if (parsed) { + results.push({ ...parsed, _sourceFile: file }); + } + } catch (err) { + throw new Error(`Failed to parse YAML file ${filePath}: ${err}`); + } + } + + return results; +} + +/** + * Validation error for manifest loading. + */ +export class ManifestValidationError extends Error { + constructor( + message: string, + public readonly sourceFile?: string, + ) { + super(sourceFile ? `${message} (in ${sourceFile})` : message); + this.name = 'ManifestValidationError'; + } +} + +/** + * Load and validate the complete manifest registry. + * Merges all YAML files from manifests/tools/ and manifests/workflows/. + */ +export function loadManifest(): ResolvedManifest { + const manifestsDir = getManifestsDir(); + const toolsDir = path.join(manifestsDir, 'tools'); + const workflowsDir = path.join(manifestsDir, 'workflows'); + + const tools = new Map(); + const workflows = new Map(); + + // Load tools + const toolFiles = loadYamlFiles(toolsDir); + for (const raw of toolFiles) { + const sourceFile = (raw as { _sourceFile?: string })._sourceFile; + const result = toolManifestEntrySchema.safeParse(raw); + if (!result.success) { + throw new ManifestValidationError( + `Invalid tool manifest: ${result.error.message}`, + sourceFile, + ); + } + + const tool = result.data; + + // Check for duplicate ID + if (tools.has(tool.id)) { + throw new ManifestValidationError(`Duplicate tool ID '${tool.id}'`, sourceFile); + } + + // Validate predicates + for (const pred of tool.predicates) { + if (!isValidPredicate(pred)) { + throw new ManifestValidationError( + `Unknown predicate '${pred}' in tool '${tool.id}'`, + sourceFile, + ); + } + } + + tools.set(tool.id, tool); + } + + // Load workflows + const workflowFiles = loadYamlFiles(workflowsDir); + for (const raw of workflowFiles) { + const sourceFile = (raw as { _sourceFile?: string })._sourceFile; + const result = workflowManifestEntrySchema.safeParse(raw); + if (!result.success) { + throw new ManifestValidationError( + `Invalid workflow manifest: ${result.error.message}`, + sourceFile, + ); + } + + const workflow = result.data; + + // Check for duplicate ID + if (workflows.has(workflow.id)) { + throw new ManifestValidationError(`Duplicate workflow ID '${workflow.id}'`, sourceFile); + } + + // Validate predicates + for (const pred of workflow.predicates) { + if (!isValidPredicate(pred)) { + throw new ManifestValidationError( + `Unknown predicate '${pred}' in workflow '${workflow.id}'`, + sourceFile, + ); + } + } + + // Validate tool references + for (const toolId of workflow.tools) { + if (!tools.has(toolId)) { + throw new ManifestValidationError( + `Workflow '${workflow.id}' references unknown tool '${toolId}'`, + sourceFile, + ); + } + } + + workflows.set(workflow.id, workflow); + } + + // Validate MCP name uniqueness + const mcpNames = new Map(); // mcpName -> toolId + for (const [toolId, tool] of tools) { + const existing = mcpNames.get(tool.names.mcp); + if (existing) { + throw new ManifestValidationError( + `Duplicate MCP name '${tool.names.mcp}' used by tools '${existing}' and '${toolId}'`, + ); + } + mcpNames.set(tool.names.mcp, toolId); + } + + // Validate CLI name uniqueness (after derivation) + const cliNames = new Map(); // cliName -> toolId + for (const [toolId, tool] of tools) { + const cliName = getEffectiveCliName(tool); + const existing = cliNames.get(cliName); + if (existing) { + throw new ManifestValidationError( + `Duplicate CLI name '${cliName}' used by tools '${existing}' and '${toolId}'. ` + + `Set explicit 'names.cli' in one of the tool manifests to resolve.`, + ); + } + cliNames.set(cliName, toolId); + } + + return { tools, workflows }; +} + +/** + * Validate that all tool modules exist on disk. + * Call this at startup to fail fast on missing modules. + */ +export function validateToolModules(manifest: ResolvedManifest): void { + const packageRoot = getPackageRoot(); + + for (const [toolId, tool] of manifest.tools) { + const modulePath = path.join(packageRoot, 'build', `${tool.module}.js`); + if (!fs.existsSync(modulePath)) { + throw new ManifestValidationError( + `Tool '${toolId}' references missing module: ${modulePath}`, + ); + } + } +} + +/** + * Get tools for a specific workflow. + */ +export function getWorkflowTools( + manifest: ResolvedManifest, + workflowId: string, +): ToolManifestEntry[] { + const workflow = manifest.workflows.get(workflowId); + if (!workflow) { + return []; + } + + return workflow.tools + .map((toolId) => manifest.tools.get(toolId)) + .filter((t): t is ToolManifestEntry => t !== undefined); +} + +/** + * Get all unique tools across selected workflows. + */ +export function getToolsForWorkflows( + manifest: ResolvedManifest, + workflowIds: string[], +): ToolManifestEntry[] { + const seenToolIds = new Set(); + const tools: ToolManifestEntry[] = []; + + for (const workflowId of workflowIds) { + const workflowTools = getWorkflowTools(manifest, workflowId); + for (const tool of workflowTools) { + if (!seenToolIds.has(tool.id)) { + seenToolIds.add(tool.id); + tools.push(tool); + } + } + } + + return tools; +} + +/** + * Get workflow metadata from the manifest. + * Returns a record mapping workflow IDs to their title/description. + */ +export function getWorkflowMetadataFromManifest(): Record< + string, + { name: string; description: string } +> { + const manifest = loadManifest(); + const metadata: Record = {}; + + for (const [id, workflow] of manifest.workflows.entries()) { + metadata[id] = { + name: workflow.title, + description: workflow.description, + }; + } + + return metadata; +} diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts new file mode 100644 index 00000000..5658f3ff --- /dev/null +++ b/src/core/manifest/schema.ts @@ -0,0 +1,150 @@ +/** + * Zod schemas for manifest YAML validation. + * These schemas define the canonical data model for tools and workflows. + */ + +import { z } from 'zod'; + +/** + * Availability flags for different runtimes. + */ +export const availabilitySchema = z.object({ + mcp: z.boolean().default(true), + cli: z.boolean().default(true), + daemon: z.boolean().default(true), +}); + +export type Availability = z.infer; + +/** + * Routing hints for daemon affinity. + */ +export const routingSchema = z.object({ + stateful: z.boolean().default(false), + daemonAffinity: z.enum(['preferred', 'required']).optional(), +}); + +export type Routing = z.infer; + +/** + * Tool names for MCP and CLI. + */ +export const toolNamesSchema = z.object({ + /** MCP name is required and must be globally unique */ + mcp: z.string(), + /** CLI name is optional; if omitted, derived from MCP name */ + cli: z.string().optional(), +}); + +export type ToolNames = z.infer; + +/** + * Tool manifest entry schema. + * Describes a single tool's metadata and configuration. + */ +export const toolManifestEntrySchema = z.object({ + /** Unique tool identifier */ + id: z.string(), + + /** + * Module path (extensionless, package-relative). + * Resolved to build/.js at runtime. + */ + module: z.string(), + + /** Tool names for MCP and CLI */ + names: toolNamesSchema, + + /** Tool description */ + description: z.string().optional(), + + /** Per-runtime availability flags */ + availability: availabilitySchema.default({ mcp: true, cli: true, daemon: true }), + + /** Predicate names for visibility filtering (all must pass) */ + predicates: z.array(z.string()).default([]), + + /** Routing hints for daemon */ + routing: routingSchema.optional(), +}); + +export type ToolManifestEntry = z.infer; + +/** + * MCP-specific workflow selection rules. + */ +export const workflowSelectionMcpSchema = z.object({ + /** Mandatory workflows are always included in MCP selection */ + mandatory: z.boolean().default(false), + /** Used when config.enabledWorkflows is empty */ + defaultEnabled: z.boolean().default(false), + /** Include when predicates pass even if not requested */ + autoInclude: z.boolean().default(false), +}); + +export type WorkflowSelectionMcp = z.infer; + +/** + * Workflow selection rules. + */ +export const workflowSelectionSchema = z.object({ + mcp: workflowSelectionMcpSchema.optional(), +}); + +export type WorkflowSelection = z.infer; + +/** + * Workflow manifest entry schema. + * Describes a workflow's metadata and tool composition. + */ +export const workflowManifestEntrySchema = z.object({ + /** Unique workflow identifier (matches directory name) */ + id: z.string(), + + /** Display title for the workflow */ + title: z.string(), + + /** Workflow description */ + description: z.string(), + + /** Per-runtime availability flags */ + availability: availabilitySchema.default({ mcp: true, cli: true, daemon: true }), + + /** MCP selection rules */ + selection: workflowSelectionSchema.optional(), + + /** Predicate names for visibility filtering (all must pass) */ + predicates: z.array(z.string()).default([]), + + /** Tool IDs belonging to this workflow */ + tools: z.array(z.string()), +}); + +export type WorkflowManifestEntry = z.infer; + +/** + * Resolved manifest containing all tools and workflows. + */ +export interface ResolvedManifest { + tools: Map; + workflows: Map; +} + +/** + * Derive CLI name from MCP name using kebab-case conversion. + * - Underscores become hyphens + * - camelCase becomes kebab-case + */ +export function deriveCliName(mcpName: string): string { + return mcpName + .replace(/_/g, '-') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); +} + +/** + * Get the effective CLI name for a tool. + */ +export function getEffectiveCliName(tool: ToolManifestEntry): string { + return tool.names.cli ?? deriveCliName(tool.names.mcp); +} diff --git a/src/core/plugin-registry.ts b/src/core/plugin-registry.ts deleted file mode 100644 index b4224157..00000000 --- a/src/core/plugin-registry.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { PluginMeta, WorkflowGroup, WorkflowMeta } from './plugin-types.ts'; -import { WORKFLOW_LOADERS, WorkflowName, WORKFLOW_METADATA } from './generated-plugins.ts'; - -export async function loadPlugins(): Promise> { - const plugins = new Map(); - - // Load all workflows and collect all their tools - const workflowGroups = await loadWorkflowGroups(); - - for (const [, workflow] of workflowGroups.entries()) { - for (const tool of workflow.tools) { - if (tool?.name && typeof tool.handler === 'function') { - plugins.set(tool.name, tool); - } - } - } - - return plugins; -} - -/** - * Load workflow groups with metadata validation using generated loaders - */ -export async function loadWorkflowGroups(): Promise> { - const workflows = new Map(); - - for (const [workflowName, loader] of Object.entries(WORKFLOW_LOADERS)) { - try { - // Dynamic import with code-splitting - const workflowModule = (await loader()) as { - workflow?: WorkflowMeta; - [key: string]: unknown; - }; - - if (!workflowModule.workflow) { - throw new Error(`Workflow metadata missing in ${workflowName}/index.js`); - } - - // Validate required fields - const workflowMeta = workflowModule.workflow as WorkflowMeta; - if (!workflowMeta.name || typeof workflowMeta.name !== 'string') { - throw new Error( - `Invalid workflow.name in ${workflowName}/index.js: must be a non-empty string`, - ); - } - - if (!workflowMeta.description || typeof workflowMeta.description !== 'string') { - throw new Error( - `Invalid workflow.description in ${workflowName}/index.js: must be a non-empty string`, - ); - } - - workflows.set(workflowName, { - workflow: workflowMeta, - tools: await loadWorkflowTools(workflowModule), - directoryName: workflowName, - }); - } catch (error) { - throw new Error( - `Failed to load workflow '${workflowName}': ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - return workflows; -} - -/** - * Load workflow tools from the workflow module - */ -async function loadWorkflowTools(workflowModule: Record): Promise { - const tools: PluginMeta[] = []; - - // Load individual tool files from the workflow module - for (const [key, value] of Object.entries(workflowModule)) { - if (key !== 'workflow' && value && typeof value === 'object') { - const tool = value as PluginMeta; - if (tool.name && typeof tool.handler === 'function') { - tools.push(tool); - } - } - } - - return tools; -} - -export function listWorkflowDirectoryNames(): string[] { - return Object.keys(WORKFLOW_LOADERS); -} - -/** - * Get workflow metadata by directory name using generated loaders - */ -export async function getWorkflowMetadata(directoryName: string): Promise { - try { - // First try to get from generated metadata (fast path) - const metadata = WORKFLOW_METADATA[directoryName as WorkflowName]; - if (metadata) { - return metadata; - } - - // Fall back to loading the actual module - const loader = WORKFLOW_LOADERS[directoryName as WorkflowName]; - if (loader) { - const workflowModule = (await loader()) as { workflow?: WorkflowMeta }; - return workflowModule.workflow ?? null; - } - - return null; - } catch { - return null; - } -} diff --git a/src/core/plugin-types.ts b/src/core/plugin-types.ts index da0e35ab..02b82e22 100644 --- a/src/core/plugin-types.ts +++ b/src/core/plugin-types.ts @@ -1,6 +1,6 @@ import * as z from 'zod'; -import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; -import { ToolResponse } from '../types/common.ts'; +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolResponse } from '../types/common.ts'; export type ToolSchemaShape = Record; diff --git a/src/core/resources.ts b/src/core/resources.ts index 478f7a8d..92250105 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -2,20 +2,19 @@ * 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. Resources allow clients - * to access data via URI references without requiring tool calls. - * - * Responsibilities: - * - Loading resources from the plugin-based resource system - * - Managing resource registration with the MCP server - * - Providing fallback compatibility for clients without resource support + * data through the Model Context Protocol resource system. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; import { log } from '../utils/logging/index.ts'; import type { CommandExecutor } from '../utils/execution/index.ts'; -import { RESOURCE_LOADERS } from './generated-resources.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'; /** * Resource metadata interface @@ -34,46 +33,46 @@ export interface ResourceMeta { } /** - * Load all resources using generated loaders + * All available resources + */ +const RESOURCES: ResourceMeta[] = [ + devicesResource, + doctorResource, + sessionStatusResource, + simulatorsResource, +]; + +/** + * Load all resources * @returns Map of resource URI to resource metadata */ export async function loadResources(): Promise> { const resources = new Map(); - for (const [resourceName, loader] of Object.entries(RESOURCE_LOADERS)) { - try { - const resource = (await loader()) as ResourceMeta; - - if (!resource.uri || !resource.handler || typeof resource.handler !== 'function') { - throw new Error(`Invalid resource structure for ${resourceName}`); - } - - resources.set(resource.uri, resource); - log('info', `Loaded resource: ${resourceName} (${resource.uri})`); - } catch (error) { - log( - 'error', - `Failed to load resource ${resourceName}: ${error instanceof Error ? error.message : String(error)}`, - ); + for (const resource of RESOURCES) { + if (!resource.uri || !resource.handler || typeof resource.handler !== 'function') { + log('error', `Invalid resource structure for ${resource.name ?? 'unknown'}`); + continue; } + + resources.set(resource.uri, resource); + log('info', `Loaded resource: ${resource.name} (${resource.uri})`); } return resources; } /** - * Register all resources with the MCP server if client supports resources + * Register all resources with the MCP server * @param server The MCP server instance - * @returns true if resources were registered, false if skipped due to client limitations + * @returns true if resources were registered */ export async function registerResources(server: McpServer): Promise { const resources = await loadResources(); for (const [uri, resource] of Array.from(resources)) { - // Create a handler wrapper that matches ReadResourceCallback signature const readCallback = async (resourceUri: URL): Promise => { const result = await resource.handler(resourceUri); - // Transform the content to match MCP SDK expectations return { contents: result.contents.map((content) => ({ uri: resourceUri.toString(), diff --git a/src/daemon.ts b/src/daemon.ts index 42e81f57..00e347ea 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,8 +3,8 @@ import net from 'node:net'; import { dirname } from 'node:path'; import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs'; import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts'; -import { listWorkflowDirectoryNames } from './core/plugin-registry.ts'; -import { buildToolCatalog } from './runtime/tool-catalog.ts'; +import { buildDaemonToolCatalogFromManifest } from './runtime/tool-catalog.ts'; +import { loadManifest } from './core/manifest/load-manifest.ts'; import { ensureSocketDir, removeStaleSocket, @@ -167,14 +167,16 @@ async function main(): Promise { // Remove stale socket file removeStaleSocket(socketPath); - const excludedWorkflows = new Set(['session-management', 'workflow-discovery']); - const allWorkflows = listWorkflowDirectoryNames(); - const daemonWorkflows = allWorkflows.filter((workflow) => !excludedWorkflows.has(workflow)); + const excludedWorkflows = ['session-management', 'workflow-discovery']; - // Build tool catalog (CLI daemon always loads all workflows except MCP-only ones) - const catalog = await buildToolCatalog({ - enabledWorkflows: allWorkflows, - excludeWorkflows: [...excludedWorkflows], + // Get all workflows from manifest (for reporting purposes) + const manifest = loadManifest(); + const allWorkflowIds = Array.from(manifest.workflows.keys()); + const daemonWorkflows = allWorkflowIds.filter((wf) => !excludedWorkflows.includes(wf)); + + // Build tool catalog using manifest system + const catalog = await buildDaemonToolCatalogFromManifest({ + excludeWorkflows: excludedWorkflows, }); log('info', `[Daemon] Loaded ${catalog.tools.length} tools`); diff --git a/src/mcp/resources/doctor.ts b/src/mcp/resources/doctor.ts index 851b9a1d..ee07509c 100644 --- a/src/mcp/resources/doctor.ts +++ b/src/mcp/resources/doctor.ts @@ -6,7 +6,8 @@ */ import { log } from '../../utils/logging/index.ts'; -import { getDefaultCommandExecutor, CommandExecutor } from '../../utils/execution/index.ts'; +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 diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 48f18c0c..882ee419 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index 66c01e7c..1d8ed9c9 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts index 656d9d9b..35d8b944 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { diff --git a/src/mcp/tools/debugging/debug_continue.ts b/src/mcp/tools/debugging/debug_continue.ts index b6d67a88..ed9db35e 100644 --- a/src/mcp/tools/debugging/debug_continue.ts +++ b/src/mcp/tools/debugging/debug_continue.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts index 3543eccf..a3a6819a 100644 --- a/src/mcp/tools/debugging/debug_detach.ts +++ b/src/mcp/tools/debugging/debug_detach.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts index 9e7b71bd..eadeef89 100644 --- a/src/mcp/tools/debugging/debug_lldb_command.ts +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts index c3f3ef1b..710cf02b 100644 --- a/src/mcp/tools/debugging/debug_stack.ts +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts index c60c6341..e9a53bbf 100644 --- a/src/mcp/tools/debugging/debug_variables.ts +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import { diff --git a/src/mcp/tools/debugging/index.ts b/src/mcp/tools/debugging/index.ts deleted file mode 100644 index e417e654..00000000 --- a/src/mcp/tools/debugging/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'Simulator Debugging', - description: - 'Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands.', -}; diff --git a/src/mcp/tools/device/__tests__/index.test.ts b/src/mcp/tools/device/__tests__/index.test.ts deleted file mode 100644 index 0ca73e76..00000000 --- a/src/mcp/tools/device/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for device-project workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('device-project workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('iOS Device Development'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index 8ad40868..336ed317 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -6,7 +6,8 @@ */ import * as z from 'zod'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/device/clean.ts b/src/mcp/tools/device/clean.ts deleted file mode 100644 index 552c9c17..00000000 --- a/src/mcp/tools/device/clean.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified clean tool for device-project workflow -export { default } from '../utilities/clean.ts'; diff --git a/src/mcp/tools/device/discover_projs.ts b/src/mcp/tools/device/discover_projs.ts deleted file mode 100644 index 58fbf05d..00000000 --- a/src/mcp/tools/device/discover_projs.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from project-discovery to complete workflow -export { default } from '../project-discovery/discover_projs.ts'; diff --git a/src/mcp/tools/device/get_app_bundle_id.ts b/src/mcp/tools/device/get_app_bundle_id.ts deleted file mode 100644 index 6c0bfc0d..00000000 --- a/src/mcp/tools/device/get_app_bundle_id.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from project-discovery to complete workflow -export { default } from '../project-discovery/get_app_bundle_id.ts'; diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 46c81e86..77ef6d76 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -6,7 +6,8 @@ */ import * as z from 'zod'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/device/index.ts b/src/mcp/tools/device/index.ts deleted file mode 100644 index 7f0a4cde..00000000 --- a/src/mcp/tools/device/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'iOS Device Development', - description: - 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware.', -}; diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index 00682207..a7f22c77 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index 1bcb41df..bcc77d2c 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/device/list_schemes.ts b/src/mcp/tools/device/list_schemes.ts deleted file mode 100644 index b046dde4..00000000 --- a/src/mcp/tools/device/list_schemes.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified list_schemes tool for device-project workflow -export { default } from '../project-discovery/list_schemes.ts'; diff --git a/src/mcp/tools/device/show_build_settings.ts b/src/mcp/tools/device/show_build_settings.ts deleted file mode 100644 index 0e15b943..00000000 --- a/src/mcp/tools/device/show_build_settings.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified tool for device-project workflow -export { default } from '../project-discovery/show_build_settings.ts'; diff --git a/src/mcp/tools/device/start_device_log_cap.ts b/src/mcp/tools/device/start_device_log_cap.ts deleted file mode 100644 index 19dd6c04..00000000 --- a/src/mcp/tools/device/start_device_log_cap.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from logging to complete workflow -export { default } from '../logging/start_device_log_cap.ts'; diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index 6fb9ccf8..f4c91c23 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/device/stop_device_log_cap.ts b/src/mcp/tools/device/stop_device_log_cap.ts deleted file mode 100644 index 48a20e09..00000000 --- a/src/mcp/tools/device/stop_device_log_cap.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from logging to complete workflow -export { default } from '../logging/stop_device_log_cap.ts'; diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index 3186a11e..717dc27b 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -7,7 +7,8 @@ import * as z from 'zod'; import { join } from 'path'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; diff --git a/src/mcp/tools/doctor/__tests__/index.test.ts b/src/mcp/tools/doctor/__tests__/index.test.ts deleted file mode 100644 index 60c4e3ed..00000000 --- a/src/mcp/tools/doctor/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for doctor workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('doctor workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('System Doctor'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 2fc96f79..c85d3e40 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -9,7 +9,7 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { version } from '../../../utils/version/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getConfig } from '../../../utils/config-store.ts'; import { detectXcodeRuntime } from '../../../utils/xcode-process.ts'; diff --git a/src/mcp/tools/doctor/index.ts b/src/mcp/tools/doctor/index.ts deleted file mode 100644 index fbd9e478..00000000 --- a/src/mcp/tools/doctor/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'System Doctor', - description: - 'Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability.', -}; diff --git a/src/mcp/tools/doctor/lib/doctor.deps.ts b/src/mcp/tools/doctor/lib/doctor.deps.ts index 110f8c4e..5f1aea4f 100644 --- a/src/mcp/tools/doctor/lib/doctor.deps.ts +++ b/src/mcp/tools/doctor/lib/doctor.deps.ts @@ -1,6 +1,6 @@ import * as os from 'os'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; -import { loadWorkflowGroups } from '../../../../utils/plugin-registry/index.ts'; +import { loadManifest } from '../../../../core/manifest/load-manifest.ts'; import type { RuntimeToolInfo } from '../../../../utils/tool-registry.ts'; import { getRuntimeRegistration } from '../../../../utils/tool-registry.ts'; import { areAxeToolsAvailable, resolveAxeBinary } from '../../../../utils/axe/index.ts'; @@ -217,25 +217,27 @@ export function createDoctorDependencies(executor: CommandExecutor): DoctorDepen const plugins: PluginInfoProvider = { async getPluginSystemInfo() { try { - const workflows = await loadWorkflowGroups(); + const manifest = loadManifest(); const pluginsByDirectory: Record = {}; let totalPlugins = 0; - for (const [dirName, wf] of workflows.entries()) { - const toolNames = wf.tools.map((t) => t.name).filter(Boolean) as string[]; + for (const [workflowId, workflow] of manifest.workflows.entries()) { + const toolNames = workflow.tools + .map((toolId) => manifest.tools.get(toolId)?.names.mcp) + .filter((name): name is string => name !== undefined); totalPlugins += toolNames.length; - pluginsByDirectory[dirName] = toolNames; + pluginsByDirectory[workflowId] = toolNames; } return { totalPlugins, - pluginDirectories: workflows.size, + pluginDirectories: manifest.workflows.size, pluginsByDirectory, - systemMode: 'plugin-based', + systemMode: 'manifest-based', }; } catch (error) { return { - error: `Failed to load plugins: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to load manifest: ${error instanceof Error ? error.message : 'Unknown error'}`, systemMode: 'error', }; } diff --git a/src/mcp/tools/logging/__tests__/index.test.ts b/src/mcp/tools/logging/__tests__/index.test.ts deleted file mode 100644 index 1bc97952..00000000 --- a/src/mcp/tools/logging/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for logging workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('logging workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('Log Capture & Management'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/logging/index.ts b/src/mcp/tools/logging/index.ts deleted file mode 100644 index b634c991..00000000 --- a/src/mcp/tools/logging/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'Log Capture & Management', - description: - 'Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing.', -}; diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 750df756..51bb5831 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -14,7 +14,7 @@ import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index 4b82093b..8b49475e 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -6,8 +6,10 @@ import * as z from 'zod'; import { startLogCapture } from '../../../utils/log-capture/index.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; -import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/command.ts'; +import { getDefaultCommandExecutor } from '../../../utils/command.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextContent } from '../../../types/common.ts'; import type { SubsystemFilter } from '../../../utils/log_capture.ts'; import { createSessionAwareTool, diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 7dda1471..e9e51169 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -11,9 +11,9 @@ import { activeDeviceLogSessions, type DeviceLogSession, } from '../../../utils/log-capture/device-log-sessions.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; -import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index 3a2ce23e..2e820d7d 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -6,7 +6,8 @@ import * as z from 'zod'; import { stopLogCapture as _stopLogCapture } from '../../../utils/log-capture/index.ts'; -import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextContent } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import type { CommandExecutor } from '../../../utils/command.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; diff --git a/src/mcp/tools/macos/__tests__/index.test.ts b/src/mcp/tools/macos/__tests__/index.test.ts deleted file mode 100644 index 0b6f902d..00000000 --- a/src/mcp/tools/macos/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for macos-project workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('macos-project workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('macOS Development'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index 219e4a74..3eb8a449 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -8,7 +8,8 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index e9be9d8f..b71abc8f 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -9,7 +9,8 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/macos/clean.ts b/src/mcp/tools/macos/clean.ts deleted file mode 100644 index 5af33211..00000000 --- a/src/mcp/tools/macos/clean.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified clean tool for macos-project workflow -export { default } from '../utilities/clean.ts'; diff --git a/src/mcp/tools/macos/discover_projs.ts b/src/mcp/tools/macos/discover_projs.ts deleted file mode 100644 index 58fbf05d..00000000 --- a/src/mcp/tools/macos/discover_projs.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from project-discovery to complete workflow -export { default } from '../project-discovery/discover_projs.ts'; diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index 8ad3d8a6..f10e2366 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/macos/get_mac_bundle_id.ts b/src/mcp/tools/macos/get_mac_bundle_id.ts deleted file mode 100644 index 9935d53e..00000000 --- a/src/mcp/tools/macos/get_mac_bundle_id.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from project-discovery to complete workflow -export { default } from '../project-discovery/get_mac_bundle_id.ts'; diff --git a/src/mcp/tools/macos/index.ts b/src/mcp/tools/macos/index.ts deleted file mode 100644 index 55c5afce..00000000 --- a/src/mcp/tools/macos/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'macOS Development', - description: - 'Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications.', -}; diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts index 200c51f3..2c91a3c2 100644 --- a/src/mcp/tools/macos/launch_mac_app.ts +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -8,7 +8,7 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; diff --git a/src/mcp/tools/macos/list_schemes.ts b/src/mcp/tools/macos/list_schemes.ts deleted file mode 100644 index 67519898..00000000 --- a/src/mcp/tools/macos/list_schemes.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified list_schemes tool for macos-project workflow -export { default } from '../project-discovery/list_schemes.ts'; diff --git a/src/mcp/tools/macos/show_build_settings.ts b/src/mcp/tools/macos/show_build_settings.ts deleted file mode 100644 index 77db451b..00000000 --- a/src/mcp/tools/macos/show_build_settings.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified tool for macos-project workflow -export { default } from '../project-discovery/show_build_settings.ts'; diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index 20b7484d..54538020 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -1,6 +1,6 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts index f82357e7..659914fe 100644 --- a/src/mcp/tools/macos/test_macos.ts +++ b/src/mcp/tools/macos/test_macos.ts @@ -7,7 +7,8 @@ import * as z from 'zod'; import { join } from 'path'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; diff --git a/src/mcp/tools/project-discovery/__tests__/index.test.ts b/src/mcp/tools/project-discovery/__tests__/index.test.ts deleted file mode 100644 index 603cac67..00000000 --- a/src/mcp/tools/project-discovery/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for project-discovery workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('project-discovery workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('Project Discovery'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index 3fbdc904..b3f881d2 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -8,9 +8,10 @@ import * as z from 'zod'; import * as path from 'node:path'; import { log } from '../../../utils/logging/index.ts'; -import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextContent } from '../../../types/common.ts'; import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; -import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Constants diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index e5d14019..121d488e 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -7,13 +7,10 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; -import { - CommandExecutor, - getDefaultFileSystemExecutor, - getDefaultCommandExecutor, -} from '../../../utils/command.ts'; -import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/command.ts'; +import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index 0e537a16..fa78f533 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -6,13 +6,10 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; -import { - CommandExecutor, - getDefaultFileSystemExecutor, - getDefaultCommandExecutor, -} from '../../../utils/command.ts'; -import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/command.ts'; +import { getDefaultFileSystemExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; /** diff --git a/src/mcp/tools/project-discovery/index.ts b/src/mcp/tools/project-discovery/index.ts deleted file mode 100644 index 995888a2..00000000 --- a/src/mcp/tools/project-discovery/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'Project Discovery', - description: - 'Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information.', -}; diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 2530842f..19e684f7 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -10,7 +10,7 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index 8a3e6394..a1c3922c 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -10,7 +10,7 @@ import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/project-scaffolding/__tests__/index.test.ts b/src/mcp/tools/project-scaffolding/__tests__/index.test.ts deleted file mode 100644 index 5755664c..00000000 --- a/src/mcp/tools/project-scaffolding/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for project-scaffolding workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('project-scaffolding workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('Project Scaffolding'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/project-scaffolding/index.ts b/src/mcp/tools/project-scaffolding/index.ts deleted file mode 100644 index d10cefd2..00000000 --- a/src/mcp/tools/project-scaffolding/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Project Scaffolding workflow - * - * Provides tools for creating new iOS and macOS projects from templates. - * These tools are used at project inception to bootstrap new applications - * with best practices and standard configurations. - */ - -export const workflow = { - name: 'Project Scaffolding', - description: - 'Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures.', -}; diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index 46a6a287..cc2bfc50 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -14,7 +14,7 @@ import { getDefaultCommandExecutor, getDefaultFileSystemExecutor, } from '../../../utils/execution/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; // Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index 35df0491..13025838 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -9,13 +9,10 @@ import { join, dirname, basename } from 'path'; import { log } from '../../../utils/logging/index.ts'; import { ValidationError } from '../../../utils/responses/index.ts'; import { TemplateManager } from '../../../utils/template/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; -import { - CommandExecutor, - getDefaultCommandExecutor, - getDefaultFileSystemExecutor, -} from '../../../utils/command.ts'; -import { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/command.ts'; +import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../../utils/command.ts'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; // Common base schema for both iOS and macOS const BaseScaffoldSchema = z.object({ diff --git a/src/mcp/tools/session-management/__tests__/index.test.ts b/src/mcp/tools/session-management/__tests__/index.test.ts deleted file mode 100644 index a5d76d1b..00000000 --- a/src/mcp/tools/session-management/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for session-management workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('session-management workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('session-management'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/session-management/index.ts b/src/mcp/tools/session-management/index.ts deleted file mode 100644 index 1d0d807d..00000000 --- a/src/mcp/tools/session-management/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'session-management', - description: - 'Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup.', -}; diff --git a/src/mcp/tools/simulator-management/__tests__/index.test.ts b/src/mcp/tools/simulator-management/__tests__/index.test.ts deleted file mode 100644 index 5d1da4ec..00000000 --- a/src/mcp/tools/simulator-management/__tests__/index.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Tests for simulator-management workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('simulator-management workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('Simulator Management'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance.', - ); - }); - }); -}); diff --git a/src/mcp/tools/simulator-management/boot_sim.ts b/src/mcp/tools/simulator-management/boot_sim.ts deleted file mode 100644 index 174a6c68..00000000 --- a/src/mcp/tools/simulator-management/boot_sim.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from simulator to avoid duplication -export { default } from '../simulator/boot_sim.ts'; diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts index 91fc53b5..bf12436b 100644 --- a/src/mcp/tools/simulator-management/erase_sims.ts +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/simulator-management/index.ts b/src/mcp/tools/simulator-management/index.ts deleted file mode 100644 index 66e8dbb5..00000000 --- a/src/mcp/tools/simulator-management/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Simulator Management workflow - * - * Provides tools for working with simulators like booting and opening simulators, launching apps, - * listing sims, stopping apps, erasing simulator content and settings, and setting sim environment - * options like location, network, statusbar and appearance. - */ - -export const workflow = { - name: 'Simulator Management', - description: - 'Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance.', -}; diff --git a/src/mcp/tools/simulator-management/list_sims.ts b/src/mcp/tools/simulator-management/list_sims.ts deleted file mode 100644 index 3c5a2ff0..00000000 --- a/src/mcp/tools/simulator-management/list_sims.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from simulator to avoid duplication -export { default } from '../simulator/list_sims.ts'; diff --git a/src/mcp/tools/simulator-management/open_sim.ts b/src/mcp/tools/simulator-management/open_sim.ts deleted file mode 100644 index 43a8857f..00000000 --- a/src/mcp/tools/simulator-management/open_sim.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from simulator to avoid duplication -export { default } from '../simulator/open_sim.ts'; diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts index 30b3b34d..d860ebfb 100644 --- a/src/mcp/tools/simulator-management/reset_sim_location.ts +++ b/src/mcp/tools/simulator-management/reset_sim_location.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts index 97c5bf9e..12ee39f4 100644 --- a/src/mcp/tools/simulator-management/set_sim_appearance.ts +++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts index 6a4a7cac..c302227c 100644 --- a/src/mcp/tools/simulator-management/set_sim_location.ts +++ b/src/mcp/tools/simulator-management/set_sim_location.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts index 11b3a2a2..507dd78f 100644 --- a/src/mcp/tools/simulator-management/sim_statusbar.ts +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -1,7 +1,8 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; -import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/simulator/__tests__/index.test.ts b/src/mcp/tools/simulator/__tests__/index.test.ts deleted file mode 100644 index a698604f..00000000 --- a/src/mcp/tools/simulator/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for simulator-project workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('simulator-project workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('iOS Simulator Development'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index eb61d52d..8a85cb0f 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index 39a85fb0..beaf6b71 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -7,7 +7,8 @@ */ import * as z from 'zod'; -import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index 5a8d75e2..1d46361a 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -9,7 +9,8 @@ import * as z from 'zod'; import { log } from '../../../utils/logging/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { ToolResponse, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { diff --git a/src/mcp/tools/simulator/clean.ts b/src/mcp/tools/simulator/clean.ts deleted file mode 100644 index 76494c98..00000000 --- a/src/mcp/tools/simulator/clean.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified clean tool for simulator-project workflow -export { default } from '../utilities/clean.ts'; diff --git a/src/mcp/tools/simulator/discover_projs.ts b/src/mcp/tools/simulator/discover_projs.ts deleted file mode 100644 index 58fbf05d..00000000 --- a/src/mcp/tools/simulator/discover_projs.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from project-discovery to complete workflow -export { default } from '../project-discovery/discover_projs.ts'; diff --git a/src/mcp/tools/simulator/get_app_bundle_id.ts b/src/mcp/tools/simulator/get_app_bundle_id.ts deleted file mode 100644 index 6c0bfc0d..00000000 --- a/src/mcp/tools/simulator/get_app_bundle_id.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from project-discovery to complete workflow -export { default } from '../project-discovery/get_app_bundle_id.ts'; diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index 65b8f3df..d661e937 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -11,7 +11,7 @@ import { log } from '../../../utils/logging/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/simulator/index.ts b/src/mcp/tools/simulator/index.ts deleted file mode 100644 index 51c14874..00000000 --- a/src/mcp/tools/simulator/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'iOS Simulator Development', - description: - 'Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators.', -}; diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index 9a5df615..eee3538a 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { validateFileExists } from '../../../utils/validation/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index d0d354ed..65421b06 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -1,5 +1,6 @@ import * as z from 'zod'; -import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextContent } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { startLogCapture } from '../../../utils/log-capture/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index 5f9181db..71f710b6 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/simulator/list_schemes.ts b/src/mcp/tools/simulator/list_schemes.ts deleted file mode 100644 index 1ecdf67f..00000000 --- a/src/mcp/tools/simulator/list_schemes.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified list_schemes tool for simulator-project workflow -export { default } from '../project-discovery/list_schemes.ts'; diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index 4e2d61ce..f06affed 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/simulator/screenshot.ts b/src/mcp/tools/simulator/screenshot.ts deleted file mode 100644 index 3eb979a5..00000000 --- a/src/mcp/tools/simulator/screenshot.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from ui-testing to avoid duplication -export { default } from '../ui-automation/screenshot.ts'; diff --git a/src/mcp/tools/simulator/show_build_settings.ts b/src/mcp/tools/simulator/show_build_settings.ts deleted file mode 100644 index 14d779c0..00000000 --- a/src/mcp/tools/simulator/show_build_settings.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export unified tool for simulator-project workflow -export { default } from '../project-discovery/show_build_settings.ts'; diff --git a/src/mcp/tools/simulator/snapshot_ui.ts b/src/mcp/tools/simulator/snapshot_ui.ts deleted file mode 100644 index 04e4efb0..00000000 --- a/src/mcp/tools/simulator/snapshot_ui.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from ui-automation to avoid duplication -export { default } from '../ui-automation/snapshot_ui.ts'; diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index 628a4aa1..3e3fea49 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; diff --git a/src/mcp/tools/simulator/stop_sim_log_cap.ts b/src/mcp/tools/simulator/stop_sim_log_cap.ts deleted file mode 100644 index d73b2a0f..00000000 --- a/src/mcp/tools/simulator/stop_sim_log_cap.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from logging to complete workflow -export { default } from '../logging/stop_sim_log_cap.ts'; diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index c34bffef..264dfe90 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -9,8 +9,8 @@ import * as z from 'zod'; import { handleTestLogic } from '../../../utils/test/index.ts'; import { log } from '../../../utils/logging/index.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { XcodePlatform } from '../../../types/common.ts'; -import { ToolResponse } from '../../../types/common.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; diff --git a/src/mcp/tools/swift-package/__tests__/index.test.ts b/src/mcp/tools/swift-package/__tests__/index.test.ts deleted file mode 100644 index 6dcf752c..00000000 --- a/src/mcp/tools/swift-package/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for swift-package workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('swift-package workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('Swift Package Manager'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/swift-package/index.ts b/src/mcp/tools/swift-package/index.ts deleted file mode 100644 index 18ec53a2..00000000 --- a/src/mcp/tools/swift-package/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'Swift Package Manager', - description: - 'Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support.', -}; diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts index a1cc75bb..b80524da 100644 --- a/src/mcp/tools/swift-package/swift_package_build.ts +++ b/src/mcp/tools/swift-package/swift_package_build.ts @@ -4,7 +4,7 @@ import { createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/swift-package/swift_package_clean.ts b/src/mcp/tools/swift-package/swift_package_clean.ts index be191bf0..543378f4 100644 --- a/src/mcp/tools/swift-package/swift_package_clean.ts +++ b/src/mcp/tools/swift-package/swift_package_clean.ts @@ -4,7 +4,7 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; // Define schema as ZodObject diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 9ed1b960..1506d2a5 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -4,7 +4,8 @@ // Import the shared activeProcesses map from swift_package_run // This maintains the same behavior as the original implementation import * as z from 'zod'; -import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextContent } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/command.ts'; import { activeProcesses } from './active-processes.ts'; diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts index 43684c04..87c7626b 100644 --- a/src/mcp/tools/swift-package/swift_package_run.ts +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -4,7 +4,8 @@ import { createTextResponse, createErrorResponse } from '../../../utils/response import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { ToolResponse, createTextContent } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createTextContent } from '../../../types/common.ts'; import { addProcess } from './active-processes.ts'; import { createSessionAwareTool, diff --git a/src/mcp/tools/swift-package/swift_package_stop.ts b/src/mcp/tools/swift-package/swift_package_stop.ts index 57115145..a8f56f7a 100644 --- a/src/mcp/tools/swift-package/swift_package_stop.ts +++ b/src/mcp/tools/swift-package/swift_package_stop.ts @@ -1,7 +1,7 @@ import * as z from 'zod'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { getProcess, removeProcess, type ProcessInfo } from './active-processes.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; // Define schema as ZodObject const swiftPackageStopSchema = z.object({ diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts index 3eee4323..5fb389e0 100644 --- a/src/mcp/tools/swift-package/swift_package_test.ts +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -4,7 +4,7 @@ import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { log } from '../../../utils/logging/index.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/ui-automation/__tests__/index.test.ts b/src/mcp/tools/ui-automation/__tests__/index.test.ts deleted file mode 100644 index e2a0e294..00000000 --- a/src/mcp/tools/ui-automation/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for ui-testing workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('ui-automation workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('UI Automation'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/ui-automation/gesture.ts b/src/mcp/tools/ui-automation/gesture.ts index 2622fb25..3201daa6 100644 --- a/src/mcp/tools/ui-automation/gesture.ts +++ b/src/mcp/tools/ui-automation/gesture.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, diff --git a/src/mcp/tools/ui-automation/index.ts b/src/mcp/tools/ui-automation/index.ts deleted file mode 100644 index 8b283529..00000000 --- a/src/mcp/tools/ui-automation/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'UI Automation', - description: - 'UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows.', -}; diff --git a/src/mcp/tools/ui-automation/key_press.ts b/src/mcp/tools/ui-automation/key_press.ts index c2c47fd1..2e61e557 100644 --- a/src/mcp/tools/ui-automation/key_press.ts +++ b/src/mcp/tools/ui-automation/key_press.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, diff --git a/src/mcp/tools/ui-automation/key_sequence.ts b/src/mcp/tools/ui-automation/key_sequence.ts index b865d08f..c493b297 100644 --- a/src/mcp/tools/ui-automation/key_sequence.ts +++ b/src/mcp/tools/ui-automation/key_sequence.ts @@ -5,7 +5,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, diff --git a/src/mcp/tools/ui-automation/long_press.ts b/src/mcp/tools/ui-automation/long_press.ts index ccdc8a15..bc00bee9 100644 --- a/src/mcp/tools/ui-automation/long_press.ts +++ b/src/mcp/tools/ui-automation/long_press.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index e63847a7..9a8fd567 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -10,7 +10,8 @@ import * as path from 'path'; import { tmpdir } from 'os'; import * as z from 'zod'; import { v4 as uuidv4 } from 'uuid'; -import { ToolResponse, createImageContent } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; +import { createImageContent } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createErrorResponse, diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts index 5f4128e7..28084181 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; diff --git a/src/mcp/tools/ui-automation/swipe.ts b/src/mcp/tools/ui-automation/swipe.ts index 366d098a..6dc935e8 100644 --- a/src/mcp/tools/ui-automation/swipe.ts +++ b/src/mcp/tools/ui-automation/swipe.ts @@ -5,7 +5,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; diff --git a/src/mcp/tools/ui-automation/touch.ts b/src/mcp/tools/ui-automation/touch.ts index eee8f32a..1a77c1c2 100644 --- a/src/mcp/tools/ui-automation/touch.ts +++ b/src/mcp/tools/ui-automation/touch.ts @@ -19,7 +19,7 @@ import { getAxePath, getBundledAxeEnvironment, } from '../../../utils/axe-helpers.ts'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, diff --git a/src/mcp/tools/ui-automation/type_text.ts b/src/mcp/tools/ui-automation/type_text.ts index ef652507..89dcea93 100644 --- a/src/mcp/tools/ui-automation/type_text.ts +++ b/src/mcp/tools/ui-automation/type_text.ts @@ -6,7 +6,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../../../types/common.ts'; +import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import { createTextResponse, createErrorResponse } from '../../../utils/responses/index.ts'; import { DependencyError, AxeError, SystemError } from '../../../utils/errors.ts'; diff --git a/src/mcp/tools/utilities/__tests__/index.test.ts b/src/mcp/tools/utilities/__tests__/index.test.ts deleted file mode 100644 index b772dbd7..00000000 --- a/src/mcp/tools/utilities/__tests__/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Tests for utilities workflow metadata - */ -import { describe, it, expect } from 'vitest'; -import { workflow } from '../index.ts'; - -describe('utilities workflow metadata', () => { - describe('Workflow Structure', () => { - it('should export workflow object with required properties', () => { - expect(workflow).toHaveProperty('name'); - expect(workflow).toHaveProperty('description'); - }); - - it('should have correct workflow name', () => { - expect(workflow.name).toBe('Project Utilities'); - }); - - it('should have correct description', () => { - expect(workflow.description).toBe( - 'Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files.', - ); - }); - }); - - describe('Workflow Validation', () => { - it('should have valid string properties', () => { - expect(typeof workflow.name).toBe('string'); - expect(typeof workflow.description).toBe('string'); - expect(workflow.name.length).toBeGreaterThan(0); - expect(workflow.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index 0df58ecb..df82d9ae 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -13,7 +13,8 @@ import { import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; -import { ToolResponse, SharedBuildParams, XcodePlatform } from '../../../types/common.ts'; +import type { ToolResponse, SharedBuildParams } from '../../../types/common.ts'; +import { XcodePlatform } from '../../../types/common.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; diff --git a/src/mcp/tools/utilities/index.ts b/src/mcp/tools/utilities/index.ts deleted file mode 100644 index 905e7541..00000000 --- a/src/mcp/tools/utilities/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'Project Utilities', - description: - 'Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files.', -}; diff --git a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts index 1aaeda12..904fe443 100644 --- a/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts +++ b/src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts @@ -1,23 +1,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../../../utils/tool-registry.ts', () => ({ - applyWorkflowSelection: vi.fn(), + applyWorkflowSelectionFromManifest: vi.fn(), getRegisteredWorkflows: vi.fn(), + getMcpPredicateContext: vi.fn().mockReturnValue({ + runtime: 'mcp', + config: { debug: false }, + runningUnderXcode: false, + xcodeToolsActive: false, + }), +})); + +vi.mock('../../../../utils/config-store.ts', () => ({ + getConfig: vi.fn().mockReturnValue({ + debug: false, + experimentalWorkflowDiscovery: false, + enabledWorkflows: [], + }), })); import { manage_workflowsLogic } from '../manage_workflows.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import { applyWorkflowSelection, getRegisteredWorkflows } from '../../../../utils/tool-registry.ts'; +import { + applyWorkflowSelectionFromManifest, + getRegisteredWorkflows, +} from '../../../../utils/tool-registry.ts'; describe('manage_workflows tool', () => { beforeEach(() => { - vi.mocked(applyWorkflowSelection).mockReset(); + vi.mocked(applyWorkflowSelectionFromManifest).mockReset(); vi.mocked(getRegisteredWorkflows).mockReset(); }); it('merges new workflows with current set when enable is true', async () => { vi.mocked(getRegisteredWorkflows).mockReturnValue(['simulator']); - vi.mocked(applyWorkflowSelection).mockResolvedValue({ + vi.mocked(applyWorkflowSelectionFromManifest).mockResolvedValue({ enabledWorkflows: ['simulator', 'device'], registeredToolCount: 0, }); @@ -28,13 +45,16 @@ describe('manage_workflows tool', () => { executor, ); - expect(vi.mocked(applyWorkflowSelection)).toHaveBeenCalledWith(['simulator', 'device']); + expect(vi.mocked(applyWorkflowSelectionFromManifest)).toHaveBeenCalledWith( + ['simulator', 'device'], + expect.objectContaining({ runtime: 'mcp' }), + ); expect(result.content[0].text).toBe('Workflows enabled: simulator, device'); }); it('removes requested workflows when enable is false', async () => { vi.mocked(getRegisteredWorkflows).mockReturnValue(['simulator', 'device']); - vi.mocked(applyWorkflowSelection).mockResolvedValue({ + vi.mocked(applyWorkflowSelectionFromManifest).mockResolvedValue({ enabledWorkflows: ['simulator'], registeredToolCount: 0, }); @@ -45,13 +65,16 @@ describe('manage_workflows tool', () => { executor, ); - expect(vi.mocked(applyWorkflowSelection)).toHaveBeenCalledWith(['simulator']); + expect(vi.mocked(applyWorkflowSelectionFromManifest)).toHaveBeenCalledWith( + ['simulator'], + expect.objectContaining({ runtime: 'mcp' }), + ); expect(result.content[0].text).toBe('Workflows enabled: simulator'); }); it('accepts workflowName as an array', async () => { vi.mocked(getRegisteredWorkflows).mockReturnValue(['simulator']); - vi.mocked(applyWorkflowSelection).mockResolvedValue({ + vi.mocked(applyWorkflowSelectionFromManifest).mockResolvedValue({ enabledWorkflows: ['simulator', 'device', 'logging'], registeredToolCount: 0, }); @@ -59,10 +82,9 @@ describe('manage_workflows tool', () => { const executor = createMockExecutor({ success: true, output: '' }); await manage_workflowsLogic({ workflowNames: ['device', 'logging'], enable: true }, executor); - expect(vi.mocked(applyWorkflowSelection)).toHaveBeenCalledWith([ - 'simulator', - 'device', - 'logging', - ]); + expect(vi.mocked(applyWorkflowSelectionFromManifest)).toHaveBeenCalledWith( + ['simulator', 'device', 'logging'], + expect.objectContaining({ runtime: 'mcp' }), + ); }); }); diff --git a/src/mcp/tools/workflow-discovery/index.ts b/src/mcp/tools/workflow-discovery/index.ts deleted file mode 100644 index 3e4cd290..00000000 --- a/src/mcp/tools/workflow-discovery/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const workflow = { - name: 'Workflow Discovery', - description: `Manage the workflows that are enabled and disabled.`, -}; diff --git a/src/mcp/tools/workflow-discovery/manage_workflows.ts b/src/mcp/tools/workflow-discovery/manage_workflows.ts index ee622d4c..3cc08d9d 100644 --- a/src/mcp/tools/workflow-discovery/manage_workflows.ts +++ b/src/mcp/tools/workflow-discovery/manage_workflows.ts @@ -4,8 +4,12 @@ import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor, type CommandExecutor } from '../../../utils/execution/index.ts'; import { createTextResponse } from '../../../utils/responses/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -import { applyWorkflowSelection, getRegisteredWorkflows } from '../../../utils/tool-registry.ts'; -import { listWorkflowDirectoryNames } from '../../../core/plugin-registry.ts'; +import { + applyWorkflowSelectionFromManifest, + getRegisteredWorkflows, + getMcpPredicateContext, +} from '../../../utils/tool-registry.ts'; +import { loadManifest } from '../../../core/manifest/load-manifest.ts'; const baseSchemaObject = z.object({ workflowNames: z.array(z.string()).describe('Workflow directory name(s).'), @@ -31,14 +35,19 @@ export async function manage_workflowsLogic( } else { nextWorkflows = [...new Set([...currentWorkflows, ...workflowNames])]; } - const registryState = await applyWorkflowSelection(nextWorkflows); + + // Use the stored MCP predicate context to preserve Xcode detection state + const ctx = getMcpPredicateContext(); + + const registryState = await applyWorkflowSelectionFromManifest(nextWorkflows, ctx); return createTextResponse(`Workflows enabled: ${registryState.enabledWorkflows.join(', ')}`); } -const workflowNames = listWorkflowDirectoryNames(); +const manifest = loadManifest(); +const allWorkflowIds = Array.from(manifest.workflows.keys()); const availableWorkflows = - workflowNames.length > 0 ? workflowNames.join(', ') : 'none (no workflows discovered)'; + allWorkflowIds.length > 0 ? allWorkflowIds.join(', ') : 'none (no workflows discovered)'; export default { name: 'manage-workflows', diff --git a/src/mcp/tools/xcode-ide/index.ts b/src/mcp/tools/xcode-ide/index.ts deleted file mode 100644 index be744665..00000000 --- a/src/mcp/tools/xcode-ide/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const workflow = { - name: 'Xcode IDE (mcpbridge)', - description: - "Proxy Xcode's built-in 'Xcode Tools' MCP service via `xcrun mcpbridge`. Registers dynamic `xcode_tools_*` tools when available. Bridge debug tools are only registered when `debug: true`.", -}; diff --git a/src/runtime/naming.ts b/src/runtime/naming.ts index 90d053bc..415381e2 100644 --- a/src/runtime/naming.ts +++ b/src/runtime/naming.ts @@ -1,5 +1,3 @@ -import type { ToolDefinition } from './types.ts'; - /** * Convert a tool name to kebab-case for CLI usage. * Examples: @@ -36,31 +34,6 @@ export function toCamelCase(kebab: string): string { return kebab.replace(/-([a-z])/g, (_match: string, letter: string) => letter.toUpperCase()); } -/** - * Disambiguate CLI names when duplicates exist across workflows. - * If multiple tools have the same kebab-case name, prefix with workflow name. - */ -export function disambiguateCliNames(tools: ToolDefinition[]): ToolDefinition[] { - // Group tools by their base CLI name - const groups = new Map(); - for (const tool of tools) { - const existing = groups.get(tool.cliName) ?? []; - groups.set(tool.cliName, [...existing, tool]); - } - - // Disambiguate tools that share the same CLI name - return tools.map((tool) => { - const sameNameTools = groups.get(tool.cliName) ?? []; - if (sameNameTools.length <= 1) { - return tool; - } - - // Prefix with workflow name for disambiguation - const disambiguatedName = `${tool.workflow}-${tool.cliName}`; - return { ...tool, cliName: disambiguatedName }; - }); -} - /** * Convert CLI argv keys (kebab-case) back to tool param keys (camelCase). */ diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index aa4b6314..5ed60995 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -1,47 +1,17 @@ -import { loadWorkflowGroups } from '../core/plugin-registry.ts'; -import { resolveSelectedWorkflows } from '../utils/workflow-selection.ts'; -import { shouldExposeTool } from '../utils/tool-visibility.ts'; import type { ToolCatalog, ToolDefinition, ToolResolution } from './types.ts'; -import { toKebabCase, disambiguateCliNames } from './naming.ts'; - -export async function buildToolCatalog(opts: { - enabledWorkflows: string[]; - excludeWorkflows?: string[]; -}): Promise { - const workflowGroups = await loadWorkflowGroups(); - const selection = resolveSelectedWorkflows(opts.enabledWorkflows, workflowGroups); - - const excludeSet = new Set(opts.excludeWorkflows?.map((w) => w.toLowerCase()) ?? []); - const tools: ToolDefinition[] = []; - - for (const wf of selection.selectedWorkflows) { - if (excludeSet.has(wf.directoryName.toLowerCase())) { - continue; - } - for (const tool of wf.tools) { - if (!shouldExposeTool(wf.directoryName, tool.name)) { - continue; - } - const baseCliName = tool.cli?.name ?? toKebabCase(tool.name); - tools.push({ - cliName: baseCliName, // Will be disambiguated below - mcpName: tool.name, - workflow: wf.directoryName, - description: tool.description, - annotations: tool.annotations, - mcpSchema: tool.schema, - cliSchema: tool.cli?.schema ?? tool.schema, - stateful: Boolean(tool.cli?.stateful), - daemonAffinity: tool.cli?.daemonAffinity, - handler: tool.handler, - }); - } - } - - const disambiguated = disambiguateCliNames(tools); - - return createCatalog(disambiguated); -} +import { toKebabCase } from './naming.ts'; +import { loadManifest, type WorkflowManifestEntry } from '../core/manifest/load-manifest.ts'; +import { getEffectiveCliName } from '../core/manifest/schema.ts'; +import { importToolModule } from '../core/manifest/import-tool-module.ts'; +import type { PredicateContext, RuntimeKind } from '../visibility/predicate-types.ts'; +import { + isWorkflowAvailableForRuntime, + isToolAvailableForRuntime, + isToolExposedForRuntime, + isWorkflowEnabledForRuntime, +} from '../visibility/exposure.ts'; +import { getConfig } from '../utils/config-store.ts'; +import { log } from '../utils/logging/index.ts'; function createCatalog(tools: ToolDefinition[]): ToolCatalog { // Build lookup maps for fast resolution @@ -120,3 +90,134 @@ export function groupToolsByWorkflow(catalog: ToolCatalog): Map { + const manifest = loadManifest(); + const excludeSet = new Set(opts.excludeWorkflows?.map((w) => w.toLowerCase()) ?? []); + + // Get workflows to include + let workflowsToInclude: WorkflowManifestEntry[]; + if (opts.enabledWorkflows && opts.enabledWorkflows.length > 0) { + // Use specified workflows + workflowsToInclude = opts.enabledWorkflows + .map((id) => manifest.workflows.get(id)) + .filter((wf): wf is WorkflowManifestEntry => wf !== undefined); + } else { + // Use all workflows available for the runtime + workflowsToInclude = Array.from(manifest.workflows.values()); + } + + // Filter workflows + const filteredWorkflows = workflowsToInclude.filter((wf) => { + // Check exclusion list + if (excludeSet.has(wf.id.toLowerCase())) return false; + // Check runtime availability + if (!isWorkflowAvailableForRuntime(wf, opts.runtime)) return false; + // Check predicates + if (!isWorkflowEnabledForRuntime(wf, opts.ctx)) return false; + return true; + }); + + // Cache imported modules to avoid re-importing the same tool + const moduleCache = new Map>>(); + const tools: ToolDefinition[] = []; + + for (const workflow of filteredWorkflows) { + for (const toolId of workflow.tools) { + const toolManifest = manifest.tools.get(toolId); + if (!toolManifest) continue; + + // Check tool availability for runtime + if (!isToolAvailableForRuntime(toolManifest, opts.runtime)) continue; + + // Check tool predicates + if (!isToolExposedForRuntime(toolManifest, opts.ctx)) continue; + + // Import the tool module (cached) + let toolModule = moduleCache.get(toolId); + if (!toolModule) { + try { + toolModule = await importToolModule(toolManifest.module); + moduleCache.set(toolId, toolModule); + } catch (err) { + log('warning', `Failed to import tool module ${toolManifest.module}: ${err}`); + continue; + } + } + + const cliName = getEffectiveCliName(toolManifest); + tools.push({ + cliName, + mcpName: toolManifest.names.mcp, + workflow: workflow.id, + description: toolManifest.description, + annotations: toolModule.annotations, + mcpSchema: toolModule.schema, + cliSchema: toolModule.schema, + stateful: toolManifest.routing?.stateful ?? false, + daemonAffinity: toolManifest.routing?.daemonAffinity, + handler: toolModule.handler as ToolDefinition['handler'], + }); + } + } + + return createCatalog(tools); +} + +/** + * Build a CLI tool catalog from the manifest system. + * CLI shows ALL workflows (not config-driven) except excluded ones. + */ +export async function buildCliToolCatalogFromManifest(opts?: { + excludeWorkflows?: string[]; +}): Promise { + const defaultExclude = ['session-management', 'workflow-discovery']; + const excludeWorkflows = opts?.excludeWorkflows ?? defaultExclude; + + // CLI context: not running under Xcode, no Xcode tools active + const ctx: PredicateContext = { + runtime: 'cli', + config: getConfig(), + runningUnderXcode: false, + xcodeToolsActive: false, + }; + + return buildToolCatalogFromManifest({ + runtime: 'cli', + ctx, + excludeWorkflows, + }); +} + +/** + * Build a daemon tool catalog from the manifest system. + * Daemon shows ALL workflows (not config-driven) except excluded ones. + */ +export async function buildDaemonToolCatalogFromManifest(opts?: { + excludeWorkflows?: string[]; +}): Promise { + const defaultExclude = ['session-management', 'workflow-discovery']; + const excludeWorkflows = opts?.excludeWorkflows ?? defaultExclude; + + // Daemon context: not running under Xcode, no Xcode tools active + const ctx: PredicateContext = { + runtime: 'daemon', + config: getConfig(), + runningUnderXcode: false, + xcodeToolsActive: false, + }; + + return buildToolCatalogFromManifest({ + runtime: 'daemon', + ctx, + excludeWorkflows, + }); +} diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 176ba85c..1f873f8b 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -24,7 +24,7 @@ function enrichNextStepsForCli(response: ToolResponse, catalog: ToolCatalog): To return { ...step, workflow: target.workflow, - cliTool: target.cliName, + cliTool: target.cliName, // Canonical CLI name from manifest }; }), }; diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 927f7afc..b2a71311 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -4,9 +4,12 @@ import { registerResources } from '../core/resources.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import { log, setLogLevel, type LogLevel } from '../utils/logger.ts'; import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; -import { registerWorkflows } from '../utils/tool-registry.ts'; +import { registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; +import { detectXcodeRuntime } from '../utils/xcode-process.ts'; +import { getDefaultCommandExecutor } from '../utils/command.ts'; +import type { PredicateContext } from '../visibility/predicate-types.ts'; export interface BootstrapOptions { enabledWorkflows?: string[]; @@ -54,17 +57,39 @@ export async function bootstrapServer( const enabledWorkflows = result.runtime.config.enabledWorkflows; log('info', `🚀 Initializing server...`); - await registerWorkflows(enabledWorkflows); + + // Detect if running under Xcode + const xcodeDetection = await detectXcodeRuntime(getDefaultCommandExecutor()); + if (xcodeDetection.runningUnderXcode) { + log('info', `[xcode] Running under Xcode agent environment`); + } + + // Build predicate context for manifest-based registration + const ctx: PredicateContext = { + runtime: 'mcp', + config: result.runtime.config, + runningUnderXcode: xcodeDetection.runningUnderXcode, + xcodeToolsActive: false, // Will be updated after Xcode tools bridge sync + }; + + // Register workflows using manifest system + await registerWorkflowsFromManifest(enabledWorkflows, ctx); const xcodeIdeEnabled = enabledWorkflows.includes('xcode-ide'); const xcodeToolsBridge = getXcodeToolsBridgeManager(server); xcodeToolsBridge?.setWorkflowEnabled(xcodeIdeEnabled); if (xcodeIdeEnabled && xcodeToolsBridge) { try { - await xcodeToolsBridge.syncTools({ reason: 'startup' }); + const syncResult = await xcodeToolsBridge.syncTools({ reason: 'startup' }); + // After sync, if Xcode tools are active, re-register with updated context + if (syncResult.total > 0 && xcodeDetection.runningUnderXcode) { + log('info', `[xcode-ide] Xcode tools active - applying conflict filtering`); + ctx.xcodeToolsActive = true; + await registerWorkflowsFromManifest(enabledWorkflows, ctx); + } } catch (error) { log( - 'warn', + 'warning', `[xcode-ide] Startup sync failed: ${error instanceof Error ? error.message : String(error)}`, ); } diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index 05b3ca7f..f9105bb2 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -8,7 +8,7 @@ */ import { createServer, startServer } from './server.ts'; -import { log } from '../utils/logger.ts'; +import { log, setLogLevel } from '../utils/logger.ts'; import { initSentry } from '../utils/sentry.ts'; import { getDefaultDebuggerManager } from '../utils/debugger/index.ts'; import { version } from '../version.ts'; @@ -23,6 +23,10 @@ import { shutdownXcodeToolsBridge } from '../integrations/xcode-tools-bridge/ind */ export async function startMcpServer(): Promise { try { + // MCP mode defaults to info level logging + // Clients can override via logging/setLevel MCP request + setLogLevel('info'); + initSentry(); const server = createServer(); diff --git a/src/test-utils/mock-executors.ts b/src/test-utils/mock-executors.ts index 361b2173..03b09000 100644 --- a/src/test-utils/mock-executors.ts +++ b/src/test-utils/mock-executors.ts @@ -19,8 +19,8 @@ import { ChildProcess } from 'child_process'; import type { WriteStream } from 'fs'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; -import { CommandExecutor, type CommandResponse } from '../utils/CommandExecutor.ts'; -import { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; +import type { CommandExecutor, CommandResponse } from '../utils/CommandExecutor.ts'; +import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import type { InteractiveProcess, InteractiveSpawner } from '../utils/execution/index.ts'; export type { CommandExecutor, FileSystemExecutor }; diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts index e2964011..94e65b0d 100644 --- a/src/utils/CommandExecutor.ts +++ b/src/utils/CommandExecutor.ts @@ -1,5 +1,8 @@ import { ChildProcess } from 'child_process'; +// Runtime marker to prevent empty output in unbundled builds +export const _typeModule = true as const; + export interface CommandExecOptions { env?: Record; cwd?: string; diff --git a/src/utils/FileSystemExecutor.ts b/src/utils/FileSystemExecutor.ts index 4453e29d..4baf499c 100644 --- a/src/utils/FileSystemExecutor.ts +++ b/src/utils/FileSystemExecutor.ts @@ -4,6 +4,9 @@ import type { WriteStream } from 'fs'; +// Runtime marker to prevent empty output in unbundled builds +export const _typeModule = true as const; + export interface FileSystemExecutor { mkdir(path: string, options?: { recursive?: boolean }): Promise; readFile(path: string, encoding?: BufferEncoding): Promise; diff --git a/src/utils/axe-helpers.ts b/src/utils/axe-helpers.ts index 38a2944d..1f947395 100644 --- a/src/utils/axe-helpers.ts +++ b/src/utils/axe-helpers.ts @@ -8,7 +8,7 @@ import { accessSync, constants, existsSync } from 'fs'; import { dirname, join, resolve, delimiter } from 'path'; import { createTextResponse } from './validation.ts'; -import { ToolResponse } from '../types/common.ts'; +import type { ToolResponse } from '../types/common.ts'; import type { CommandExecutor } from './execution/index.ts'; import { getDefaultCommandExecutor } from './execution/index.ts'; import { getConfig } from './config-store.ts'; diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index d2f053f8..b786d2aa 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -19,8 +19,8 @@ import { log } from './logger.ts'; import { XcodePlatform, constructDestinationString } from './xcode.ts'; -import { CommandExecutor, CommandExecOptions } from './command.ts'; -import { ToolResponse, SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; +import type { CommandExecutor, CommandExecOptions } from './command.ts'; +import type { ToolResponse, SharedBuildParams, PlatformBuildOptions } from '../types/common.ts'; import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; import { isXcodemakeEnabled, diff --git a/src/utils/command.ts b/src/utils/command.ts index 01757c9e..44b6dbb5 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -13,12 +13,12 @@ import { spawn } from 'child_process'; import { createWriteStream, existsSync } from 'fs'; import { tmpdir as osTmpdir } from 'os'; import { log } from './logger.ts'; -import { FileSystemExecutor } from './FileSystemExecutor.ts'; -import { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; +import type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; // Re-export types for backward compatibility -export { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; -export { FileSystemExecutor } from './FileSystemExecutor.ts'; +export type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; +export type { FileSystemExecutor } from './FileSystemExecutor.ts'; /** * Default executor implementation using spawn (current production behavior) diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 359399cb..70997b59 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,4 +1,4 @@ -import { ToolResponse } from '../types/common.ts'; +import type { ToolResponse } from '../types/common.ts'; /** * Error Utilities - Type-safe error hierarchy for the application diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index daece866..be521114 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -4,12 +4,9 @@ import type { Writable } from 'stream'; import { finished } from 'stream/promises'; import { v4 as uuidv4 } from 'uuid'; import { log } from '../utils/logger.ts'; -import { - CommandExecutor, - getDefaultCommandExecutor, - getDefaultFileSystemExecutor, -} from './command.ts'; -import { FileSystemExecutor } from './FileSystemExecutor.ts'; +import type { CommandExecutor } from './command.ts'; +import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from './command.ts'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; /** * Log file retention policy: diff --git a/src/utils/plugin-registry/index.ts b/src/utils/plugin-registry/index.ts deleted file mode 100644 index 5a7f54fb..00000000 --- a/src/utils/plugin-registry/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - loadWorkflowGroups, - loadPlugins, - listWorkflowDirectoryNames, -} from '../../core/plugin-registry.ts'; diff --git a/src/utils/responses/__tests__/next-steps-renderer.test.ts b/src/utils/responses/__tests__/next-steps-renderer.test.ts index cdd157c8..418b8a59 100644 --- a/src/utils/responses/__tests__/next-steps-renderer.test.ts +++ b/src/utils/responses/__tests__/next-steps-renderer.test.ts @@ -11,6 +11,7 @@ describe('next-steps-renderer', () => { it('should format step for CLI with workflow and no params', () => { const step: NextStep = { tool: 'open_sim', + cliTool: 'open-sim', workflow: 'simulator', label: 'Open the Simulator app', params: {}, @@ -23,6 +24,7 @@ describe('next-steps-renderer', () => { it('should format step for CLI with workflow and params', () => { const step: NextStep = { tool: 'install_app_sim', + cliTool: 'install-app-sim', workflow: 'simulator', label: 'Install an app', params: { simulatorId: 'ABC123', appPath: '/path/to/app' }, @@ -34,7 +36,7 @@ describe('next-steps-renderer', () => { ); }); - it('should prefer cliTool when provided', () => { + it('should use cliTool for CLI rendering', () => { const step: NextStep = { tool: 'install_app_sim', cliTool: 'install-app', @@ -49,13 +51,26 @@ describe('next-steps-renderer', () => { ); }); - it('should format step for CLI without workflow (backwards compat)', () => { + it('should throw error for CLI without cliTool', () => { const step: NextStep = { tool: 'open_sim', label: 'Open the Simulator app', params: {}, }; + expect(() => renderNextStep(step, 'cli')).toThrow( + "Next step for tool 'open_sim' is missing cliTool - ensure enrichNextStepsForCli was called", + ); + }); + + it('should format step for CLI without workflow', () => { + const step: NextStep = { + tool: 'open_sim', + cliTool: 'open-sim', + label: 'Open the Simulator app', + params: {}, + }; + const result = renderNextStep(step, 'cli'); expect(result).toBe('Open the Simulator app: xcodebuildmcp open-sim'); }); @@ -63,6 +78,7 @@ describe('next-steps-renderer', () => { it('should format step for CLI with boolean param (true)', () => { const step: NextStep = { tool: 'some_tool', + cliTool: 'some-tool', label: 'Do something', params: { verbose: true }, }; @@ -74,6 +90,7 @@ describe('next-steps-renderer', () => { it('should format step for CLI with boolean param (false)', () => { const step: NextStep = { tool: 'some_tool', + cliTool: 'some-tool', label: 'Do something', params: { verbose: false }, }; @@ -148,8 +165,13 @@ describe('next-steps-renderer', () => { it('should render numbered list for CLI', () => { const steps: NextStep[] = [ - { tool: 'open_sim', label: 'Open Simulator', params: {} }, - { tool: 'install_app_sim', label: 'Install app', params: { simulatorId: 'X' } }, + { tool: 'open_sim', cliTool: 'open-sim', label: 'Open Simulator', params: {} }, + { + tool: 'install_app_sim', + cliTool: 'install-app-sim', + label: 'Install app', + params: { simulatorId: 'X' }, + }, ]; const result = renderNextStepsSection(steps, 'cli'); @@ -214,7 +236,7 @@ describe('next-steps-renderer', () => { it('should strip nextSteps in minimal style', () => { const response: ToolResponse = { content: [{ type: 'text', text: 'Success!' }], - nextSteps: [{ tool: 'foo', label: 'Do foo', params: {} }], + nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Do foo', params: {} }], }; const result = processToolResponse(response, 'cli', 'minimal'); @@ -227,7 +249,15 @@ describe('next-steps-renderer', () => { it('should append next steps to last text content in normal style', () => { const response: ToolResponse = { content: [{ type: 'text', text: 'Simulator booted.' }], - nextSteps: [{ tool: 'open_sim', label: 'Open Simulator', params: {}, priority: 1 }], + nextSteps: [ + { + tool: 'open_sim', + cliTool: 'open-sim', + label: 'Open Simulator', + params: {}, + priority: 1, + }, + ], }; const result = processToolResponse(response, 'cli', 'normal'); @@ -266,7 +296,7 @@ describe('next-steps-renderer', () => { content: [{ type: 'text', text: 'Error!' }], isError: true, _meta: { foo: 'bar' }, - nextSteps: [{ tool: 'retry', label: 'Retry', params: {} }], + nextSteps: [{ tool: 'retry', cliTool: 'retry', label: 'Retry', params: {} }], }; const result = processToolResponse(response, 'cli', 'minimal'); @@ -277,7 +307,7 @@ describe('next-steps-renderer', () => { it('should not mutate original response', () => { const response: ToolResponse = { content: [{ type: 'text', text: 'Original' }], - nextSteps: [{ tool: 'foo', label: 'Foo', params: {} }], + nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Foo', params: {} }], }; processToolResponse(response, 'cli', 'normal'); @@ -289,7 +319,7 @@ describe('next-steps-renderer', () => { it('should default to normal style when not specified', () => { const response: ToolResponse = { content: [{ type: 'text', text: 'Success!' }], - nextSteps: [{ tool: 'foo', label: 'Do foo', params: {} }], + nextSteps: [{ tool: 'foo', cliTool: 'foo', label: 'Do foo', params: {} }], }; const result = processToolResponse(response, 'cli'); diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts index 045db633..4aa77871 100644 --- a/src/utils/responses/next-steps-renderer.ts +++ b/src/utils/responses/next-steps-renderer.ts @@ -1,6 +1,15 @@ import type { RuntimeKind } from '../../runtime/types.ts'; import type { NextStep, OutputStyle, ToolResponse } from '../../types/common.ts'; -import { toKebabCase } from '../../runtime/naming.ts'; + +/** + * Convert a string to kebab-case for CLI flag names. + */ +function toKebabCase(name: string): string { + return name + .replace(/_/g, '-') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); +} /** * Format a single next step for CLI output. @@ -8,7 +17,11 @@ import { toKebabCase } from '../../runtime/naming.ts'; * Example: xcodebuildmcp simulator install-app-sim --simulator-id "ABC123" --app-path "PATH" */ function formatNextStepForCli(step: NextStep): string { - const cliName = step.cliTool ?? toKebabCase(step.tool); + if (!step.cliTool) { + throw new Error( + `Next step for tool '${step.tool}' is missing cliTool - ensure enrichNextStepsForCli was called`, + ); + } const parts = ['xcodebuildmcp']; // Include workflow as subcommand if provided @@ -16,7 +29,7 @@ function formatNextStepForCli(step: NextStep): string { parts.push(step.workflow); } - parts.push(cliName); + parts.push(step.cliTool); for (const [key, value] of Object.entries(step.params)) { const flagName = toKebabCase(key); diff --git a/src/utils/simulator-utils.ts b/src/utils/simulator-utils.ts index b07d7a9a..547cebf2 100644 --- a/src/utils/simulator-utils.ts +++ b/src/utils/simulator-utils.ts @@ -3,7 +3,7 @@ */ import type { CommandExecutor } from './execution/index.ts'; -import { ToolResponse } from '../types/common.ts'; +import type { ToolResponse } from '../types/common.ts'; import { log } from './logging/index.ts'; import { createErrorResponse } from './responses/index.ts'; diff --git a/src/utils/template-manager.ts b/src/utils/template-manager.ts index b767a098..03727838 100644 --- a/src/utils/template-manager.ts +++ b/src/utils/template-manager.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'os'; import { randomUUID } from 'crypto'; import { log } from './logger.ts'; import { iOSTemplateVersion, macOSTemplateVersion } from '../version.ts'; -import { CommandExecutor } from './command.ts'; -import { FileSystemExecutor } from './FileSystemExecutor.ts'; +import type { CommandExecutor } from './command.ts'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import { getConfig } from './config-store.ts'; /** diff --git a/src/utils/test-common.ts b/src/utils/test-common.ts index cc9333e6..8703f134 100644 --- a/src/utils/test-common.ts +++ b/src/utils/test-common.ts @@ -18,12 +18,13 @@ import { mkdtemp, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { log } from './logger.ts'; -import { XcodePlatform } from './xcode.ts'; +import type { XcodePlatform } from './xcode.ts'; import { executeXcodeBuildCommand } from './build/index.ts'; import { createTextResponse, consolidateContentForClaudeCode } from './validation.ts'; import { normalizeTestRunnerEnv } from './environment.ts'; -import { ToolResponse } from '../types/common.ts'; -import { CommandExecutor, CommandExecOptions, getDefaultCommandExecutor } from './command.ts'; +import type { ToolResponse } from '../types/common.ts'; +import type { CommandExecutor, CommandExecOptions } from './command.ts'; +import { getDefaultCommandExecutor } from './command.ts'; /** * Type definition for test summary structure from xcresulttool diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index c2d5f1d5..b35a5f43 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -1,11 +1,13 @@ import { type RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { server } from '../server/server-state.ts'; -import { ToolResponse } from '../types/common.ts'; +import type { ToolResponse } from '../types/common.ts'; import { log } from './logger.ts'; -import { loadWorkflowGroups } from '../core/plugin-registry.ts'; -import { resolveSelectedWorkflows } from './workflow-selection.ts'; import { processToolResponse } from './responses/index.ts'; -import { shouldExposeTool } from './tool-visibility.ts'; +import { loadManifest } from '../core/manifest/load-manifest.ts'; +import { importToolModule } from '../core/manifest/import-tool-module.ts'; +import type { PredicateContext } from '../visibility/predicate-types.ts'; +import { selectWorkflowsForMcp, isToolExposedForRuntime } from '../visibility/exposure.ts'; +import { getConfig } from './config-store.ts'; export interface RuntimeToolInfo { enabledWorkflows: string[]; @@ -15,9 +17,12 @@ export interface RuntimeToolInfo { const registryState: { tools: Map; enabledWorkflows: Set; + /** Current MCP predicate context (stored for use by manage_workflows) */ + currentContext: PredicateContext | null; } = { tools: new Map(), enabledWorkflows: new Set(), + currentContext: null, }; export function getRuntimeRegistration(): RuntimeToolInfo | null { @@ -30,43 +35,94 @@ export function getRuntimeRegistration(): RuntimeToolInfo | null { }; } -export async function applyWorkflowSelection(workflowNames: string[]): Promise { +export function getRegisteredWorkflows(): string[] { + return [...registryState.enabledWorkflows]; +} + +/** + * 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 { + if (registryState.currentContext) { + return registryState.currentContext; + } + // Default context when not yet initialized + return { + runtime: 'mcp', + config: getConfig(), + runningUnderXcode: false, + xcodeToolsActive: false, + }; +} + +/** + * Apply workflow selection using the manifest system. + */ +export async function applyWorkflowSelectionFromManifest( + requestedWorkflows: string[] | undefined, + ctx: PredicateContext, +): Promise { if (!server) { throw new Error('Tool registry has not been initialized.'); } - const workflowGroups = await loadWorkflowGroups(); - const selection = resolveSelectedWorkflows(workflowNames, workflowGroups); + // Store the context for later use (e.g., by manage_workflows) + registryState.currentContext = ctx; + + const manifest = loadManifest(); + const allWorkflows = Array.from(manifest.workflows.values()); + + // Select workflows using manifest-driven rules + const selectedWorkflows = selectWorkflowsForMcp(allWorkflows, requestedWorkflows, ctx); + const desiredToolNames = new Set(); const desiredWorkflows = new Set(); - for (const workflow of selection.selectedWorkflows) { - desiredWorkflows.add(workflow.directoryName); - for (const tool of workflow.tools) { - const { name, description, schema, annotations, handler } = tool; - if (!shouldExposeTool(workflow.directoryName, name)) { + for (const workflow of selectedWorkflows) { + desiredWorkflows.add(workflow.id); + + for (const toolId of workflow.tools) { + const toolManifest = manifest.tools.get(toolId); + if (!toolManifest) continue; + + // Check tool visibility using predicates + if (!isToolExposedForRuntime(toolManifest, ctx)) { continue; } - desiredToolNames.add(name); - if (!registryState.tools.has(name)) { + + const toolName = toolManifest.names.mcp; + desiredToolNames.add(toolName); + + if (!registryState.tools.has(toolName)) { + // Import the tool module + let toolModule; + try { + toolModule = await importToolModule(toolManifest.module); + } catch (err) { + log('warn', `Failed to import tool module ${toolManifest.module}: ${err}`); + continue; + } + const registeredTool = server.registerTool( - name, + toolName, { - description: description ?? '', - inputSchema: schema, - annotations, + description: toolManifest.description ?? '', + inputSchema: toolModule.schema, + annotations: toolModule.annotations, }, async (args: unknown): Promise => { - const response = await handler(args as Record); - // Apply MCP-style next steps rendering - return processToolResponse(response, 'mcp', 'normal'); + const response = await toolModule.handler(args as Record); + return processToolResponse(response as ToolResponse, 'mcp', 'normal'); }, ); - registryState.tools.set(name, registeredTool); + registryState.tools.set(toolName, registeredTool); } } } + // Unregister tools no longer in selection for (const [toolName, registeredTool] of registryState.tools.entries()) { if (!desiredToolNames.has(toolName)) { registeredTool.remove(); @@ -76,8 +132,8 @@ export async function applyWorkflowSelection(workflowNames: string[]): Promise w.id).join(', '); + log('info', `Registered ${desiredToolNames.size} tools from workflows: ${workflowLabel}`); return { enabledWorkflows: [...registryState.enabledWorkflows], @@ -85,17 +141,28 @@ export async function applyWorkflowSelection(workflowNames: string[]): Promise { - await applyWorkflowSelection(workflowNames ?? []); +export async function registerWorkflowsFromManifest( + workflowNames?: string[], + ctx?: PredicateContext, +): Promise { + const effectiveCtx: PredicateContext = ctx ?? { + runtime: 'mcp', + config: getConfig(), + runningUnderXcode: false, + xcodeToolsActive: false, + }; + await applyWorkflowSelectionFromManifest(workflowNames, effectiveCtx); } -export async function updateWorkflows(workflowNames?: string[]): Promise { - await applyWorkflowSelection(workflowNames ?? []); +/** + * Update workflows using manifest system. + */ +export async function updateWorkflowsFromManifest( + workflowNames?: string[], + ctx?: PredicateContext, +): Promise { + await registerWorkflowsFromManifest(workflowNames, ctx); } diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index 8b240d1e..0071243a 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -10,7 +10,7 @@ */ import * as z from 'zod'; -import { ToolResponse } from '../types/common.ts'; +import type { ToolResponse } from '../types/common.ts'; import type { CommandExecutor } from './execution/index.ts'; import { createErrorResponse } from './responses/index.ts'; import { sessionStore, type SessionDefaults } from './session-store.ts'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 4c2a7f84..33a3682f 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -22,8 +22,8 @@ import * as fs from 'fs'; import { log } from './logger.ts'; -import { ToolResponse, ValidationResult } from '../types/common.ts'; -import { FileSystemExecutor } from './FileSystemExecutor.ts'; +import type { ToolResponse, ValidationResult } from '../types/common.ts'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import { getDefaultEnvironmentDetector } from './environment.ts'; /** @@ -259,4 +259,4 @@ export function consolidateContentForClaudeCode(response: ToolResponse): ToolRes } // Export the ToolResponse type for use in other files -export { ToolResponse, ValidationResult }; +export type { ToolResponse, ValidationResult }; diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index 9f4b026b..c4d4336f 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -14,7 +14,8 @@ */ import { log } from './logger.ts'; -import { CommandResponse, getDefaultCommandExecutor } from './command.ts'; +import type { CommandResponse } from './command.ts'; +import { getDefaultCommandExecutor } from './command.ts'; import { existsSync, readdirSync } from 'fs'; import * as path from 'path'; import * as os from 'os'; diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts new file mode 100644 index 00000000..0bd2dd36 --- /dev/null +++ b/src/visibility/__tests__/exposure.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from 'vitest'; +import { + isWorkflowAvailableForRuntime, + isWorkflowEnabledForRuntime, + isToolAvailableForRuntime, + isToolExposedForRuntime, + isToolInWorkflowExposed, + filterExposedTools, + filterEnabledWorkflows, + getMandatoryWorkflows, + getDefaultEnabledWorkflows, + getAutoIncludeWorkflows, + selectWorkflowsForMcp, +} from '../exposure.ts'; +import type { ToolManifestEntry, WorkflowManifestEntry } from '../../core/manifest/schema.ts'; +import type { PredicateContext } from '../predicate-types.ts'; +import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; + +function createContext(overrides: Partial = {}): PredicateContext { + const defaultConfig: ResolvedRuntimeConfig = { + debug: false, + enabledWorkflows: [], + experimentalWorkflowDiscovery: false, + }; + + return { + runtime: 'mcp', + config: defaultConfig, + runningUnderXcode: false, + xcodeToolsActive: false, + ...overrides, + }; +} + +function createTool(overrides: Partial = {}): ToolManifestEntry { + return { + id: 'test_tool', + module: 'mcp/tools/test/test_tool', + names: { mcp: 'test_tool' }, + availability: { mcp: true, cli: true, daemon: true }, + predicates: [], + ...overrides, + }; +} + +function createWorkflow(overrides: Partial = {}): WorkflowManifestEntry { + return { + id: 'test-workflow', + title: 'Test Workflow', + description: 'A test workflow', + availability: { mcp: true, cli: true, daemon: true }, + predicates: [], + tools: ['test_tool'], + ...overrides, + }; +} + +describe('exposure', () => { + describe('isWorkflowAvailableForRuntime', () => { + it('should return true when workflow is available for runtime', () => { + const workflow = createWorkflow({ availability: { mcp: true, cli: false, daemon: false } }); + expect(isWorkflowAvailableForRuntime(workflow, 'mcp')).toBe(true); + }); + + it('should return false when workflow is not available for runtime', () => { + const workflow = createWorkflow({ availability: { mcp: true, cli: false, daemon: false } }); + expect(isWorkflowAvailableForRuntime(workflow, 'cli')).toBe(false); + }); + }); + + describe('isWorkflowEnabledForRuntime', () => { + it('should return true when available and predicates pass', () => { + const workflow = createWorkflow(); + const ctx = createContext({ runtime: 'mcp' }); + expect(isWorkflowEnabledForRuntime(workflow, ctx)).toBe(true); + }); + + it('should return false when not available', () => { + const workflow = createWorkflow({ availability: { mcp: false, cli: true, daemon: true } }); + const ctx = createContext({ runtime: 'mcp' }); + expect(isWorkflowEnabledForRuntime(workflow, ctx)).toBe(false); + }); + + it('should return false when predicate fails', () => { + const workflow = createWorkflow({ predicates: ['debugEnabled'] }); + const ctx = createContext({ + runtime: 'mcp', + config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + expect(isWorkflowEnabledForRuntime(workflow, ctx)).toBe(false); + }); + }); + + describe('isToolAvailableForRuntime', () => { + it('should return true when tool is available for runtime', () => { + const tool = createTool({ availability: { mcp: true, cli: false, daemon: false } }); + expect(isToolAvailableForRuntime(tool, 'mcp')).toBe(true); + }); + + it('should return false when tool is not available for runtime', () => { + const tool = createTool({ availability: { mcp: false, cli: true, daemon: true } }); + expect(isToolAvailableForRuntime(tool, 'mcp')).toBe(false); + }); + }); + + describe('isToolExposedForRuntime', () => { + it('should return true when available and predicates pass', () => { + const tool = createTool(); + const ctx = createContext({ runtime: 'mcp' }); + expect(isToolExposedForRuntime(tool, ctx)).toBe(true); + }); + + it('should return false when not available', () => { + const tool = createTool({ availability: { mcp: false, cli: true, daemon: true } }); + const ctx = createContext({ runtime: 'mcp' }); + expect(isToolExposedForRuntime(tool, ctx)).toBe(false); + }); + + it('should return false when hideWhenXcodeAgentMode predicate fails', () => { + const tool = createTool({ predicates: ['hideWhenXcodeAgentMode'] }); + const ctx = createContext({ + runtime: 'mcp', + runningUnderXcode: true, + xcodeToolsActive: true, + }); + expect(isToolExposedForRuntime(tool, ctx)).toBe(false); + }); + + it('should return true when hideWhenXcodeAgentMode predicate passes', () => { + const tool = createTool({ predicates: ['hideWhenXcodeAgentMode'] }); + const ctx = createContext({ + runtime: 'mcp', + runningUnderXcode: true, + xcodeToolsActive: false, + }); + expect(isToolExposedForRuntime(tool, ctx)).toBe(true); + }); + }); + + describe('isToolInWorkflowExposed', () => { + it('should return true when both workflow and tool are enabled', () => { + const workflow = createWorkflow(); + const tool = createTool(); + const ctx = createContext({ runtime: 'mcp' }); + expect(isToolInWorkflowExposed(tool, workflow, ctx)).toBe(true); + }); + + it('should return false when workflow is not enabled', () => { + const workflow = createWorkflow({ + availability: { mcp: false, cli: true, daemon: true }, + }); + const tool = createTool(); + const ctx = createContext({ runtime: 'mcp' }); + expect(isToolInWorkflowExposed(tool, workflow, ctx)).toBe(false); + }); + + it('should return false when tool is not exposed', () => { + const workflow = createWorkflow(); + const tool = createTool({ availability: { mcp: false, cli: true, daemon: true } }); + const ctx = createContext({ runtime: 'mcp' }); + expect(isToolInWorkflowExposed(tool, workflow, ctx)).toBe(false); + }); + }); + + describe('filterExposedTools', () => { + it('should filter out tools that are not exposed', () => { + const tools = [ + createTool({ id: 'tool1' }), + createTool({ id: 'tool2', availability: { mcp: false, cli: true, daemon: true } }), + createTool({ id: 'tool3' }), + ]; + const ctx = createContext({ runtime: 'mcp' }); + + const filtered = filterExposedTools(tools, ctx); + expect(filtered).toHaveLength(2); + expect(filtered.map((t) => t.id)).toEqual(['tool1', 'tool3']); + }); + }); + + describe('filterEnabledWorkflows', () => { + it('should filter out workflows that are not enabled', () => { + const workflows = [ + createWorkflow({ id: 'wf1' }), + createWorkflow({ id: 'wf2', availability: { mcp: false, cli: true, daemon: true } }), + createWorkflow({ id: 'wf3' }), + ]; + const ctx = createContext({ runtime: 'mcp' }); + + const filtered = filterEnabledWorkflows(workflows, ctx); + expect(filtered).toHaveLength(2); + expect(filtered.map((w) => w.id)).toEqual(['wf1', 'wf3']); + }); + }); + + describe('getMandatoryWorkflows', () => { + it('should return only mandatory workflows', () => { + const workflows = [ + createWorkflow({ id: 'wf1', selection: { mcp: { mandatory: true } } }), + createWorkflow({ id: 'wf2', selection: { mcp: { mandatory: false } } }), + createWorkflow({ id: 'wf3', selection: { mcp: { mandatory: true } } }), + ]; + + const mandatory = getMandatoryWorkflows(workflows); + expect(mandatory).toHaveLength(2); + expect(mandatory.map((w) => w.id)).toEqual(['wf1', 'wf3']); + }); + }); + + describe('getDefaultEnabledWorkflows', () => { + it('should return only default-enabled workflows', () => { + const workflows = [ + createWorkflow({ id: 'wf1', selection: { mcp: { defaultEnabled: true } } }), + createWorkflow({ id: 'wf2', selection: { mcp: { defaultEnabled: false } } }), + createWorkflow({ id: 'wf3', selection: { mcp: { defaultEnabled: true } } }), + ]; + + const defaultEnabled = getDefaultEnabledWorkflows(workflows); + expect(defaultEnabled).toHaveLength(2); + expect(defaultEnabled.map((w) => w.id)).toEqual(['wf1', 'wf3']); + }); + }); + + describe('getAutoIncludeWorkflows', () => { + it('should return auto-include workflows whose predicates pass', () => { + const workflows = [ + createWorkflow({ + id: 'wf1', + selection: { mcp: { autoInclude: true } }, + predicates: [], + }), + createWorkflow({ + id: 'wf2', + selection: { mcp: { autoInclude: true } }, + predicates: ['debugEnabled'], + }), + createWorkflow({ + id: 'wf3', + selection: { mcp: { autoInclude: false } }, + }), + ]; + + const ctx = createContext({ + config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + + const autoInclude = getAutoIncludeWorkflows(workflows, ctx); + expect(autoInclude).toHaveLength(1); + expect(autoInclude[0].id).toBe('wf1'); + }); + + it('should include auto-include workflows when their predicates pass', () => { + const workflows = [ + createWorkflow({ + id: 'doctor', + selection: { mcp: { autoInclude: true } }, + predicates: ['debugEnabled'], + }), + ]; + + const ctx = createContext({ + config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + + const autoInclude = getAutoIncludeWorkflows(workflows, ctx); + expect(autoInclude).toHaveLength(1); + expect(autoInclude[0].id).toBe('doctor'); + }); + }); + + describe('selectWorkflowsForMcp', () => { + const allWorkflows = [ + createWorkflow({ + id: 'session-management', + selection: { mcp: { mandatory: true, defaultEnabled: true, autoInclude: true } }, + }), + createWorkflow({ + id: 'simulator', + selection: { mcp: { mandatory: false, defaultEnabled: true, autoInclude: false } }, + }), + createWorkflow({ + id: 'device', + selection: { mcp: { mandatory: false, defaultEnabled: false, autoInclude: false } }, + }), + createWorkflow({ + id: 'doctor', + selection: { mcp: { mandatory: false, defaultEnabled: false, autoInclude: true } }, + predicates: ['debugEnabled'], + }), + ]; + + it('should include mandatory workflows', () => { + const ctx = createContext(); + const selected = selectWorkflowsForMcp(allWorkflows, undefined, ctx); + expect(selected.map((w) => w.id)).toContain('session-management'); + }); + + it('should include default-enabled workflows when no workflows requested', () => { + const ctx = createContext(); + const selected = selectWorkflowsForMcp(allWorkflows, undefined, ctx); + expect(selected.map((w) => w.id)).toContain('simulator'); + }); + + it('should include requested workflows', () => { + const ctx = createContext(); + const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); + expect(selected.map((w) => w.id)).toContain('device'); + expect(selected.map((w) => w.id)).toContain('session-management'); // mandatory + }); + + it('should not include default-enabled when workflows are requested', () => { + const ctx = createContext(); + const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); + // simulator is default-enabled but not requested + expect(selected.map((w) => w.id)).not.toContain('simulator'); + }); + + it('should include auto-include workflows when predicates pass', () => { + const ctx = createContext({ + config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); + expect(selected.map((w) => w.id)).toContain('doctor'); + }); + + it('should not include auto-include workflows when predicates fail', () => { + const ctx = createContext({ + config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); + expect(selected.map((w) => w.id)).not.toContain('doctor'); + }); + }); +}); diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts new file mode 100644 index 00000000..4bbf11ea --- /dev/null +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + PREDICATES, + evalPredicates, + getPredicateNames, + isValidPredicate, +} from '../predicate-registry.ts'; +import type { PredicateContext } from '../predicate-types.ts'; +import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; + +function createContext(overrides: Partial = {}): PredicateContext { + const defaultConfig: ResolvedRuntimeConfig = { + debug: false, + enabledWorkflows: [], + experimentalWorkflowDiscovery: false, + }; + + return { + runtime: 'mcp', + config: defaultConfig, + runningUnderXcode: false, + xcodeToolsActive: false, + ...overrides, + }; +} + +describe('predicate-registry', () => { + describe('PREDICATES', () => { + describe('debugEnabled', () => { + it('should return true when debug is enabled', () => { + const ctx = createContext({ + config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + expect(PREDICATES.debugEnabled(ctx)).toBe(true); + }); + + it('should return false when debug is disabled', () => { + const ctx = createContext({ + config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + expect(PREDICATES.debugEnabled(ctx)).toBe(false); + }); + }); + + describe('experimentalWorkflowDiscoveryEnabled', () => { + it('should return true when experimental workflow discovery is enabled', () => { + const ctx = createContext({ + config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: true }, + }); + expect(PREDICATES.experimentalWorkflowDiscoveryEnabled(ctx)).toBe(true); + }); + + it('should return false when experimental workflow discovery is disabled', () => { + const ctx = createContext({ + config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + expect(PREDICATES.experimentalWorkflowDiscoveryEnabled(ctx)).toBe(false); + }); + }); + + describe('hideWhenXcodeAgentMode', () => { + it('should return true when not running under Xcode', () => { + const ctx = createContext({ runningUnderXcode: false, xcodeToolsActive: false }); + expect(PREDICATES.hideWhenXcodeAgentMode(ctx)).toBe(true); + }); + + it('should return true when Xcode tools are not active', () => { + const ctx = createContext({ runningUnderXcode: true, xcodeToolsActive: false }); + expect(PREDICATES.hideWhenXcodeAgentMode(ctx)).toBe(true); + }); + + it('should return false when running under Xcode AND tools are active', () => { + const ctx = createContext({ runningUnderXcode: true, xcodeToolsActive: true }); + expect(PREDICATES.hideWhenXcodeAgentMode(ctx)).toBe(false); + }); + }); + + describe('always', () => { + it('should always return true', () => { + const ctx = createContext(); + expect(PREDICATES.always(ctx)).toBe(true); + }); + }); + + describe('never', () => { + it('should always return false', () => { + const ctx = createContext(); + expect(PREDICATES.never(ctx)).toBe(false); + }); + }); + }); + + describe('evalPredicates', () => { + it('should return true for empty predicate list', () => { + const ctx = createContext(); + expect(evalPredicates([], ctx)).toBe(true); + }); + + it('should return true for undefined predicate list', () => { + const ctx = createContext(); + expect(evalPredicates(undefined, ctx)).toBe(true); + }); + + it('should return true when all predicates pass', () => { + const ctx = createContext({ + config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: true }, + }); + expect(evalPredicates(['debugEnabled', 'experimentalWorkflowDiscoveryEnabled'], ctx)).toBe( + true, + ); + }); + + it('should return false when any predicate fails', () => { + const ctx = createContext({ + config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + }); + expect(evalPredicates(['debugEnabled', 'experimentalWorkflowDiscoveryEnabled'], ctx)).toBe( + false, + ); + }); + + it('should throw for unknown predicate', () => { + const ctx = createContext(); + expect(() => evalPredicates(['unknownPredicate'], ctx)).toThrow( + "Unknown predicate 'unknownPredicate'", + ); + }); + }); + + describe('getPredicateNames', () => { + it('should return all predicate names', () => { + const names = getPredicateNames(); + expect(names).toContain('debugEnabled'); + expect(names).toContain('experimentalWorkflowDiscoveryEnabled'); + expect(names).toContain('hideWhenXcodeAgentMode'); + expect(names).toContain('always'); + expect(names).toContain('never'); + }); + }); + + describe('isValidPredicate', () => { + it('should return true for valid predicates', () => { + expect(isValidPredicate('debugEnabled')).toBe(true); + expect(isValidPredicate('hideWhenXcodeAgentMode')).toBe(true); + }); + + it('should return false for invalid predicates', () => { + expect(isValidPredicate('unknownPredicate')).toBe(false); + expect(isValidPredicate('')).toBe(false); + }); + }); +}); diff --git a/src/visibility/exposure.ts b/src/visibility/exposure.ts new file mode 100644 index 00000000..61e8a0e0 --- /dev/null +++ b/src/visibility/exposure.ts @@ -0,0 +1,170 @@ +/** + * Exposure evaluation for tools and workflows. + * Determines whether tools/workflows should be visible based on + * availability flags and predicate evaluation. + */ + +import type { ToolManifestEntry, WorkflowManifestEntry } from '../core/manifest/schema.ts'; +import type { PredicateContext, RuntimeKind } from './predicate-types.ts'; +import { evalPredicates } from './predicate-registry.ts'; + +/** + * Check if a workflow is available for the current runtime. + * This checks the availability flag only, not predicates. + */ +export function isWorkflowAvailableForRuntime( + workflow: WorkflowManifestEntry, + runtime: RuntimeKind, +): boolean { + return workflow.availability[runtime]; +} + +/** + * Check if a workflow is enabled (visible) for the current runtime context. + * Checks both availability flag and all predicates. + */ +export function isWorkflowEnabledForRuntime( + workflow: WorkflowManifestEntry, + ctx: PredicateContext, +): boolean { + // Check availability flag first + if (!isWorkflowAvailableForRuntime(workflow, ctx.runtime)) { + return false; + } + + // Then check predicates + return evalPredicates(workflow.predicates, ctx); +} + +/** + * Check if a tool is available for the current runtime. + * This checks the availability flag only, not predicates. + */ +export function isToolAvailableForRuntime(tool: ToolManifestEntry, runtime: RuntimeKind): boolean { + return tool.availability[runtime]; +} + +/** + * Check if a tool is exposed (visible) for the current runtime context. + * Checks both availability flag and all predicates. + */ +export function isToolExposedForRuntime(tool: ToolManifestEntry, ctx: PredicateContext): boolean { + // Check availability flag first + if (!isToolAvailableForRuntime(tool, ctx.runtime)) { + return false; + } + + // Then check predicates + return evalPredicates(tool.predicates, ctx); +} + +/** + * Check if a tool within a workflow is exposed. + * Both the workflow and tool must be enabled for the tool to be exposed. + */ +export function isToolInWorkflowExposed( + tool: ToolManifestEntry, + workflow: WorkflowManifestEntry, + ctx: PredicateContext, +): boolean { + // Workflow must be enabled + if (!isWorkflowEnabledForRuntime(workflow, ctx)) { + return false; + } + + // Tool must be exposed + return isToolExposedForRuntime(tool, ctx); +} + +/** + * Filter tools based on exposure rules. + */ +export function filterExposedTools( + tools: ToolManifestEntry[], + ctx: PredicateContext, +): ToolManifestEntry[] { + return tools.filter((tool) => isToolExposedForRuntime(tool, ctx)); +} + +/** + * Filter workflows based on exposure rules. + */ +export function filterEnabledWorkflows( + workflows: WorkflowManifestEntry[], + ctx: PredicateContext, +): WorkflowManifestEntry[] { + return workflows.filter((workflow) => isWorkflowEnabledForRuntime(workflow, ctx)); +} + +/** + * Get mandatory workflows that should always be included. + */ +export function getMandatoryWorkflows(workflows: WorkflowManifestEntry[]): WorkflowManifestEntry[] { + return workflows.filter((wf) => wf.selection?.mcp?.mandatory === true); +} + +/** + * Get default-enabled workflows (used when no workflows are explicitly selected). + */ +export function getDefaultEnabledWorkflows( + workflows: WorkflowManifestEntry[], +): WorkflowManifestEntry[] { + return workflows.filter((wf) => wf.selection?.mcp?.defaultEnabled === true); +} + +/** + * Get auto-include workflows (included when their predicates pass). + */ +export function getAutoIncludeWorkflows( + workflows: WorkflowManifestEntry[], + ctx: PredicateContext, +): WorkflowManifestEntry[] { + return workflows.filter( + (wf) => wf.selection?.mcp?.autoInclude === true && isWorkflowEnabledForRuntime(wf, ctx), + ); +} + +/** + * Select workflows for MCP runtime according to the manifest-driven selection rules. + * + * Selection logic: + * 1. Always include mandatory workflows + * 2. Include auto-include workflows whose predicates pass + * 3. If user specified workflows, include those + * 4. If no workflows specified, include default-enabled workflows + * 5. Filter all by availability + predicates + */ +export function selectWorkflowsForMcp( + allWorkflows: WorkflowManifestEntry[], + requestedWorkflowIds: string[] | undefined, + ctx: PredicateContext, +): WorkflowManifestEntry[] { + const selectedIds = new Set(); + + // 1. Always include mandatory workflows + for (const wf of getMandatoryWorkflows(allWorkflows)) { + selectedIds.add(wf.id); + } + + // 2. Include auto-include workflows whose predicates pass + for (const wf of getAutoIncludeWorkflows(allWorkflows, ctx)) { + selectedIds.add(wf.id); + } + + // 3/4. Include requested or default-enabled workflows + if (requestedWorkflowIds && requestedWorkflowIds.length > 0) { + for (const id of requestedWorkflowIds) { + selectedIds.add(id); + } + } else { + for (const wf of getDefaultEnabledWorkflows(allWorkflows)) { + selectedIds.add(wf.id); + } + } + + // Build final list from selected IDs + const selected = allWorkflows.filter((wf) => selectedIds.has(wf.id)); + + // 5. Filter by availability + predicates + return filterEnabledWorkflows(selected, ctx); +} diff --git a/src/visibility/index.ts b/src/visibility/index.ts new file mode 100644 index 00000000..3df35b66 --- /dev/null +++ b/src/visibility/index.ts @@ -0,0 +1,7 @@ +/** + * Visibility system exports. + */ + +export * from './predicate-types.ts'; +export * from './predicate-registry.ts'; +export * from './exposure.ts'; diff --git a/src/visibility/predicate-registry.ts b/src/visibility/predicate-registry.ts new file mode 100644 index 00000000..fc359557 --- /dev/null +++ b/src/visibility/predicate-registry.ts @@ -0,0 +1,84 @@ +/** + * Predicate registry for tool/workflow visibility filtering. + * YAML manifests reference predicate names; this registry provides the implementations. + */ + +import type { PredicateFn, PredicateContext } from './predicate-types.ts'; + +/** + * Registry of named predicate functions. + * All predicates return true to show the tool/workflow, false to hide. + */ +export const PREDICATES: Record = { + /** + * Show only when debug mode is enabled in config. + */ + debugEnabled: (ctx: PredicateContext): boolean => ctx.config.debug, + + /** + * Show only when experimental workflow discovery is enabled. + */ + experimentalWorkflowDiscoveryEnabled: (ctx: PredicateContext): boolean => + ctx.config.experimentalWorkflowDiscovery, + + /** + * Hide when running under Xcode agent mode AND Xcode Tools bridge is active. + * This implements the conflict policy from XCODE_IDE_TOOL_CONFLICTS.md: + * - When Xcode provides equivalent tools via mcpbridge, hide our versions + * - Outside Xcode or when bridge is inactive, show our tools + */ + hideWhenXcodeAgentMode: (ctx: PredicateContext): boolean => + !(ctx.runningUnderXcode && ctx.xcodeToolsActive), + + /** + * Always visible - useful for explicit documentation in YAML. + */ + always: (): boolean => true, + + /** + * Never visible - useful for temporarily disabling tools. + */ + never: (): boolean => false, +}; + +/** + * Evaluate a list of predicate names against a context. + * All predicates must pass (AND logic) for the result to be true. + * + * @param names - Array of predicate names to evaluate + * @param ctx - Predicate context + * @returns true if all predicates pass, false if any fails + * @throws Error if an unknown predicate name is referenced + */ +export function evalPredicates(names: string[] | undefined, ctx: PredicateContext): boolean { + if (!names || names.length === 0) { + return true; + } + + for (const name of names) { + const fn = PREDICATES[name]; + if (!fn) { + throw new Error( + `Unknown predicate '${name}'. Available predicates: ${Object.keys(PREDICATES).join(', ')}`, + ); + } + if (!fn(ctx)) { + return false; + } + } + return true; +} + +/** + * Get all available predicate names. + */ +export function getPredicateNames(): string[] { + return Object.keys(PREDICATES); +} + +/** + * Check if a predicate name is valid. + */ +export function isValidPredicate(name: string): boolean { + return name in PREDICATES; +} diff --git a/src/visibility/predicate-types.ts b/src/visibility/predicate-types.ts new file mode 100644 index 00000000..b96c8581 --- /dev/null +++ b/src/visibility/predicate-types.ts @@ -0,0 +1,35 @@ +/** + * Predicate context and type definitions for visibility filtering. + * Predicates are named functions that determine tool/workflow visibility + * based on runtime context. + */ + +import type { ResolvedRuntimeConfig } from '../utils/config-store.ts'; + +/** + * Runtime kind for predicate evaluation. + */ +export type RuntimeKind = 'cli' | 'mcp' | 'daemon'; + +/** + * Context passed to predicate functions for visibility evaluation. + */ +export interface PredicateContext { + /** Current runtime mode */ + runtime: RuntimeKind; + + /** Resolved runtime configuration */ + config: ResolvedRuntimeConfig; + + /** Whether running under Xcode agent environment */ + runningUnderXcode: boolean; + + /** Whether Xcode Tools bridge is active (MCP only; false otherwise) */ + xcodeToolsActive: boolean; +} + +/** + * Predicate function type. + * Returns true if the tool/workflow should be visible, false to hide. + */ +export type PredicateFn = (ctx: PredicateContext) => boolean; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..360df4d2 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": true, + "outDir": "./build", + "rootDir": "./src" + }, + "include": [ + "src/**/*", + "src/core/generated-plugins.ts", + "src/core/generated-resources.ts" + ], + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/__tests__/**", + "tests-vitest/**/*", + "plugins/**/*" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 8727b3a5..08cc02f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "forceConsistentCasingInFileNames": true, "sourceMap": true, "inlineSources": true, + "verbatimModuleSyntax": true, // Set `sourceRoot` to "/" to strip the build path prefix // from generated source code references. diff --git a/tsup.config.ts b/tsup.config.ts index 925b1701..f4c2a1d1 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,34 +1,57 @@ import { defineConfig } from 'tsup'; -import { chmodSync, existsSync } from 'fs'; -import { createPluginDiscoveryPlugin } from './build-plugins/plugin-discovery.js'; +import { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { glob } from 'glob'; +import { join } from 'path'; + +/** + * Recursively rewrites .ts imports to .js in all JavaScript files. + * Required because Node.js cannot resolve .ts extensions at runtime. + */ +function rewriteTsImportsInDir(dir: string): void { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + rewriteTsImportsInDir(fullPath); + } else if (entry.name.endsWith('.js')) { + let content = readFileSync(fullPath, 'utf-8'); + // Rewrite: import ... from "./path.ts" or import ... from "../path.ts" + // Also handles: export ... from "./path.ts" + const rewritten = content.replace( + /((?:import|export)[^'"]*['"])([^'"]+)(\.ts)(['"])/g, + '$1$2.js$4' + ); + if (rewritten !== content) { + writeFileSync(fullPath, rewritten, 'utf-8'); + } + } + } +} export default defineConfig({ - entry: { - index: 'src/cli.ts', - 'doctor-cli': 'src/doctor-cli.ts', - daemon: 'src/daemon.ts', - }, + // Include all TypeScript files for unbundled output + entry: await glob('src/**/*.ts', { + ignore: ['**/*.test.ts', '**/__tests__/**'], + }), format: ['esm'], target: 'node18', platform: 'node', outDir: 'build', clean: true, - sourcemap: true, // Enable source maps for debugging - dts: { - entry: { - index: 'src/cli.ts', - }, - }, + sourcemap: true, + dts: false, // Skip declaration files for speed + bundle: false, // UNBUNDLED: Output individual files splitting: false, shims: false, - treeshake: true, + treeshake: false, // Disable treeshake for unbundled minify: false, - esbuildPlugins: [createPluginDiscoveryPlugin()], onSuccess: async () => { + // Rewrite .ts imports to .js in all output files + rewriteTsImportsInDir('build'); console.log('✅ Build complete!'); // Set executable permissions for built files - const executables = ['build/index.js', 'build/doctor-cli.js', 'build/daemon.js']; + const executables = ['build/cli.js', 'build/doctor-cli.js', 'build/daemon.js']; for (const file of executables) { if (existsSync(file)) { chmodSync(file, '755'); From 7bba87881383c08f6df34d581c6ab7c50095df95 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 4 Feb 2026 23:33:51 +0000 Subject: [PATCH 04/23] Add annotations to manifest schema - Add annotationsSchema with title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint - Add annotations field to toolManifestEntrySchema - Update tool-catalog to prefer manifest annotations with module fallback --- src/core/manifest/schema.ts | 17 +++++++++++++++++ src/runtime/tool-catalog.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index 5658f3ff..43cee6db 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -26,6 +26,20 @@ export const routingSchema = z.object({ export type Routing = z.infer; +/** + * MCP tool annotations (hints for clients). + * All properties are optional hints, not guarantees. + */ +export const annotationsSchema = z.object({ + title: z.string().optional(), + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional(), +}); + +export type Annotations = z.infer; + /** * Tool names for MCP and CLI. */ @@ -66,6 +80,9 @@ export const toolManifestEntrySchema = z.object({ /** Routing hints for daemon */ routing: routingSchema.optional(), + + /** MCP annotations (hints for clients) */ + annotations: annotationsSchema.optional(), }); export type ToolManifestEntry = z.infer; diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index 5ed60995..b597e223 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -154,12 +154,14 @@ export async function buildToolCatalogFromManifest(opts: { } const cliName = getEffectiveCliName(toolManifest); + // Prefer annotations from manifest, fall back to module for backward compatibility + const annotations = toolManifest.annotations ?? toolModule.annotations; tools.push({ cliName, mcpName: toolManifest.names.mcp, workflow: workflow.id, description: toolManifest.description, - annotations: toolModule.annotations, + annotations, mcpSchema: toolModule.schema, cliSchema: toolModule.schema, stateful: toolManifest.routing?.stateful ?? false, From da74a21b3214e74aa8e11e090c2a5c6eae7f23fe Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 10:30:46 +0000 Subject: [PATCH 05/23] Refactor tool system to use YAML manifests and named exports Migrate all tools from default export objects to named schema handler exports. Add annotations metadata to YAML manifests as single source of truth for tool metadata. Introduce predicates (runningUnderXcodeAgent, requiresXcodeTools) for conditional tool visibility. Remove mandatory workflow selection concept and simplify workflow resolution logic. --- docs/dev/MANIFEST_FORMAT.md | 46 ++++---- docs/dev/TOOL_DISCOVERY_LOGIC.md | 10 +- manifests/tools/boot_sim.yaml | 3 + manifests/tools/build_device.yaml | 3 + manifests/tools/build_macos.yaml | 3 + manifests/tools/build_run_macos.yaml | 3 + manifests/tools/build_run_sim.yaml | 3 + manifests/tools/build_sim.yaml | 3 + manifests/tools/button.yaml | 3 + manifests/tools/clean.yaml | 3 + manifests/tools/discover_projs.yaml | 3 + manifests/tools/doctor.yaml | 6 +- manifests/tools/erase_sims.yaml | 3 + manifests/tools/gesture.yaml | 3 + manifests/tools/get_app_bundle_id.yaml | 3 + manifests/tools/get_device_app_path.yaml | 3 + manifests/tools/get_mac_app_path.yaml | 3 + manifests/tools/get_mac_bundle_id.yaml | 3 + manifests/tools/get_sim_app_path.yaml | 3 + manifests/tools/install_app_device.yaml | 3 + manifests/tools/install_app_sim.yaml | 3 + manifests/tools/key_press.yaml | 3 + manifests/tools/key_sequence.yaml | 3 + manifests/tools/launch_app_device.yaml | 3 + manifests/tools/launch_app_logs_sim.yaml | 3 + manifests/tools/launch_app_sim.yaml | 3 + manifests/tools/launch_mac_app.yaml | 3 + manifests/tools/list_devices.yaml | 3 + manifests/tools/list_schemes.yaml | 3 + manifests/tools/list_sims.yaml | 3 + manifests/tools/long_press.yaml | 3 + manifests/tools/manage_workflows.yaml | 3 +- manifests/tools/open_sim.yaml | 3 + manifests/tools/record_sim_video.yaml | 3 + manifests/tools/reset_sim_location.yaml | 3 + manifests/tools/scaffold_ios_project.yaml | 3 + manifests/tools/scaffold_macos_project.yaml | 3 + manifests/tools/screenshot.yaml | 3 + manifests/tools/session_clear_defaults.yaml | 3 + manifests/tools/session_set_defaults.yaml | 3 + manifests/tools/session_show_defaults.yaml | 3 + manifests/tools/set_sim_appearance.yaml | 3 + manifests/tools/set_sim_location.yaml | 3 + manifests/tools/show_build_settings.yaml | 3 + manifests/tools/sim_statusbar.yaml | 3 + manifests/tools/snapshot_ui.yaml | 3 + manifests/tools/start_device_log_cap.yaml | 3 + manifests/tools/start_sim_log_cap.yaml | 3 + manifests/tools/stop_app_device.yaml | 3 + manifests/tools/stop_app_sim.yaml | 3 + manifests/tools/stop_device_log_cap.yaml | 3 + manifests/tools/stop_mac_app.yaml | 3 + manifests/tools/stop_sim_log_cap.yaml | 3 + manifests/tools/swift_package_build.yaml | 3 + manifests/tools/swift_package_clean.yaml | 3 + manifests/tools/swift_package_list.yaml | 3 + manifests/tools/swift_package_run.yaml | 3 + manifests/tools/swift_package_stop.yaml | 3 + manifests/tools/swift_package_test.yaml | 3 + manifests/tools/swipe.yaml | 3 + manifests/tools/tap.yaml | 3 + manifests/tools/test_device.yaml | 3 + manifests/tools/test_macos.yaml | 3 + manifests/tools/test_sim.yaml | 3 + manifests/tools/touch.yaml | 3 + manifests/tools/type_text.yaml | 3 + .../tools/xcode_tools_bridge_disconnect.yaml | 6 +- .../tools/xcode_tools_bridge_status.yaml | 6 +- manifests/tools/xcode_tools_bridge_sync.yaml | 6 +- manifests/workflows/debugging.yaml | 1 - manifests/workflows/device.yaml | 1 - manifests/workflows/doctor.yaml | 1 - manifests/workflows/logging.yaml | 1 - manifests/workflows/macos.yaml | 1 - manifests/workflows/project-discovery.yaml | 1 - manifests/workflows/project-scaffolding.yaml | 1 - manifests/workflows/session-management.yaml | 1 - manifests/workflows/simulator-management.yaml | 1 - manifests/workflows/simulator.yaml | 1 - manifests/workflows/swift-package.yaml | 3 +- manifests/workflows/ui-automation.yaml | 1 - manifests/workflows/utilities.yaml | 3 +- manifests/workflows/workflow-discovery.yaml | 1 - manifests/workflows/xcode-ide.yaml | 8 +- src/cli/commands/tools.ts | 9 +- src/cli/yargs-app.ts | 11 +- .../manifest/__tests__/load-manifest.test.ts | 4 +- src/core/manifest/__tests__/schema.test.ts | 6 +- src/core/manifest/schema.ts | 4 +- src/mcp/tools/debugging/debug_attach_sim.ts | 22 ++-- .../tools/debugging/debug_breakpoint_add.ts | 20 ++-- .../debugging/debug_breakpoint_remove.ts | 20 ++-- src/mcp/tools/debugging/debug_continue.ts | 20 ++-- src/mcp/tools/debugging/debug_detach.ts | 20 ++-- src/mcp/tools/debugging/debug_lldb_command.ts | 20 ++-- src/mcp/tools/debugging/debug_stack.ts | 20 ++-- src/mcp/tools/debugging/debug_variables.ts | 20 ++-- .../device/__tests__/build_device.test.ts | 38 +++---- .../__tests__/get_device_app_path.test.ts | 32 +++--- .../__tests__/install_app_device.test.ts | 24 ++-- .../__tests__/launch_app_device.test.ts | 28 ++--- .../device/__tests__/list_devices.test.ts | 16 +-- .../tools/device/__tests__/re-exports.test.ts | 89 ++++++--------- .../device/__tests__/stop_app_device.test.ts | 24 ++-- .../device/__tests__/test_device.test.ts | 36 +++--- src/mcp/tools/device/build_device.ts | 37 +++--- src/mcp/tools/device/get_device_app_path.ts | 37 +++--- src/mcp/tools/device/install_app_device.ts | 29 ++--- src/mcp/tools/device/launch_app_device.ts | 31 ++--- src/mcp/tools/device/list_devices.ts | 17 ++- src/mcp/tools/device/stop_app_device.ts | 29 ++--- src/mcp/tools/device/test_device.ts | 53 ++++----- src/mcp/tools/doctor/__tests__/doctor.test.ts | 34 ++---- src/mcp/tools/doctor/doctor.ts | 13 +-- .../__tests__/start_device_log_cap.test.ts | 32 ++---- .../__tests__/start_sim_log_cap.test.ts | 54 ++++----- .../__tests__/stop_device_log_cap.test.ts | 30 ++--- .../__tests__/stop_sim_log_cap.test.ts | 37 +++--- src/mcp/tools/logging/start_device_log_cap.ts | 35 ++---- src/mcp/tools/logging/start_sim_log_cap.ts | 36 +++--- src/mcp/tools/logging/stop_device_log_cap.ts | 26 ++--- src/mcp/tools/logging/stop_sim_log_cap.ts | 26 ++--- .../tools/macos/__tests__/build_macos.test.ts | 37 +++--- .../macos/__tests__/build_run_macos.test.ts | 33 +++--- .../macos/__tests__/get_mac_app_path.test.ts | 37 +++--- .../macos/__tests__/launch_mac_app.test.ts | 35 +++--- .../tools/macos/__tests__/re-exports.test.ts | 106 +++++++++--------- .../macos/__tests__/stop_mac_app.test.ts | 27 ++--- .../tools/macos/__tests__/test_macos.test.ts | 39 +++---- src/mcp/tools/macos/build_macos.ts | 37 +++--- src/mcp/tools/macos/build_run_macos.ts | 37 +++--- src/mcp/tools/macos/get_mac_app_path.ts | 37 +++--- src/mcp/tools/macos/launch_mac_app.ts | 17 ++- src/mcp/tools/macos/stop_mac_app.ts | 17 ++- src/mcp/tools/macos/test_macos.ts | 39 +++---- .../__tests__/discover_projs.test.ts | 44 +++----- .../__tests__/get_app_bundle_id.test.ts | 38 +++---- .../__tests__/get_mac_bundle_id.test.ts | 28 ++--- .../__tests__/list_schemes.test.ts | 30 ++--- .../__tests__/show_build_settings.test.ts | 32 ++---- .../tools/project-discovery/discover_projs.ts | 24 ++-- .../project-discovery/get_app_bundle_id.ts | 23 ++-- .../project-discovery/get_mac_bundle_id.ts | 23 ++-- .../tools/project-discovery/list_schemes.ts | 35 +++--- .../project-discovery/show_build_settings.ts | 40 +++---- .../__tests__/scaffold_ios_project.test.ts | 28 ++--- .../__tests__/scaffold_macos_project.test.ts | 24 ++-- .../scaffold_ios_project.ts | 27 ++--- .../scaffold_macos_project.ts | 28 ++--- .../__tests__/session_clear_defaults.test.ts | 20 +--- .../__tests__/session_set_defaults.test.ts | 22 +--- .../__tests__/session_show_defaults.test.ts | 18 +-- .../session_clear_defaults.ts | 17 ++- .../session_set_defaults.ts | 13 +-- .../session_show_defaults.ts | 17 +-- .../__tests__/erase_sims.test.ts | 22 +--- .../__tests__/reset_sim_location.test.ts | 22 +--- .../__tests__/set_sim_appearance.test.ts | 26 ++--- .../__tests__/set_sim_location.test.ts | 32 ++---- .../__tests__/sim_statusbar.test.ts | 28 ++--- .../tools/simulator-management/erase_sims.ts | 29 ++--- .../reset_sim_location.ts | 35 +++--- .../set_sim_appearance.ts | 29 ++--- .../simulator-management/set_sim_location.ts | 35 +++--- .../simulator-management/sim_statusbar.ts | 29 ++--- .../simulator/__tests__/boot_sim.test.ts | 20 +--- .../simulator/__tests__/build_run_sim.test.ts | 30 ++--- .../simulator/__tests__/build_sim.test.ts | 36 +++--- .../__tests__/get_sim_app_path.test.ts | 32 ++---- .../__tests__/install_app_sim.test.ts | 26 ++--- .../__tests__/launch_app_logs_sim.test.ts | 27 ++--- .../__tests__/launch_app_sim.test.ts | 27 ++--- .../simulator/__tests__/list_sims.test.ts | 30 ++--- .../simulator/__tests__/open_sim.test.ts | 20 +--- .../__tests__/record_sim_video.test.ts | 6 +- .../simulator/__tests__/screenshot.test.ts | 20 +--- .../simulator/__tests__/stop_app_sim.test.ts | 25 ++--- .../simulator/__tests__/test_sim.test.ts | 36 +++--- src/mcp/tools/simulator/boot_sim.ts | 29 ++--- src/mcp/tools/simulator/build_run_sim.ts | 48 ++++---- src/mcp/tools/simulator/build_sim.ts | 45 ++++---- src/mcp/tools/simulator/get_sim_app_path.ts | 45 ++++---- src/mcp/tools/simulator/install_app_sim.ts | 29 ++--- .../tools/simulator/launch_app_logs_sim.ts | 42 +++---- src/mcp/tools/simulator/launch_app_sim.ts | 37 +++--- src/mcp/tools/simulator/list_sims.ts | 13 +-- src/mcp/tools/simulator/open_sim.ts | 13 +-- src/mcp/tools/simulator/record_sim_video.ts | 32 ++---- src/mcp/tools/simulator/stop_app_sim.ts | 37 +++--- src/mcp/tools/simulator/test_sim.ts | 45 ++++---- .../__tests__/swift_package_build.test.ts | 31 ++--- .../__tests__/swift_package_clean.test.ts | 20 +--- .../__tests__/swift_package_list.test.ts | 16 +-- .../__tests__/swift_package_run.test.ts | 46 ++++---- .../__tests__/swift_package_stop.test.ts | 30 ++--- .../__tests__/swift_package_test.test.ts | 40 +++---- .../swift-package/swift_package_build.ts | 27 ++--- .../swift-package/swift_package_clean.ts | 21 ++-- .../tools/swift-package/swift_package_list.ts | 26 ++--- .../tools/swift-package/swift_package_run.ts | 30 ++--- .../tools/swift-package/swift_package_stop.ts | 37 +++--- .../tools/swift-package/swift_package_test.ts | 27 ++--- .../ui-automation/__tests__/button.test.ts | 38 +++---- .../ui-automation/__tests__/gesture.test.ts | 33 ++---- .../ui-automation/__tests__/key_press.test.ts | 41 +++---- .../__tests__/key_sequence.test.ts | 44 +++----- .../__tests__/long_press.test.ts | 30 ++--- .../__tests__/screenshot.test.ts | 25 ++--- .../__tests__/snapshot_ui.test.ts | 26 ++--- .../ui-automation/__tests__/swipe.test.ts | 44 +++----- .../tools/ui-automation/__tests__/tap.test.ts | 58 +++++----- .../ui-automation/__tests__/touch.test.ts | 32 ++---- .../ui-automation/__tests__/type_text.test.ts | 35 +++--- src/mcp/tools/ui-automation/button.ts | 42 +++---- src/mcp/tools/ui-automation/gesture.ts | 42 +++---- src/mcp/tools/ui-automation/key_press.ts | 42 +++---- src/mcp/tools/ui-automation/key_sequence.ts | 42 +++---- src/mcp/tools/ui-automation/long_press.ts | 42 +++---- src/mcp/tools/ui-automation/screenshot.ts | 31 ++--- src/mcp/tools/ui-automation/snapshot_ui.ts | 43 +++---- src/mcp/tools/ui-automation/swipe.ts | 42 +++---- src/mcp/tools/ui-automation/tap.ts | 42 +++---- src/mcp/tools/ui-automation/touch.ts | 32 ++---- src/mcp/tools/ui-automation/type_text.ts | 34 ++---- .../tools/utilities/__tests__/clean.test.ts | 30 +++-- src/mcp/tools/utilities/clean.ts | 35 +++--- .../workflow-discovery/manage_workflows.ts | 23 ++-- .../xcode_tools_bridge_disconnect.ts | 31 ++--- .../xcode-ide/xcode_tools_bridge_status.ts | 31 ++--- .../xcode-ide/xcode_tools_bridge_sync.ts | 31 ++--- src/utils/command.ts | 4 +- src/visibility/__tests__/exposure.test.ts | 27 +---- .../__tests__/predicate-registry.test.ts | 26 +++++ src/visibility/exposure.ts | 27 ++--- src/visibility/predicate-registry.ts | 18 ++- 235 files changed, 1992 insertions(+), 2882 deletions(-) diff --git a/docs/dev/MANIFEST_FORMAT.md b/docs/dev/MANIFEST_FORMAT.md index 47a91d7f..388accba 100644 --- a/docs/dev/MANIFEST_FORMAT.md +++ b/docs/dev/MANIFEST_FORMAT.md @@ -61,6 +61,12 @@ predicates: string[] # Predicate names for visibility filtering (default: []) routing: # Daemon routing hints stateful: boolean # Tool maintains state (default: false) daemonAffinity: enum # 'preferred' or 'required' (optional) +annotations: # MCP tool annotations (hints for clients) + title: string # Human-readable title (optional) + readOnlyHint: boolean # Tool only reads data (optional) + destructiveHint: boolean # Tool may modify/delete data (optional) + idempotentHint: boolean # Safe to retry (optional) + openWorldHint: boolean # May access external resources (optional) ``` ### Example: Basic Tool @@ -79,6 +85,9 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "List Simulators" + readOnlyHint: true ``` ### Example: Tool with Predicates @@ -136,7 +145,6 @@ availability: # Per-runtime availability flags daemon: boolean # Available via daemon (default: true) selection: # MCP selection rules mcp: - mandatory: boolean # Always included, cannot be disabled (default: false) defaultEnabled: boolean # Enabled when config.enabledWorkflows is empty (default: false) autoInclude: boolean # Include when predicates pass, even if not requested (default: false) predicates: string[] # Predicate names for visibility filtering (default: []) @@ -154,7 +162,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: true # Enabled by default autoInclude: false predicates: [] @@ -179,7 +186,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: true # Auto-included when predicates pass predicates: @@ -200,7 +206,6 @@ availability: daemon: false selection: mcp: - mandatory: false defaultEnabled: false autoInclude: true predicates: @@ -226,6 +231,11 @@ tools: | `predicates` | string[] | No | `[]` | Visibility predicates (all must pass) | | `routing.stateful` | boolean | No | `false` | Tool maintains state | | `routing.daemonAffinity` | enum | No | - | `'preferred'` or `'required'` | +| `annotations.title` | string | No | - | Human-readable title | +| `annotations.readOnlyHint` | boolean | No | - | Tool only reads data | +| `annotations.destructiveHint` | boolean | No | - | Tool may modify/delete data | +| `annotations.idempotentHint` | boolean | No | - | Safe to retry | +| `annotations.openWorldHint` | boolean | No | - | May access external resources | ### Workflow Fields @@ -238,7 +248,6 @@ tools: | `availability.mcp` | boolean | No | `true` | Available via MCP | | `availability.cli` | boolean | No | `true` | Available via CLI | | `availability.daemon` | boolean | No | `true` | Available via daemon | -| `selection.mcp.mandatory` | boolean | No | `false` | Cannot be disabled | | `selection.mcp.defaultEnabled` | boolean | No | `false` | Enabled when no workflows configured | | `selection.mcp.autoInclude` | boolean | No | `false` | Auto-include when predicates pass | | `predicates` | string[] | No | `[]` | Visibility predicates (all must pass) | @@ -257,8 +266,10 @@ build/mcp/tools//.js ``` The module must export either: -1. **Named exports** (preferred): `{ schema, handler, annotations? }` -2. **Legacy default export**: `export default { name, schema, handler, annotations? }` +1. **Named exports** (preferred): `{ schema, handler }` +2. **Legacy default export**: `export default { schema, handler }` + +Note: `name`, `description`, and `annotations` are defined in the YAML manifest, not the module. Example module structure: ```typescript @@ -273,11 +284,6 @@ export const schema = z.object({ export async function handler(params: z.infer) { // Implementation } - -export const annotations = { - title: 'Build for Simulator', - // ... -}; ``` ## Naming Conventions @@ -311,6 +317,8 @@ Predicates control visibility based on runtime context. All predicates in the ar |-----------|-------------| | `debugEnabled` | Show only when `config.debug` is `true` | | `experimentalWorkflowDiscoveryEnabled` | Show only when experimental workflow discovery is enabled | +| `runningUnderXcodeAgent` | Show only when running under Xcode's coding agent | +| `requiresXcodeTools` | Show only when Xcode Tools bridge is active | | `hideWhenXcodeAgentMode` | Hide when running under Xcode agent AND Xcode Tools bridge is active | | `always` | Always visible (explicit documentation) | | `never` | Never visible (temporarily disable) | @@ -346,25 +354,25 @@ export const PREDICATES: Record = { For MCP runtime, workflows are selected based on these rules (in order): -1. **Mandatory workflows** (`mandatory: true`) are always included +1. **Auto-include workflows** (`autoInclude: true`) when their predicates pass 2. **Explicitly requested workflows** from `config.enabledWorkflows` 3. **Default workflows** (`defaultEnabled: true`) when `config.enabledWorkflows` is empty -4. **Auto-include workflows** (`autoInclude: true`) when their predicates pass +4. All selected workflows are filtered by availability + predicates ### Selection Examples ```yaml -# Always included regardless of config +# Always included (autoInclude with no predicates = always passes) selection: mcp: - mandatory: true + autoInclude: true -# Enabled by default, can be disabled +# Enabled by default when no workflows configured selection: mcp: defaultEnabled: true -# Auto-included when predicates pass (e.g., debug mode) +# Auto-included only when predicates pass (e.g., debug mode) selection: mcp: autoInclude: true @@ -418,7 +426,7 @@ At startup, tools are registered dynamically from manifests: 2. selectWorkflowsForMcp(workflows, requestedWorkflows, ctx) └── Filters workflows by availability (mcp: true) - └── Applies selection rules (mandatory, defaultEnabled, autoInclude) + └── Applies selection rules (defaultEnabled, autoInclude) └── Evaluates predicates against context 3. For each selected workflow: diff --git a/docs/dev/TOOL_DISCOVERY_LOGIC.md b/docs/dev/TOOL_DISCOVERY_LOGIC.md index 8ddf3711..5008b8f8 100644 --- a/docs/dev/TOOL_DISCOVERY_LOGIC.md +++ b/docs/dev/TOOL_DISCOVERY_LOGIC.md @@ -38,11 +38,11 @@ At MCP server startup: 3) `registerWorkflows(enabledWorkflows)` runs, which calls `applyWorkflowSelection(...)` (`src/utils/tool-registry.ts`). -4) Workflow selection uses `resolveSelectedWorkflows(...)` (`src/utils/workflow-selection.ts`) which: - - always includes `session-management`, - - optionally includes `doctor` when `debug: true`, - - optionally includes `workflow-discovery` when `experimentalWorkflowDiscovery: true`, - - defaults to `simulator` when `enabledWorkflows` is empty. +4) Workflow selection uses `selectWorkflowsForMcp(...)` (`src/visibility/exposure.ts`) which: + - includes auto-include workflows whose predicates pass (e.g., `session-management` with no predicates is always included), + - includes explicitly requested workflows from config, + - defaults to `defaultEnabled: true` workflows (e.g., `simulator`) when `enabledWorkflows` is empty, + - filters all selected workflows by availability + predicates. 5) For each selected workflow, each tool is considered for registration, then filtered by `shouldExposeTool(workflowName, toolName)` (`src/utils/tool-registry.ts`). diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml index d10a392d..f055dd6d 100644 --- a/manifests/tools/boot_sim.yaml +++ b/manifests/tools/boot_sim.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Boot Simulator" + destructiveHint: true diff --git a/manifests/tools/build_device.yaml b/manifests/tools/build_device.yaml index f2a1e6d6..d5581bdf 100644 --- a/manifests/tools/build_device.yaml +++ b/manifests/tools/build_device.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Build Device" + destructiveHint: true diff --git a/manifests/tools/build_macos.yaml b/manifests/tools/build_macos.yaml index 2f887d04..952a6976 100644 --- a/manifests/tools/build_macos.yaml +++ b/manifests/tools/build_macos.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Build macOS" + destructiveHint: true diff --git a/manifests/tools/build_run_macos.yaml b/manifests/tools/build_run_macos.yaml index 700aa843..709d5c76 100644 --- a/manifests/tools/build_run_macos.yaml +++ b/manifests/tools/build_run_macos.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Build Run macOS" + destructiveHint: true diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index a4dfb615..b79366c5 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Build Run Simulator" + destructiveHint: true diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml index c6949f5d..27796d9c 100644 --- a/manifests/tools/build_sim.yaml +++ b/manifests/tools/build_sim.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Build Simulator" + destructiveHint: true diff --git a/manifests/tools/button.yaml b/manifests/tools/button.yaml index c09af445..60450abc 100644 --- a/manifests/tools/button.yaml +++ b/manifests/tools/button.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Hardware Button" + destructiveHint: true diff --git a/manifests/tools/clean.yaml b/manifests/tools/clean.yaml index 532bdcf9..34b92870 100644 --- a/manifests/tools/clean.yaml +++ b/manifests/tools/clean.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Clean" + destructiveHint: true diff --git a/manifests/tools/discover_projs.yaml b/manifests/tools/discover_projs.yaml index 2feb803c..5437cb7c 100644 --- a/manifests/tools/discover_projs.yaml +++ b/manifests/tools/discover_projs.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Discover Projects" + readOnlyHint: true diff --git a/manifests/tools/doctor.yaml b/manifests/tools/doctor.yaml index be2f7545..10105eb4 100644 --- a/manifests/tools/doctor.yaml +++ b/manifests/tools/doctor.yaml @@ -7,5 +7,7 @@ availability: mcp: true cli: true daemon: true -predicates: - - debugEnabled +predicates: [] +annotations: + title: "Doctor" + readOnlyHint: true diff --git a/manifests/tools/erase_sims.yaml b/manifests/tools/erase_sims.yaml index 59b3d26e..b330cb1d 100644 --- a/manifests/tools/erase_sims.yaml +++ b/manifests/tools/erase_sims.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Erase Simulators" + destructiveHint: true diff --git a/manifests/tools/gesture.yaml b/manifests/tools/gesture.yaml index ff347464..88cc860e 100644 --- a/manifests/tools/gesture.yaml +++ b/manifests/tools/gesture.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Gesture" + destructiveHint: true diff --git a/manifests/tools/get_app_bundle_id.yaml b/manifests/tools/get_app_bundle_id.yaml index 4c38f4dc..44ecf6ed 100644 --- a/manifests/tools/get_app_bundle_id.yaml +++ b/manifests/tools/get_app_bundle_id.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Get App Bundle ID" + readOnlyHint: true diff --git a/manifests/tools/get_device_app_path.yaml b/manifests/tools/get_device_app_path.yaml index f17a386e..cacac535 100644 --- a/manifests/tools/get_device_app_path.yaml +++ b/manifests/tools/get_device_app_path.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Get Device App Path" + readOnlyHint: true diff --git a/manifests/tools/get_mac_app_path.yaml b/manifests/tools/get_mac_app_path.yaml index ae871032..ae6c00b7 100644 --- a/manifests/tools/get_mac_app_path.yaml +++ b/manifests/tools/get_mac_app_path.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Get macOS App Path" + readOnlyHint: true diff --git a/manifests/tools/get_mac_bundle_id.yaml b/manifests/tools/get_mac_bundle_id.yaml index 642dec7c..b1ff8679 100644 --- a/manifests/tools/get_mac_bundle_id.yaml +++ b/manifests/tools/get_mac_bundle_id.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Get Mac Bundle ID" + readOnlyHint: true diff --git a/manifests/tools/get_sim_app_path.yaml b/manifests/tools/get_sim_app_path.yaml index b367a0b5..52830811 100644 --- a/manifests/tools/get_sim_app_path.yaml +++ b/manifests/tools/get_sim_app_path.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Get Simulator App Path" + readOnlyHint: true diff --git a/manifests/tools/install_app_device.yaml b/manifests/tools/install_app_device.yaml index fd41cd57..aa832ea7 100644 --- a/manifests/tools/install_app_device.yaml +++ b/manifests/tools/install_app_device.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Install App Device" + destructiveHint: true diff --git a/manifests/tools/install_app_sim.yaml b/manifests/tools/install_app_sim.yaml index 931a6a7f..94dedce1 100644 --- a/manifests/tools/install_app_sim.yaml +++ b/manifests/tools/install_app_sim.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Install App Simulator" + destructiveHint: true diff --git a/manifests/tools/key_press.yaml b/manifests/tools/key_press.yaml index 601e5282..4898ce51 100644 --- a/manifests/tools/key_press.yaml +++ b/manifests/tools/key_press.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Key Press" + destructiveHint: true diff --git a/manifests/tools/key_sequence.yaml b/manifests/tools/key_sequence.yaml index 34b74a73..9c08a139 100644 --- a/manifests/tools/key_sequence.yaml +++ b/manifests/tools/key_sequence.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Key Sequence" + destructiveHint: true diff --git a/manifests/tools/launch_app_device.yaml b/manifests/tools/launch_app_device.yaml index 84bf5539..b1f51597 100644 --- a/manifests/tools/launch_app_device.yaml +++ b/manifests/tools/launch_app_device.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Launch App Device" + destructiveHint: true diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml index 7bb4047f..1d8c8d2f 100644 --- a/manifests/tools/launch_app_logs_sim.yaml +++ b/manifests/tools/launch_app_logs_sim.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: preferred +annotations: + title: "Launch App Logs Simulator" + destructiveHint: true diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index 4d7aafb6..6682e0fb 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Launch App Simulator" + destructiveHint: true diff --git a/manifests/tools/launch_mac_app.yaml b/manifests/tools/launch_mac_app.yaml index b7ffc026..b31f766e 100644 --- a/manifests/tools/launch_mac_app.yaml +++ b/manifests/tools/launch_mac_app.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Launch macOS App" + destructiveHint: true diff --git a/manifests/tools/list_devices.yaml b/manifests/tools/list_devices.yaml index c315a743..e7b5ef54 100644 --- a/manifests/tools/list_devices.yaml +++ b/manifests/tools/list_devices.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "List Devices" + readOnlyHint: true diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml index e754bb58..84ad0640 100644 --- a/manifests/tools/list_schemes.yaml +++ b/manifests/tools/list_schemes.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "List Schemes" + readOnlyHint: true diff --git a/manifests/tools/list_sims.yaml b/manifests/tools/list_sims.yaml index b7af7179..0f4fe2ed 100644 --- a/manifests/tools/list_sims.yaml +++ b/manifests/tools/list_sims.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "List Simulators" + readOnlyHint: true diff --git a/manifests/tools/long_press.yaml b/manifests/tools/long_press.yaml index 8abffb16..5b2d2c86 100644 --- a/manifests/tools/long_press.yaml +++ b/manifests/tools/long_press.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Long Press" + destructiveHint: true diff --git a/manifests/tools/manage_workflows.yaml b/manifests/tools/manage_workflows.yaml index 34c91d0f..eb0533d0 100644 --- a/manifests/tools/manage_workflows.yaml +++ b/manifests/tools/manage_workflows.yaml @@ -7,5 +7,4 @@ availability: mcp: true cli: false daemon: false -predicates: - - experimentalWorkflowDiscoveryEnabled +predicates: [] diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index 78395e38..fcb9d33c 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Open Simulator" + destructiveHint: true diff --git a/manifests/tools/record_sim_video.yaml b/manifests/tools/record_sim_video.yaml index 75b8a9f9..123e591c 100644 --- a/manifests/tools/record_sim_video.yaml +++ b/manifests/tools/record_sim_video.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: preferred +annotations: + title: "Record Simulator Video" + destructiveHint: true diff --git a/manifests/tools/reset_sim_location.yaml b/manifests/tools/reset_sim_location.yaml index 297ba7e4..0c4dd4a5 100644 --- a/manifests/tools/reset_sim_location.yaml +++ b/manifests/tools/reset_sim_location.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Reset Simulator Location" + destructiveHint: true diff --git a/manifests/tools/scaffold_ios_project.yaml b/manifests/tools/scaffold_ios_project.yaml index 977a0978..66f306c8 100644 --- a/manifests/tools/scaffold_ios_project.yaml +++ b/manifests/tools/scaffold_ios_project.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Scaffold iOS Project" + destructiveHint: true diff --git a/manifests/tools/scaffold_macos_project.yaml b/manifests/tools/scaffold_macos_project.yaml index c0c17516..c1ea93f8 100644 --- a/manifests/tools/scaffold_macos_project.yaml +++ b/manifests/tools/scaffold_macos_project.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Scaffold macOS Project" + destructiveHint: true diff --git a/manifests/tools/screenshot.yaml b/manifests/tools/screenshot.yaml index 00dd9c59..dcb0e85a 100644 --- a/manifests/tools/screenshot.yaml +++ b/manifests/tools/screenshot.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Screenshot" + readOnlyHint: true diff --git a/manifests/tools/session_clear_defaults.yaml b/manifests/tools/session_clear_defaults.yaml index 63d7bc68..3891ce51 100644 --- a/manifests/tools/session_clear_defaults.yaml +++ b/manifests/tools/session_clear_defaults.yaml @@ -8,3 +8,6 @@ availability: cli: false daemon: false predicates: [] +annotations: + title: "Clear Session Defaults" + destructiveHint: true diff --git a/manifests/tools/session_set_defaults.yaml b/manifests/tools/session_set_defaults.yaml index 2014a4b6..cae43dcf 100644 --- a/manifests/tools/session_set_defaults.yaml +++ b/manifests/tools/session_set_defaults.yaml @@ -8,3 +8,6 @@ availability: cli: false daemon: false predicates: [] +annotations: + title: "Set Session Defaults" + destructiveHint: true diff --git a/manifests/tools/session_show_defaults.yaml b/manifests/tools/session_show_defaults.yaml index bd8f8d6c..99bf5ca8 100644 --- a/manifests/tools/session_show_defaults.yaml +++ b/manifests/tools/session_show_defaults.yaml @@ -8,3 +8,6 @@ availability: cli: false daemon: false predicates: [] +annotations: + title: "Show Session Defaults" + readOnlyHint: true diff --git a/manifests/tools/set_sim_appearance.yaml b/manifests/tools/set_sim_appearance.yaml index f60891d4..a7e2cc98 100644 --- a/manifests/tools/set_sim_appearance.yaml +++ b/manifests/tools/set_sim_appearance.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Set Simulator Appearance" + destructiveHint: true diff --git a/manifests/tools/set_sim_location.yaml b/manifests/tools/set_sim_location.yaml index f46acf6b..0141cbad 100644 --- a/manifests/tools/set_sim_location.yaml +++ b/manifests/tools/set_sim_location.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Set Simulator Location" + destructiveHint: true diff --git a/manifests/tools/show_build_settings.yaml b/manifests/tools/show_build_settings.yaml index 68d2121d..45062a47 100644 --- a/manifests/tools/show_build_settings.yaml +++ b/manifests/tools/show_build_settings.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Show Build Settings" + readOnlyHint: true diff --git a/manifests/tools/sim_statusbar.yaml b/manifests/tools/sim_statusbar.yaml index 33160578..405f5431 100644 --- a/manifests/tools/sim_statusbar.yaml +++ b/manifests/tools/sim_statusbar.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Simulator Statusbar" + destructiveHint: true diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml index b3ba2a8b..743cd1d9 100644 --- a/manifests/tools/snapshot_ui.yaml +++ b/manifests/tools/snapshot_ui.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Snapshot UI" + readOnlyHint: true diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml index e0b79404..398ea424 100644 --- a/manifests/tools/start_device_log_cap.yaml +++ b/manifests/tools/start_device_log_cap.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Start Device Log Capture" + destructiveHint: true diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml index 6540a9e8..c7f093dc 100644 --- a/manifests/tools/start_sim_log_cap.yaml +++ b/manifests/tools/start_sim_log_cap.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Start Simulator Log Capture" + destructiveHint: true diff --git a/manifests/tools/stop_app_device.yaml b/manifests/tools/stop_app_device.yaml index 19feb719..b2bb1a24 100644 --- a/manifests/tools/stop_app_device.yaml +++ b/manifests/tools/stop_app_device.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Stop App Device" + destructiveHint: true diff --git a/manifests/tools/stop_app_sim.yaml b/manifests/tools/stop_app_sim.yaml index 8f6a92b7..e9b77008 100644 --- a/manifests/tools/stop_app_sim.yaml +++ b/manifests/tools/stop_app_sim.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Stop App Simulator" + destructiveHint: true diff --git a/manifests/tools/stop_device_log_cap.yaml b/manifests/tools/stop_device_log_cap.yaml index 65e23705..296aad1e 100644 --- a/manifests/tools/stop_device_log_cap.yaml +++ b/manifests/tools/stop_device_log_cap.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Stop Device and Return Logs" + destructiveHint: true diff --git a/manifests/tools/stop_mac_app.yaml b/manifests/tools/stop_mac_app.yaml index f617e231..98c0f2e5 100644 --- a/manifests/tools/stop_mac_app.yaml +++ b/manifests/tools/stop_mac_app.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Stop macOS App" + destructiveHint: true diff --git a/manifests/tools/stop_sim_log_cap.yaml b/manifests/tools/stop_sim_log_cap.yaml index cdd421c2..622ce7d0 100644 --- a/manifests/tools/stop_sim_log_cap.yaml +++ b/manifests/tools/stop_sim_log_cap.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Stop Simulator and Return Logs" + destructiveHint: true diff --git a/manifests/tools/swift_package_build.yaml b/manifests/tools/swift_package_build.yaml index 90c93143..5b72ffb0 100644 --- a/manifests/tools/swift_package_build.yaml +++ b/manifests/tools/swift_package_build.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Swift Package Build" + destructiveHint: true diff --git a/manifests/tools/swift_package_clean.yaml b/manifests/tools/swift_package_clean.yaml index 39894a7e..eb6efeee 100644 --- a/manifests/tools/swift_package_clean.yaml +++ b/manifests/tools/swift_package_clean.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Swift Package Clean" + destructiveHint: true diff --git a/manifests/tools/swift_package_list.yaml b/manifests/tools/swift_package_list.yaml index 42b33a5e..2181b020 100644 --- a/manifests/tools/swift_package_list.yaml +++ b/manifests/tools/swift_package_list.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Swift Package List" + readOnlyHint: true diff --git a/manifests/tools/swift_package_run.yaml b/manifests/tools/swift_package_run.yaml index 96898b3d..8587c327 100644 --- a/manifests/tools/swift_package_run.yaml +++ b/manifests/tools/swift_package_run.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Swift Package Run" + destructiveHint: true diff --git a/manifests/tools/swift_package_stop.yaml b/manifests/tools/swift_package_stop.yaml index a0cf398f..4760bb4f 100644 --- a/manifests/tools/swift_package_stop.yaml +++ b/manifests/tools/swift_package_stop.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: true daemonAffinity: required +annotations: + title: "Swift Package Stop" + destructiveHint: true diff --git a/manifests/tools/swift_package_test.yaml b/manifests/tools/swift_package_test.yaml index 5616dbe4..a7844d0d 100644 --- a/manifests/tools/swift_package_test.yaml +++ b/manifests/tools/swift_package_test.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Swift Package Test" + destructiveHint: true diff --git a/manifests/tools/swipe.yaml b/manifests/tools/swipe.yaml index c4c38e59..036eab50 100644 --- a/manifests/tools/swipe.yaml +++ b/manifests/tools/swipe.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Swipe" + destructiveHint: true diff --git a/manifests/tools/tap.yaml b/manifests/tools/tap.yaml index 18a03c8d..9c838fa5 100644 --- a/manifests/tools/tap.yaml +++ b/manifests/tools/tap.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Tap" + destructiveHint: true diff --git a/manifests/tools/test_device.yaml b/manifests/tools/test_device.yaml index 19eb19c3..43ab838f 100644 --- a/manifests/tools/test_device.yaml +++ b/manifests/tools/test_device.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Test Device" + destructiveHint: true diff --git a/manifests/tools/test_macos.yaml b/manifests/tools/test_macos.yaml index 39c17d8d..b34ca104 100644 --- a/manifests/tools/test_macos.yaml +++ b/manifests/tools/test_macos.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Test macOS" + destructiveHint: true diff --git a/manifests/tools/test_sim.yaml b/manifests/tools/test_sim.yaml index 0142bbb2..9104255e 100644 --- a/manifests/tools/test_sim.yaml +++ b/manifests/tools/test_sim.yaml @@ -12,3 +12,6 @@ predicates: routing: stateful: false daemonAffinity: preferred +annotations: + title: "Test Simulator" + destructiveHint: true diff --git a/manifests/tools/touch.yaml b/manifests/tools/touch.yaml index ef3ff2b7..fac3b280 100644 --- a/manifests/tools/touch.yaml +++ b/manifests/tools/touch.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Touch" + destructiveHint: true diff --git a/manifests/tools/type_text.yaml b/manifests/tools/type_text.yaml index 30903e5f..9391b774 100644 --- a/manifests/tools/type_text.yaml +++ b/manifests/tools/type_text.yaml @@ -11,3 +11,6 @@ predicates: [] routing: stateful: false daemonAffinity: preferred +annotations: + title: "Type Text" + destructiveHint: true diff --git a/manifests/tools/xcode_tools_bridge_disconnect.yaml b/manifests/tools/xcode_tools_bridge_disconnect.yaml index 9b370eff..55bbad8d 100644 --- a/manifests/tools/xcode_tools_bridge_disconnect.yaml +++ b/manifests/tools/xcode_tools_bridge_disconnect.yaml @@ -7,5 +7,7 @@ availability: mcp: true cli: false daemon: false -predicates: - - debugEnabled +predicates: [] +annotations: + title: "Disconnect Xcode Tools Bridge" + readOnlyHint: false diff --git a/manifests/tools/xcode_tools_bridge_status.yaml b/manifests/tools/xcode_tools_bridge_status.yaml index 414773fe..137625a7 100644 --- a/manifests/tools/xcode_tools_bridge_status.yaml +++ b/manifests/tools/xcode_tools_bridge_status.yaml @@ -7,5 +7,7 @@ availability: mcp: true cli: false daemon: false -predicates: - - debugEnabled +predicates: [] +annotations: + title: "Xcode Tools Bridge Status" + readOnlyHint: true diff --git a/manifests/tools/xcode_tools_bridge_sync.yaml b/manifests/tools/xcode_tools_bridge_sync.yaml index ec3e0ce8..15378d53 100644 --- a/manifests/tools/xcode_tools_bridge_sync.yaml +++ b/manifests/tools/xcode_tools_bridge_sync.yaml @@ -7,5 +7,7 @@ availability: mcp: true cli: false daemon: false -predicates: - - debugEnabled +predicates: [] +annotations: + title: "Sync Xcode Tools Bridge" + readOnlyHint: false diff --git a/manifests/workflows/debugging.yaml b/manifests/workflows/debugging.yaml index 246db98e..27d5848a 100644 --- a/manifests/workflows/debugging.yaml +++ b/manifests/workflows/debugging.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index c71cdc2b..d8669d34 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/doctor.yaml b/manifests/workflows/doctor.yaml index 818d1dd5..ad1172a0 100644 --- a/manifests/workflows/doctor.yaml +++ b/manifests/workflows/doctor.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: true predicates: diff --git a/manifests/workflows/logging.yaml b/manifests/workflows/logging.yaml index e44a44c3..6c406b66 100644 --- a/manifests/workflows/logging.yaml +++ b/manifests/workflows/logging.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/macos.yaml b/manifests/workflows/macos.yaml index c7a1d62c..a2ddfa7e 100644 --- a/manifests/workflows/macos.yaml +++ b/manifests/workflows/macos.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/project-discovery.yaml b/manifests/workflows/project-discovery.yaml index ae0f6900..c21ba3d4 100644 --- a/manifests/workflows/project-discovery.yaml +++ b/manifests/workflows/project-discovery.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/project-scaffolding.yaml b/manifests/workflows/project-scaffolding.yaml index c7ad17c6..2c3e0a0b 100644 --- a/manifests/workflows/project-scaffolding.yaml +++ b/manifests/workflows/project-scaffolding.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml index 8505ae64..fb81c747 100644 --- a/manifests/workflows/session-management.yaml +++ b/manifests/workflows/session-management.yaml @@ -7,7 +7,6 @@ availability: daemon: false selection: mcp: - mandatory: true defaultEnabled: true autoInclude: true predicates: [] diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index 764cd1e4..917828c4 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml index f2ac86f2..782ce81f 100644 --- a/manifests/workflows/simulator.yaml +++ b/manifests/workflows/simulator.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: true autoInclude: false predicates: [] diff --git a/manifests/workflows/swift-package.yaml b/manifests/workflows/swift-package.yaml index 2929df15..c85a9d0a 100644 --- a/manifests/workflows/swift-package.yaml +++ b/manifests/workflows/swift-package.yaml @@ -7,8 +7,7 @@ availability: daemon: true selection: mcp: - mandatory: false - defaultEnabled: true + defaultEnabled: false autoInclude: false predicates: [] tools: diff --git a/manifests/workflows/ui-automation.yaml b/manifests/workflows/ui-automation.yaml index d6261b2e..29c43968 100644 --- a/manifests/workflows/ui-automation.yaml +++ b/manifests/workflows/ui-automation.yaml @@ -7,7 +7,6 @@ availability: daemon: true selection: mcp: - mandatory: false defaultEnabled: false autoInclude: false predicates: [] diff --git a/manifests/workflows/utilities.yaml b/manifests/workflows/utilities.yaml index e404d13b..2c2e64c7 100644 --- a/manifests/workflows/utilities.yaml +++ b/manifests/workflows/utilities.yaml @@ -7,8 +7,7 @@ availability: daemon: true selection: mcp: - mandatory: false - defaultEnabled: true + defaultEnabled: false autoInclude: false predicates: [] tools: diff --git a/manifests/workflows/workflow-discovery.yaml b/manifests/workflows/workflow-discovery.yaml index 19176994..b8316984 100644 --- a/manifests/workflows/workflow-discovery.yaml +++ b/manifests/workflows/workflow-discovery.yaml @@ -7,7 +7,6 @@ availability: daemon: false selection: mcp: - mandatory: false defaultEnabled: false autoInclude: true predicates: diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index 67cef62b..f0c09893 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -7,10 +7,12 @@ availability: daemon: false selection: mcp: - mandatory: false defaultEnabled: false - autoInclude: false -predicates: [] + autoInclude: true +predicates: + - debugEnabled + - runningUnderXcodeAgent + - requiresXcodeTools tools: - xcode_tools_bridge_status - xcode_tools_bridge_sync diff --git a/src/cli/commands/tools.ts b/src/cli/commands/tools.ts index 6d16381e..c67d6660 100644 --- a/src/cli/commands/tools.ts +++ b/src/cli/commands/tools.ts @@ -211,9 +211,11 @@ export function registerToolsCommand(app: Argv): void { }) .map((tool) => toFlatJsonTool(tool)); + const canonicalCount = flatTools.filter((t) => !t.canonicalWorkflow).length; writeLine( JSON.stringify( { + canonicalToolCount: canonicalCount, toolCount: flatTools.length, tools: flatTools, }, @@ -240,10 +242,12 @@ export function registerToolsCommand(app: Argv): void { .map((tool) => toGroupedJsonTool(tool)), })); + const canonicalCount = tools.filter((t) => t.isCanonical).length; writeLine( JSON.stringify( { workflowCount: grouped.length, + canonicalToolCount: canonicalCount, toolCount: tools.length, workflows: grouped, }, @@ -252,8 +256,9 @@ export function registerToolsCommand(app: Argv): void { ), ); } else { - const count = tools.length; - writeLine(`Available tools (${count}):\n`); + const totalCount = tools.length; + const canonicalCount = tools.filter((t) => t.isCanonical).length; + writeLine(`Available tools (${canonicalCount} canonical, ${totalCount} total):\n`); // Default to grouped view (use --flat for flat list) writeLine( formatToolList(tools, { diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 69d07b48..ff47b130 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -81,17 +81,18 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { // Register command groups with workspace context registerMcpCommand(app); - registerDaemonCommands(app, { - defaultSocketPath: opts.defaultSocketPath, - workspaceRoot: opts.workspaceRoot, - workspaceKey: opts.workspaceKey, - }); registerToolsCommand(app); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, enabledWorkflows: opts.enabledWorkflows, workflowNames: opts.workflowNames, }); + // Daemon management is an advanced debugging tool - register last + registerDaemonCommands(app, { + defaultSocketPath: opts.defaultSocketPath, + workspaceRoot: opts.workspaceRoot, + workspaceKey: opts.workspaceKey, + }); return app; } diff --git a/src/core/manifest/__tests__/load-manifest.test.ts b/src/core/manifest/__tests__/load-manifest.test.ts index 6331113a..6054b565 100644 --- a/src/core/manifest/__tests__/load-manifest.test.ts +++ b/src/core/manifest/__tests__/load-manifest.test.ts @@ -59,12 +59,12 @@ describe('load-manifest', () => { } }); - it('should have session-management as mandatory workflow', () => { + it('should have session-management as auto-include workflow', () => { const manifest = loadManifest(); const sessionMgmt = manifest.workflows.get('session-management'); expect(sessionMgmt).toBeDefined(); - expect(sessionMgmt?.selection?.mcp?.mandatory).toBe(true); + expect(sessionMgmt?.selection?.mcp?.autoInclude).toBe(true); }); it('should have simulator as default-enabled workflow', () => { diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts index 6cb4f7b2..c9cb0de0 100644 --- a/src/core/manifest/__tests__/schema.test.ts +++ b/src/core/manifest/__tests__/schema.test.ts @@ -96,7 +96,6 @@ describe('schema', () => { availability: { mcp: true, cli: true, daemon: true }, selection: { mcp: { - mandatory: false, defaultEnabled: true, autoInclude: false, }, @@ -143,7 +142,7 @@ describe('schema', () => { expect(result.success).toBe(true); }); - it('should parse mandatory workflow', () => { + it('should parse autoInclude workflow', () => { const input = { id: 'session-management', title: 'Session Management', @@ -151,7 +150,6 @@ describe('schema', () => { availability: { mcp: true, cli: false, daemon: false }, selection: { mcp: { - mandatory: true, defaultEnabled: true, autoInclude: true, }, @@ -162,7 +160,7 @@ describe('schema', () => { const result = workflowManifestEntrySchema.safeParse(input); expect(result.success).toBe(true); if (result.success) { - expect(result.data.selection?.mcp?.mandatory).toBe(true); + expect(result.data.selection?.mcp?.autoInclude).toBe(true); expect(result.data.availability.cli).toBe(false); } }); diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index 43cee6db..f88055b9 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -91,11 +91,9 @@ export type ToolManifestEntry = z.infer; * MCP-specific workflow selection rules. */ export const workflowSelectionMcpSchema = z.object({ - /** Mandatory workflows are always included in MCP selection */ - mandatory: z.boolean().default(false), /** Used when config.enabledWorkflows is empty */ defaultEnabled: z.boolean().default(false), - /** Include when predicates pass even if not requested */ + /** Include when predicates pass, regardless of user selection */ autoInclude: z.boolean().default(false), }); diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts index 882ee419..143058ea 100644 --- a/src/mcp/tools/debugging/debug_attach_sim.ts +++ b/src/mcp/tools/debugging/debug_attach_sim.ts @@ -181,17 +181,13 @@ const publicSchemaObject = z.strictObject( }).shape, ); -export default { - name: 'debug_attach_sim', - description: 'Attach LLDB to sim app.', - cli: { - stateful: true, - }, - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - handler: createSessionAwareToolWithContext({ +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareToolWithContext( + { internalSchema: debugAttachSchema as unknown as z.ZodType, logicFunction: debug_attach_simLogic, getContext: getDefaultDebuggerToolContext, @@ -199,5 +195,5 @@ export default { { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, ], exclusivePairs: [['simulatorId', 'simulatorName']], - }), -}; + }, +); diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts index 1d8ed9c9..fe6d17c5 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_add.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts @@ -53,16 +53,10 @@ export async function debug_breakpoint_addLogic( } } -export default { - name: 'debug_breakpoint_add', - description: 'Add breakpoint.', - cli: { - stateful: true, - }, - schema: baseSchemaObject.shape, - handler: createTypedToolWithContext( - debugBreakpointAddSchema as unknown as z.ZodType, - debug_breakpoint_addLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = baseSchemaObject.shape; + +export const handler = createTypedToolWithContext( + debugBreakpointAddSchema as unknown as z.ZodType, + debug_breakpoint_addLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts index 35d8b944..53e7b95d 100644 --- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts +++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts @@ -27,16 +27,10 @@ export async function debug_breakpoint_removeLogic( } } -export default { - name: 'debug_breakpoint_remove', - description: 'Remove breakpoint.', - cli: { - stateful: true, - }, - schema: debugBreakpointRemoveSchema.shape, - handler: createTypedToolWithContext( - debugBreakpointRemoveSchema, - debug_breakpoint_removeLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = debugBreakpointRemoveSchema.shape; + +export const handler = createTypedToolWithContext( + debugBreakpointRemoveSchema, + debug_breakpoint_removeLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/debugging/debug_continue.ts b/src/mcp/tools/debugging/debug_continue.ts index ed9db35e..4da697cd 100644 --- a/src/mcp/tools/debugging/debug_continue.ts +++ b/src/mcp/tools/debugging/debug_continue.ts @@ -28,16 +28,10 @@ export async function debug_continueLogic( } } -export default { - name: 'debug_continue', - description: 'Continue debug session.', - cli: { - stateful: true, - }, - schema: debugContinueSchema.shape, - handler: createTypedToolWithContext( - debugContinueSchema, - debug_continueLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = debugContinueSchema.shape; + +export const handler = createTypedToolWithContext( + debugContinueSchema, + debug_continueLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts index a3a6819a..a1bb25ec 100644 --- a/src/mcp/tools/debugging/debug_detach.ts +++ b/src/mcp/tools/debugging/debug_detach.ts @@ -28,16 +28,10 @@ export async function debug_detachLogic( } } -export default { - name: 'debug_detach', - description: 'Detach debugger.', - cli: { - stateful: true, - }, - schema: debugDetachSchema.shape, - handler: createTypedToolWithContext( - debugDetachSchema, - debug_detachLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = debugDetachSchema.shape; + +export const handler = createTypedToolWithContext( + debugDetachSchema, + debug_detachLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts index eadeef89..7e34475e 100644 --- a/src/mcp/tools/debugging/debug_lldb_command.ts +++ b/src/mcp/tools/debugging/debug_lldb_command.ts @@ -33,16 +33,10 @@ export async function debug_lldb_commandLogic( } } -export default { - name: 'debug_lldb_command', - description: 'Run LLDB command.', - cli: { - stateful: true, - }, - schema: baseSchemaObject.shape, - handler: createTypedToolWithContext( - debugLldbCommandSchema as unknown as z.ZodType, - debug_lldb_commandLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = baseSchemaObject.shape; + +export const handler = createTypedToolWithContext( + debugLldbCommandSchema as unknown as z.ZodType, + debug_lldb_commandLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts index 710cf02b..46f149c6 100644 --- a/src/mcp/tools/debugging/debug_stack.ts +++ b/src/mcp/tools/debugging/debug_stack.ts @@ -31,16 +31,10 @@ export async function debug_stackLogic( } } -export default { - name: 'debug_stack', - description: 'Get backtrace.', - cli: { - stateful: true, - }, - schema: debugStackSchema.shape, - handler: createTypedToolWithContext( - debugStackSchema, - debug_stackLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = debugStackSchema.shape; + +export const handler = createTypedToolWithContext( + debugStackSchema, + debug_stackLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts index e9a53bbf..7946b011 100644 --- a/src/mcp/tools/debugging/debug_variables.ts +++ b/src/mcp/tools/debugging/debug_variables.ts @@ -29,16 +29,10 @@ export async function debug_variablesLogic( } } -export default { - name: 'debug_variables', - description: 'Get frame variables.', - cli: { - stateful: true, - }, - schema: debugVariablesSchema.shape, - handler: createTypedToolWithContext( - debugVariablesSchema, - debug_variablesLogic, - getDefaultDebuggerToolContext, - ), -}; +export const schema = debugVariablesSchema.shape; + +export const handler = createTypedToolWithContext( + debugVariablesSchema, + debug_variablesLogic, + getDefaultDebuggerToolContext, +); diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index 592b78c5..aa2153d9 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -11,7 +11,7 @@ import { createMockExecutor, createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; -import buildDevice, { buildDeviceLogic } from '../build_device.ts'; +import { schema, handler, buildDeviceLogic } from '../build_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('build_device plugin', () => { @@ -20,34 +20,28 @@ describe('build_device plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(buildDevice.name).toBe('build_device'); - }); - - it('should have correct description', () => { - expect(buildDevice.description).toBe('Build for device.'); - }); - it('should have handler function', () => { - expect(typeof buildDevice.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose only optional build-tuning fields in public schema', () => { - const schema = z.strictObject(buildDevice.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ extraArgs: [] }).success).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false); - - const schemaKeys = Object.keys(buildDevice.schema).sort(); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ extraArgs: [] }).success).toBe(true); + expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); + expect(schemaObj.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(schemaObj.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe( + false, + ); + + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs']); }); }); describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await buildDevice.handler({ + const result = await handler({ scheme: 'MyScheme', }); @@ -57,7 +51,7 @@ describe('build_device plugin', () => { }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await buildDevice.handler({ + const result = await handler({ projectPath: '/path/to/MyProject.xcodeproj', workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyScheme', @@ -71,7 +65,7 @@ describe('build_device plugin', () => { describe('Parameter Validation (via Handler)', () => { it('should return Zod validation error for missing scheme', async () => { - const result = await buildDevice.handler({ + const result = await handler({ projectPath: '/path/to/MyProject.xcodeproj', }); @@ -81,7 +75,7 @@ describe('build_device plugin', () => { }); it('should return Zod validation error for invalid parameter types', async () => { - const result = await buildDevice.handler({ + const result = await handler({ projectPath: 123, // Should be string scheme: 'MyScheme', }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index fc4935ef..f9534b18 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -10,7 +10,7 @@ import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -import getDeviceAppPath, { get_device_app_pathLogic } from '../get_device_app_path.ts'; +import { schema, handler, get_device_app_pathLogic } from '../get_device_app_path.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('get_device_app_path plugin', () => { @@ -19,32 +19,26 @@ describe('get_device_app_path plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(getDeviceAppPath.name).toBe('get_device_app_path'); - }); - - it('should have correct description', () => { - expect(getDeviceAppPath.description).toBe('Get device built app path.'); - }); - it('should have handler function', () => { - expect(typeof getDeviceAppPath.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose empty public schema', () => { - const schema = z.strictObject(getDeviceAppPath.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); - expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe( + false, + ); - const schemaKeys = Object.keys(getDeviceAppPath.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual([]); }); }); describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await getDeviceAppPath.handler({ + const result = await handler({ scheme: 'MyScheme', }); expect(result.isError).toBe(true); @@ -53,7 +47,7 @@ describe('get_device_app_path plugin', () => { }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await getDeviceAppPath.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', @@ -66,7 +60,7 @@ describe('get_device_app_path plugin', () => { describe('Handler Requirements', () => { it('should require scheme when missing', async () => { - const result = await getDeviceAppPath.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', }); expect(result.isError).toBe(true); @@ -77,7 +71,7 @@ describe('get_device_app_path plugin', () => { it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await getDeviceAppPath.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); }); diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts index 489d2955..0806bb2e 100644 --- a/src/mcp/tools/device/__tests__/install_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import installAppDevice, { install_app_deviceLogic } from '../install_app_device.ts'; +import { schema, handler, install_app_deviceLogic } from '../install_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('install_app_device plugin', () => { @@ -17,7 +17,7 @@ describe('install_app_device plugin', () => { describe('Handler Requirements', () => { it('should require deviceId when session defaults are missing', async () => { - const result = await installAppDevice.handler({ + const result = await handler({ appPath: '/path/to/test.app', }); @@ -27,25 +27,17 @@ describe('install_app_device plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(installAppDevice.name).toBe('install_app_device'); - }); - - it('should have correct description', () => { - expect(installAppDevice.description).toBe('Install app on device.'); - }); - it('should have handler function', () => { - expect(typeof installAppDevice.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should require appPath in public schema', () => { - const schema = z.strictObject(installAppDevice.schema); - expect(schema.safeParse({ appPath: '/path/to/test.app' }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({ appPath: '/path/to/test.app' }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); - expect(Object.keys(installAppDevice.schema)).toEqual(['appPath']); + expect(Object.keys(schema)).toEqual(['appPath']); }); }); diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts index 7f4a83a5..958e1546 100644 --- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts @@ -13,7 +13,7 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import launchAppDevice, { launch_app_deviceLogic } from '../launch_app_device.ts'; +import { schema, handler, launch_app_deviceLogic } from '../launch_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('launch_app_device plugin (device-shared)', () => { @@ -22,35 +22,27 @@ describe('launch_app_device plugin (device-shared)', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(launchAppDevice.name).toBe('launch_app_device'); - }); - - it('should have correct description', () => { - expect(launchAppDevice.description).toBe('Launch app on device.'); - }); - it('should have handler function', () => { - expect(typeof launchAppDevice.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema with valid inputs', () => { - const schema = z.strictObject(launchAppDevice.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(false); - expect(Object.keys(launchAppDevice.schema)).toEqual([]); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(false); + expect(Object.keys(schema)).toEqual([]); }); it('should validate schema with invalid inputs', () => { - const schema = z.strictObject(launchAppDevice.schema); - expect(schema.safeParse({ bundleId: null }).success).toBe(false); - expect(schema.safeParse({ bundleId: 123 }).success).toBe(false); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({ bundleId: null }).success).toBe(false); + expect(schemaObj.safeParse({ bundleId: 123 }).success).toBe(false); }); }); describe('Handler Requirements', () => { it('should require deviceId and bundleId when not provided', async () => { - const result = await launchAppDevice.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts index 4b4e2bf4..d976f934 100644 --- a/src/mcp/tools/device/__tests__/list_devices.test.ts +++ b/src/mcp/tools/device/__tests__/list_devices.test.ts @@ -12,8 +12,8 @@ import { createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -// Import the logic function and re-export -import listDevices, { list_devicesLogic } from '../list_devices.ts'; +// Import the logic function and named exports +import { schema, handler, list_devicesLogic } from '../list_devices.ts'; describe('list_devices plugin (device-shared)', () => { describe('Export Field Validation (Literal)', () => { @@ -21,20 +21,12 @@ describe('list_devices plugin (device-shared)', () => { expect(typeof list_devicesLogic).toBe('function'); }); - it('should have correct name', () => { - expect(listDevices.name).toBe('list_devices'); - }); - - it('should have correct description', () => { - expect(listDevices.description).toBe('List connected devices.'); - }); - it('should have handler function', () => { - expect(typeof listDevices.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have empty schema', () => { - expect(listDevices.schema).toEqual({}); + expect(schema).toEqual({}); }); }); diff --git a/src/mcp/tools/device/__tests__/re-exports.test.ts b/src/mcp/tools/device/__tests__/re-exports.test.ts index 000b88bb..def310cd 100644 --- a/src/mcp/tools/device/__tests__/re-exports.test.ts +++ b/src/mcp/tools/device/__tests__/re-exports.test.ts @@ -1,88 +1,63 @@ /** - * Tests for device-project re-export files - * These files re-export tools from device-workspace to avoid duplication + * Tests for device tool named exports + * Verifies that device tools export schema and handler as named exports */ import { describe, it, expect } from 'vitest'; -// Import all re-export tools -import launchAppDevice from '../launch_app_device.ts'; -import stopAppDevice from '../stop_app_device.ts'; -import listDevices from '../list_devices.ts'; -import installAppDevice from '../install_app_device.ts'; +// Import all tools as modules to check named exports +import * as launchAppDevice from '../launch_app_device.ts'; +import * as stopAppDevice from '../stop_app_device.ts'; +import * as listDevices from '../list_devices.ts'; +import * as installAppDevice from '../install_app_device.ts'; -describe('device-project re-exports', () => { - describe('launch_app_device re-export', () => { - it('should re-export launch_app_device tool correctly', () => { - expect(launchAppDevice.name).toBe('launch_app_device'); - expect(typeof launchAppDevice.handler).toBe('function'); +describe('device tool named exports', () => { + describe('launch_app_device exports', () => { + it('should export schema and handler', () => { expect(launchAppDevice.schema).toBeDefined(); - expect(typeof launchAppDevice.description).toBe('string'); + expect(typeof launchAppDevice.handler).toBe('function'); }); }); - describe('stop_app_device re-export', () => { - it('should re-export stop_app_device tool correctly', () => { - expect(stopAppDevice.name).toBe('stop_app_device'); - expect(typeof stopAppDevice.handler).toBe('function'); + describe('stop_app_device exports', () => { + it('should export schema and handler', () => { expect(stopAppDevice.schema).toBeDefined(); - expect(typeof stopAppDevice.description).toBe('string'); + expect(typeof stopAppDevice.handler).toBe('function'); }); }); - describe('list_devices re-export', () => { - it('should re-export list_devices tool correctly', () => { - expect(listDevices.name).toBe('list_devices'); - expect(typeof listDevices.handler).toBe('function'); + describe('list_devices exports', () => { + it('should export schema and handler', () => { expect(listDevices.schema).toBeDefined(); - expect(typeof listDevices.description).toBe('string'); + expect(typeof listDevices.handler).toBe('function'); }); }); - describe('install_app_device re-export', () => { - it('should re-export install_app_device tool correctly', () => { - expect(installAppDevice.name).toBe('install_app_device'); - expect(typeof installAppDevice.handler).toBe('function'); + describe('install_app_device exports', () => { + it('should export schema and handler', () => { expect(installAppDevice.schema).toBeDefined(); - expect(typeof installAppDevice.description).toBe('string'); + expect(typeof installAppDevice.handler).toBe('function'); }); }); - describe('All re-exports validation', () => { - const reExports = [ - { tool: launchAppDevice, name: 'launch_app_device' }, - { tool: stopAppDevice, name: 'stop_app_device' }, - { tool: listDevices, name: 'list_devices' }, - { tool: installAppDevice, name: 'install_app_device' }, + describe('All exports validation', () => { + const modules = [ + { mod: launchAppDevice, name: 'launch_app_device' }, + { mod: stopAppDevice, name: 'stop_app_device' }, + { mod: listDevices, name: 'list_devices' }, + { mod: installAppDevice, name: 'install_app_device' }, ]; - it('should have all required tool properties', () => { - reExports.forEach(({ tool, name }) => { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('schema'); - expect(tool).toHaveProperty('handler'); - expect(tool.name).toBe(name); - }); - }); - it('should have callable handlers', () => { - reExports.forEach(({ tool, name }) => { - expect(typeof tool.handler).toBe('function'); - expect(tool.handler.length).toBeGreaterThanOrEqual(0); + modules.forEach(({ mod }) => { + expect(typeof mod.handler).toBe('function'); + expect(mod.handler.length).toBeGreaterThanOrEqual(0); }); }); it('should have valid schemas', () => { - reExports.forEach(({ tool, name }) => { - expect(tool.schema).toBeDefined(); - expect(typeof tool.schema).toBe('object'); - }); - }); - - it('should have non-empty descriptions', () => { - reExports.forEach(({ tool, name }) => { - expect(typeof tool.description).toBe('string'); - expect(tool.description.length).toBeGreaterThan(0); + modules.forEach(({ mod }) => { + expect(mod.schema).toBeDefined(); + expect(typeof mod.schema).toBe('object'); }); }); }); diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts index 6d870297..0ae186c4 100644 --- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts +++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; -import stopAppDevice, { stop_app_deviceLogic } from '../stop_app_device.ts'; +import { schema, handler, stop_app_deviceLogic } from '../stop_app_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('stop_app_device plugin', () => { @@ -16,31 +16,23 @@ describe('stop_app_device plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(stopAppDevice.name).toBe('stop_app_device'); - }); - - it('should have correct description', () => { - expect(stopAppDevice.description).toBe('Stop device app.'); - }); - it('should have handler function', () => { - expect(typeof stopAppDevice.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should require processId in public schema', () => { - const schema = z.strictObject(stopAppDevice.schema); - expect(schema.safeParse({ processId: 12345 }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({ processId: 12345 }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ deviceId: 'test-device-123' }).success).toBe(false); - expect(Object.keys(stopAppDevice.schema)).toEqual(['processId']); + expect(Object.keys(schema)).toEqual(['processId']); }); }); describe('Handler Requirements', () => { it('should require deviceId when not provided', async () => { - const result = await stopAppDevice.handler({ processId: 12345 }); + const result = await handler({ processId: 12345 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('deviceId is required'); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index 6468dcf6..1179dda4 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -12,7 +12,7 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import testDevice, { testDeviceLogic } from '../test_device.ts'; +import { schema, handler, testDeviceLogic } from '../test_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('test_device plugin', () => { @@ -21,33 +21,27 @@ describe('test_device plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(testDevice.name).toBe('test_device'); - }); - - it('should have correct description', () => { - expect(testDevice.description).toBe('Test on device.'); - }); - it('should have handler function', () => { - expect(typeof testDevice.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose only session-free fields in public schema', () => { - const schema = z.strictObject(testDevice.schema); + const schemaObj = z.strictObject(schema); expect( - schema.safeParse({ + schemaObj.safeParse({ extraArgs: ['--arg1'], testRunnerEnv: { FOO: 'bar' }, }).success, ).toBe(true); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); - expect(schema.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe(false); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); + expect(schemaObj.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe( + false, + ); - const schemaKeys = Object.keys(testDevice.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv']); }); @@ -105,7 +99,7 @@ describe('test_device plugin', () => { describe('Handler Requirements', () => { it('should require scheme and device defaults', async () => { - const result = await testDevice.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -115,7 +109,7 @@ describe('test_device plugin', () => { it('should require project or workspace when defaults provide scheme and device', async () => { sessionStore.setDefaults({ scheme: 'MyScheme', deviceId: 'test-device-123' }); - const result = await testDevice.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -124,7 +118,7 @@ describe('test_device plugin', () => { it('should reject mutually exclusive project inputs when defaults satisfy requirements', async () => { sessionStore.setDefaults({ scheme: 'MyScheme', deviceId: 'test-device-123' }); - const result = await testDevice.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index 336ed317..fd649122 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -75,25 +75,18 @@ export async function buildDeviceLogic( ); } -export default { - name: 'build_device', - description: 'Build for device.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Build Device', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: buildDeviceSchema as unknown as z.ZodType, - logicFunction: buildDeviceLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buildDeviceSchema as unknown as z.ZodType, + logicFunction: buildDeviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 77ef6d76..99375e1d 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -165,25 +165,18 @@ export async function get_device_app_pathLogic( } } -export default { - name: 'get_device_app_path', - description: 'Get device built app path.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Get Device App Path', - readOnlyHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: getDeviceAppPathSchema as unknown as z.ZodType, - logicFunction: get_device_app_pathLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: getDeviceAppPathSchema as unknown as z.ZodType, + logicFunction: get_device_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts index a7f22c77..3cd6d133 100644 --- a/src/mcp/tools/device/install_app_device.ts +++ b/src/mcp/tools/device/install_app_device.ts @@ -83,21 +83,14 @@ export async function install_app_deviceLogic( } } -export default { - name: 'install_app_device', - description: 'Install app on device.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: installAppDeviceSchema, - }), - annotations: { - title: 'Install App Device', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: installAppDeviceSchema as unknown as z.ZodType, - logicFunction: install_app_deviceLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: installAppDeviceSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: installAppDeviceSchema as unknown as z.ZodType, + logicFunction: install_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], +}); diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts index bcc77d2c..987629d4 100644 --- a/src/mcp/tools/device/launch_app_device.ts +++ b/src/mcp/tools/device/launch_app_device.ts @@ -162,22 +162,15 @@ export async function launch_app_deviceLogic( } } -export default { - name: 'launch_app_device', - description: 'Launch app on device.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: launchAppDeviceSchema, - }), - annotations: { - title: 'Launch App Device', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: launchAppDeviceSchema as unknown as z.ZodType, - logicFunction: (params, executor) => - launch_app_deviceLogic(params, executor, getDefaultFileSystemExecutor()), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['deviceId', 'bundleId'], message: 'Provide deviceId and bundleId' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: launchAppDeviceSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: launchAppDeviceSchema as unknown as z.ZodType, + logicFunction: (params, executor) => + launch_app_deviceLogic(params, executor, getDefaultFileSystemExecutor()), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId', 'bundleId'], message: 'Provide deviceId and bundleId' }], +}); diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts index 82106cf4..4b2109ad 100644 --- a/src/mcp/tools/device/list_devices.ts +++ b/src/mcp/tools/device/list_devices.ts @@ -452,13 +452,10 @@ export async function list_devicesLogic( } } -export default { - name: 'list_devices', - description: 'List connected devices.', - schema: listDevicesSchema.shape, // MCP SDK compatibility - annotations: { - title: 'List Devices', - readOnlyHint: true, - }, - handler: createTypedTool(listDevicesSchema, list_devicesLogic, getDefaultCommandExecutor), -}; +export const schema = listDevicesSchema.shape; + +export const handler = createTypedTool( + listDevicesSchema, + list_devicesLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts index f4c91c23..4bbf4319 100644 --- a/src/mcp/tools/device/stop_app_device.ts +++ b/src/mcp/tools/device/stop_app_device.ts @@ -87,21 +87,14 @@ export async function stop_app_deviceLogic( } } -export default { - name: 'stop_app_device', - description: 'Stop device app.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: stopAppDeviceSchema, - }), - annotations: { - title: 'Stop App Device', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: stopAppDeviceSchema as unknown as z.ZodType, - logicFunction: stop_app_deviceLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: stopAppDeviceSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: stopAppDeviceSchema as unknown as z.ZodType, + logicFunction: stop_app_deviceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId'], message: 'deviceId is required' }], +}); diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index 717dc27b..257c7724 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -286,33 +286,26 @@ export async function testDeviceLogic( } } -export default { - name: 'test_device', - description: 'Test on device.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Test Device', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: testDeviceSchema as unknown as z.ZodType, - logicFunction: (params: TestDeviceParams, executor: CommandExecutor) => - testDeviceLogic( - { - ...params, - platform: params.platform ?? 'iOS', - }, - executor, - getDefaultFileSystemExecutor(), - ), - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: testDeviceSchema as unknown as z.ZodType, + logicFunction: (params: TestDeviceParams, executor: CommandExecutor) => + testDeviceLogic( + { + ...params, + platform: params.platform ?? 'iOS', + }, + executor, + getDefaultFileSystemExecutor(), + ), + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme', 'deviceId'], message: 'Provide scheme and deviceId' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/doctor/__tests__/doctor.test.ts b/src/mcp/tools/doctor/__tests__/doctor.test.ts index 852787c4..862e7bac 100644 --- a/src/mcp/tools/doctor/__tests__/doctor.test.ts +++ b/src/mcp/tools/doctor/__tests__/doctor.test.ts @@ -4,9 +4,9 @@ * Using dependency injection for deterministic testing */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import doctor, { runDoctor, type DoctorDependencies } from '../doctor.ts'; +import { schema, runDoctor, type DoctorDependencies } from '../doctor.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; function createDeps(overrides?: Partial): DoctorDependencies { @@ -122,33 +122,19 @@ function createDeps(overrides?: Partial): DoctorDependencies } describe('doctor tool', () => { - // Reset any state if needed - - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(doctor.name).toBe('doctor'); - }); - - it('should have correct description', () => { - expect(doctor.description).toBe('MCP environment info.'); - }); - - it('should have handler function', () => { - expect(typeof doctor.handler).toBe('function'); - }); - + describe('Schema Validation', () => { it('should have correct schema with enabled boolean field', () => { - const schema = z.object(doctor.schema); + const schemaObj = z.object(schema); // Valid inputs - expect(schema.safeParse({ enabled: true }).success).toBe(true); - expect(schema.safeParse({ enabled: false }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(true); // enabled is optional + expect(schemaObj.safeParse({ enabled: true }).success).toBe(true); + expect(schemaObj.safeParse({ enabled: false }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); // enabled is optional // Invalid inputs - expect(schema.safeParse({ enabled: 'true' }).success).toBe(false); - expect(schema.safeParse({ enabled: 1 }).success).toBe(false); - expect(schema.safeParse({ enabled: null }).success).toBe(false); + expect(schemaObj.safeParse({ enabled: 'true' }).success).toBe(false); + expect(schemaObj.safeParse({ enabled: 1 }).success).toBe(false); + expect(schemaObj.safeParse({ enabled: null }).success).toBe(false); }); }); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index c85d3e40..3e2fd83d 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -384,15 +384,8 @@ async function doctorMcpHandler( return doctorLogic(params, executor, false); // Always false for MCP } -export default { - name: 'doctor', - description: 'MCP environment info.', - schema: doctorSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Doctor', - readOnlyHint: true, - }, - handler: createTypedTool(doctorSchema, doctorMcpHandler, getDefaultCommandExecutor), -}; +export const schema = doctorSchema.shape; // MCP SDK compatibility + +export const handler = createTypedTool(doctorSchema, doctorMcpHandler, getDefaultCommandExecutor); export type { DoctorDependencies } from './lib/doctor.deps.ts'; diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index 03dc2019..4be522b0 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -11,7 +11,7 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import plugin, { start_device_log_capLogic } from '../start_device_log_cap.ts'; +import { schema, handler, start_device_log_capLogic } from '../start_device_log_cap.ts'; import { activeDeviceLogSessions } from '../../../../utils/log-capture/device-log-sessions.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { @@ -54,40 +54,30 @@ describe('start_device_log_cap plugin', () => { }); describe('Plugin Structure', () => { - it('should export an object with required properties', () => { - expect(plugin).toHaveProperty('name'); - expect(plugin).toHaveProperty('description'); - expect(plugin).toHaveProperty('schema'); - expect(plugin).toHaveProperty('handler'); - }); - - it('should have correct tool name', () => { - expect(plugin.name).toBe('start_device_log_cap'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Start device log capture.'); + it('should export schema and handler', () => { + expect(schema).toBeDefined(); + expect(handler).toBeDefined(); }); it('should have correct schema structure', () => { // Schema should be a plain object for MCP protocol compliance - expect(typeof plugin.schema).toBe('object'); - expect(Object.keys(plugin.schema)).toEqual([]); + expect(typeof schema).toBe('object'); + expect(Object.keys(schema)).toEqual([]); // Validate that schema fields are Zod types that can be used for validation - const schema = z.strictObject(plugin.schema); - expect(schema.safeParse({ bundleId: 'com.test.app' }).success).toBe(false); - expect(schema.safeParse({}).success).toBe(true); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({ bundleId: 'com.test.app' }).success).toBe(false); + expect(schemaObj.safeParse({}).success).toBe(true); }); it('should have handler as a function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); }); describe('Handler Requirements', () => { it('should require deviceId and bundleId when not provided', async () => { - const result = await plugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts index d0a77114..bf42b977 100644 --- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts @@ -3,62 +3,52 @@ */ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import plugin, { start_sim_log_capLogic } from '../start_sim_log_cap.ts'; +import { schema, handler, start_sim_log_capLogic } from '../start_sim_log_cap.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; describe('start_sim_log_cap plugin', () => { // Reset any test state if needed describe('Export Field Validation (Literal)', () => { - it('should export an object with required properties', () => { - expect(plugin).toHaveProperty('name'); - expect(plugin).toHaveProperty('description'); - expect(plugin).toHaveProperty('schema'); - expect(plugin).toHaveProperty('handler'); - }); - - it('should have correct tool name', () => { - expect(plugin.name).toBe('start_sim_log_cap'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Start sim log capture.'); + it('should export schema and handler', () => { + expect(schema).toBeDefined(); + expect(handler).toBeDefined(); }); it('should have handler as a function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema with valid parameters', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ captureConsole: true }).success).toBe(true); - expect(schema.safeParse({ captureConsole: false }).success).toBe(true); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ captureConsole: true }).success).toBe(true); + expect(schemaObj.safeParse({ captureConsole: false }).success).toBe(true); }); it('should validate schema with subsystemFilter parameter', () => { - const schema = z.object(plugin.schema); + const schemaObj = z.object(schema); // Valid enum values - expect(schema.safeParse({ subsystemFilter: 'app' }).success).toBe(true); - expect(schema.safeParse({ subsystemFilter: 'all' }).success).toBe(true); - expect(schema.safeParse({ subsystemFilter: 'swiftui' }).success).toBe(true); + expect(schemaObj.safeParse({ subsystemFilter: 'app' }).success).toBe(true); + expect(schemaObj.safeParse({ subsystemFilter: 'all' }).success).toBe(true); + expect(schemaObj.safeParse({ subsystemFilter: 'swiftui' }).success).toBe(true); // Valid array of subsystems - expect(schema.safeParse({ subsystemFilter: ['com.apple.UIKit'] }).success).toBe(true); + expect(schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit'] }).success).toBe(true); expect( - schema.safeParse({ subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'] }).success, + schemaObj.safeParse({ subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'] }).success, ).toBe(true); // Invalid values - expect(schema.safeParse({ subsystemFilter: [] }).success).toBe(false); - expect(schema.safeParse({ subsystemFilter: 'invalid' }).success).toBe(false); - expect(schema.safeParse({ subsystemFilter: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ subsystemFilter: [] }).success).toBe(false); + expect(schemaObj.safeParse({ subsystemFilter: 'invalid' }).success).toBe(false); + expect(schemaObj.safeParse({ subsystemFilter: 123 }).success).toBe(false); }); it('should reject invalid schema parameters', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({ captureConsole: 'yes' }).success).toBe(false); - expect(schema.safeParse({ captureConsole: 123 }).success).toBe(false); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ captureConsole: 'yes' }).success).toBe(false); + expect(schemaObj.safeParse({ captureConsole: 123 }).success).toBe(false); - const withSimId = schema.safeParse({ simulatorId: 'test-uuid' }); + const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid' }); expect(withSimId.success).toBe(true); expect('simulatorId' in (withSimId.data as any)).toBe(false); }); diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts index 60d4c229..df805eda 100644 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import * as z from 'zod'; -import plugin, { stop_device_log_capLogic } from '../stop_device_log_cap.ts'; +import { schema, handler, stop_device_log_capLogic } from '../stop_device_log_cap.ts'; import { activeDeviceLogSessions, type DeviceLogSession, @@ -20,34 +20,24 @@ describe('stop_device_log_cap plugin', () => { }); describe('Plugin Structure', () => { - it('should export an object with required properties', () => { - expect(plugin).toHaveProperty('name'); - expect(plugin).toHaveProperty('description'); - expect(plugin).toHaveProperty('schema'); - expect(plugin).toHaveProperty('handler'); - }); - - it('should have correct tool name', () => { - expect(plugin.name).toBe('stop_device_log_cap'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Stop device app and return logs.'); + it('should export schema and handler', () => { + expect(schema).toBeDefined(); + expect(handler).toBeDefined(); }); it('should have correct schema structure', () => { // Schema should be a plain object for MCP protocol compliance - expect(typeof plugin.schema).toBe('object'); - expect(plugin.schema).toHaveProperty('logSessionId'); + expect(typeof schema).toBe('object'); + expect(schema).toHaveProperty('logSessionId'); // Validate that schema fields are Zod types that can be used for validation - const schema = z.object(plugin.schema); - expect(schema.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); - expect(schema.safeParse({ logSessionId: 123 }).success).toBe(false); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); + expect(schemaObj.safeParse({ logSessionId: 123 }).success).toBe(false); }); it('should have handler as a function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); }); diff --git a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts index b3c37504..ceefa5b2 100644 --- a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import stopSimLogCap, { stop_sim_log_capLogic } from '../stop_sim_log_cap.ts'; +import { schema, handler, stop_sim_log_capLogic } from '../stop_sim_log_cap.ts'; import { createMockExecutor, createMockFileSystemExecutor, @@ -24,38 +24,33 @@ describe('stop_sim_log_cap plugin', () => { const mockFileSystem = createMockFileSystemExecutor(); describe('Export Field Validation (Literal)', () => { - it('should have correct plugin structure', () => { - expect(stopSimLogCap).toHaveProperty('name'); - expect(stopSimLogCap).toHaveProperty('description'); - expect(stopSimLogCap).toHaveProperty('schema'); - expect(stopSimLogCap).toHaveProperty('handler'); - - expect(stopSimLogCap.name).toBe('stop_sim_log_cap'); - expect(stopSimLogCap.description).toBe('Stop sim app and return logs.'); - expect(typeof stopSimLogCap.handler).toBe('function'); - expect(typeof stopSimLogCap.schema).toBe('object'); + it('should export schema and handler', () => { + expect(schema).toBeDefined(); + expect(handler).toBeDefined(); + expect(typeof handler).toBe('function'); + expect(typeof schema).toBe('object'); }); it('should have correct schema structure', () => { // Schema should be a plain object for MCP protocol compliance - expect(typeof stopSimLogCap.schema).toBe('object'); - expect(stopSimLogCap.schema).toHaveProperty('logSessionId'); + expect(typeof schema).toBe('object'); + expect(schema).toHaveProperty('logSessionId'); // Validate that schema fields are Zod types that can be used for validation - const schema = z.object(stopSimLogCap.schema); - expect(schema.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); - expect(schema.safeParse({ logSessionId: 123 }).success).toBe(false); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ logSessionId: 'test-session-id' }).success).toBe(true); + expect(schemaObj.safeParse({ logSessionId: 123 }).success).toBe(false); }); it('should validate schema with valid parameters', () => { - expect(stopSimLogCap.schema.logSessionId.safeParse('test-session-id').success).toBe(true); + expect(schema.logSessionId.safeParse('test-session-id').success).toBe(true); }); it('should reject invalid schema parameters', () => { - expect(stopSimLogCap.schema.logSessionId.safeParse(null).success).toBe(false); - expect(stopSimLogCap.schema.logSessionId.safeParse(undefined).success).toBe(false); - expect(stopSimLogCap.schema.logSessionId.safeParse(123).success).toBe(false); - expect(stopSimLogCap.schema.logSessionId.safeParse(true).success).toBe(false); + expect(schema.logSessionId.safeParse(null).success).toBe(false); + expect(schema.logSessionId.safeParse(undefined).success).toBe(false); + expect(schema.logSessionId.safeParse(123).success).toBe(false); + expect(schema.logSessionId.safeParse(true).success).toBe(false); }); }); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 51bb5831..76d0315e 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -671,27 +671,14 @@ export async function start_device_log_capLogic( }; } -export default { - name: 'start_device_log_cap', - description: 'Start device log capture.', - cli: { - stateful: true, - }, - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: startDeviceLogCapSchema, - }), - annotations: { - title: 'Start Device Log Capture', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: startDeviceLogCapSchema as unknown as z.ZodType< - StartDeviceLogCapParams, - unknown - >, - logicFunction: start_device_log_capLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['deviceId', 'bundleId'], message: 'Provide deviceId and bundleId' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: startDeviceLogCapSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: startDeviceLogCapSchema as unknown as z.ZodType, + logicFunction: start_device_log_capLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['deviceId', 'bundleId'], message: 'Provide deviceId and bundleId' }], +}); diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts index 8b49475e..dbb06cda 100644 --- a/src/mcp/tools/logging/start_sim_log_cap.ts +++ b/src/mcp/tools/logging/start_sim_log_cap.ts @@ -93,26 +93,16 @@ const publicSchemaObject = z.strictObject( startSimLogCapSchema.omit({ simulatorId: true, bundleId: true } as const).shape, ); -export default { - name: 'start_sim_log_cap', - description: 'Start sim log capture.', - cli: { - stateful: true, - }, - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: startSimLogCapSchema, - }), - annotations: { - title: 'Start Simulator Log Capture', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: startSimLogCapSchema as unknown as z.ZodType, - logicFunction: start_sim_log_capLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['simulatorId', 'bundleId'], message: 'Provide simulatorId and bundleId' }, - ], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: startSimLogCapSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: startSimLogCapSchema as unknown as z.ZodType, + logicFunction: start_sim_log_capLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['simulatorId', 'bundleId'], message: 'Provide simulatorId and bundleId' }, + ], +}); diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index e9e51169..8ecb1b51 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -326,22 +326,12 @@ export async function stopDeviceLogCapture( return { logContent }; } -export default { - name: 'stop_device_log_cap', - description: 'Stop device app and return logs.', - cli: { - stateful: true, - }, - schema: stopDeviceLogCapSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Stop Device and Return Logs', - destructiveHint: true, +export const schema = stopDeviceLogCapSchema.shape; // MCP SDK compatibility + +export const handler = createTypedTool( + stopDeviceLogCapSchema, + (params: StopDeviceLogCapParams) => { + return stop_device_log_capLogic(params, getDefaultFileSystemExecutor()); }, - handler: createTypedTool( - stopDeviceLogCapSchema, - (params: StopDeviceLogCapParams) => { - return stop_device_log_capLogic(params, getDefaultFileSystemExecutor()); - }, - getDefaultCommandExecutor, - ), -}; + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index 2e820d7d..c6995b1d 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -53,21 +53,11 @@ export async function stop_sim_log_capLogic( }; } -export default { - name: 'stop_sim_log_cap', - description: 'Stop sim app and return logs.', - cli: { - stateful: true, - }, - schema: stopSimLogCapSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Stop Simulator and Return Logs', - destructiveHint: true, - }, - handler: createTypedTool( - stopSimLogCapSchema, - (params: StopSimLogCapParams, executor: CommandExecutor) => - stop_sim_log_capLogic(params, executor), - getDefaultCommandExecutor, - ), -}; +export const schema = stopSimLogCapSchema.shape; // MCP SDK compatibility + +export const handler = createTypedTool( + stopSimLogCapSchema, + (params: StopSimLogCapParams, executor: CommandExecutor) => + stop_sim_log_capLogic(params, executor), + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/macos/__tests__/build_macos.test.ts b/src/mcp/tools/macos/__tests__/build_macos.test.ts index dbd1ea7b..38ab2f52 100644 --- a/src/mcp/tools/macos/__tests__/build_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_macos.test.ts @@ -9,7 +9,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import buildMacOS, { buildMacOSLogic } from '../build_macos.ts'; +import { schema, handler } from '../build_macos.ts'; +import { buildMacOSLogic } from '../build_macos.ts'; describe('build_macos plugin', () => { beforeEach(() => { @@ -17,36 +18,28 @@ describe('build_macos plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(buildMacOS.name).toBe('build_macos'); - }); - - it('should have correct description', () => { - expect(buildMacOS.description).toBe('Build macOS app.'); - }); - it('should have handler function', () => { - expect(typeof buildMacOS.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - const schema = z.strictObject(buildMacOS.schema); + const zodSchema = z.strictObject(schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ extraArgs: ['--arg1', '--arg2'] }).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(true); + expect(zodSchema.safeParse({ extraArgs: ['--arg1', '--arg2'] }).success).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); - expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(zodSchema.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); + expect(zodSchema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); + expect(zodSchema.safeParse({ preferXcodebuild: true }).success).toBe(false); - const schemaKeys = Object.keys(buildMacOS.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs']); }); }); describe('Handler Requirements', () => { it('should require scheme when no defaults provided', async () => { - const result = await buildMacOS.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); @@ -56,7 +49,7 @@ describe('build_macos plugin', () => { it('should require project or workspace once scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await buildMacOS.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -65,7 +58,7 @@ describe('build_macos plugin', () => { it('should reject when both projectPath and workspacePath provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await buildMacOS.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); @@ -435,13 +428,13 @@ describe('build_macos plugin', () => { describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await buildMacOS.handler({ scheme: 'MyScheme' }); + const result = await handler({ scheme: 'MyScheme' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await buildMacOS.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts index 64d0b113..552710a2 100644 --- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import tool, { buildRunMacOSLogic } from '../build_run_macos.ts'; +import { schema, handler } from '../build_run_macos.ts'; +import { buildRunMacOSLogic } from '../build_run_macos.ts'; describe('build_run_macos', () => { beforeEach(() => { @@ -10,36 +11,28 @@ describe('build_run_macos', () => { }); describe('Export Field Validation (Literal)', () => { - it('should export the correct name', () => { - expect(tool.name).toBe('build_run_macos'); - }); - - it('should export the correct description', () => { - expect(tool.description).toBe('Build and run macOS app.'); - }); - it('should export a handler function', () => { - expect(typeof tool.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose only non-session fields in schema', () => { - const schema = z.strictObject(tool.schema); + const zodSchema = z.strictObject(schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ extraArgs: ['--verbose'] }).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(true); + expect(zodSchema.safeParse({ extraArgs: ['--verbose'] }).success).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/tmp/derived' }).success).toBe(false); - expect(schema.safeParse({ extraArgs: ['--ok', 2] }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(zodSchema.safeParse({ derivedDataPath: '/tmp/derived' }).success).toBe(false); + expect(zodSchema.safeParse({ extraArgs: ['--ok', 2] }).success).toBe(false); + expect(zodSchema.safeParse({ preferXcodebuild: true }).success).toBe(false); - const schemaKeys = Object.keys(tool.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs']); }); }); describe('Handler Requirements', () => { it('should require scheme before executing', async () => { - const result = await tool.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); @@ -48,7 +41,7 @@ describe('build_run_macos', () => { it('should require project or workspace once scheme is set', async () => { sessionStore.setDefaults({ scheme: 'MyApp' }); - const result = await tool.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -57,7 +50,7 @@ describe('build_run_macos', () => { it('should fail when both project and workspace provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyApp' }); - const result = await tool.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts index 4dcfa168..a26cd47f 100644 --- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts +++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts @@ -11,7 +11,8 @@ import { type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import getMacAppPath, { get_mac_app_pathLogic } from '../get_mac_app_path.ts'; +import { schema, handler } from '../get_mac_app_path.ts'; +import { get_mac_app_pathLogic } from '../get_mac_app_path.ts'; describe('get_mac_app_path plugin', () => { beforeEach(() => { @@ -19,40 +20,32 @@ describe('get_mac_app_path plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(getMacAppPath.name).toBe('get_mac_app_path'); - }); - - it('should have correct description', () => { - expect(getMacAppPath.description).toBe('Get macOS built app path.'); - }); - it('should have handler function', () => { - expect(typeof getMacAppPath.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - const schema = z.object(getMacAppPath.schema); + const zodSchema = z.object(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + zodSchema.safeParse({ derivedDataPath: '/path/to/derived', extraArgs: ['--verbose'], }).success, ).toBe(true); - expect(schema.safeParse({ derivedDataPath: 7 }).success).toBe(false); - expect(schema.safeParse({ extraArgs: ['--bad', 1] }).success).toBe(false); + expect(zodSchema.safeParse({ derivedDataPath: 7 }).success).toBe(false); + expect(zodSchema.safeParse({ extraArgs: ['--bad', 1] }).success).toBe(false); - const schemaKeys = Object.keys(getMacAppPath.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['derivedDataPath', 'extraArgs'].sort()); }); }); describe('Handler Requirements', () => { it('should require scheme before running', async () => { - const result = await getMacAppPath.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); @@ -61,7 +54,7 @@ describe('get_mac_app_path plugin', () => { it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await getMacAppPath.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -70,7 +63,7 @@ describe('get_mac_app_path plugin', () => { it('should reject when both projectPath and workspacePath provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await getMacAppPath.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); @@ -82,7 +75,7 @@ describe('get_mac_app_path plugin', () => { describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await getMacAppPath.handler({ + const result = await handler({ scheme: 'MyScheme', }); @@ -91,7 +84,7 @@ describe('get_mac_app_path plugin', () => { }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await getMacAppPath.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', @@ -351,7 +344,7 @@ describe('get_mac_app_path plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should return Zod validation error for missing scheme', async () => { - const result = await getMacAppPath.handler({ + const result = await handler({ workspacePath: '/path/to/MyProject.xcworkspace', }); diff --git a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts index 59cba47a..39e5ee5d 100644 --- a/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/launch_mac_app.test.ts @@ -13,37 +13,30 @@ import { createMockCommandResponse, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; -import launchMacApp, { launch_mac_appLogic } from '../launch_mac_app.ts'; +import { schema, handler } from '../launch_mac_app.ts'; +import { launch_mac_appLogic } from '../launch_mac_app.ts'; describe('launch_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(launchMacApp.name).toBe('launch_mac_app'); - }); - - it('should have correct description', () => { - expect(launchMacApp.description).toBe('Launch macOS app.'); - }); - it('should have handler function', () => { - expect(typeof launchMacApp.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema with valid inputs', () => { - const schema = z.object(launchMacApp.schema); + const zodSchema = z.object(schema); expect( - schema.safeParse({ + zodSchema.safeParse({ appPath: '/path/to/MyApp.app', }).success, ).toBe(true); expect( - schema.safeParse({ + zodSchema.safeParse({ appPath: '/Applications/Calculator.app', args: ['--debug'], }).success, ).toBe(true); expect( - schema.safeParse({ + zodSchema.safeParse({ appPath: '/path/to/MyApp.app', args: ['--debug', '--verbose'], }).success, @@ -51,13 +44,13 @@ describe('launch_mac_app plugin', () => { }); it('should validate schema with invalid inputs', () => { - const schema = z.object(launchMacApp.schema); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ appPath: null }).success).toBe(false); - expect(schema.safeParse({ appPath: 123 }).success).toBe(false); - expect(schema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success).toBe( - false, - ); + const zodSchema = z.object(schema); + expect(zodSchema.safeParse({}).success).toBe(false); + expect(zodSchema.safeParse({ appPath: null }).success).toBe(false); + expect(zodSchema.safeParse({ appPath: 123 }).success).toBe(false); + expect( + zodSchema.safeParse({ appPath: '/path/to/MyApp.app', args: 'not-array' }).success, + ).toBe(false); }); }); diff --git a/src/mcp/tools/macos/__tests__/re-exports.test.ts b/src/mcp/tools/macos/__tests__/re-exports.test.ts index f8f31ca6..ee4540c2 100644 --- a/src/mcp/tools/macos/__tests__/re-exports.test.ts +++ b/src/mcp/tools/macos/__tests__/re-exports.test.ts @@ -1,88 +1,94 @@ /** - * Tests for macos-project re-export files - * These files re-export tools from macos-workspace to avoid duplication + * Tests for macos tool module exports + * Validates that tools export the required named exports (schema, handler) + * Note: name and description are now defined in manifests, not in modules */ import { describe, it, expect } from 'vitest'; -// Import all re-export tools -import testMacos from '../test_macos.ts'; -import buildMacos from '../build_macos.ts'; -import buildRunMacos from '../build_run_macos.ts'; -import getMacAppPath from '../get_mac_app_path.ts'; +// Import all tool modules using named exports +import * as testMacos from '../test_macos.ts'; +import * as buildMacos from '../build_macos.ts'; +import * as buildRunMacos from '../build_run_macos.ts'; +import * as getMacAppPath from '../get_mac_app_path.ts'; +import * as launchMacApp from '../launch_mac_app.ts'; +import * as stopMacApp from '../stop_mac_app.ts'; -describe('macos-project re-exports', () => { - describe('test_macos re-export', () => { - it('should re-export test_macos tool correctly', () => { - expect(testMacos.name).toBe('test_macos'); +describe('macos tool module exports', () => { + describe('test_macos exports', () => { + it('should export schema and handler', () => { expect(typeof testMacos.handler).toBe('function'); expect(testMacos.schema).toBeDefined(); - expect(typeof testMacos.description).toBe('string'); + expect(typeof testMacos.schema).toBe('object'); }); }); - describe('build_macos re-export', () => { - it('should re-export build_macos tool correctly', () => { - expect(buildMacos.name).toBe('build_macos'); + describe('build_macos exports', () => { + it('should export schema and handler', () => { expect(typeof buildMacos.handler).toBe('function'); expect(buildMacos.schema).toBeDefined(); - expect(typeof buildMacos.description).toBe('string'); + expect(typeof buildMacos.schema).toBe('object'); }); }); - describe('build_run_macos re-export', () => { - it('should re-export build_run_macos tool correctly', () => { - expect(buildRunMacos.name).toBe('build_run_macos'); + describe('build_run_macos exports', () => { + it('should export schema and handler', () => { expect(typeof buildRunMacos.handler).toBe('function'); expect(buildRunMacos.schema).toBeDefined(); - expect(typeof buildRunMacos.description).toBe('string'); + expect(typeof buildRunMacos.schema).toBe('object'); }); }); - describe('get_mac_app_path re-export', () => { - it('should re-export get_mac_app_path tool correctly', () => { - expect(getMacAppPath.name).toBe('get_mac_app_path'); + describe('get_mac_app_path exports', () => { + it('should export schema and handler', () => { expect(typeof getMacAppPath.handler).toBe('function'); expect(getMacAppPath.schema).toBeDefined(); - expect(typeof getMacAppPath.description).toBe('string'); + expect(typeof getMacAppPath.schema).toBe('object'); }); }); - describe('All re-exports validation', () => { - const reExports = [ - { tool: testMacos, name: 'test_macos' }, - { tool: buildMacos, name: 'build_macos' }, - { tool: buildRunMacos, name: 'build_run_macos' }, - { tool: getMacAppPath, name: 'get_mac_app_path' }, + describe('launch_mac_app exports', () => { + it('should export schema and handler', () => { + expect(typeof launchMacApp.handler).toBe('function'); + expect(launchMacApp.schema).toBeDefined(); + expect(typeof launchMacApp.schema).toBe('object'); + }); + }); + + describe('stop_mac_app exports', () => { + it('should export schema and handler', () => { + expect(typeof stopMacApp.handler).toBe('function'); + expect(stopMacApp.schema).toBeDefined(); + expect(typeof stopMacApp.schema).toBe('object'); + }); + }); + + describe('All tool modules validation', () => { + const toolModules = [ + { module: testMacos, name: 'test_macos' }, + { module: buildMacos, name: 'build_macos' }, + { module: buildRunMacos, name: 'build_run_macos' }, + { module: getMacAppPath, name: 'get_mac_app_path' }, + { module: launchMacApp, name: 'launch_mac_app' }, + { module: stopMacApp, name: 'stop_mac_app' }, ]; - it('should have all required tool properties', () => { - reExports.forEach(({ tool, name }) => { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('schema'); - expect(tool).toHaveProperty('handler'); - expect(tool.name).toBe(name); + it('should have all required exports', () => { + toolModules.forEach(({ module, name }) => { + expect(module).toHaveProperty('schema'); + expect(module).toHaveProperty('handler'); }); }); it('should have callable handlers', () => { - reExports.forEach(({ tool, name }) => { - expect(typeof tool.handler).toBe('function'); - expect(tool.handler.length).toBeGreaterThanOrEqual(0); + toolModules.forEach(({ module }) => { + expect(typeof module.handler).toBe('function'); }); }); it('should have valid schemas', () => { - reExports.forEach(({ tool, name }) => { - expect(tool.schema).toBeDefined(); - expect(typeof tool.schema).toBe('object'); - }); - }); - - it('should have non-empty descriptions', () => { - reExports.forEach(({ tool, name }) => { - expect(typeof tool.description).toBe('string'); - expect(tool.description.length).toBeGreaterThan(0); + toolModules.forEach(({ module }) => { + expect(module.schema).toBeDefined(); + expect(typeof module.schema).toBe('object'); }); }); }); diff --git a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts index 2ab03f94..86086966 100644 --- a/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts +++ b/src/mcp/tools/macos/__tests__/stop_mac_app.test.ts @@ -11,33 +11,26 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import stopMacApp, { stop_mac_appLogic } from '../stop_mac_app.ts'; +import { schema, handler } from '../stop_mac_app.ts'; +import { stop_mac_appLogic } from '../stop_mac_app.ts'; describe('stop_mac_app plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(stopMacApp.name).toBe('stop_mac_app'); - }); - - it('should have correct description', () => { - expect(stopMacApp.description).toBe('Stop macOS app.'); - }); - it('should have handler function', () => { - expect(typeof stopMacApp.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { // Test optional fields - expect(stopMacApp.schema.appName.safeParse('Calculator').success).toBe(true); - expect(stopMacApp.schema.appName.safeParse(undefined).success).toBe(true); - expect(stopMacApp.schema.processId.safeParse(1234).success).toBe(true); - expect(stopMacApp.schema.processId.safeParse(undefined).success).toBe(true); + expect(schema.appName.safeParse('Calculator').success).toBe(true); + expect(schema.appName.safeParse(undefined).success).toBe(true); + expect(schema.processId.safeParse(1234).success).toBe(true); + expect(schema.processId.safeParse(undefined).success).toBe(true); // Test invalid inputs - expect(stopMacApp.schema.appName.safeParse(null).success).toBe(false); - expect(stopMacApp.schema.processId.safeParse('not-number').success).toBe(false); - expect(stopMacApp.schema.processId.safeParse(null).success).toBe(false); + expect(schema.appName.safeParse(null).success).toBe(false); + expect(schema.processId.safeParse('not-number').success).toBe(false); + expect(schema.processId.safeParse(null).success).toBe(false); }); }); diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts index d64345ac..71f6d562 100644 --- a/src/mcp/tools/macos/__tests__/test_macos.test.ts +++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts @@ -12,7 +12,8 @@ import { type FileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import testMacos, { testMacosLogic } from '../test_macos.ts'; +import { schema, handler } from '../test_macos.ts'; +import { testMacosLogic } from '../test_macos.ts'; const createTestFileSystemExecutor = (overrides: Partial = {}) => createMockFileSystemExecutor({ @@ -29,42 +30,34 @@ describe('test_macos plugin (unified)', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(testMacos.name).toBe('test_macos'); - }); - - it('should have correct description', () => { - expect(testMacos.description).toBe('Test macOS target.'); - }); - it('should have handler function', () => { - expect(typeof testMacos.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - const schema = z.strictObject(testMacos.schema); + const zodSchema = z.strictObject(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + zodSchema.safeParse({ extraArgs: ['--arg1', '--arg2'], testRunnerEnv: { FOO: 'BAR' }, }).success, ).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); - expect(schema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); + expect(zodSchema.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); + expect(zodSchema.safeParse({ extraArgs: ['--ok', 1] }).success).toBe(false); + expect(zodSchema.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(zodSchema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); - const schemaKeys = Object.keys(testMacos.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv'].sort()); }); }); describe('Handler Requirements', () => { it('should require scheme before running', async () => { - const result = await testMacos.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); @@ -73,7 +66,7 @@ describe('test_macos plugin (unified)', () => { it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await testMacos.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -82,7 +75,7 @@ describe('test_macos plugin (unified)', () => { it('should reject when both projectPath and workspacePath provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await testMacos.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); @@ -95,7 +88,7 @@ describe('test_macos plugin (unified)', () => { describe('XOR Parameter Validation', () => { it('should validate that either projectPath or workspacePath is provided', async () => { // Should return error response when neither is provided - const result = await testMacos.handler({ + const result = await handler({ scheme: 'MyScheme', }); @@ -105,7 +98,7 @@ describe('test_macos plugin (unified)', () => { it('should validate that both projectPath and workspacePath cannot be provided', async () => { // Should return error response when both are provided - const result = await testMacos.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', scheme: 'MyScheme', diff --git a/src/mcp/tools/macos/build_macos.ts b/src/mcp/tools/macos/build_macos.ts index 3eb8a449..b91e4129 100644 --- a/src/mcp/tools/macos/build_macos.ts +++ b/src/mcp/tools/macos/build_macos.ts @@ -96,25 +96,18 @@ export async function buildMacOSLogic( ); } -export default { - name: 'build_macos', - description: 'Build macOS app.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Build macOS', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: buildMacOSSchema as unknown as z.ZodType, - logicFunction: buildMacOSLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buildMacOSSchema as unknown as z.ZodType, + logicFunction: buildMacOSLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts index b71abc8f..6233b2cb 100644 --- a/src/mcp/tools/macos/build_run_macos.ts +++ b/src/mcp/tools/macos/build_run_macos.ts @@ -214,25 +214,18 @@ export async function buildRunMacOSLogic( } } -export default { - name: 'build_run_macos', - description: 'Build and run macOS app.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Build Run macOS', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: buildRunMacOSSchema as unknown as z.ZodType, - logicFunction: buildRunMacOSLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buildRunMacOSSchema as unknown as z.ZodType, + logicFunction: buildRunMacOSLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts index f10e2366..0541ee57 100644 --- a/src/mcp/tools/macos/get_mac_app_path.ts +++ b/src/mcp/tools/macos/get_mac_app_path.ts @@ -194,25 +194,18 @@ export async function get_mac_app_pathLogic( } } -export default { - name: 'get_mac_app_path', - description: 'Get macOS built app path.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Get macOS App Path', - readOnlyHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: getMacosAppPathSchema as unknown as z.ZodType, - logicFunction: get_mac_app_pathLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: getMacosAppPathSchema as unknown as z.ZodType, + logicFunction: get_mac_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/macos/launch_mac_app.ts b/src/mcp/tools/macos/launch_mac_app.ts index 2c91a3c2..0cb65b3c 100644 --- a/src/mcp/tools/macos/launch_mac_app.ts +++ b/src/mcp/tools/macos/launch_mac_app.ts @@ -72,13 +72,10 @@ export async function launch_mac_appLogic( } } -export default { - name: 'launch_mac_app', - description: 'Launch macOS app.', - schema: launchMacAppSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Launch macOS App', - destructiveHint: true, - }, - handler: createTypedTool(launchMacAppSchema, launch_mac_appLogic, getDefaultCommandExecutor), -}; +export const schema = launchMacAppSchema.shape; + +export const handler = createTypedTool( + launchMacAppSchema, + launch_mac_appLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/macos/stop_mac_app.ts b/src/mcp/tools/macos/stop_mac_app.ts index 54538020..6db67748 100644 --- a/src/mcp/tools/macos/stop_mac_app.ts +++ b/src/mcp/tools/macos/stop_mac_app.ts @@ -75,13 +75,10 @@ export async function stop_mac_appLogic( } } -export default { - name: 'stop_mac_app', - description: 'Stop macOS app.', - schema: stopMacAppSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Stop macOS App', - destructiveHint: true, - }, - handler: createTypedTool(stopMacAppSchema, stop_mac_appLogic, getDefaultCommandExecutor), -}; +export const schema = stopMacAppSchema.shape; + +export const handler = createTypedTool( + stopMacAppSchema, + stop_mac_appLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/macos/test_macos.ts b/src/mcp/tools/macos/test_macos.ts index 659914fe..09120fd9 100644 --- a/src/mcp/tools/macos/test_macos.ts +++ b/src/mcp/tools/macos/test_macos.ts @@ -324,26 +324,19 @@ export async function testMacosLogic( } } -export default { - name: 'test_macos', - description: 'Test macOS target.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Test macOS', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: testMacosSchema as unknown as z.ZodType, - logicFunction: (params, executor) => - testMacosLogic(params, executor, getDefaultFileSystemExecutor()), - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: testMacosSchema as unknown as z.ZodType, + logicFunction: (params, executor) => + testMacosLogic(params, executor, getDefaultFileSystemExecutor()), + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts index 3d88b1d7..f45e9da9 100644 --- a/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import plugin, { discover_projsLogic } from '../discover_projs.ts'; +import { schema, handler, discover_projsLogic } from '../discover_projs.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; describe('discover_projs plugin', () => { @@ -22,31 +22,21 @@ describe('discover_projs plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('discover_projs'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe( - 'Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.', - ); - }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema with valid inputs', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({ workspaceRoot: '/path/to/workspace' }).success).toBe(true); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ workspaceRoot: '/path/to/workspace' }).success).toBe(true); expect( - schema.safeParse({ workspaceRoot: '/path/to/workspace', scanPath: 'subdir' }).success, + schemaObj.safeParse({ workspaceRoot: '/path/to/workspace', scanPath: 'subdir' }).success, + ).toBe(true); + expect( + schemaObj.safeParse({ workspaceRoot: '/path/to/workspace', maxDepth: 3 }).success, ).toBe(true); - expect(schema.safeParse({ workspaceRoot: '/path/to/workspace', maxDepth: 3 }).success).toBe( - true, - ); expect( - schema.safeParse({ + schemaObj.safeParse({ workspaceRoot: '/path/to/workspace', scanPath: 'subdir', maxDepth: 5, @@ -55,13 +45,15 @@ describe('discover_projs plugin', () => { }); it('should validate schema with invalid inputs', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ workspaceRoot: 123 }).success).toBe(false); - expect(schema.safeParse({ workspaceRoot: '/path', scanPath: 123 }).success).toBe(false); - expect(schema.safeParse({ workspaceRoot: '/path', maxDepth: 'invalid' }).success).toBe(false); - expect(schema.safeParse({ workspaceRoot: '/path', maxDepth: -1 }).success).toBe(false); - expect(schema.safeParse({ workspaceRoot: '/path', maxDepth: 1.5 }).success).toBe(false); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ workspaceRoot: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ workspaceRoot: '/path', scanPath: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ workspaceRoot: '/path', maxDepth: 'invalid' }).success).toBe( + false, + ); + expect(schemaObj.safeParse({ workspaceRoot: '/path', maxDepth: -1 }).success).toBe(false); + expect(schemaObj.safeParse({ workspaceRoot: '/path', maxDepth: 1.5 }).success).toBe(false); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts index 63cbcf15..cde2704d 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import plugin, { get_app_bundle_idLogic } from '../get_app_bundle_id.ts'; +import { schema, handler, get_app_bundle_idLogic } from '../get_app_bundle_id.ts'; import { createMockFileSystemExecutor, createCommandMatchingMockExecutor, @@ -32,37 +32,29 @@ describe('get_app_bundle_id plugin', () => { }; describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('get_app_bundle_id'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Extract bundle id from .app.'); - }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema with valid inputs', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({ appPath: '/path/to/MyApp.app' }).success).toBe(true); - expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ appPath: '/path/to/MyApp.app' }).success).toBe(true); + expect(schemaObj.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); }); it('should validate schema with invalid inputs', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ appPath: 123 }).success).toBe(false); - expect(schema.safeParse({ appPath: null }).success).toBe(false); - expect(schema.safeParse({ appPath: undefined }).success).toBe(false); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ appPath: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ appPath: null }).success).toBe(false); + expect(schemaObj.safeParse({ appPath: undefined }).success).toBe(false); }); }); describe('Handler Behavior (Complete Literal Returns)', () => { it('should return error when appPath validation fails', async () => { // Test validation through the handler which uses Zod validation - const result = await plugin.handler({}); + const result = await handler({}); expect(result).toEqual({ content: [ @@ -305,7 +297,7 @@ describe('get_app_bundle_id plugin', () => { it('should handle schema validation error when appPath is null', async () => { // Test validation through the handler which uses Zod validation - const result = await plugin.handler({ appPath: null }); + const result = await handler({ appPath: null }); expect(result).toEqual({ content: [ @@ -320,7 +312,7 @@ describe('get_app_bundle_id plugin', () => { it('should handle schema validation with missing appPath', async () => { // Test validation through the handler which uses Zod validation - const result = await plugin.handler({}); + const result = await handler({}); expect(result).toEqual({ content: [ @@ -335,7 +327,7 @@ describe('get_app_bundle_id plugin', () => { it('should handle schema validation with undefined appPath', async () => { // Test validation through the handler which uses Zod validation - const result = await plugin.handler({ appPath: undefined }); + const result = await handler({ appPath: undefined }); expect(result).toEqual({ content: [ @@ -350,7 +342,7 @@ describe('get_app_bundle_id plugin', () => { it('should handle schema validation with number type appPath', async () => { // Test validation through the handler which uses Zod validation - const result = await plugin.handler({ appPath: 123 }); + const result = await handler({ appPath: 123 }); expect(result).toEqual({ content: [ diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts index 012399d8..a578fbca 100644 --- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import plugin, { get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts'; +import { schema, handler, get_mac_bundle_idLogic } from '../get_mac_bundle_id.ts'; import { createMockFileSystemExecutor, createCommandMatchingMockExecutor, @@ -22,30 +22,22 @@ describe('get_mac_bundle_id plugin', () => { }; describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('get_mac_bundle_id'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Extract bundle id from macOS .app.'); - }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema with valid inputs', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true); - expect(schema.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ appPath: '/Applications/TextEdit.app' }).success).toBe(true); + expect(schemaObj.safeParse({ appPath: '/Users/dev/MyApp.app' }).success).toBe(true); }); it('should validate schema with invalid inputs', () => { - const schema = z.object(plugin.schema); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ appPath: 123 }).success).toBe(false); - expect(schema.safeParse({ appPath: null }).success).toBe(false); - expect(schema.safeParse({ appPath: undefined }).success).toBe(false); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ appPath: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ appPath: null }).success).toBe(false); + expect(schemaObj.safeParse({ appPath: undefined }).success).toBe(false); }); }); diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts index 12c79f53..417456c1 100644 --- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts @@ -10,7 +10,7 @@ import { createMockCommandResponse, createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -import plugin, { listSchemesLogic } from '../list_schemes.ts'; +import { schema, handler, listSchemesLogic } from '../list_schemes.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('list_schemes plugin', () => { @@ -19,23 +19,17 @@ describe('list_schemes plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('list_schemes'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('List Xcode schemes.'); - }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose an empty public schema', () => { - const schema = z.strictObject(plugin.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe(false); - expect(Object.keys(plugin.schema)).toEqual([]); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ projectPath: '/path/to/MyProject.xcodeproj' }).success).toBe( + false, + ); + expect(Object.keys(schema)).toEqual([]); }); }); @@ -250,7 +244,7 @@ describe('list_schemes plugin', () => { it('should handle validation when testing with missing projectPath via plugin handler', async () => { // Note: Direct logic function calls bypass Zod validation, so we test the actual plugin handler // to verify Zod validation works properly. The createTypedTool wrapper handles validation. - const result = await plugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -259,14 +253,14 @@ describe('list_schemes plugin', () => { describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await plugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); expect(result.content[0].text).toContain('Provide a project or workspace'); }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await plugin.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace.xcworkspace', }); @@ -275,7 +269,7 @@ describe('list_schemes plugin', () => { }); it('should handle empty strings as undefined', async () => { - const result = await plugin.handler({ + const result = await handler({ projectPath: '', workspacePath: '', }); diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts index a2912026..e96ef473 100644 --- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts +++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, type CommandExecutor } from '../../../../test-utils/mock-executors.ts'; -import plugin, { showBuildSettingsLogic } from '../show_build_settings.ts'; +import { schema, handler, showBuildSettingsLogic } from '../show_build_settings.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; describe('show_build_settings plugin', () => { @@ -9,24 +9,16 @@ describe('show_build_settings plugin', () => { sessionStore.clear(); }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('show_build_settings'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Show build settings.'); - }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose an empty public schema', () => { - const schema = z.strictObject(plugin.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ projectPath: '/path.xcodeproj' }).success).toBe(false); - expect(schema.safeParse({ scheme: 'App' }).success).toBe(false); - expect(Object.keys(plugin.schema)).toEqual([]); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ projectPath: '/path.xcodeproj' }).success).toBe(false); + expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); + expect(Object.keys(schema)).toEqual([]); }); }); @@ -49,7 +41,7 @@ describe('show_build_settings plugin', () => { it('should test Zod validation through handler', async () => { // Test the actual tool handler which includes Zod validation - const result = await plugin.handler({ + const result = await handler({ projectPath: null, scheme: 'MyScheme', }); @@ -193,7 +185,7 @@ describe('show_build_settings plugin', () => { describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await plugin.handler({ + const result = await handler({ scheme: 'MyScheme', }); @@ -203,7 +195,7 @@ describe('show_build_settings plugin', () => { }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await plugin.handler({ + const result = await handler({ projectPath: '/path/project.xcodeproj', workspacePath: '/path/workspace.xcworkspace', scheme: 'MyScheme', @@ -246,7 +238,7 @@ describe('show_build_settings plugin', () => { describe('Session requirement handling', () => { it('should require scheme when not provided', async () => { - const result = await plugin.handler({ + const result = await handler({ projectPath: '/path/to/MyProject.xcodeproj', } as any); @@ -258,7 +250,7 @@ describe('show_build_settings plugin', () => { it('should surface project/workspace requirement even with scheme default', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await plugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); diff --git a/src/mcp/tools/project-discovery/discover_projs.ts b/src/mcp/tools/project-discovery/discover_projs.ts index b3f881d2..f0f3c9f3 100644 --- a/src/mcp/tools/project-discovery/discover_projs.ts +++ b/src/mcp/tools/project-discovery/discover_projs.ts @@ -270,20 +270,12 @@ export async function discover_projsLogic( }; } -export default { - name: 'discover_projs', - description: - 'Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files.', - schema: discoverProjsSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Discover Projects', - readOnlyHint: true, +export const schema = discoverProjsSchema.shape; + +export const handler = createTypedTool( + discoverProjsSchema, + (params: DiscoverProjsParams) => { + return discover_projsLogic(params, getDefaultFileSystemExecutor()); }, - handler: createTypedTool( - discoverProjsSchema, - (params: DiscoverProjsParams) => { - return discover_projsLogic(params, getDefaultFileSystemExecutor()); - }, - getDefaultCommandExecutor, - ), -}; + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts index 121d488e..a3778d4f 100644 --- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts @@ -136,18 +136,11 @@ export async function get_app_bundle_idLogic( } } -export default { - name: 'get_app_bundle_id', - description: 'Extract bundle id from .app.', - schema: getAppBundleIdSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Get App Bundle ID', - readOnlyHint: true, - }, - handler: createTypedTool( - getAppBundleIdSchema, - (params: GetAppBundleIdParams) => - get_app_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), - getDefaultCommandExecutor, - ), -}; +export const schema = getAppBundleIdSchema.shape; + +export const handler = createTypedTool( + getAppBundleIdSchema, + (params: GetAppBundleIdParams) => + get_app_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts index fa78f533..b54c59df 100644 --- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts +++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts @@ -121,18 +121,11 @@ export async function get_mac_bundle_idLogic( } } -export default { - name: 'get_mac_bundle_id', - description: 'Extract bundle id from macOS .app.', - schema: getMacBundleIdSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Get Mac Bundle ID', - readOnlyHint: true, - }, - handler: createTypedTool( - getMacBundleIdSchema, - (params: GetMacBundleIdParams) => - get_mac_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), - getDefaultCommandExecutor, - ), -}; +export const schema = getMacBundleIdSchema.shape; + +export const handler = createTypedTool( + getMacBundleIdSchema, + (params: GetMacBundleIdParams) => + get_mac_bundle_idLogic(params, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()), + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts index 19e684f7..431fc549 100644 --- a/src/mcp/tools/project-discovery/list_schemes.ts +++ b/src/mcp/tools/project-discovery/list_schemes.ts @@ -143,24 +143,17 @@ const publicSchemaObject = baseSchemaObject.omit({ workspacePath: true, } as const); -export default { - name: 'list_schemes', - description: 'List Xcode schemes.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'List Schemes', - readOnlyHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: listSchemesSchema as unknown as z.ZodType, - logicFunction: listSchemesLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: listSchemesSchema as unknown as z.ZodType, + logicFunction: listSchemesLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts index a1c3922c..b7b812ea 100644 --- a/src/mcp/tools/project-discovery/show_build_settings.ts +++ b/src/mcp/tools/project-discovery/show_build_settings.ts @@ -134,28 +134,18 @@ const publicSchemaObject = baseSchemaObject.omit({ scheme: true, } as const); -export default { - name: 'show_build_settings', - description: 'Show build settings.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Show Build Settings', - readOnlyHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: showBuildSettingsSchema as unknown as z.ZodType< - ShowBuildSettingsParams, - unknown - >, - logicFunction: showBuildSettingsLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: showBuildSettingsSchema as unknown as z.ZodType, + logicFunction: showBuildSettingsLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index b5f7216c..6e759547 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as z from 'zod'; -import scaffoldIosProject, { scaffold_ios_projectLogic } from '../scaffold_ios_project.ts'; +import { schema, handler, scaffold_ios_projectLogic } from '../scaffold_ios_project.ts'; import { createMockExecutor, createMockFileSystemExecutor, @@ -66,24 +66,16 @@ describe('scaffold_ios_project plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { - expect(scaffoldIosProject.name).toBe('scaffold_ios_project'); - }); - - it('should have correct description field', () => { - expect(scaffoldIosProject.description).toBe('Scaffold iOS project.'); - }); - it('should have handler as function', () => { - expect(typeof scaffoldIosProject.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have valid schema with required fields', () => { - const schema = z.object(scaffoldIosProject.schema); + const schemaObj = z.object(schema); // Test valid input expect( - schema.safeParse({ + schemaObj.safeParse({ projectName: 'MyTestApp', outputPath: '/path/to/output', bundleIdentifier: 'com.test.myapp', @@ -100,7 +92,7 @@ describe('scaffold_ios_project plugin', () => { // Test minimal valid input expect( - schema.safeParse({ + schemaObj.safeParse({ projectName: 'MyTestApp', outputPath: '/path/to/output', }).success, @@ -108,21 +100,21 @@ describe('scaffold_ios_project plugin', () => { // Test invalid input - missing projectName expect( - schema.safeParse({ + schemaObj.safeParse({ outputPath: '/path/to/output', }).success, ).toBe(false); // Test invalid input - missing outputPath expect( - schema.safeParse({ + schemaObj.safeParse({ projectName: 'MyTestApp', }).success, ).toBe(false); // Test invalid input - wrong type for customizeNames expect( - schema.safeParse({ + schemaObj.safeParse({ projectName: 'MyTestApp', outputPath: '/path/to/output', customizeNames: 'true', @@ -131,7 +123,7 @@ describe('scaffold_ios_project plugin', () => { // Test invalid input - wrong enum value for targetedDeviceFamily expect( - schema.safeParse({ + schemaObj.safeParse({ projectName: 'MyTestApp', outputPath: '/path/to/output', targetedDeviceFamily: ['invalid-device'], @@ -140,7 +132,7 @@ describe('scaffold_ios_project plugin', () => { // Test invalid input - wrong enum value for supportedOrientations expect( - schema.safeParse({ + schemaObj.safeParse({ projectName: 'MyTestApp', outputPath: '/path/to/output', supportedOrientations: ['invalid-orientation'], diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts index c119934a..9a7d94c6 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -16,7 +16,7 @@ import { createMockExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import plugin, { scaffold_macos_projectLogic } from '../scaffold_macos_project.ts'; +import { schema, handler, scaffold_macos_projectLogic } from '../scaffold_macos_project.ts'; import { TemplateManager } from '../../../../utils/template/index.ts'; import { __resetConfigStoreForTests, @@ -99,26 +99,18 @@ describe('scaffold_macos_project plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { - expect(plugin.name).toBe('scaffold_macos_project'); - }); - - it('should have correct description field', () => { - expect(plugin.description).toBe('Scaffold macOS project.'); - }); - it('should have handler as function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have valid schema with required fields', () => { // Test the schema object exists - expect(plugin.schema).toBeDefined(); - expect(plugin.schema.projectName).toBeDefined(); - expect(plugin.schema.outputPath).toBeDefined(); - expect(plugin.schema.bundleIdentifier).toBeDefined(); - expect(plugin.schema.customizeNames).toBeDefined(); - expect(plugin.schema.deploymentTarget).toBeDefined(); + expect(schema).toBeDefined(); + expect(schema.projectName).toBeDefined(); + expect(schema.outputPath).toBeDefined(); + expect(schema.bundleIdentifier).toBeDefined(); + expect(schema.customizeNames).toBeDefined(); + expect(schema.deploymentTarget).toBeDefined(); }); }); diff --git a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts index cc2bfc50..eefd9f6c 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_ios_project.ts @@ -469,20 +469,13 @@ async function scaffoldProject( } } -export default { - name: 'scaffold_ios_project', - description: 'Scaffold iOS project.', - schema: ScaffoldiOSProjectSchema.shape, - annotations: { - title: 'Scaffold iOS Project', - destructiveHint: true, - }, - async handler(args: Record): Promise { - const params = ScaffoldiOSProjectSchema.parse(args); - return scaffold_ios_projectLogic( - params, - getDefaultCommandExecutor(), - getDefaultFileSystemExecutor(), - ); - }, -}; +export const schema = ScaffoldiOSProjectSchema.shape; + +export async function handler(args: Record): Promise { + const params = ScaffoldiOSProjectSchema.parse(args); + return scaffold_ios_projectLogic( + params, + getDefaultCommandExecutor(), + getDefaultFileSystemExecutor(), + ); +} diff --git a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts index 13025838..2e9cced8 100644 --- a/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts +++ b/src/mcp/tools/project-scaffolding/scaffold_macos_project.ts @@ -377,21 +377,13 @@ export async function scaffold_macos_projectLogic( } } -export default { - name: 'scaffold_macos_project', - description: 'Scaffold macOS project.', - schema: ScaffoldmacOSProjectSchema.shape, - annotations: { - title: 'Scaffold macOS Project', - destructiveHint: true, - }, - async handler(args: Record): Promise { - // Validate the arguments against the schema before processing - const validatedArgs = ScaffoldmacOSProjectSchema.parse(args); - return scaffold_macos_projectLogic( - validatedArgs, - getDefaultCommandExecutor(), - getDefaultFileSystemExecutor(), - ); - }, -}; +export const schema = ScaffoldmacOSProjectSchema.shape; + +export async function handler(args: Record): Promise { + const validatedArgs = ScaffoldmacOSProjectSchema.parse(args); + return scaffold_macos_projectLogic( + validatedArgs, + getDefaultCommandExecutor(), + getDefaultFileSystemExecutor(), + ); +} diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index 4506121f..878ea02d 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { sessionStore } from '../../../../utils/session-store.ts'; -import plugin, { sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; +import { schema, handler, sessionClearDefaultsLogic } from '../session_clear_defaults.ts'; describe('session-clear-defaults tool', () => { beforeEach(() => { @@ -20,22 +20,14 @@ describe('session-clear-defaults tool', () => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('session-clear-defaults'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Clear session defaults.'); - }); - + describe('Export Field Validation', () => { it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have schema object', () => { - expect(plugin.schema).toBeDefined(); - expect(typeof plugin.schema).toBe('object'); + expect(schema).toBeDefined(); + expect(typeof schema).toBe('object'); }); }); @@ -74,7 +66,7 @@ describe('session-clear-defaults tool', () => { }); it('should validate keys enum', async () => { - const result = (await plugin.handler({ keys: ['invalid' as any] })) as any; + const result = (await handler({ keys: ['invalid' as any] })) as any; expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); expect(result.content[0].text).toContain('keys'); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 0f0350fc..1d6013ad 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -4,7 +4,7 @@ import { parse as parseYaml } from 'yaml'; import { __resetConfigStoreForTests, initConfigStore } from '../../../../utils/config-store.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; -import plugin, { sessionSetDefaultsLogic } from '../session_set_defaults.ts'; +import { schema, handler, sessionSetDefaultsLogic } from '../session_set_defaults.ts'; describe('session-set-defaults tool', () => { beforeEach(() => { @@ -19,24 +19,14 @@ describe('session-set-defaults tool', () => { return {}; } - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('session-set-defaults'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe( - 'Set the session defaults, should be called at least once to set tool defaults.', - ); - }); - + describe('Export Field Validation', () => { it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have schema object', () => { - expect(plugin.schema).toBeDefined(); - expect(typeof plugin.schema).toBe('object'); + expect(schema).toBeDefined(); + expect(typeof schema).toBe('object'); }); }); @@ -63,7 +53,7 @@ describe('session-set-defaults tool', () => { }); it('should validate parameter types via Zod', async () => { - const result = await plugin.handler({ + const result = await handler({ useLatestOS: 'yes' as unknown as boolean, }); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index 9e182b2d..bd836024 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { sessionStore } from '../../../../utils/session-store.ts'; -import plugin from '../session_show_defaults.ts'; +import { schema, handler } from '../session_show_defaults.ts'; describe('session-show-defaults tool', () => { beforeEach(() => { @@ -12,26 +12,18 @@ describe('session-show-defaults tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(plugin.name).toBe('session-show-defaults'); - }); - - it('should have correct description', () => { - expect(plugin.description).toBe('Show session defaults.'); - }); - it('should have handler function', () => { - expect(typeof plugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have empty schema', () => { - expect(plugin.schema).toEqual({}); + expect(schema).toEqual({}); }); }); describe('Handler Behavior', () => { it('should return empty defaults when none set', async () => { - const result = await plugin.handler(); + const result = await handler(); expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); expect(typeof result.content[0].text).toBe('string'); @@ -41,7 +33,7 @@ describe('session-show-defaults tool', () => { it('should return current defaults when set', async () => { sessionStore.setDefaults({ scheme: 'MyScheme', simulatorId: 'SIM-123' }); - const result = await plugin.handler(); + const result = await handler(); expect(result.isError).toBe(false); expect(result.content).toHaveLength(1); expect(typeof result.content[0].text).toBe('string'); diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index be3990e7..f2acee97 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -20,13 +20,10 @@ export async function sessionClearDefaultsLogic(params: Params): Promise ({})), -}; +export const schema = schemaObj.shape; + +export const handler = createTypedToolWithContext(schemaObj, sessionSetDefaultsLogic, () => ({})); diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index ff5291e8..03ce99da 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -1,16 +1,9 @@ import { sessionStore } from '../../../utils/session-store.ts'; import type { ToolResponse } from '../../../types/common.ts'; -export default { - name: 'session-show-defaults', - description: 'Show session defaults.', - schema: {}, - annotations: { - title: 'Show Session Defaults', - readOnlyHint: true, - }, - handler: async (): Promise => { - const current = sessionStore.getAll(); - return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }], isError: false }; - }, +export const schema = {}; + +export const handler = async (): Promise => { + const current = sessionStore.getAll(); + return { content: [{ type: 'text', text: JSON.stringify(current, null, 2) }], isError: false }; }; diff --git a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts index e453fb50..f830a41b 100644 --- a/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/erase_sims.test.ts @@ -1,26 +1,14 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import eraseSims, { erase_simsLogic } from '../erase_sims.ts'; +import { schema, erase_simsLogic } from '../erase_sims.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; describe('erase_sims tool (single simulator)', () => { - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(eraseSims.name).toBe('erase_sims'); - }); - - it('should have correct description', () => { - expect(eraseSims.description).toBe('Erase simulator.'); - }); - - it('should have handler function', () => { - expect(typeof eraseSims.handler).toBe('function'); - }); - + describe('Schema Validation', () => { it('should validate schema fields (shape only)', () => { - const schema = z.object(eraseSims.schema); - expect(schema.safeParse({ shutdownFirst: true }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(true); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ shutdownFirst: true }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts index 8cc0be06..131aeee1 100644 --- a/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/reset_sim_location.test.ts @@ -1,28 +1,16 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import resetSimLocationPlugin, { reset_sim_locationLogic } from '../reset_sim_location.ts'; +import { schema, reset_sim_locationLogic } from '../reset_sim_location.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; describe('reset_sim_location plugin', () => { - describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { - expect(resetSimLocationPlugin.name).toBe('reset_sim_location'); - }); - - it('should have correct description field', () => { - expect(resetSimLocationPlugin.description).toBe('Reset sim location.'); - }); - - it('should have handler function', () => { - expect(typeof resetSimLocationPlugin.handler).toBe('function'); - }); - + describe('Schema Validation', () => { it('should hide simulatorId from public schema', () => { - const schema = z.object(resetSimLocationPlugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); - const withSimId = schema.safeParse({ simulatorId: 'abc123' }); + const withSimId = schemaObj.safeParse({ simulatorId: 'abc123' }); expect(withSimId.success).toBe(true); expect('simulatorId' in (withSimId.data as any)).toBe(false); }); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts index eccdb166..a5b3a0a5 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import setSimAppearancePlugin, { set_sim_appearanceLogic } from '../set_sim_appearance.ts'; +import { schema, handler, set_sim_appearanceLogic } from '../set_sim_appearance.ts'; import { createMockCommandResponse, createMockExecutor, @@ -8,28 +8,20 @@ import { describe('set_sim_appearance plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { - expect(setSimAppearancePlugin.name).toBe('set_sim_appearance'); - }); - - it('should have correct description field', () => { - expect(setSimAppearancePlugin.description).toBe('Set sim appearance.'); - }); - it('should have handler function', () => { - expect(typeof setSimAppearancePlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(setSimAppearancePlugin.schema); + const schemaObject = z.object(schema); - expect(schema.safeParse({ mode: 'dark' }).success).toBe(true); - expect(schema.safeParse({ mode: 'light' }).success).toBe(true); - expect(schema.safeParse({ mode: 'invalid' }).success).toBe(false); + expect(schemaObject.safeParse({ mode: 'dark' }).success).toBe(true); + expect(schemaObject.safeParse({ mode: 'light' }).success).toBe(true); + expect(schemaObject.safeParse({ mode: 'invalid' }).success).toBe(false); - const withSimId = schema.safeParse({ simulatorId: 'abc123', mode: 'dark' }); + const withSimId = schemaObject.safeParse({ simulatorId: 'abc123', mode: 'dark' }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as object)).toBe(false); }); }); @@ -84,7 +76,7 @@ describe('set_sim_appearance plugin', () => { }); it('should surface session default requirement when simulatorId is missing', async () => { - const result = await setSimAppearancePlugin.handler({ mode: 'dark' }); + const result = await handler({ mode: 'dark' }); const message = result.content?.[0]?.text ?? ''; expect(message).toContain('Error: Missing required session defaults'); diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts index 89bcfd93..bdffd902 100644 --- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts @@ -1,48 +1,38 @@ /** - * Tests for set_sim_location plugin + * Tests for set_sim_location tool * Following CLAUDE.md testing standards with literal validation * Using pure dependency injection for deterministic testing */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockCommandResponse, createMockExecutor, createNoopExecutor, } from '../../../../test-utils/mock-executors.ts'; -import setSimLocation, { set_sim_locationLogic } from '../set_sim_location.ts'; +import { schema, handler, set_sim_locationLogic } from '../set_sim_location.ts'; describe('set_sim_location tool', () => { - // No mocks to clear since we use pure dependency injection - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(setSimLocation.name).toBe('set_sim_location'); - }); - - it('should have correct description', () => { - expect(setSimLocation.description).toBe('Set sim location.'); - }); - it('should have handler function', () => { - expect(typeof setSimLocation.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(setSimLocation.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ latitude: 37.7749, longitude: -122.4194 }).success).toBe(true); - expect(schema.safeParse({ latitude: 0, longitude: 0 }).success).toBe(true); - expect(schema.safeParse({ latitude: 37.7749 }).success).toBe(false); - expect(schema.safeParse({ longitude: -122.4194 }).success).toBe(false); - const withSimId = schema.safeParse({ + expect(schemaObj.safeParse({ latitude: 37.7749, longitude: -122.4194 }).success).toBe(true); + expect(schemaObj.safeParse({ latitude: 0, longitude: 0 }).success).toBe(true); + expect(schemaObj.safeParse({ latitude: 37.7749 }).success).toBe(false); + expect(schemaObj.safeParse({ longitude: -122.4194 }).success).toBe(false); + const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid-123', latitude: 37.7749, longitude: -122.4194, }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); }); }); diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts index ae812972..89d576e5 100644 --- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts +++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts @@ -11,32 +11,20 @@ import { createMockExecutor, type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; -import simStatusbar, { sim_statusbarLogic } from '../sim_statusbar.ts'; +import { schema, sim_statusbarLogic } from '../sim_statusbar.ts'; describe('sim_statusbar tool', () => { - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(simStatusbar.name).toBe('sim_statusbar'); - }); - - it('should have correct description', () => { - expect(simStatusbar.description).toBe('Set sim status bar network.'); - }); - - it('should have handler function', () => { - expect(typeof simStatusbar.handler).toBe('function'); - }); - + describe('Schema Validation', () => { it('should expose public schema without simulatorId field', () => { - const schema = z.object(simStatusbar.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ dataNetwork: 'wifi' }).success).toBe(true); - expect(schema.safeParse({ dataNetwork: 'clear' }).success).toBe(true); - expect(schema.safeParse({ dataNetwork: 'invalid' }).success).toBe(false); + expect(schemaObj.safeParse({ dataNetwork: 'wifi' }).success).toBe(true); + expect(schemaObj.safeParse({ dataNetwork: 'clear' }).success).toBe(true); + expect(schemaObj.safeParse({ dataNetwork: 'invalid' }).success).toBe(false); - const withSimId = schema.safeParse({ simulatorId: 'test-uuid', dataNetwork: 'wifi' }); + const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid', dataNetwork: 'wifi' }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as object)).toBe(false); }); }); diff --git a/src/mcp/tools/simulator-management/erase_sims.ts b/src/mcp/tools/simulator-management/erase_sims.ts index bf12436b..2fa20467 100644 --- a/src/mcp/tools/simulator-management/erase_sims.ts +++ b/src/mcp/tools/simulator-management/erase_sims.ts @@ -81,21 +81,14 @@ export async function erase_simsLogic( const publicSchemaObject = eraseSimsSchema.omit({ simulatorId: true } as const).passthrough(); -export default { - name: 'erase_sims', - description: 'Erase simulator.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: eraseSimsSchema, - }), - annotations: { - title: 'Erase Simulators', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: eraseSimsSchema as unknown as z.ZodType, - logicFunction: erase_simsLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: eraseSimsSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: eraseSimsSchema as unknown as z.ZodType, + logicFunction: erase_simsLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts index d860ebfb..fc7a15c4 100644 --- a/src/mcp/tools/simulator-management/reset_sim_location.ts +++ b/src/mcp/tools/simulator-management/reset_sim_location.ts @@ -90,24 +90,17 @@ const publicSchemaObject = z.strictObject( resetSimulatorLocationSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'reset_sim_location', - description: 'Reset sim location.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: resetSimulatorLocationSchema, - }), - annotations: { - title: 'Reset Simulator Location', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: resetSimulatorLocationSchema as unknown as z.ZodType< - ResetSimulatorLocationParams, - unknown - >, - logicFunction: reset_sim_locationLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: resetSimulatorLocationSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: resetSimulatorLocationSchema as unknown as z.ZodType< + ResetSimulatorLocationParams, + unknown + >, + logicFunction: reset_sim_locationLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts index 12ee39f4..cb272b30 100644 --- a/src/mcp/tools/simulator-management/set_sim_appearance.ts +++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts @@ -92,21 +92,14 @@ const publicSchemaObject = z.strictObject( setSimAppearanceSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'set_sim_appearance', - description: 'Set sim appearance.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: setSimAppearanceSchema, - }), - annotations: { - title: 'Set Simulator Appearance', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: setSimAppearanceSchema as unknown as z.ZodType, - logicFunction: set_sim_appearanceLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: setSimAppearanceSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: setSimAppearanceSchema as unknown as z.ZodType, + logicFunction: set_sim_appearanceLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts index c302227c..f2fc0a99 100644 --- a/src/mcp/tools/simulator-management/set_sim_location.ts +++ b/src/mcp/tools/simulator-management/set_sim_location.ts @@ -120,24 +120,17 @@ const publicSchemaObject = z.strictObject( setSimulatorLocationSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'set_sim_location', - description: 'Set sim location.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: setSimulatorLocationSchema, - }), - annotations: { - title: 'Set Simulator Location', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: setSimulatorLocationSchema as unknown as z.ZodType< - SetSimulatorLocationParams, - unknown - >, - logicFunction: set_sim_locationLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: setSimulatorLocationSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: setSimulatorLocationSchema as unknown as z.ZodType< + SetSimulatorLocationParams, + unknown + >, + logicFunction: set_sim_locationLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts index 507dd78f..6ee5390f 100644 --- a/src/mcp/tools/simulator-management/sim_statusbar.ts +++ b/src/mcp/tools/simulator-management/sim_statusbar.ts @@ -91,21 +91,14 @@ const publicSchemaObject = z.strictObject( simStatusbarSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'sim_statusbar', - description: 'Set sim status bar network.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: simStatusbarSchema, - }), // MCP SDK compatibility - annotations: { - title: 'Simulator Statusbar', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: simStatusbarSchema as unknown as z.ZodType, - logicFunction: sim_statusbarLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: simStatusbarSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: simStatusbarSchema as unknown as z.ZodType, + logicFunction: sim_statusbarLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 23974e46..5ce01325 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -10,7 +10,7 @@ import { createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import bootSim, { boot_simLogic } from '../boot_sim.ts'; +import { schema, handler, boot_simLogic } from '../boot_sim.ts'; describe('boot_sim tool', () => { beforeEach(() => { @@ -18,20 +18,12 @@ describe('boot_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(bootSim.name).toBe('boot_sim'); - }); - - it('should have concise description', () => { - expect(bootSim.description).toBe('Boot iOS simulator.'); - }); - it('should expose empty public schema', () => { - const schema = z.object(bootSim.schema); - expect(schema.safeParse({}).success).toBe(true); - expect(Object.keys(bootSim.schema)).toHaveLength(0); + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(Object.keys(schema)).toHaveLength(0); - const withSimId = schema.safeParse({ simulatorId: 'abc' }); + const withSimId = schemaObj.safeParse({ simulatorId: 'abc' }); expect(withSimId.success).toBe(true); expect('simulatorId' in (withSimId.data as Record)).toBe(false); }); @@ -39,7 +31,7 @@ describe('boot_sim tool', () => { describe('Handler Requirements', () => { it('should require simulatorId when not provided', async () => { - const result = await bootSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const message = result.content[0].text; diff --git a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts index 5af66ccd..827f17a9 100644 --- a/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_run_sim.test.ts @@ -11,7 +11,7 @@ import { } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import buildRunSim, { build_run_simLogic } from '../build_run_sim.ts'; +import { schema, handler, build_run_simLogic } from '../build_run_sim.ts'; describe('build_run_sim tool', () => { beforeEach(() => { @@ -19,34 +19,26 @@ describe('build_run_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(buildRunSim.name).toBe('build_run_sim'); - }); - - it('should have correct description', () => { - expect(buildRunSim.description).toBe('Build and run iOS sim.'); - }); - it('should have handler function', () => { - expect(typeof buildRunSim.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose only non-session fields in public schema', () => { - const schema = z.strictObject(buildRunSim.schema); + const schemaObj = z.strictObject(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ extraArgs: ['--verbose'], }).success, ).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/path/to/derived' }).success).toBe(false); - expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: false }).success).toBe(false); + expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived' }).success).toBe(false); + expect(schemaObj.safeParse({ extraArgs: [123] }).success).toBe(false); + expect(schemaObj.safeParse({ preferXcodebuild: false }).success).toBe(false); - const schemaKeys = Object.keys(buildRunSim.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs']); expect(schemaKeys).not.toContain('scheme'); expect(schemaKeys).not.toContain('simulatorName'); @@ -503,7 +495,7 @@ describe('build_run_sim tool', () => { describe('XOR Validation', () => { it('should error when neither projectPath nor workspacePath provided', async () => { - const result = await buildRunSim.handler({ + const result = await handler({ scheme: 'MyScheme', simulatorName: 'iPhone 16', }); @@ -513,7 +505,7 @@ describe('build_run_sim tool', () => { }); it('should error when both projectPath and workspacePath provided', async () => { - const result = await buildRunSim.handler({ + const result = await handler({ projectPath: '/path/project.xcodeproj', workspacePath: '/path/workspace.xcworkspace', scheme: 'MyScheme', diff --git a/src/mcp/tools/simulator/__tests__/build_sim.test.ts b/src/mcp/tools/simulator/__tests__/build_sim.test.ts index 4773bfde..895152bc 100644 --- a/src/mcp/tools/simulator/__tests__/build_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/build_sim.test.ts @@ -7,8 +7,8 @@ import { import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -// Import the plugin and logic function -import buildSim, { build_simLogic } from '../build_sim.ts'; +// Import the named exports and logic function +import { schema, handler, build_simLogic } from '../build_sim.ts'; describe('build_sim tool', () => { beforeEach(() => { @@ -16,41 +16,33 @@ describe('build_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(buildSim.name).toBe('build_sim'); - }); - - it('should have correct description', () => { - expect(buildSim.description).toBe('Build for iOS sim.'); - }); - it('should have handler function', () => { - expect(typeof buildSim.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have correct public schema (only non-session fields)', () => { - const schema = z.strictObject(buildSim.schema); + const schemaObj = z.strictObject(schema); // Public schema should allow empty input - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); // Valid public inputs expect( - schema.safeParse({ + schemaObj.safeParse({ extraArgs: ['--verbose'], }).success, ).toBe(true); // Invalid types or unknown fields on public inputs - expect(schema.safeParse({ derivedDataPath: '/path/to/derived' }).success).toBe(false); - expect(schema.safeParse({ extraArgs: [123] }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: false }).success).toBe(false); + expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived' }).success).toBe(false); + expect(schemaObj.safeParse({ extraArgs: [123] }).success).toBe(false); + expect(schemaObj.safeParse({ preferXcodebuild: false }).success).toBe(false); }); }); describe('Parameter Validation', () => { it('should handle missing both projectPath and workspacePath', async () => { - const result = await buildSim.handler({ + const result = await handler({ scheme: 'MyScheme', simulatorName: 'iPhone 16', }); @@ -61,7 +53,7 @@ describe('build_sim tool', () => { }); it('should handle both projectPath and workspacePath provided', async () => { - const result = await buildSim.handler({ + const result = await handler({ projectPath: '/path/to/project.xcodeproj', workspacePath: '/path/to/workspace', scheme: 'MyScheme', @@ -101,7 +93,7 @@ describe('build_sim tool', () => { }); it('should handle missing scheme parameter', async () => { - const result = await buildSim.handler({ + const result = await handler({ workspacePath: '/path/to/workspace', simulatorName: 'iPhone 16', }); @@ -137,7 +129,7 @@ describe('build_sim tool', () => { }); it('should handle missing both simulatorId and simulatorName', async () => { - const result = await buildSim.handler({ + const result = await handler({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', }); @@ -151,7 +143,7 @@ describe('build_sim tool', () => { const mockExecutor = createMockExecutor({ success: true, output: 'Build succeeded' }); // Should fail with XOR validation - const result = await buildSim.handler({ + const result = await handler({ workspacePath: '/path/to/workspace', scheme: 'MyScheme', simulatorId: 'ABC-123', diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts index dc65db7d..c94cfc63 100644 --- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts +++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts @@ -8,7 +8,7 @@ import { ChildProcess } from 'child_process'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import getSimAppPath, { get_sim_app_pathLogic } from '../get_sim_app_path.ts'; +import { schema, handler, get_sim_app_pathLogic } from '../get_sim_app_path.ts'; import type { CommandExecutor } from '../../../../utils/CommandExecutor.ts'; describe('get_sim_app_path tool', () => { @@ -17,33 +17,25 @@ describe('get_sim_app_path tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(getSimAppPath.name).toBe('get_sim_app_path'); - }); - - it('should have concise description', () => { - expect(getSimAppPath.description).toBe('Get sim built app path.'); - }); - it('should have handler function', () => { - expect(typeof getSimAppPath.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose only platform in public schema', () => { - const schema = z.object(getSimAppPath.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(false); - expect(schema.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); - const schemaKeys = Object.keys(getSimAppPath.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['platform']); }); }); describe('Handler Requirements', () => { it('should require scheme when not provided', async () => { - const result = await getSimAppPath.handler({ + const result = await handler({ platform: 'iOS Simulator', }); @@ -54,7 +46,7 @@ describe('get_sim_app_path tool', () => { it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await getSimAppPath.handler({ + const result = await handler({ platform: 'iOS Simulator', }); @@ -68,7 +60,7 @@ describe('get_sim_app_path tool', () => { projectPath: '/path/to/project.xcodeproj', }); - const result = await getSimAppPath.handler({ + const result = await handler({ platform: 'iOS Simulator', }); @@ -79,7 +71,7 @@ describe('get_sim_app_path tool', () => { it('should error when both projectPath and workspacePath provided explicitly', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await getSimAppPath.handler({ + const result = await handler({ platform: 'iOS Simulator', projectPath: '/path/project.xcodeproj', workspacePath: '/path/workspace.xcworkspace', @@ -97,7 +89,7 @@ describe('get_sim_app_path tool', () => { workspacePath: '/path/to/workspace.xcworkspace', }); - const result = await getSimAppPath.handler({ + const result = await handler({ platform: 'iOS Simulator', simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts index a509450f..0ac87197 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -8,7 +8,7 @@ import { } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; -import installAppSim, { install_app_simLogic } from '../install_app_sim.ts'; +import { schema, handler, install_app_simLogic } from '../install_app_sim.ts'; describe('install_app_sim tool', () => { beforeEach(() => { @@ -16,24 +16,16 @@ describe('install_app_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(installAppSim.name).toBe('install_app_sim'); - }); - - it('should have concise description', () => { - expect(installAppSim.description).toBe('Install app on sim.'); - }); - it('should expose public schema with only appPath', () => { - const schema = z.object(installAppSim.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ appPath: '/path/to/app.app' }).success).toBe(true); - expect(schema.safeParse({ appPath: 42 }).success).toBe(false); - expect(schema.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ appPath: '/path/to/app.app' }).success).toBe(true); + expect(schemaObj.safeParse({ appPath: 42 }).success).toBe(false); + expect(schemaObj.safeParse({}).success).toBe(false); - expect(Object.keys(installAppSim.schema)).toEqual(['appPath']); + expect(Object.keys(schema)).toEqual(['appPath']); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: 'test-uuid-123', appPath: '/path/app.app', }); @@ -44,7 +36,7 @@ describe('install_app_sim tool', () => { describe('Handler Requirements', () => { it('should require simulatorId when not provided', async () => { - const result = await installAppSim.handler({ appPath: '/path/to/app.app' }); + const result = await handler({ appPath: '/path/to/app.app' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -55,7 +47,7 @@ describe('install_app_sim tool', () => { it('should validate appPath when simulatorId default exists', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - const result = await installAppSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Parameter validation failed'); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 33e0adc5..34a72d34 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -5,7 +5,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import launchAppLogsSim, { +import { + schema, + handler, launch_app_logs_simLogic, LogCaptureFunction, } from '../launch_app_logs_sim.ts'; @@ -18,22 +20,17 @@ describe('launch_app_logs_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should expose correct metadata', () => { - expect(launchAppLogsSim.name).toBe('launch_app_logs_sim'); - expect(launchAppLogsSim.description).toBe('Launch sim app with logs.'); - }); - it('should expose only non-session fields in public schema', () => { - const schema = z.object(launchAppLogsSim.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ args: ['--debug'] }).success).toBe(true); - expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); - expect(schema.safeParse({ bundleId: 42 }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ args: ['--debug'] }).success).toBe(true); + expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schemaObj.safeParse({ bundleId: 42 }).success).toBe(true); - expect(Object.keys(launchAppLogsSim.schema).sort()).toEqual(['args']); + expect(Object.keys(schema).sort()).toEqual(['args']); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: 'abc123', }); expect(withSimId.success).toBe(true); @@ -43,7 +40,7 @@ describe('launch_app_logs_sim tool', () => { describe('Handler Requirements', () => { it('should require simulatorId when not provided', async () => { - const result = await launchAppLogsSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -54,7 +51,7 @@ describe('launch_app_logs_sim tool', () => { it('should require bundleId when simulatorId default exists', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - const result = await launchAppLogsSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index 51b536a0..11403f70 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import launchAppSim, { launch_app_simLogic } from '../launch_app_sim.ts'; +import { schema, handler, launch_app_simLogic } from '../launch_app_sim.ts'; describe('launch_app_sim tool', () => { beforeEach(() => { @@ -10,28 +10,23 @@ describe('launch_app_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should expose correct name and description', () => { - expect(launchAppSim.name).toBe('launch_app_sim'); - expect(launchAppSim.description).toBe('Launch app on simulator.'); - }); - it('should expose only non-session fields in public schema', () => { - const schema = z.strictObject(launchAppSim.schema); + const schemaObj = z.strictObject(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ args: ['--debug'], }).success, ).toBe(true); - expect(schema.safeParse({ bundleId: 'com.example.testapp' }).success).toBe(false); - expect(schema.safeParse({ bundleId: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ bundleId: 'com.example.testapp' }).success).toBe(false); + expect(schemaObj.safeParse({ bundleId: 123 }).success).toBe(false); - expect(Object.keys(launchAppSim.schema).sort()).toEqual(['args']); + expect(Object.keys(schema).sort()).toEqual(['args']); - const withSimDefaults = schema.safeParse({ + const withSimDefaults = schemaObj.safeParse({ simulatorId: 'sim-default', simulatorName: 'iPhone 16', }); @@ -41,7 +36,7 @@ describe('launch_app_sim tool', () => { describe('Handler Requirements', () => { it('should require simulator identifier when not provided', async () => { - const result = await launchAppSim.handler({ bundleId: 'com.example.testapp' }); + const result = await handler({ bundleId: 'com.example.testapp' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -52,7 +47,7 @@ describe('launch_app_sim tool', () => { it('should require bundleId when simulatorId default exists', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - const result = await launchAppSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -60,7 +55,7 @@ describe('launch_app_sim tool', () => { }); it('should reject when both simulatorId and simulatorName provided explicitly', async () => { - const result = await launchAppSim.handler({ + const result = await handler({ simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', bundleId: 'com.example.testapp', diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts index 49147c3c..ab0e998c 100644 --- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts +++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts @@ -5,8 +5,8 @@ import { createMockExecutor, } from '../../../../test-utils/mock-executors.ts'; -// Import the plugin and logic function -import listSims, { list_simsLogic } from '../list_sims.ts'; +// Import the named exports and logic function +import { schema, handler, list_simsLogic } from '../list_sims.ts'; describe('list_sims tool', () => { let callHistory: Array<{ @@ -19,31 +19,23 @@ describe('list_sims tool', () => { callHistory = []; describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(listSims.name).toBe('list_sims'); - }); - - it('should have correct description', () => { - expect(listSims.description).toBe('List iOS simulators.'); - }); - it('should have handler function', () => { - expect(typeof listSims.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have correct schema with enabled boolean field', () => { - const schema = z.object(listSims.schema); + const schemaObj = z.object(schema); // Valid inputs - expect(schema.safeParse({ enabled: true }).success).toBe(true); - expect(schema.safeParse({ enabled: false }).success).toBe(true); - expect(schema.safeParse({ enabled: undefined }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ enabled: true }).success).toBe(true); + expect(schemaObj.safeParse({ enabled: false }).success).toBe(true); + expect(schemaObj.safeParse({ enabled: undefined }).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); // Invalid inputs - expect(schema.safeParse({ enabled: 'yes' }).success).toBe(false); - expect(schema.safeParse({ enabled: 1 }).success).toBe(false); - expect(schema.safeParse({ enabled: null }).success).toBe(false); + expect(schemaObj.safeParse({ enabled: 'yes' }).success).toBe(false); + expect(schemaObj.safeParse({ enabled: 1 }).success).toBe(false); + expect(schemaObj.safeParse({ enabled: null }).success).toBe(false); }); }); diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts index 2d853bdf..6954a871 100644 --- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts @@ -11,37 +11,29 @@ import { createMockExecutor, type CommandExecutor, } from '../../../../test-utils/mock-executors.ts'; -import openSim, { open_simLogic } from '../open_sim.ts'; +import { schema, handler, open_simLogic } from '../open_sim.ts'; describe('open_sim tool', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { - expect(openSim.name).toBe('open_sim'); - }); - - it('should have correct description field', () => { - expect(openSim.description).toBe('Open Simulator app.'); - }); - it('should have handler function', () => { - expect(typeof openSim.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have correct schema validation', () => { - const schema = z.object(openSim.schema); + const schemaObj = z.object(schema); // Schema is empty, so any object should pass - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ anyProperty: 'value', }).success, ).toBe(true); // Empty schema should accept anything expect( - schema.safeParse({ + schemaObj.safeParse({ enabled: true, }).success, ).toBe(true); diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts index b7e8b384..56fc0676 100644 --- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts +++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // Import the tool and logic -import tool, { record_sim_videoLogic } from '../record_sim_video.ts'; +import { schema, handler, record_sim_videoLogic } from '../record_sim_video.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; const DUMMY_EXECUTOR: any = (async () => ({ success: true })) as any; // CommandExecutor stub @@ -13,7 +13,7 @@ afterEach(() => { describe('record_sim_video tool - validation', () => { it('errors when start and stop are both true (mutually exclusive)', async () => { - const res = await tool.handler({ + const res = await handler({ simulatorId: VALID_SIM_ID, start: true, stop: true, @@ -25,7 +25,7 @@ describe('record_sim_video tool - validation', () => { }); it('errors when stop=true but outputFile is missing', async () => { - const res = await tool.handler({ + const res = await handler({ simulatorId: VALID_SIM_ID, stop: true, } as any); diff --git a/src/mcp/tools/simulator/__tests__/screenshot.test.ts b/src/mcp/tools/simulator/__tests__/screenshot.test.ts index ad6c75d4..0f249472 100644 --- a/src/mcp/tools/simulator/__tests__/screenshot.test.ts +++ b/src/mcp/tools/simulator/__tests__/screenshot.test.ts @@ -15,7 +15,7 @@ import { import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { SystemError } from '../../../../utils/responses/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import screenshotPlugin, { screenshotLogic } from '../../ui-automation/screenshot.ts'; +import { schema, handler, screenshotLogic } from '../../ui-automation/screenshot.ts'; describe('screenshot plugin', () => { beforeEach(() => { @@ -23,24 +23,16 @@ describe('screenshot plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name field', () => { - expect(screenshotPlugin.name).toBe('screenshot'); - }); - - it('should have correct description field', () => { - expect(screenshotPlugin.description).toBe('Capture screenshot.'); - }); - it('should have handler function', () => { - expect(typeof screenshotPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should have correct schema validation', () => { - const schema = z.object(screenshotPlugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '550e8400-e29b-41d4-a716-446655440000', }); expect(withSimId.success).toBe(true); @@ -334,7 +326,7 @@ describe('screenshot plugin', () => { }); it('should handle missing simulatorId via handler', async () => { - const result = await screenshotPlugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const message = result.content[0].text; diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts index 228d79cb..f5ae1af2 100644 --- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -6,7 +6,7 @@ import { } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import plugin, { stop_app_simLogic } from '../stop_app_sim.ts'; +import { schema, handler, stop_app_simLogic } from '../stop_app_sim.ts'; describe('stop_app_sim tool', () => { beforeEach(() => { @@ -14,20 +14,15 @@ describe('stop_app_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should expose correct metadata', () => { - expect(plugin.name).toBe('stop_app_sim'); - expect(plugin.description).toBe('Stop sim app.'); - }); - it('should expose empty public schema', () => { - const schema = z.object(plugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({}).success).toBe(true); - expect(schema.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); - expect(schema.safeParse({ bundleId: 42 }).success).toBe(true); - expect(Object.keys(plugin.schema)).toEqual([]); + expect(schemaObj.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({ bundleId: 'com.example.app' }).success).toBe(true); + expect(schemaObj.safeParse({ bundleId: 42 }).success).toBe(true); + expect(Object.keys(schema)).toEqual([]); - const withSessionDefaults = schema.safeParse({ + const withSessionDefaults = schemaObj.safeParse({ simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', }); @@ -40,7 +35,7 @@ describe('stop_app_sim tool', () => { describe('Handler Requirements', () => { it('should require simulator identifier when not provided', async () => { - const result = await plugin.handler({ bundleId: 'com.example.app' }); + const result = await handler({ bundleId: 'com.example.app' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -51,7 +46,7 @@ describe('stop_app_sim tool', () => { it('should require bundleId when simulatorId default exists', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - const result = await plugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -59,7 +54,7 @@ describe('stop_app_sim tool', () => { }); it('should reject mutually exclusive simulator parameters', async () => { - const result = await plugin.handler({ + const result = await handler({ simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', bundleId: 'com.example.app', diff --git a/src/mcp/tools/simulator/__tests__/test_sim.test.ts b/src/mcp/tools/simulator/__tests__/test_sim.test.ts index 3769d918..bcb17b16 100644 --- a/src/mcp/tools/simulator/__tests__/test_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/test_sim.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { sessionStore } from '../../../../utils/session-store.ts'; -import testSim from '../test_sim.ts'; +import { schema, handler, test_simLogic } from '../test_sim.ts'; describe('test_sim tool', () => { beforeEach(() => { @@ -14,42 +14,34 @@ describe('test_sim tool', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(testSim.name).toBe('test_sim'); - }); - - it('should have concise description', () => { - expect(testSim.description).toBe('Test on iOS sim.'); - }); - it('should have handler function', () => { - expect(typeof testSim.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose only non-session fields in public schema', () => { - const schema = z.strictObject(testSim.schema); + const schemaObj = z.strictObject(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ extraArgs: ['--quiet'], testRunnerEnv: { FOO: 'BAR' }, }).success, ).toBe(true); - expect(schema.safeParse({ derivedDataPath: 123 }).success).toBe(false); - expect(schema.safeParse({ extraArgs: ['--ok', 42] }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schema.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); + expect(schemaObj.safeParse({ derivedDataPath: 123 }).success).toBe(false); + expect(schemaObj.safeParse({ extraArgs: ['--ok', 42] }).success).toBe(false); + expect(schemaObj.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(schemaObj.safeParse({ testRunnerEnv: { FOO: 123 } }).success).toBe(false); - const schemaKeys = Object.keys(testSim.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs', 'testRunnerEnv'].sort()); }); }); describe('Handler Requirements', () => { it('should require scheme when not provided', async () => { - const result = await testSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('scheme is required'); @@ -58,7 +50,7 @@ describe('test_sim tool', () => { it('should require project or workspace when scheme default exists', async () => { sessionStore.setDefaults({ scheme: 'MyScheme' }); - const result = await testSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide a project or workspace'); @@ -70,7 +62,7 @@ describe('test_sim tool', () => { projectPath: '/path/to/project.xcodeproj', }); - const result = await testSim.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); @@ -82,7 +74,7 @@ describe('test_sim tool', () => { workspacePath: '/path/to/workspace.xcworkspace', }); - const result = await testSim.handler({ + const result = await handler({ simulatorId: 'SIM-UUID', simulatorName: 'iPhone 16', }); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index 8a85cb0f..40e3a08e 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -83,21 +83,14 @@ export async function boot_simLogic( } } -export default { - name: 'boot_sim', - description: 'Boot iOS simulator.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: bootSimSchemaObject, - }), - annotations: { - title: 'Boot Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: bootSimSchemaObject as unknown as z.ZodType, - logicFunction: boot_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: bootSimSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: bootSimSchemaObject as unknown as z.ZodType, + logicFunction: boot_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts index beaf6b71..5a61a234 100644 --- a/src/mcp/tools/simulator/build_run_sim.ts +++ b/src/mcp/tools/simulator/build_run_sim.ts @@ -506,32 +506,22 @@ const publicSchemaObject = baseSchemaObject.omit({ preferXcodebuild: true, } as const); -export default { - name: 'build_run_sim', - description: 'Build and run iOS sim.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Build Run Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: buildRunSimulatorSchema as unknown as z.ZodType< - BuildRunSimulatorParams, - unknown - >, - logicFunction: build_run_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - ], - exclusivePairs: [ - ['projectPath', 'workspacePath'], - ['simulatorId', 'simulatorName'], - ], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buildRunSimulatorSchema as unknown as z.ZodType, + logicFunction: build_run_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], +}); diff --git a/src/mcp/tools/simulator/build_sim.ts b/src/mcp/tools/simulator/build_sim.ts index 1d46361a..a36303c3 100644 --- a/src/mcp/tools/simulator/build_sim.ts +++ b/src/mcp/tools/simulator/build_sim.ts @@ -146,29 +146,22 @@ const publicSchemaObject = baseSchemaObject.omit({ preferXcodebuild: true, } as const); -export default { - name: 'build_sim', - description: 'Build for iOS sim.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), // MCP SDK compatibility (public inputs only) - annotations: { - title: 'Build Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: buildSimulatorSchema as unknown as z.ZodType, - logicFunction: build_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - ], - exclusivePairs: [ - ['projectPath', 'workspacePath'], - ['simulatorId', 'simulatorName'], - ], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buildSimulatorSchema as unknown as z.ZodType, + logicFunction: build_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], +}); diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts index d661e937..d4b8781f 100644 --- a/src/mcp/tools/simulator/get_sim_app_path.ts +++ b/src/mcp/tools/simulator/get_sim_app_path.ts @@ -348,29 +348,22 @@ const publicSchemaObject = baseGetSimulatorAppPathSchema.omit({ arch: true, } as const); -export default { - name: 'get_sim_app_path', - description: 'Get sim built app path.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseGetSimulatorAppPathSchema, - }), - annotations: { - title: 'Get Simulator App Path', - readOnlyHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: getSimulatorAppPathSchema as unknown as z.ZodType, - logicFunction: get_sim_app_pathLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - ], - exclusivePairs: [ - ['projectPath', 'workspacePath'], - ['simulatorId', 'simulatorName'], - ], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseGetSimulatorAppPathSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: getSimulatorAppPathSchema as unknown as z.ZodType, + logicFunction: get_sim_app_pathLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], +}); diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index eee3538a..510f9b74 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -103,21 +103,14 @@ export async function install_app_simLogic( } } -export default { - name: 'install_app_sim', - description: 'Install app on sim.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: installAppSimSchemaObject, - }), - annotations: { - title: 'Install App Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: installAppSimSchemaObject as unknown as z.ZodType, - logicFunction: install_app_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: installAppSimSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: installAppSimSchemaObject as unknown as z.ZodType, + logicFunction: install_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index 65421b06..7e998e02 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -75,29 +75,19 @@ export async function launch_app_logs_simLogic( }; } -export default { - name: 'launch_app_logs_sim', - description: 'Launch sim app with logs.', - cli: { - stateful: true, - }, - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: launchAppLogsSimSchemaObject, - }), - annotations: { - title: 'Launch App Logs Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: launchAppLogsSimSchemaObject as unknown as z.ZodType< - LaunchAppLogsSimParams, - unknown - >, - logicFunction: launch_app_logs_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['simulatorId', 'bundleId'], message: 'Provide simulatorId and bundleId' }, - ], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: launchAppLogsSimSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: launchAppLogsSimSchemaObject as unknown as z.ZodType< + LaunchAppLogsSimParams, + unknown + >, + logicFunction: launch_app_logs_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['simulatorId', 'bundleId'], message: 'Provide simulatorId and bundleId' }, + ], +}); diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index 71f710b6..c913efb2 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -217,25 +217,18 @@ const publicSchemaObject = z.strictObject( } as const).shape, ); -export default { - name: 'launch_app_sim', - description: 'Launch app on simulator.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Launch App Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: launchAppSimSchema as unknown as z.ZodType, - logicFunction: launch_app_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - { allOf: ['bundleId'], message: 'bundleId is required' }, - ], - exclusivePairs: [['simulatorId', 'simulatorName']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: launchAppSimSchema as unknown as z.ZodType, + logicFunction: launch_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + { allOf: ['bundleId'], message: 'bundleId is required' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], +}); diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts index 89de957e..c1e3a69e 100644 --- a/src/mcp/tools/simulator/list_sims.ts +++ b/src/mcp/tools/simulator/list_sims.ts @@ -238,13 +238,6 @@ export async function list_simsLogic( } } -export default { - name: 'list_sims', - description: 'List iOS simulators.', - schema: listSimsSchema.shape, // MCP SDK compatibility - annotations: { - title: 'List Simulators', - readOnlyHint: true, - }, - handler: createTypedTool(listSimsSchema, list_simsLogic, getDefaultCommandExecutor), -}; +export const schema = listSimsSchema.shape; + +export const handler = createTypedTool(listSimsSchema, list_simsLogic, getDefaultCommandExecutor); diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts index f06affed..68f4c281 100644 --- a/src/mcp/tools/simulator/open_sim.ts +++ b/src/mcp/tools/simulator/open_sim.ts @@ -80,13 +80,6 @@ export async function open_simLogic( } } -export default { - name: 'open_sim', - description: 'Open Simulator app.', - schema: openSimSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Open Simulator', - destructiveHint: true, - }, - handler: createTypedTool(openSimSchema, open_simLogic, getDefaultCommandExecutor), -}; +export const schema = openSimSchema.shape; + +export const handler = createTypedTool(openSimSchema, open_simLogic, getDefaultCommandExecutor); diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts index 787e6274..90e85d23 100644 --- a/src/mcp/tools/simulator/record_sim_video.ts +++ b/src/mcp/tools/simulator/record_sim_video.ts @@ -225,24 +225,14 @@ const publicSchemaObject = z.strictObject( recordSimVideoSchemaObject.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'record_sim_video', - description: 'Record sim video.', - cli: { - stateful: true, - }, - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: recordSimVideoSchemaObject, - }), - annotations: { - title: 'Record Simulator Video', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: recordSimVideoSchema as unknown as z.ZodType, - logicFunction: record_sim_videoLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: recordSimVideoSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: recordSimVideoSchema as unknown as z.ZodType, + logicFunction: record_sim_videoLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index 3e3fea49..eb337c18 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -156,25 +156,18 @@ const publicSchemaObject = z.strictObject( } as const).shape, ); -export default { - name: 'stop_app_sim', - description: 'Stop sim app.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Stop App Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: stopAppSimSchema as unknown as z.ZodType, - logicFunction: stop_app_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - { allOf: ['bundleId'], message: 'bundleId is required' }, - ], - exclusivePairs: [['simulatorId', 'simulatorName']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: stopAppSimSchema as unknown as z.ZodType, + logicFunction: stop_app_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + { allOf: ['bundleId'], message: 'bundleId is required' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], +}); diff --git a/src/mcp/tools/simulator/test_sim.ts b/src/mcp/tools/simulator/test_sim.ts index 264dfe90..c6fc5e86 100644 --- a/src/mcp/tools/simulator/test_sim.ts +++ b/src/mcp/tools/simulator/test_sim.ts @@ -122,29 +122,22 @@ const publicSchemaObject = baseSchemaObject.omit({ preferXcodebuild: true, } as const); -export default { - name: 'test_sim', - description: 'Test on iOS sim.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Test Simulator', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: testSimulatorSchema as unknown as z.ZodType, - logicFunction: test_simLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { allOf: ['scheme'], message: 'scheme is required' }, - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, - ], - exclusivePairs: [ - ['projectPath', 'workspacePath'], - ['simulatorId', 'simulatorName'], - ], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: testSimulatorSchema as unknown as z.ZodType, + logicFunction: test_simLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { allOf: ['scheme'], message: 'scheme is required' }, + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [ + ['projectPath', 'workspacePath'], + ['simulatorId', 'simulatorName'], + ], +}); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts index 8da9cfdd..d44c25a8 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts @@ -12,31 +12,23 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import swiftPackageBuild, { swift_package_buildLogic } from '../swift_package_build.ts'; +import { schema, handler, swift_package_buildLogic } from '../swift_package_build.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('swift_package_build plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swiftPackageBuild.name).toBe('swift_package_build'); - }); - - it('should have correct description', () => { - expect(swiftPackageBuild.description).toBe('swift package target build.'); - }); - it('should have handler function', () => { - expect(typeof swiftPackageBuild.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - const schema = z.strictObject(swiftPackageBuild.schema); + const strictSchema = z.strictObject(schema); - expect(schema.safeParse({ packagePath: '/test/package' }).success).toBe(true); - expect(schema.safeParse({ packagePath: '' }).success).toBe(true); + expect(strictSchema.safeParse({ packagePath: '/test/package' }).success).toBe(true); + expect(strictSchema.safeParse({ packagePath: '' }).success).toBe(true); expect( - schema.safeParse({ + strictSchema.safeParse({ packagePath: '/test/package', targetName: 'MyTarget', architectures: ['arm64'], @@ -44,18 +36,19 @@ describe('swift_package_build plugin', () => { }).success, ).toBe(true); - expect(schema.safeParse({ packagePath: null }).success).toBe(false); + expect(strictSchema.safeParse({ packagePath: null }).success).toBe(false); expect( - schema.safeParse({ packagePath: '/test/package', configuration: 'release' }).success, + strictSchema.safeParse({ packagePath: '/test/package', configuration: 'release' }).success, ).toBe(false); expect( - schema.safeParse({ packagePath: '/test/package', architectures: 'not-array' }).success, + strictSchema.safeParse({ packagePath: '/test/package', architectures: 'not-array' }) + .success, ).toBe(false); expect( - schema.safeParse({ packagePath: '/test/package', parseAsLibrary: 'yes' }).success, + strictSchema.safeParse({ packagePath: '/test/package', parseAsLibrary: 'yes' }).success, ).toBe(false); - const schemaKeys = Object.keys(swiftPackageBuild.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['architectures', 'packagePath', 'parseAsLibrary', 'targetName']); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts index 5a3d0360..f739054a 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts @@ -11,31 +11,23 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import swiftPackageClean, { swift_package_cleanLogic } from '../swift_package_clean.ts'; +import { schema, handler, swift_package_cleanLogic } from '../swift_package_clean.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('swift_package_clean plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swiftPackageClean.name).toBe('swift_package_clean'); - }); - - it('should have correct description', () => { - expect(swiftPackageClean.description).toBe('swift package clean.'); - }); - it('should have handler function', () => { - expect(typeof swiftPackageClean.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { // Test required fields - expect(swiftPackageClean.schema.packagePath.safeParse('/test/package').success).toBe(true); - expect(swiftPackageClean.schema.packagePath.safeParse('').success).toBe(true); + expect(schema.packagePath.safeParse('/test/package').success).toBe(true); + expect(schema.packagePath.safeParse('').success).toBe(true); // Test invalid inputs - expect(swiftPackageClean.schema.packagePath.safeParse(null).success).toBe(false); - expect(swiftPackageClean.schema.packagePath.safeParse(undefined).success).toBe(false); + expect(schema.packagePath.safeParse(null).success).toBe(false); + expect(schema.packagePath.safeParse(undefined).success).toBe(false); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts index 17f8be76..32a0b22c 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_list.test.ts @@ -5,28 +5,20 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import swiftPackageList, { swift_package_listLogic } from '../swift_package_list.ts'; +import { schema, handler, swift_package_listLogic } from '../swift_package_list.ts'; describe('swift_package_list plugin', () => { // No mocks to clear with pure dependency injection describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swiftPackageList.name).toBe('swift_package_list'); - }); - - it('should have correct description', () => { - expect(swiftPackageList.description).toBe('List SwiftPM processes.'); - }); - it('should have handler function', () => { - expect(typeof swiftPackageList.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { // The schema is an empty object, so any input should be valid - expect(typeof swiftPackageList.schema).toBe('object'); - expect(Object.keys(swiftPackageList.schema)).toEqual([]); + expect(typeof schema).toBe('object'); + expect(Object.keys(schema)).toEqual([]); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts index 6f730417..0e55cb84 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts @@ -11,31 +11,23 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import swiftPackageRun, { swift_package_runLogic } from '../swift_package_run.ts'; +import { schema, handler, swift_package_runLogic } from '../swift_package_run.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('swift_package_run plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swiftPackageRun.name).toBe('swift_package_run'); - }); - - it('should have correct description', () => { - expect(swiftPackageRun.description).toBe('swift package target run.'); - }); - it('should have handler function', () => { - expect(typeof swiftPackageRun.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - const schema = z.strictObject(swiftPackageRun.schema); + const strictSchema = z.strictObject(schema); - expect(schema.safeParse({ packagePath: 'valid/path' }).success).toBe(true); - expect(schema.safeParse({ packagePath: null }).success).toBe(false); + expect(strictSchema.safeParse({ packagePath: 'valid/path' }).success).toBe(true); + expect(strictSchema.safeParse({ packagePath: null }).success).toBe(false); expect( - schema.safeParse({ + strictSchema.safeParse({ packagePath: 'valid/path', executableName: 'MyExecutable', arguments: ['arg1', 'arg2'], @@ -45,24 +37,26 @@ describe('swift_package_run plugin', () => { }).success, ).toBe(true); - expect(schema.safeParse({ packagePath: 'valid/path', executableName: 123 }).success).toBe( - false, - ); expect( - schema.safeParse({ packagePath: 'valid/path', arguments: ['arg1', 123] }).success, + strictSchema.safeParse({ packagePath: 'valid/path', executableName: 123 }).success, ).toBe(false); expect( - schema.safeParse({ packagePath: 'valid/path', configuration: 'release' }).success, + strictSchema.safeParse({ packagePath: 'valid/path', arguments: ['arg1', 123] }).success, ).toBe(false); - expect(schema.safeParse({ packagePath: 'valid/path', timeout: '30' }).success).toBe(false); - expect(schema.safeParse({ packagePath: 'valid/path', background: 'true' }).success).toBe( - false, - ); - expect(schema.safeParse({ packagePath: 'valid/path', parseAsLibrary: 'true' }).success).toBe( + expect( + strictSchema.safeParse({ packagePath: 'valid/path', configuration: 'release' }).success, + ).toBe(false); + expect(strictSchema.safeParse({ packagePath: 'valid/path', timeout: '30' }).success).toBe( false, ); + expect( + strictSchema.safeParse({ packagePath: 'valid/path', background: 'true' }).success, + ).toBe(false); + expect( + strictSchema.safeParse({ packagePath: 'valid/path', parseAsLibrary: 'true' }).success, + ).toBe(false); - const schemaKeys = Object.keys(swiftPackageRun.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual( [ 'arguments', @@ -293,7 +287,7 @@ describe('swift_package_run plugin', () => { it('should return validation error for missing packagePath', async () => { // Since the tool now uses createTypedTool, Zod validation happens at the handler level // Test the handler directly to see Zod validation - const result = await swiftPackageRun.handler({}); + const result = await handler({}); expect(result).toEqual({ content: [ diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts index de300a14..0ba8c883 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_stop.test.ts @@ -6,7 +6,9 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; -import swiftPackageStop, { +import { + schema, + handler, createMockProcessManager, swift_package_stopLogic, type ProcessManager, @@ -51,30 +53,22 @@ class MockProcess { describe('swift_package_stop plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swiftPackageStop.name).toBe('swift_package_stop'); - }); - - it('should have correct description', () => { - expect(swiftPackageStop.description).toBe('Stop SwiftPM run.'); - }); - it('should have handler function', () => { - expect(typeof swiftPackageStop.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { // Test valid inputs - expect(swiftPackageStop.schema.pid.safeParse(12345).success).toBe(true); - expect(swiftPackageStop.schema.pid.safeParse(0).success).toBe(true); - expect(swiftPackageStop.schema.pid.safeParse(-1).success).toBe(true); + expect(schema.pid.safeParse(12345).success).toBe(true); + expect(schema.pid.safeParse(0).success).toBe(true); + expect(schema.pid.safeParse(-1).success).toBe(true); // Test invalid inputs - expect(swiftPackageStop.schema.pid.safeParse('not-a-number').success).toBe(false); - expect(swiftPackageStop.schema.pid.safeParse(null).success).toBe(false); - expect(swiftPackageStop.schema.pid.safeParse(undefined).success).toBe(false); - expect(swiftPackageStop.schema.pid.safeParse({}).success).toBe(false); - expect(swiftPackageStop.schema.pid.safeParse([]).success).toBe(false); + expect(schema.pid.safeParse('not-a-number').success).toBe(false); + expect(schema.pid.safeParse(null).success).toBe(false); + expect(schema.pid.safeParse(undefined).success).toBe(false); + expect(schema.pid.safeParse({}).success).toBe(false); + expect(schema.pid.safeParse([]).success).toBe(false); }); }); diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts index e553d040..2e12f9cd 100644 --- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts +++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts @@ -12,31 +12,23 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import swiftPackageTest, { swift_package_testLogic } from '../swift_package_test.ts'; +import { schema, handler, swift_package_testLogic } from '../swift_package_test.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('swift_package_test plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swiftPackageTest.name).toBe('swift_package_test'); - }); - - it('should have correct description', () => { - expect(swiftPackageTest.description).toBe('Run swift package target tests.'); - }); - it('should have handler function', () => { - expect(typeof swiftPackageTest.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema correctly', () => { - const schema = z.strictObject(swiftPackageTest.schema); + const strictSchema = z.strictObject(schema); - expect(schema.safeParse({ packagePath: '/test/package' }).success).toBe(true); - expect(schema.safeParse({ packagePath: '' }).success).toBe(true); + expect(strictSchema.safeParse({ packagePath: '/test/package' }).success).toBe(true); + expect(strictSchema.safeParse({ packagePath: '' }).success).toBe(true); expect( - schema.safeParse({ + strictSchema.safeParse({ packagePath: '/test/package', testProduct: 'MyTests', filter: 'Test.*', @@ -46,21 +38,21 @@ describe('swift_package_test plugin', () => { }).success, ).toBe(true); - expect(schema.safeParse({ packagePath: null }).success).toBe(false); + expect(strictSchema.safeParse({ packagePath: null }).success).toBe(false); expect( - schema.safeParse({ packagePath: '/test/package', configuration: 'release' }).success, + strictSchema.safeParse({ packagePath: '/test/package', configuration: 'release' }).success, + ).toBe(false); + expect( + strictSchema.safeParse({ packagePath: '/test/package', parallel: 'yes' }).success, + ).toBe(false); + expect( + strictSchema.safeParse({ packagePath: '/test/package', showCodecov: 'yes' }).success, ).toBe(false); - expect(schema.safeParse({ packagePath: '/test/package', parallel: 'yes' }).success).toBe( - false, - ); - expect(schema.safeParse({ packagePath: '/test/package', showCodecov: 'yes' }).success).toBe( - false, - ); expect( - schema.safeParse({ packagePath: '/test/package', parseAsLibrary: 'yes' }).success, + strictSchema.safeParse({ packagePath: '/test/package', parseAsLibrary: 'yes' }).success, ).toBe(false); - const schemaKeys = Object.keys(swiftPackageTest.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual( [ 'filter', diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts index b80524da..96383809 100644 --- a/src/mcp/tools/swift-package/swift_package_build.ts +++ b/src/mcp/tools/swift-package/swift_package_build.ts @@ -79,20 +79,13 @@ export async function swift_package_buildLogic( } } -export default { - name: 'swift_package_build', - description: 'swift package target build.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), // MCP SDK compatibility - annotations: { - title: 'Swift Package Build', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: swiftPackageBuildSchema, - logicFunction: swift_package_buildLogic, - getExecutor: getDefaultCommandExecutor, - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: swiftPackageBuildSchema, + logicFunction: swift_package_buildLogic, + getExecutor: getDefaultCommandExecutor, +}); diff --git a/src/mcp/tools/swift-package/swift_package_clean.ts b/src/mcp/tools/swift-package/swift_package_clean.ts index 543378f4..4d8b47aa 100644 --- a/src/mcp/tools/swift-package/swift_package_clean.ts +++ b/src/mcp/tools/swift-package/swift_package_clean.ts @@ -48,17 +48,10 @@ export async function swift_package_cleanLogic( } } -export default { - name: 'swift_package_clean', - description: 'swift package clean.', - schema: swiftPackageCleanSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Swift Package Clean', - destructiveHint: true, - }, - handler: createTypedTool( - swiftPackageCleanSchema, - swift_package_cleanLogic, - getDefaultCommandExecutor, - ), -}; +export const schema = swiftPackageCleanSchema.shape; + +export const handler = createTypedTool( + swiftPackageCleanSchema, + swift_package_cleanLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 1506d2a5..42d14de0 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -85,22 +85,12 @@ const swiftPackageListSchema = z.object({}); // Use z.infer for type safety type SwiftPackageListParams = z.infer; -export default { - name: 'swift_package_list', - description: 'List SwiftPM processes.', - cli: { - stateful: true, - }, - schema: swiftPackageListSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Swift Package List', - readOnlyHint: true, +export const schema = swiftPackageListSchema.shape; + +export const handler = createTypedTool( + swiftPackageListSchema, + (params: SwiftPackageListParams) => { + return swift_package_listLogic(params); }, - handler: createTypedTool( - swiftPackageListSchema, - (params: SwiftPackageListParams) => { - return swift_package_listLogic(params); - }, - getDefaultCommandExecutor, - ), -}; + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts index 87c7626b..38dbd1ab 100644 --- a/src/mcp/tools/swift-package/swift_package_run.ts +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -219,23 +219,13 @@ export async function swift_package_runLogic( } } -export default { - name: 'swift_package_run', - description: 'swift package target run.', - cli: { - stateful: true, - }, - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), // MCP SDK compatibility - annotations: { - title: 'Swift Package Run', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: swiftPackageRunSchema, - logicFunction: swift_package_runLogic, - getExecutor: getDefaultCommandExecutor, - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: swiftPackageRunSchema, + logicFunction: swift_package_runLogic, + getExecutor: getDefaultCommandExecutor, +}); diff --git a/src/mcp/tools/swift-package/swift_package_stop.ts b/src/mcp/tools/swift-package/swift_package_stop.ts index a8f56f7a..916546d7 100644 --- a/src/mcp/tools/swift-package/swift_package_stop.ts +++ b/src/mcp/tools/swift-package/swift_package_stop.ts @@ -101,27 +101,16 @@ export async function swift_package_stopLogic( } } -export default { - name: 'swift_package_stop', - description: 'Stop SwiftPM run.', - cli: { - stateful: true, - }, - schema: swiftPackageStopSchema.shape, // MCP SDK compatibility - annotations: { - title: 'Swift Package Stop', - destructiveHint: true, - }, - async handler(args: Record): Promise { - // Validate parameters using Zod - const parseResult = swiftPackageStopSchema.safeParse(args); - if (!parseResult.success) { - return createErrorResponse( - 'Parameter validation failed', - parseResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '), - ); - } - - return swift_package_stopLogic(parseResult.data); - }, -}; +export const schema = swiftPackageStopSchema.shape; + +export async function handler(args: Record): Promise { + const parseResult = swiftPackageStopSchema.safeParse(args); + if (!parseResult.success) { + return createErrorResponse( + 'Parameter validation failed', + parseResult.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', '), + ); + } + + return swift_package_stopLogic(parseResult.data); +} diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts index 5fb389e0..25579b1e 100644 --- a/src/mcp/tools/swift-package/swift_package_test.ts +++ b/src/mcp/tools/swift-package/swift_package_test.ts @@ -89,20 +89,13 @@ export async function swift_package_testLogic( } } -export default { - name: 'swift_package_test', - description: 'Run swift package target tests.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), // MCP SDK compatibility - annotations: { - title: 'Swift Package Test', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: swiftPackageTestSchema, - logicFunction: swift_package_testLogic, - getExecutor: getDefaultCommandExecutor, - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: swiftPackageTestSchema, + logicFunction: swift_package_testLogic, + getExecutor: getDefaultCommandExecutor, +}); diff --git a/src/mcp/tools/ui-automation/__tests__/button.test.ts b/src/mcp/tools/ui-automation/__tests__/button.test.ts index f990b1f9..62f89079 100644 --- a/src/mcp/tools/ui-automation/__tests__/button.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/button.test.ts @@ -9,39 +9,31 @@ import { createNoopExecutor, createMockCommandResponse, } from '../../../../test-utils/mock-executors.ts'; -import buttonPlugin, { buttonLogic } from '../button.ts'; +import { schema, handler, buttonLogic } from '../button.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('Button Plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(buttonPlugin.name).toBe('button'); - }); - - it('should have correct description', () => { - expect(buttonPlugin.description).toBe('Press simulator hardware button.'); - }); - it('should have handler function', () => { - expect(typeof buttonPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(buttonPlugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ buttonType: 'home' }).success).toBe(true); - expect(schema.safeParse({ buttonType: 'home', duration: 2.5 }).success).toBe(true); - expect(schema.safeParse({ buttonType: 'invalid-button' }).success).toBe(false); - expect(schema.safeParse({ buttonType: 'home', duration: -1 }).success).toBe(false); + expect(schemaObj.safeParse({ buttonType: 'home' }).success).toBe(true); + expect(schemaObj.safeParse({ buttonType: 'home', duration: 2.5 }).success).toBe(true); + expect(schemaObj.safeParse({ buttonType: 'invalid-button' }).success).toBe(false); + expect(schemaObj.safeParse({ buttonType: 'home', duration: -1 }).success).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', buttonType: 'home', }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); - expect(schema.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({}).success).toBe(false); }); }); @@ -204,7 +196,7 @@ describe('Button Plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should surface session default requirement when simulatorId is missing', async () => { - const result = await buttonPlugin.handler({ buttonType: 'home' }); + const result = await handler({ buttonType: 'home' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -212,7 +204,7 @@ describe('Button Plugin', () => { }); it('should return error for missing buttonType', async () => { - const result = await buttonPlugin.handler({ + const result = await handler({ simulatorId: '12345678-1234-4234-8234-123456789012', }); @@ -224,7 +216,7 @@ describe('Button Plugin', () => { }); it('should return error for invalid simulatorId format', async () => { - const result = await buttonPlugin.handler({ + const result = await handler({ simulatorId: 'invalid-uuid-format', buttonType: 'home', }); @@ -235,7 +227,7 @@ describe('Button Plugin', () => { }); it('should return error for invalid buttonType', async () => { - const result = await buttonPlugin.handler({ + const result = await handler({ simulatorId: '12345678-1234-4234-8234-123456789012', buttonType: 'invalid-button', }); @@ -245,7 +237,7 @@ describe('Button Plugin', () => { }); it('should return error for negative duration', async () => { - const result = await buttonPlugin.handler({ + const result = await handler({ simulatorId: '12345678-1234-4234-8234-123456789012', buttonType: 'home', duration: -1, diff --git a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts index 0c0d8dfe..29ff4cb5 100644 --- a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts @@ -6,12 +6,11 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, - createMockFileSystemExecutor, createNoopExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import gesturePlugin, { gestureLogic } from '../gesture.ts'; +import { schema, handler, gestureLogic } from '../gesture.ts'; describe('Gesture Plugin', () => { beforeEach(() => { @@ -19,24 +18,16 @@ describe('Gesture Plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(gesturePlugin.name).toBe('gesture'); - }); - - it('should have correct description', () => { - expect(gesturePlugin.description).toBe('Simulator gesture preset.'); - }); - it('should have handler function', () => { - expect(typeof gesturePlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(gesturePlugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ preset: 'scroll-up' }).success).toBe(true); + expect(schemaObj.safeParse({ preset: 'scroll-up' }).success).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ preset: 'scroll-up', screenWidth: 375, screenHeight: 667, @@ -46,22 +37,22 @@ describe('Gesture Plugin', () => { postDelay: 0.2, }).success, ).toBe(true); - expect(schema.safeParse({ preset: 'invalid-preset' }).success).toBe(false); - expect(schema.safeParse({ preset: 'scroll-up', screenWidth: 0 }).success).toBe(false); - expect(schema.safeParse({ preset: 'scroll-up', duration: -1 }).success).toBe(false); + expect(schemaObj.safeParse({ preset: 'invalid-preset' }).success).toBe(false); + expect(schemaObj.safeParse({ preset: 'scroll-up', screenWidth: 0 }).success).toBe(false); + expect(schemaObj.safeParse({ preset: 'scroll-up', duration: -1 }).success).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', preset: 'scroll-up', }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as object)).toBe(false); }); }); describe('Handler Requirements', () => { it('should require simulatorId session default when not provided', async () => { - const result = await gesturePlugin.handler({ preset: 'scroll-up' }); + const result = await handler({ preset: 'scroll-up' }); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -73,7 +64,7 @@ describe('Gesture Plugin', () => { it('should surface validation errors once simulator defaults exist', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await gesturePlugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const message = result.content[0].text; diff --git a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts index b7085a39..aaec8033 100644 --- a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts @@ -1,5 +1,5 @@ /** - * Tests for key_press tool plugin + * Tests for key_press tool */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -7,54 +7,45 @@ import * as z from 'zod'; import { createMockCommandResponse, createMockExecutor, - createMockFileSystemExecutor, createNoopExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import keyPressPlugin, { key_pressLogic } from '../key_press.ts'; +import { schema, handler, key_pressLogic } from '../key_press.ts'; -describe('Key Press Plugin', () => { +describe('Key Press Tool', () => { beforeEach(() => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(keyPressPlugin.name).toBe('key_press'); - }); - - it('should have correct description', () => { - expect(keyPressPlugin.description).toBe('Press key by keycode.'); - }); - + describe('Schema Validation', () => { it('should have handler function', () => { - expect(typeof keyPressPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(keyPressPlugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ keyCode: 40 }).success).toBe(true); - expect(schema.safeParse({ keyCode: 40, duration: 1.5 }).success).toBe(true); - expect(schema.safeParse({ keyCode: 'invalid' }).success).toBe(false); - expect(schema.safeParse({ keyCode: -1 }).success).toBe(false); - expect(schema.safeParse({ keyCode: 256 }).success).toBe(false); + expect(schemaObj.safeParse({ keyCode: 40 }).success).toBe(true); + expect(schemaObj.safeParse({ keyCode: 40, duration: 1.5 }).success).toBe(true); + expect(schemaObj.safeParse({ keyCode: 'invalid' }).success).toBe(false); + expect(schemaObj.safeParse({ keyCode: -1 }).success).toBe(false); + expect(schemaObj.safeParse({ keyCode: 256 }).success).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', keyCode: 40, }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as object)).toBe(false); - expect(schema.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({}).success).toBe(false); }); }); describe('Handler Requirements', () => { it('should require simulatorId session default when not provided', async () => { - const result = await keyPressPlugin.handler({ keyCode: 40 }); + const result = await handler({ keyCode: 40 }); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -66,7 +57,7 @@ describe('Key Press Plugin', () => { it('should surface validation errors once simulator default exists', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await keyPressPlugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const message = result.content[0].text; diff --git a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts index c19936af..1483e616 100644 --- a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts @@ -1,5 +1,5 @@ /** - * Tests for key_sequence plugin + * Tests for key_sequence tool */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -10,50 +10,42 @@ import { mockProcess, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import keySequencePlugin, { key_sequenceLogic } from '../key_sequence.ts'; +import { schema, handler, key_sequenceLogic } from '../key_sequence.ts'; -describe('Key Sequence Plugin', () => { +describe('Key Sequence Tool', () => { beforeEach(() => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(keySequencePlugin.name).toBe('key_sequence'); - }); - - it('should have correct description', () => { - expect(keySequencePlugin.description).toBe('Press a sequence of keys by their keycodes.'); - }); - + describe('Schema Validation', () => { it('should have handler function', () => { - expect(typeof keySequencePlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(keySequencePlugin.schema); + const schemaObj = z.object(schema); - expect(schema.safeParse({ keyCodes: [40, 42, 44] }).success).toBe(true); - expect(schema.safeParse({ keyCodes: [40], delay: 0.1 }).success).toBe(true); - expect(schema.safeParse({ keyCodes: [] }).success).toBe(false); - expect(schema.safeParse({ keyCodes: [-1] }).success).toBe(false); - expect(schema.safeParse({ keyCodes: [256] }).success).toBe(false); - expect(schema.safeParse({ keyCodes: [40], delay: -0.1 }).success).toBe(false); + expect(schemaObj.safeParse({ keyCodes: [40, 42, 44] }).success).toBe(true); + expect(schemaObj.safeParse({ keyCodes: [40], delay: 0.1 }).success).toBe(true); + expect(schemaObj.safeParse({ keyCodes: [] }).success).toBe(false); + expect(schemaObj.safeParse({ keyCodes: [-1] }).success).toBe(false); + expect(schemaObj.safeParse({ keyCodes: [256] }).success).toBe(false); + expect(schemaObj.safeParse({ keyCodes: [40], delay: -0.1 }).success).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', keyCodes: [40], }); expect(withSimId.success).toBe(true); - expect('simulatorId' in (withSimId.data as any)).toBe(false); + expect('simulatorId' in (withSimId.data as Record)).toBe(false); - expect(schema.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({}).success).toBe(false); }); }); describe('Handler Requirements', () => { it('should require simulatorId session default when not provided', async () => { - const result = await keySequencePlugin.handler({ keyCodes: [40] }); + const result = await handler({ keyCodes: [40] }); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -65,7 +57,7 @@ describe('Key Sequence Plugin', () => { it('should surface validation errors once simulator defaults exist', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await keySequencePlugin.handler({ keyCodes: [] }); + const result = await handler({ keyCodes: [] }); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -264,7 +256,7 @@ describe('Key Sequence Plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should surface session default requirement when simulatorId is missing', async () => { - const result = await keySequencePlugin.handler({ keyCodes: [40] }); + const result = await handler({ keyCodes: [40] }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); diff --git a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts index 7185af1f..dc07be0b 100644 --- a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import longPressPlugin, { long_pressLogic } from '../long_press.ts'; +import { schema, handler, long_pressLogic } from '../long_press.ts'; describe('Long Press Plugin', () => { beforeEach(() => { @@ -14,23 +14,15 @@ describe('Long Press Plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(longPressPlugin.name).toBe('long_press'); - }); - - it('should have correct description', () => { - expect(longPressPlugin.description).toBe('Long press at coords.'); - }); - it('should have handler function', () => { - expect(typeof longPressPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { - const schema = z.object(longPressPlugin.schema); + const schemaObject = z.object(schema); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200, duration: 1500, @@ -38,7 +30,7 @@ describe('Long Press Plugin', () => { ).toBe(true); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100.5, y: 200, duration: 1500, @@ -46,7 +38,7 @@ describe('Long Press Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200.5, duration: 1500, @@ -54,7 +46,7 @@ describe('Long Press Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200, duration: 0, @@ -62,14 +54,14 @@ describe('Long Press Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200, duration: -100, }).success, ).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObject.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', x: 100, y: 200, @@ -82,7 +74,7 @@ describe('Long Press Plugin', () => { describe('Handler Requirements', () => { it('should require simulatorId session default', async () => { - const result = await longPressPlugin.handler({ x: 100, y: 200, duration: 1500 }); + const result = await handler({ x: 100, y: 200, duration: 1500 }); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -94,7 +86,7 @@ describe('Long Press Plugin', () => { it('should surface validation errors once simulator default exists', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await longPressPlugin.handler({ x: 100, y: 200, duration: 0 }); + const result = await handler({ x: 100, y: 200, duration: 0 }); expect(result.isError).toBe(true); const message = result.content[0].text; diff --git a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts index d7f68dad..dda945c6 100644 --- a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts @@ -7,12 +7,13 @@ import * as z from 'zod'; import { createMockExecutor, createMockFileSystemExecutor, - createNoopExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; import { SystemError } from '../../../../utils/responses/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import screenshotPlugin, { +import { + schema, + handler, screenshotLogic, detectLandscapeMode, rotateImage, @@ -24,25 +25,17 @@ describe('Screenshot Plugin', () => { }); describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(screenshotPlugin.name).toBe('screenshot'); - }); - - it('should have correct description', () => { - expect(screenshotPlugin.description).toBe('Capture screenshot.'); - }); - it('should have handler function', () => { - expect(typeof screenshotPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { - const schema = z.object(screenshotPlugin.schema); + const schemaObj = z.object(schema); // Public schema is empty; ensure extra fields are stripped - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObj.safeParse({}).success).toBe(true); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', }); expect(withSimId.success).toBe(true); @@ -52,7 +45,7 @@ describe('Screenshot Plugin', () => { describe('Plugin Handler Validation', () => { it('should require simulatorId session default when not provided', async () => { - const result = await screenshotPlugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -62,7 +55,7 @@ describe('Screenshot Plugin', () => { }); it('should validate inline simulatorId overrides', async () => { - const result = await screenshotPlugin.handler({ + const result = await handler({ simulatorId: 'invalid-uuid', }); diff --git a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts index 1bf8c1a6..7f8d096e 100644 --- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts @@ -6,30 +6,22 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, createNoopExecutor } from '../../../../test-utils/mock-executors.ts'; import type { CommandExecutor } from '../../../../utils/execution/index.ts'; -import snapshotUIPlugin, { snapshot_uiLogic } from '../snapshot_ui.ts'; +import { schema, handler, snapshot_uiLogic } from '../snapshot_ui.ts'; describe('Snapshot UI Plugin', () => { describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(snapshotUIPlugin.name).toBe('snapshot_ui'); - }); - - it('should have correct description', () => { - expect(snapshotUIPlugin.description).toBe( - 'Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements.', - ); - }); - it('should have handler function', () => { - expect(typeof snapshotUIPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should expose public schema without simulatorId field', () => { - const schema = z.object(snapshotUIPlugin.schema); + const schemaObject = z.object(schema); - expect(schema.safeParse({}).success).toBe(true); + expect(schemaObject.safeParse({}).success).toBe(true); - const withSimId = schema.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012' }); + const withSimId = schemaObject.safeParse({ + simulatorId: '12345678-1234-4234-8234-123456789012', + }); expect(withSimId.success).toBe(true); expect('simulatorId' in (withSimId.data as any)).toBe(false); }); @@ -37,7 +29,7 @@ describe('Snapshot UI Plugin', () => { describe('Handler Behavior (Complete Literal Returns)', () => { it('should surface session default requirement when simulatorId is missing', async () => { - const result = await snapshotUIPlugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); @@ -46,7 +38,7 @@ describe('Snapshot UI Plugin', () => { it('should handle invalid simulatorId format via schema validation', async () => { // Test the actual handler with invalid UUID format - const result = await snapshotUIPlugin.handler({ + const result = await handler({ simulatorId: 'invalid-uuid-format', }); diff --git a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts index 53e541be..fe3e7bde 100644 --- a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts @@ -1,19 +1,14 @@ /** - * Tests for swipe tool plugin + * Tests for swipe tool */ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import { - createMockExecutor, - createNoopExecutor, - mockProcess, -} from '../../../../test-utils/mock-executors.ts'; -import { SystemError, DependencyError } from '../../../../utils/responses/index.ts'; +import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; +import { SystemError } from '../../../../utils/responses/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -// Import the plugin module to test -import swipePlugin, { AxeHelpers, swipeLogic, SwipeParams } from '../swipe.ts'; +import { schema, handler, AxeHelpers, swipeLogic, SwipeParams } from '../swipe.ts'; // Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { @@ -49,28 +44,21 @@ function createMockAxeHelpersWithNullPath(): AxeHelpers { }; } -describe('Swipe Plugin', () => { +describe('Swipe Tool', () => { beforeEach(() => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(swipePlugin.name).toBe('swipe'); - }); - - it('should have correct description', () => { - expect(swipePlugin.description).toBe('Swipe between points.'); - }); + describe('Schema Validation', () => { it('should have handler function', () => { - expect(typeof swipePlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { - const schema = z.object(swipePlugin.schema); + const schemaObject = z.object(schema); expect( - schema.safeParse({ + schemaObject.safeParse({ x1: 100, y1: 200, x2: 300, @@ -79,7 +67,7 @@ describe('Swipe Plugin', () => { ).toBe(true); expect( - schema.safeParse({ + schemaObject.safeParse({ x1: 100.5, y1: 200, x2: 300, @@ -88,7 +76,7 @@ describe('Swipe Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x1: 100, y1: 200, x2: 300, @@ -98,7 +86,7 @@ describe('Swipe Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x1: 100, y1: 200, x2: 300, @@ -110,7 +98,7 @@ describe('Swipe Plugin', () => { }).success, ).toBe(true); - const withSimId = schema.safeParse({ + const withSimId = schemaObject.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', x1: 100, y1: 200, @@ -317,9 +305,9 @@ describe('Swipe Plugin', () => { }); }); - describe('Handler Behavior (Complete Literal Returns)', () => { + describe('Handler Behavior', () => { it('should return error for missing simulatorId via handler', async () => { - const result = await swipePlugin.handler({ x1: 100, y1: 200, x2: 300, y2: 400 }); + const result = await handler({ x1: 100, y1: 200, x2: 300, y2: 400 }); expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); @@ -331,7 +319,7 @@ describe('Swipe Plugin', () => { it('should return validation error for missing x1 once simulator default exists', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await swipePlugin.handler({ + const result = await handler({ y1: 200, x2: 300, y2: 400, diff --git a/src/mcp/tools/ui-automation/__tests__/tap.test.ts b/src/mcp/tools/ui-automation/__tests__/tap.test.ts index 6a40f90f..db0936cc 100644 --- a/src/mcp/tools/ui-automation/__tests__/tap.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/tap.test.ts @@ -7,7 +7,7 @@ import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import tapPlugin, { AxeHelpers, tapLogic } from '../tap.ts'; +import { schema, handler, AxeHelpers, tapLogic } from '../tap.ts'; // Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { @@ -48,36 +48,28 @@ describe('Tap Plugin', () => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(tapPlugin.name).toBe('tap'); - }); - - it('should have correct description', () => { - expect(tapPlugin.description).toBe('Tap coordinate or element.'); - }); - + describe('Schema Validation', () => { it('should have handler function', () => { - expect(typeof tapPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { - const schema = z.object(tapPlugin.schema); + const schemaObject = z.object(schema); - expect(schema.safeParse({ x: 100, y: 200 }).success).toBe(true); + expect(schemaObject.safeParse({ x: 100, y: 200 }).success).toBe(true); - expect(schema.safeParse({ id: 'loginButton' }).success).toBe(true); + expect(schemaObject.safeParse({ id: 'loginButton' }).success).toBe(true); - expect(schema.safeParse({ label: 'Log in' }).success).toBe(true); + expect(schemaObject.safeParse({ label: 'Log in' }).success).toBe(true); - expect(schema.safeParse({ x: 100, y: 200, id: 'loginButton' }).success).toBe(true); + expect(schemaObject.safeParse({ x: 100, y: 200, id: 'loginButton' }).success).toBe(true); - expect(schema.safeParse({ x: 100, y: 200, id: 'loginButton', label: 'Log in' }).success).toBe( - true, - ); + expect( + schemaObject.safeParse({ x: 100, y: 200, id: 'loginButton', label: 'Log in' }).success, + ).toBe(true); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200, preDelay: 0.5, @@ -86,21 +78,21 @@ describe('Tap Plugin', () => { ).toBe(true); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 3.14, y: 200, }).success, ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 3.14, }).success, ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200, preDelay: -1, @@ -108,14 +100,14 @@ describe('Tap Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ x: 100, y: 200, postDelay: -1, }).success, ).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObject.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', x: 100, y: 200, @@ -674,7 +666,7 @@ describe('Tap Plugin', () => { describe('Plugin Handler Validation', () => { it('should require simulatorId session default when not provided', async () => { - const result = await tapPlugin.handler({ + const result = await handler({ x: 100, y: 200, }); @@ -689,7 +681,7 @@ describe('Tap Plugin', () => { it('should return validation error for missing x coordinate', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ y: 200, }); @@ -702,7 +694,7 @@ describe('Tap Plugin', () => { it('should return validation error for missing y coordinate', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ x: 100, }); @@ -715,7 +707,7 @@ describe('Tap Plugin', () => { it('should return validation error when both id and label are provided without coordinates', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ id: 'loginButton', label: 'Log in', }); @@ -729,7 +721,7 @@ describe('Tap Plugin', () => { it('should return validation error for non-integer x coordinate', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ x: 3.14, y: 200, }); @@ -743,7 +735,7 @@ describe('Tap Plugin', () => { it('should return validation error for non-integer y coordinate', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ x: 100, y: 3.14, }); @@ -757,7 +749,7 @@ describe('Tap Plugin', () => { it('should return validation error for negative preDelay', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ x: 100, y: 200, preDelay: -1, @@ -772,7 +764,7 @@ describe('Tap Plugin', () => { it('should return validation error for negative postDelay', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await tapPlugin.handler({ + const result = await handler({ x: 100, y: 200, postDelay: -1, diff --git a/src/mcp/tools/ui-automation/__tests__/touch.test.ts b/src/mcp/tools/ui-automation/__tests__/touch.test.ts index 5d0afed3..fa24971a 100644 --- a/src/mcp/tools/ui-automation/__tests__/touch.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/touch.test.ts @@ -7,31 +7,23 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import touchPlugin, { touchLogic } from '../touch.ts'; +import { schema, handler, touchLogic } from '../touch.ts'; describe('Touch Plugin', () => { beforeEach(() => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(touchPlugin.name).toBe('touch'); - }); - - it('should have correct description', () => { - expect(touchPlugin.description).toBe('Touch down/up at coords.'); - }); - + describe('Schema Validation', () => { it('should have handler function', () => { - expect(typeof touchPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { - const schema = z.object(touchPlugin.schema); + const schemaObj = z.object(schema); expect( - schema.safeParse({ + schemaObj.safeParse({ x: 100, y: 200, down: true, @@ -39,7 +31,7 @@ describe('Touch Plugin', () => { ).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ x: 100, y: 200, up: true, @@ -47,7 +39,7 @@ describe('Touch Plugin', () => { ).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ x: 100.5, y: 200, down: true, @@ -55,7 +47,7 @@ describe('Touch Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObj.safeParse({ x: 100, y: 200.5, down: true, @@ -63,7 +55,7 @@ describe('Touch Plugin', () => { ).toBe(false); expect( - schema.safeParse({ + schemaObj.safeParse({ x: 100, y: 200, down: true, @@ -71,7 +63,7 @@ describe('Touch Plugin', () => { }).success, ).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObj.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', x: 100, y: 200, @@ -84,7 +76,7 @@ describe('Touch Plugin', () => { describe('Handler Requirements', () => { it('should require simulatorId session default', async () => { - const result = await touchPlugin.handler({ + const result = await handler({ x: 100, y: 200, down: true, @@ -100,7 +92,7 @@ describe('Touch Plugin', () => { it('should surface parameter validation errors when defaults exist', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await touchPlugin.handler({ + const result = await handler({ y: 200, down: true, }); diff --git a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts index 4d7c986b..adb24e9c 100644 --- a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts @@ -1,17 +1,16 @@ /** - * Tests for type_text plugin + * Tests for type_text tool */ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createMockExecutor, - createMockFileSystemExecutor, createNoopExecutor, mockProcess, } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import typeTextPlugin, { type_textLogic } from '../type_text.ts'; +import { schema, handler, type_textLogic } from '../type_text.ts'; // Mock axe helpers for dependency injection function createMockAxeHelpers( @@ -43,48 +42,40 @@ function createRejectingExecutor(error: any) { }; } -describe('Type Text Plugin', () => { +describe('Type Text Tool', () => { beforeEach(() => { sessionStore.clear(); }); - describe('Export Field Validation (Literal)', () => { - it('should have correct name', () => { - expect(typeTextPlugin.name).toBe('type_text'); - }); - - it('should have correct description', () => { - expect(typeTextPlugin.description).toBe('Type text.'); - }); - + describe('Schema Validation', () => { it('should have handler function', () => { - expect(typeof typeTextPlugin.handler).toBe('function'); + expect(typeof handler).toBe('function'); }); it('should validate schema fields with safeParse', () => { - const schema = z.object(typeTextPlugin.schema); + const schemaObject = z.object(schema); expect( - schema.safeParse({ + schemaObject.safeParse({ text: 'Hello World', }).success, ).toBe(true); expect( - schema.safeParse({ + schemaObject.safeParse({ text: '', }).success, ).toBe(false); expect( - schema.safeParse({ + schemaObject.safeParse({ text: 123, }).success, ).toBe(false); - expect(schema.safeParse({}).success).toBe(false); + expect(schemaObject.safeParse({}).success).toBe(false); - const withSimId = schema.safeParse({ + const withSimId = schemaObject.safeParse({ simulatorId: '12345678-1234-4234-8234-123456789012', text: 'Hello World', }); @@ -95,7 +86,7 @@ describe('Type Text Plugin', () => { describe('Handler Requirements', () => { it('should require simulatorId session default', async () => { - const result = await typeTextPlugin.handler({ text: 'Hello' }); + const result = await handler({ text: 'Hello' }); expect(result.isError).toBe(true); const message = result.content[0].text; @@ -107,7 +98,7 @@ describe('Type Text Plugin', () => { it('should surface validation errors when defaults exist', async () => { sessionStore.setDefaults({ simulatorId: '12345678-1234-4234-8234-123456789012' }); - const result = await typeTextPlugin.handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const message = result.content[0].text; diff --git a/src/mcp/tools/ui-automation/button.ts b/src/mcp/tools/ui-automation/button.ts index 23c72ff7..26d36daa 100644 --- a/src/mcp/tools/ui-automation/button.ts +++ b/src/mcp/tools/ui-automation/button.ts @@ -100,32 +100,22 @@ export async function buttonLogic( const publicSchemaObject = z.strictObject(buttonSchema.omit({ simulatorId: true } as const).shape); -export default { - name: 'button', - description: 'Press simulator hardware button.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: buttonSchema, - }), - annotations: { - title: 'Hardware Button', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: buttonSchema as unknown as z.ZodType, - logicFunction: (params: ButtonParams, executor: CommandExecutor) => - buttonLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: buttonSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: buttonSchema as unknown as z.ZodType, + logicFunction: (params: ButtonParams, executor: CommandExecutor) => + buttonLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/gesture.ts b/src/mcp/tools/ui-automation/gesture.ts index 3201daa6..2cc2c66a 100644 --- a/src/mcp/tools/ui-automation/gesture.ts +++ b/src/mcp/tools/ui-automation/gesture.ts @@ -169,32 +169,22 @@ export async function gestureLogic( const publicSchemaObject = z.strictObject(gestureSchema.omit({ simulatorId: true } as const).shape); -export default { - name: 'gesture', - description: 'Simulator gesture preset.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: gestureSchema, - }), - annotations: { - title: 'Gesture', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: gestureSchema as unknown as z.ZodType, - logicFunction: (params: GestureParams, executor: CommandExecutor) => - gestureLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: gestureSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: gestureSchema as unknown as z.ZodType, + logicFunction: (params: GestureParams, executor: CommandExecutor) => + gestureLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/key_press.ts b/src/mcp/tools/ui-automation/key_press.ts index 2e61e557..aaa048a3 100644 --- a/src/mcp/tools/ui-automation/key_press.ts +++ b/src/mcp/tools/ui-automation/key_press.ts @@ -110,32 +110,22 @@ const publicSchemaObject = z.strictObject( keyPressSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'key_press', - description: 'Press key by keycode.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: keyPressSchema, - }), - annotations: { - title: 'Key Press', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: keyPressSchema as unknown as z.ZodType, - logicFunction: (params: KeyPressParams, executor: CommandExecutor) => - key_pressLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: keyPressSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: keyPressSchema as unknown as z.ZodType, + logicFunction: (params: KeyPressParams, executor: CommandExecutor) => + key_pressLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/key_sequence.ts b/src/mcp/tools/ui-automation/key_sequence.ts index c493b297..96bc9d9d 100644 --- a/src/mcp/tools/ui-automation/key_sequence.ts +++ b/src/mcp/tools/ui-automation/key_sequence.ts @@ -113,32 +113,22 @@ const publicSchemaObject = z.strictObject( keySequenceSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'key_sequence', - description: 'Press a sequence of keys by their keycodes.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: keySequenceSchema, - }), - annotations: { - title: 'Key Sequence', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: keySequenceSchema as unknown as z.ZodType, - logicFunction: (params: KeySequenceParams, executor: CommandExecutor) => - key_sequenceLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: keySequenceSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: keySequenceSchema as unknown as z.ZodType, + logicFunction: (params: KeySequenceParams, executor: CommandExecutor) => + key_sequenceLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/long_press.ts b/src/mcp/tools/ui-automation/long_press.ts index bc00bee9..f6429729 100644 --- a/src/mcp/tools/ui-automation/long_press.ts +++ b/src/mcp/tools/ui-automation/long_press.ts @@ -130,32 +130,22 @@ export async function long_pressLogic( } } -export default { - name: 'long_press', - description: 'Long press at coords.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: longPressSchema, - }), - annotations: { - title: 'Long Press', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: longPressSchema as unknown as z.ZodType, - logicFunction: (params: LongPressParams, executor: CommandExecutor) => - long_pressLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: longPressSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: longPressSchema as unknown as z.ZodType, + logicFunction: (params: LongPressParams, executor: CommandExecutor) => + long_pressLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index 9a8fd567..9b54a16f 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -327,23 +327,16 @@ export async function screenshotLogic( } } -export default { - name: 'screenshot', - description: 'Capture screenshot.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: screenshotSchema, - }), - annotations: { - title: 'Screenshot', - readOnlyHint: true, +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: screenshotSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: screenshotSchema as unknown as z.ZodType, + logicFunction: (params: ScreenshotParams, executor: CommandExecutor) => { + return screenshotLogic(params, executor); }, - handler: createSessionAwareTool({ - internalSchema: screenshotSchema as unknown as z.ZodType, - logicFunction: (params: ScreenshotParams, executor: CommandExecutor) => { - return screenshotLogic(params, executor); - }, - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts index 28084181..00ab4518 100644 --- a/src/mcp/tools/ui-automation/snapshot_ui.ts +++ b/src/mcp/tools/ui-automation/snapshot_ui.ts @@ -136,33 +136,22 @@ const publicSchemaObject = z.strictObject( snapshotUiSchema.omit({ simulatorId: true } as const).shape, ); -export default { - name: 'snapshot_ui', - description: - 'Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: snapshotUiSchema, - }), - annotations: { - title: 'Snapshot UI', - readOnlyHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: snapshotUiSchema as unknown as z.ZodType, - logicFunction: (params: SnapshotUiParams, executor: CommandExecutor) => - snapshot_uiLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: snapshotUiSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: snapshotUiSchema as unknown as z.ZodType, + logicFunction: (params: SnapshotUiParams, executor: CommandExecutor) => + snapshot_uiLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/swipe.ts b/src/mcp/tools/ui-automation/swipe.ts index 6dc935e8..58672d8a 100644 --- a/src/mcp/tools/ui-automation/swipe.ts +++ b/src/mcp/tools/ui-automation/swipe.ts @@ -147,32 +147,22 @@ export async function swipeLogic( } } -export default { - name: 'swipe', - description: 'Swipe between points.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: swipeSchema, - }), - annotations: { - title: 'Swipe', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: swipeSchema as unknown as z.ZodType, - logicFunction: (params: SwipeParams, executor: CommandExecutor) => - swipeLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: swipeSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: swipeSchema as unknown as z.ZodType, + logicFunction: (params: SwipeParams, executor: CommandExecutor) => + swipeLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/tap.ts b/src/mcp/tools/ui-automation/tap.ts index 378d631b..5ba776e5 100644 --- a/src/mcp/tools/ui-automation/tap.ts +++ b/src/mcp/tools/ui-automation/tap.ts @@ -180,32 +180,22 @@ export async function tapLogic( } } -export default { - name: 'tap', - description: 'Tap coordinate or element.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseTapSchema, - }), - annotations: { - title: 'Tap', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: tapSchema as unknown as z.ZodType, - logicFunction: (params: TapParams, executor: CommandExecutor) => - tapLogic(params, executor, { - getAxePath, - getBundledAxeEnvironment, - createAxeNotAvailableResponse, - }), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseTapSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: tapSchema as unknown as z.ZodType, + logicFunction: (params: TapParams, executor: CommandExecutor) => + tapLogic(params, executor, { + getAxePath, + getBundledAxeEnvironment, + createAxeNotAvailableResponse, + }), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/touch.ts b/src/mcp/tools/ui-automation/touch.ts index 1a77c1c2..beab8c71 100644 --- a/src/mcp/tools/ui-automation/touch.ts +++ b/src/mcp/tools/ui-automation/touch.ts @@ -129,27 +129,17 @@ export async function touchLogic( } } -export default { - name: 'touch', - description: 'Touch down/up at coords.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: touchSchema, - }), - annotations: { - title: 'Touch', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: touchSchema as unknown as z.ZodType, - logicFunction: (params: TouchParams, executor: CommandExecutor) => touchLogic(params, executor), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: touchSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: touchSchema as unknown as z.ZodType, + logicFunction: (params: TouchParams, executor: CommandExecutor) => touchLogic(params, executor), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/ui-automation/type_text.ts b/src/mcp/tools/ui-automation/type_text.ts index 89dcea93..1a1d6e85 100644 --- a/src/mcp/tools/ui-automation/type_text.ts +++ b/src/mcp/tools/ui-automation/type_text.ts @@ -101,28 +101,18 @@ export async function type_textLogic( } } -export default { - name: 'type_text', - description: 'Type text.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: typeTextSchema, - }), - annotations: { - title: 'Type Text', - destructiveHint: true, - }, - cli: { - daemonAffinity: 'preferred', - }, - handler: createSessionAwareTool({ - internalSchema: typeTextSchema as unknown as z.ZodType, - logicFunction: (params: TypeTextParams, executor: CommandExecutor) => - type_textLogic(params, executor), - getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], - }), // Safe factory -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: typeTextSchema, +}); + +export const handler = createSessionAwareTool({ + internalSchema: typeTextSchema as unknown as z.ZodType, + logicFunction: (params: TypeTextParams, executor: CommandExecutor) => + type_textLogic(params, executor), + getExecutor: getDefaultCommandExecutor, + requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], +}); // Helper function for executing axe commands (inlined from src/tools/axe/index.ts) async function executeAxeCommand( diff --git a/src/mcp/tools/utilities/__tests__/clean.test.ts b/src/mcp/tools/utilities/__tests__/clean.test.ts index ac165b4c..707a67cc 100644 --- a/src/mcp/tools/utilities/__tests__/clean.test.ts +++ b/src/mcp/tools/utilities/__tests__/clean.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; -import tool, { cleanLogic } from '../clean.ts'; +import { schema, handler, cleanLogic } from '../clean.ts'; import { createMockExecutor, createMockCommandResponse, @@ -12,29 +12,27 @@ describe('clean (unified) tool', () => { sessionStore.clear(); }); - it('exports correct name/description/schema/handler', () => { - expect(tool.name).toBe('clean'); - expect(tool.description).toBe('Clean build products.'); - expect(typeof tool.handler).toBe('function'); + it('exports correct schema/handler', () => { + expect(typeof handler).toBe('function'); - const schema = z.strictObject(tool.schema); - expect(schema.safeParse({}).success).toBe(true); + const schemaObj = z.strictObject(schema); + expect(schemaObj.safeParse({}).success).toBe(true); expect( - schema.safeParse({ + schemaObj.safeParse({ extraArgs: ['--quiet'], platform: 'iOS Simulator', }).success, ).toBe(true); - expect(schema.safeParse({ derivedDataPath: '/tmp/Derived' }).success).toBe(false); - expect(schema.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schema.safeParse({ configuration: 'Debug' }).success).toBe(false); + expect(schemaObj.safeParse({ derivedDataPath: '/tmp/Derived' }).success).toBe(false); + expect(schemaObj.safeParse({ preferXcodebuild: true }).success).toBe(false); + expect(schemaObj.safeParse({ configuration: 'Debug' }).success).toBe(false); - const schemaKeys = Object.keys(tool.schema).sort(); + const schemaKeys = Object.keys(schema).sort(); expect(schemaKeys).toEqual(['extraArgs', 'platform'].sort()); }); it('handler validation: error when neither projectPath nor workspacePath provided', async () => { - const result = await (tool as any).handler({}); + const result = await handler({}); expect(result.isError).toBe(true); const text = String(result.content?.[0]?.text ?? ''); expect(text).toContain('Missing required session defaults'); @@ -42,7 +40,7 @@ describe('clean (unified) tool', () => { }); it('handler validation: error when both projectPath and workspacePath provided', async () => { - const result = await (tool as any).handler({ + const result = await handler({ projectPath: '/p.xcodeproj', workspacePath: '/w.xcworkspace', }); @@ -67,7 +65,7 @@ describe('clean (unified) tool', () => { }); it('handler validation: requires scheme when workspacePath is provided', async () => { - const result = await (tool as any).handler({ workspacePath: '/w.xcworkspace' }); + const result = await handler({ workspacePath: '/w.xcworkspace' }); expect(result.isError).toBe(true); const text = String(result.content?.[0]?.text ?? ''); expect(text).toContain('Parameter validation failed'); @@ -140,7 +138,7 @@ describe('clean (unified) tool', () => { }); it('handler validation: rejects invalid platform values', async () => { - const result = await (tool as any).handler({ + const result = await handler({ projectPath: '/p.xcodeproj', scheme: 'App', platform: 'InvalidPlatform', diff --git a/src/mcp/tools/utilities/clean.ts b/src/mcp/tools/utilities/clean.ts index df82d9ae..1d683d1e 100644 --- a/src/mcp/tools/utilities/clean.ts +++ b/src/mcp/tools/utilities/clean.ts @@ -149,24 +149,17 @@ const publicSchemaObject = baseSchemaObject.omit({ preferXcodebuild: true, } as const); -export default { - name: 'clean', - description: 'Clean build products.', - schema: getSessionAwareToolSchemaShape({ - sessionAware: publicSchemaObject, - legacy: baseSchemaObject, - }), - annotations: { - title: 'Clean', - destructiveHint: true, - }, - handler: createSessionAwareTool({ - internalSchema: cleanSchema as unknown as z.ZodType, - logicFunction: cleanLogic, - getExecutor: getDefaultCommandExecutor, - requirements: [ - { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, - ], - exclusivePairs: [['projectPath', 'workspacePath']], - }), -}; +export const schema = getSessionAwareToolSchemaShape({ + sessionAware: publicSchemaObject, + legacy: baseSchemaObject, +}); + +export const handler = createSessionAwareTool({ + internalSchema: cleanSchema as unknown as z.ZodType, + logicFunction: cleanLogic, + getExecutor: getDefaultCommandExecutor, + requirements: [ + { oneOf: ['projectPath', 'workspacePath'], message: 'Provide a project or workspace' }, + ], + exclusivePairs: [['projectPath', 'workspacePath']], +}); diff --git a/src/mcp/tools/workflow-discovery/manage_workflows.ts b/src/mcp/tools/workflow-discovery/manage_workflows.ts index 3cc08d9d..fc082c26 100644 --- a/src/mcp/tools/workflow-discovery/manage_workflows.ts +++ b/src/mcp/tools/workflow-discovery/manage_workflows.ts @@ -9,7 +9,6 @@ import { getRegisteredWorkflows, getMcpPredicateContext, } from '../../../utils/tool-registry.ts'; -import { loadManifest } from '../../../core/manifest/load-manifest.ts'; const baseSchemaObject = z.object({ workflowNames: z.array(z.string()).describe('Workflow directory name(s).'), @@ -36,7 +35,6 @@ export async function manage_workflowsLogic( nextWorkflows = [...new Set([...currentWorkflows, ...workflowNames])]; } - // Use the stored MCP predicate context to preserve Xcode detection state const ctx = getMcpPredicateContext(); const registryState = await applyWorkflowSelectionFromManifest(nextWorkflows, ctx); @@ -44,17 +42,10 @@ export async function manage_workflowsLogic( return createTextResponse(`Workflows enabled: ${registryState.enabledWorkflows.join(', ')}`); } -const manifest = loadManifest(); -const allWorkflowIds = Array.from(manifest.workflows.keys()); -const availableWorkflows = - allWorkflowIds.length > 0 ? allWorkflowIds.join(', ') : 'none (no workflows discovered)'; - -export default { - name: 'manage-workflows', - description: `Workflows are groups of tools exposed by XcodeBuildMCP. -By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. -Some workflows are mandatory and can't be disabled. -Available workflows: ${availableWorkflows}`, - schema: baseSchemaObject.shape, - handler: createTypedTool(manageWorkflowsSchema, manage_workflowsLogic, getDefaultCommandExecutor), -}; +export const schema = baseSchemaObject.shape; + +export const handler = createTypedTool( + manageWorkflowsSchema, + manage_workflowsLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts index 5389226a..0d3daeaf 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts @@ -3,25 +3,18 @@ import { getServer } from '../../../server/server-state.ts'; import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; -export default { - name: 'xcode_tools_bridge_disconnect', - description: 'Disconnect bridge and unregister proxied `xcode_tools_*` tools.', - schema: {}, - annotations: { - title: 'Disconnect Xcode Tools Bridge', - readOnlyHint: false, - }, - handler: async (): Promise => { - const server = getServer(); - if (!server) { - return createErrorResponse('Server not initialized', 'Unable to access server instance'); - } +export const schema = {}; - const manager = getXcodeToolsBridgeManager(server); - if (!manager) { - return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); - } +export const handler = async (): Promise => { + const server = getServer(); + if (!server) { + return createErrorResponse('Server not initialized', 'Unable to access server instance'); + } - return manager.disconnectTool(); - }, + const manager = getXcodeToolsBridgeManager(server); + if (!manager) { + return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + } + + return manager.disconnectTool(); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts index 5dbf31db..96562748 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts @@ -3,25 +3,18 @@ import { getServer } from '../../../server/server-state.ts'; import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; -export default { - name: 'xcode_tools_bridge_status', - description: 'Show xcrun mcpbridge availability and proxy tool sync status.', - schema: {}, - annotations: { - title: 'Xcode Tools Bridge Status', - readOnlyHint: true, - }, - handler: async (): Promise => { - const server = getServer(); - if (!server) { - return createErrorResponse('Server not initialized', 'Unable to access server instance'); - } +export const schema = {}; - const manager = getXcodeToolsBridgeManager(server); - if (!manager) { - return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); - } +export const handler = async (): Promise => { + const server = getServer(); + if (!server) { + return createErrorResponse('Server not initialized', 'Unable to access server instance'); + } - return manager.statusTool(); - }, + const manager = getXcodeToolsBridgeManager(server); + if (!manager) { + return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + } + + return manager.statusTool(); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts index 7f6419aa..d2d8f971 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts @@ -3,25 +3,18 @@ import { getServer } from '../../../server/server-state.ts'; import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; -export default { - name: 'xcode_tools_bridge_sync', - description: 'One-shot connect + tools/list sync (manual retry; avoids background prompt spam).', - schema: {}, - annotations: { - title: 'Sync Xcode Tools Bridge', - readOnlyHint: false, - }, - handler: async (): Promise => { - const server = getServer(); - if (!server) { - return createErrorResponse('Server not initialized', 'Unable to access server instance'); - } +export const schema = {}; - const manager = getXcodeToolsBridgeManager(server); - if (!manager) { - return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); - } +export const handler = async (): Promise => { + const server = getServer(); + if (!server) { + return createErrorResponse('Server not initialized', 'Unable to access server instance'); + } - return manager.syncTool(); - }, + const manager = getXcodeToolsBridgeManager(server); + if (!manager) { + return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); + } + + return manager.syncTool(); }; diff --git a/src/utils/command.ts b/src/utils/command.ts index 44b6dbb5..d26170b9 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -70,7 +70,7 @@ async function defaultExecutor( // Log the actual command that will be executed const displayCommand = useShell && escapedCommand.length === 3 ? escapedCommand[2] : [executable, ...args].join(' '); - log('info', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); + log('debug', `Executing ${logPrefix ?? ''} command: ${displayCommand}`); const spawnOpts: Parameters[2] = { stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr @@ -78,7 +78,7 @@ async function defaultExecutor( cwd: opts?.cwd, }; - log('info', `defaultExecutor PATH: ${process.env.PATH ?? ''}`); + log('debug', `defaultExecutor PATH: ${process.env.PATH ?? ''}`); const logSpawnError = (err: Error): void => { const errnoErr = err as NodeJS.ErrnoException & { spawnargs?: string[] }; diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 0bd2dd36..44f04143 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -7,7 +7,6 @@ import { isToolInWorkflowExposed, filterExposedTools, filterEnabledWorkflows, - getMandatoryWorkflows, getDefaultEnabledWorkflows, getAutoIncludeWorkflows, selectWorkflowsForMcp, @@ -192,20 +191,6 @@ describe('exposure', () => { }); }); - describe('getMandatoryWorkflows', () => { - it('should return only mandatory workflows', () => { - const workflows = [ - createWorkflow({ id: 'wf1', selection: { mcp: { mandatory: true } } }), - createWorkflow({ id: 'wf2', selection: { mcp: { mandatory: false } } }), - createWorkflow({ id: 'wf3', selection: { mcp: { mandatory: true } } }), - ]; - - const mandatory = getMandatoryWorkflows(workflows); - expect(mandatory).toHaveLength(2); - expect(mandatory.map((w) => w.id)).toEqual(['wf1', 'wf3']); - }); - }); - describe('getDefaultEnabledWorkflows', () => { it('should return only default-enabled workflows', () => { const workflows = [ @@ -271,24 +256,24 @@ describe('exposure', () => { const allWorkflows = [ createWorkflow({ id: 'session-management', - selection: { mcp: { mandatory: true, defaultEnabled: true, autoInclude: true } }, + selection: { mcp: { defaultEnabled: true, autoInclude: true } }, }), createWorkflow({ id: 'simulator', - selection: { mcp: { mandatory: false, defaultEnabled: true, autoInclude: false } }, + selection: { mcp: { defaultEnabled: true, autoInclude: false } }, }), createWorkflow({ id: 'device', - selection: { mcp: { mandatory: false, defaultEnabled: false, autoInclude: false } }, + selection: { mcp: { defaultEnabled: false, autoInclude: false } }, }), createWorkflow({ id: 'doctor', - selection: { mcp: { mandatory: false, defaultEnabled: false, autoInclude: true } }, + selection: { mcp: { defaultEnabled: false, autoInclude: true } }, predicates: ['debugEnabled'], }), ]; - it('should include mandatory workflows', () => { + it('should include auto-include workflows', () => { const ctx = createContext(); const selected = selectWorkflowsForMcp(allWorkflows, undefined, ctx); expect(selected.map((w) => w.id)).toContain('session-management'); @@ -304,7 +289,7 @@ describe('exposure', () => { const ctx = createContext(); const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); expect(selected.map((w) => w.id)).toContain('device'); - expect(selected.map((w) => w.id)).toContain('session-management'); // mandatory + expect(selected.map((w) => w.id)).toContain('session-management'); // autoInclude }); it('should not include default-enabled when workflows are requested', () => { diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index 4bbf11ea..1932deb8 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -58,6 +58,30 @@ describe('predicate-registry', () => { }); }); + describe('runningUnderXcodeAgent', () => { + it('should return true when running under Xcode', () => { + const ctx = createContext({ runningUnderXcode: true }); + expect(PREDICATES.runningUnderXcodeAgent(ctx)).toBe(true); + }); + + it('should return false when not running under Xcode', () => { + const ctx = createContext({ runningUnderXcode: false }); + expect(PREDICATES.runningUnderXcodeAgent(ctx)).toBe(false); + }); + }); + + describe('requiresXcodeTools', () => { + it('should return true when Xcode tools are active', () => { + const ctx = createContext({ xcodeToolsActive: true }); + expect(PREDICATES.requiresXcodeTools(ctx)).toBe(true); + }); + + it('should return false when Xcode tools are not active', () => { + const ctx = createContext({ xcodeToolsActive: false }); + expect(PREDICATES.requiresXcodeTools(ctx)).toBe(false); + }); + }); + describe('hideWhenXcodeAgentMode', () => { it('should return true when not running under Xcode', () => { const ctx = createContext({ runningUnderXcode: false, xcodeToolsActive: false }); @@ -132,6 +156,8 @@ describe('predicate-registry', () => { const names = getPredicateNames(); expect(names).toContain('debugEnabled'); expect(names).toContain('experimentalWorkflowDiscoveryEnabled'); + expect(names).toContain('runningUnderXcodeAgent'); + expect(names).toContain('requiresXcodeTools'); expect(names).toContain('hideWhenXcodeAgentMode'); expect(names).toContain('always'); expect(names).toContain('never'); diff --git a/src/visibility/exposure.ts b/src/visibility/exposure.ts index 61e8a0e0..6abdfc77 100644 --- a/src/visibility/exposure.ts +++ b/src/visibility/exposure.ts @@ -96,13 +96,6 @@ export function filterEnabledWorkflows( return workflows.filter((workflow) => isWorkflowEnabledForRuntime(workflow, ctx)); } -/** - * Get mandatory workflows that should always be included. - */ -export function getMandatoryWorkflows(workflows: WorkflowManifestEntry[]): WorkflowManifestEntry[] { - return workflows.filter((wf) => wf.selection?.mcp?.mandatory === true); -} - /** * Get default-enabled workflows (used when no workflows are explicitly selected). */ @@ -128,11 +121,10 @@ export function getAutoIncludeWorkflows( * Select workflows for MCP runtime according to the manifest-driven selection rules. * * Selection logic: - * 1. Always include mandatory workflows - * 2. Include auto-include workflows whose predicates pass - * 3. If user specified workflows, include those - * 4. If no workflows specified, include default-enabled workflows - * 5. Filter all by availability + predicates + * 1. Include auto-include workflows whose predicates pass + * 2. If user specified workflows, include those + * 3. If no workflows specified, include default-enabled workflows + * 4. Filter all by availability + predicates */ export function selectWorkflowsForMcp( allWorkflows: WorkflowManifestEntry[], @@ -141,17 +133,12 @@ export function selectWorkflowsForMcp( ): WorkflowManifestEntry[] { const selectedIds = new Set(); - // 1. Always include mandatory workflows - for (const wf of getMandatoryWorkflows(allWorkflows)) { - selectedIds.add(wf.id); - } - - // 2. Include auto-include workflows whose predicates pass + // 1. Include auto-include workflows whose predicates pass for (const wf of getAutoIncludeWorkflows(allWorkflows, ctx)) { selectedIds.add(wf.id); } - // 3/4. Include requested or default-enabled workflows + // 2/3. Include requested or default-enabled workflows if (requestedWorkflowIds && requestedWorkflowIds.length > 0) { for (const id of requestedWorkflowIds) { selectedIds.add(id); @@ -165,6 +152,6 @@ export function selectWorkflowsForMcp( // Build final list from selected IDs const selected = allWorkflows.filter((wf) => selectedIds.has(wf.id)); - // 5. Filter by availability + predicates + // 4. Filter by availability + predicates return filterEnabledWorkflows(selected, ctx); } diff --git a/src/visibility/predicate-registry.ts b/src/visibility/predicate-registry.ts index fc359557..1b7a6f82 100644 --- a/src/visibility/predicate-registry.ts +++ b/src/visibility/predicate-registry.ts @@ -22,10 +22,20 @@ export const PREDICATES: Record = { ctx.config.experimentalWorkflowDiscovery, /** - * Hide when running under Xcode agent mode AND Xcode Tools bridge is active. - * This implements the conflict policy from XCODE_IDE_TOOL_CONFLICTS.md: - * - When Xcode provides equivalent tools via mcpbridge, hide our versions - * - Outside Xcode or when bridge is inactive, show our tools + * Show only when running under Xcode's coding agent. + * Use for tools/workflows that require the Xcode environment. + */ + runningUnderXcodeAgent: (ctx: PredicateContext): boolean => ctx.runningUnderXcode === true, + + /** + * Show only when Xcode Tools bridge is available and active. + * Use for tools/workflows that require the Xcode Tools integration. + */ + requiresXcodeTools: (ctx: PredicateContext): boolean => ctx.xcodeToolsActive === true, + + /** + * Hide when running in Xcode agent mode (both under Xcode AND tools bridge active). + * Use for XcodeBuildMCP tools that conflict with Xcode's native equivalents. */ hideWhenXcodeAgentMode: (ctx: PredicateContext): boolean => !(ctx.runningUnderXcode && ctx.xcodeToolsActive), From 8c0cda0e72cee453a0d805e4c61fb9d9659d0694 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 12:11:59 +0000 Subject: [PATCH 06/23] Add simulator name-to-ID resolution for session defaults Add simulator-resolver.ts utility that resolves simulator names to UDIDs via simctl. Refactor session_set_defaults to auto-resolve simulatorName to simulatorId when only a name is provided. Update bootstrap-runtime to auto-resolve simulator names from config defaults at startup. Expand exclusivePairs handling across tools that accept both simulatorId and simulatorName. --- manifests/tools/discover_projs.yaml | 3 +- manifests/tools/list_schemes.yaml | 3 +- manifests/workflows/xcode-ide.yaml | 3 +- .../__tests__/session_set_defaults.test.ts | 79 +++++-- .../session_set_defaults.ts | 55 +++-- .../simulator/__tests__/boot_sim.test.ts | 2 +- .../__tests__/install_app_sim.test.ts | 2 +- .../__tests__/launch_app_logs_sim.test.ts | 4 +- .../__tests__/launch_app_sim.test.ts | 194 +++++------------- .../simulator/__tests__/stop_app_sim.test.ts | 85 +------- src/mcp/tools/simulator/boot_sim.ts | 35 +++- src/mcp/tools/simulator/install_app_sim.ts | 36 +++- .../tools/simulator/launch_app_logs_sim.ts | 39 +++- src/mcp/tools/simulator/launch_app_sim.ts | 94 ++------- src/mcp/tools/simulator/stop_app_sim.ts | 93 ++------- src/runtime/bootstrap-runtime.ts | 21 +- src/utils/simulator-resolver.ts | 99 +++++++++ src/utils/typed-tool-factory.ts | 1 + src/visibility/__tests__/exposure.test.ts | 8 +- .../__tests__/predicate-registry.test.ts | 11 +- src/visibility/predicate-registry.ts | 5 +- 21 files changed, 407 insertions(+), 465 deletions(-) create mode 100644 src/utils/simulator-resolver.ts diff --git a/manifests/tools/discover_projs.yaml b/manifests/tools/discover_projs.yaml index 5437cb7c..d93da14b 100644 --- a/manifests/tools/discover_projs.yaml +++ b/manifests/tools/discover_projs.yaml @@ -7,8 +7,7 @@ availability: mcp: true cli: true daemon: true -predicates: - - hideWhenXcodeAgentMode +predicates: [] routing: stateful: false daemonAffinity: preferred diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml index 84ad0640..a262733a 100644 --- a/manifests/tools/list_schemes.yaml +++ b/manifests/tools/list_schemes.yaml @@ -7,8 +7,7 @@ availability: mcp: true cli: true daemon: true -predicates: - - hideWhenXcodeAgentMode +predicates: [] routing: stateful: false daemonAffinity: preferred diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index f0c09893..2dc19715 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -11,8 +11,7 @@ selection: autoInclude: true predicates: - debugEnabled - - runningUnderXcodeAgent - - requiresXcodeTools + - hideWhenXcodeAgentMode tools: - xcode_tools_bridge_status - xcode_tools_bridge_sync diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 1d6013ad..24b70374 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import path from 'node:path'; import { parse as parseYaml } from 'yaml'; import { __resetConfigStoreForTests, initConfigStore } from '../../../../utils/config-store.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import { schema, handler, sessionSetDefaultsLogic } from '../session_set_defaults.ts'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; describe('session-set-defaults tool', () => { beforeEach(() => { @@ -15,8 +16,30 @@ describe('session-set-defaults tool', () => { const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); + // Mock executor that simulates successful simulator lookup + function createMockExecutor(): CommandExecutor { + return vi.fn().mockImplementation(async (command: string[]) => { + if (command.includes('simctl') && command.includes('list')) { + return { + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'RESOLVED-SIM-UUID', name: 'iPhone 16' }, + { udid: 'OTHER-SIM-UUID', name: 'iPhone 15' }, + ], + }, + }), + }; + } + return { success: true, output: '' }; + }); + } + function createContext() { - return {}; + return { + executor: createMockExecutor(), + }; } describe('Export Field Validation', () => { @@ -42,12 +65,14 @@ describe('session-set-defaults tool', () => { createContext(), ); - expect(result.isError).toBe(false); + expect(result.isError).toBeFalsy(); expect(result.content[0].text).toContain('Defaults updated:'); const current = sessionStore.getAll(); expect(current.scheme).toBe('MyScheme'); expect(current.simulatorName).toBe('iPhone 16'); + // simulatorId should be auto-resolved from simulatorName + expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); expect(current.useLatestOS).toBe(true); expect(current.arch).toBe('arm64'); }); @@ -90,26 +115,52 @@ describe('session-set-defaults tool', () => { ); }); - it('should clear simulatorName when simulatorId is set', async () => { + it('should clear simulatorName when simulatorId is explicitly set', async () => { sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); const result = await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }, createContext()); const current = sessionStore.getAll(); expect(current.simulatorId).toBe('SIM-UUID'); expect(current.simulatorName).toBeUndefined(); expect(result.content[0].text).toContain( - 'Cleared simulatorName because simulatorId was set.', + 'Cleared simulatorName because simulatorId was explicitly set.', ); }); - it('should clear simulatorId when simulatorName is set', async () => { - sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); + it('should auto-resolve simulatorName to simulatorId when only simulatorName is set', async () => { + sessionStore.setDefaults({ simulatorId: 'OLD-SIM-UUID' }); const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }, createContext()); const current = sessionStore.getAll(); + // Both should be set now - name provided, id resolved expect(current.simulatorName).toBe('iPhone 16'); - expect(current.simulatorId).toBeUndefined(); - expect(result.content[0].text).toContain( - 'Cleared simulatorId because simulatorName was set.', + expect(current.simulatorId).toBe('RESOLVED-SIM-UUID'); + expect(result.content[0].text).toContain('Resolved simulatorName'); + }); + + it('should return error when simulatorName cannot be resolved', async () => { + const contextWithFailingExecutor = { + executor: vi.fn().mockImplementation(async (command: string[]) => { + if (command.includes('simctl') && command.includes('list')) { + return { + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'OTHER-SIM-UUID', name: 'iPhone 15' }, + ], + }, + }), + }; + } + return { success: true, output: '' }; + }), + }; + + const result = await sessionSetDefaultsLogic( + { simulatorName: 'NonExistentSimulator' }, + contextWithFailingExecutor, ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to resolve simulator name'); }); it('should prefer workspacePath when both projectPath and workspacePath are provided', async () => { @@ -128,7 +179,7 @@ describe('session-set-defaults tool', () => { ); }); - it('should prefer simulatorId when both simulatorId and simulatorName are provided', async () => { + it('should keep both simulatorId and simulatorName when both are provided', async () => { const res = await sessionSetDefaultsLogic( { simulatorId: 'SIM-1', @@ -137,10 +188,11 @@ describe('session-set-defaults tool', () => { createContext(), ); const current = sessionStore.getAll(); + // Both are kept, simulatorId takes precedence for tools expect(current.simulatorId).toBe('SIM-1'); - expect(current.simulatorName).toBeUndefined(); + expect(current.simulatorName).toBe('iPhone 16'); expect(res.content[0].text).toContain( - 'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.', + 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', ); }); @@ -184,6 +236,7 @@ describe('session-set-defaults tool', () => { expect(parsed.sessionDefaults?.workspacePath).toBe('/new/App.xcworkspace'); expect(parsed.sessionDefaults?.projectPath).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); + // simulatorName is cleared because simulatorId was explicitly set expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index a15a1059..771f676e 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -5,6 +5,9 @@ import { sessionStore, type SessionDefaults } from '../../../utils/session-store import { sessionDefaultsSchema } from '../../../utils/session-defaults-schema.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import type { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { resolveSimulatorNameToId } from '../../../utils/simulator-resolver.ts'; const schemaObj = sessionDefaultsSchema.extend({ persist: z @@ -15,11 +18,13 @@ const schemaObj = sessionDefaultsSchema.extend({ type Params = z.infer; -type SessionSetDefaultsContext = Record; +type SessionSetDefaultsContext = { + executor: CommandExecutor; +}; export async function sessionSetDefaultsLogic( params: Params, - neverContext: SessionSetDefaultsContext, + context: SessionSetDefaultsContext, ): Promise { const notices: string[] = []; const current = sessionStore.getAll(); @@ -49,10 +54,29 @@ export async function sessionSetDefaultsLogic( } if (hasSimulatorId && hasSimulatorName) { - delete nextParams.simulatorName; + // Both provided - keep both, simulatorId takes precedence for tools notices.push( - 'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.', + 'Both simulatorId and simulatorName were provided; simulatorId will be used by tools.', ); + } else if (hasSimulatorName && !hasSimulatorId) { + // Only simulatorName provided - resolve to simulatorId + const resolution = await resolveSimulatorNameToId(context.executor, nextParams.simulatorName!); + if (resolution.success) { + nextParams.simulatorId = resolution.simulatorId; + notices.push( + `Resolved simulatorName "${nextParams.simulatorName}" to simulatorId: ${resolution.simulatorId}`, + ); + } else { + return { + content: [ + { + type: 'text', + text: `Failed to resolve simulator name: ${resolution.error}`, + }, + ], + isError: true, + }; + } } // Clear mutually exclusive counterparts before merging new defaults @@ -75,22 +99,13 @@ export async function sessionSetDefaultsLogic( notices.push('Cleared projectPath because workspacePath was set.'); } } - if ( - Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId') && - nextParams.simulatorId !== undefined - ) { + // Note: simulatorId/simulatorName are no longer mutually exclusive. + // When simulatorName is provided, we auto-resolve to simulatorId and keep both. + // Only clear simulatorName if simulatorId was explicitly provided without simulatorName. + if (hasSimulatorId && !hasSimulatorName) { toClear.add('simulatorName'); if (current.simulatorName !== undefined) { - notices.push('Cleared simulatorName because simulatorId was set.'); - } - } - if ( - Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName') && - nextParams.simulatorName !== undefined - ) { - toClear.add('simulatorId'); - if (current.simulatorId !== undefined) { - notices.push('Cleared simulatorId because simulatorName was set.'); + notices.push('Cleared simulatorName because simulatorId was explicitly set.'); } } @@ -129,4 +144,6 @@ export async function sessionSetDefaultsLogic( export const schema = schemaObj.shape; -export const handler = createTypedToolWithContext(schemaObj, sessionSetDefaultsLogic, () => ({})); +export const handler = createTypedToolWithContext(schemaObj, sessionSetDefaultsLogic, () => ({ + executor: getDefaultCommandExecutor(), +})); diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts index 5ce01325..5005793f 100644 --- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts @@ -36,7 +36,7 @@ describe('boot_sim tool', () => { expect(result.isError).toBe(true); const message = result.content[0].text; expect(message).toContain('Missing required session defaults'); - expect(message).toContain('simulatorId is required'); + expect(message).toContain('Provide simulatorId or simulatorName'); expect(message).toContain('session-set-defaults'); }); }); diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts index 0ac87197..20d858dc 100644 --- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts @@ -40,7 +40,7 @@ describe('install_app_sim tool', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('simulatorId is required'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); expect(result.content[0].text).toContain('session-set-defaults'); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 34a72d34..1f5d0899 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -44,7 +44,7 @@ describe('launch_app_logs_sim tool', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('Provide simulatorId and bundleId'); + expect(result.content[0].text).toContain('Provide simulatorId or simulatorName'); expect(result.content[0].text).toContain('session-set-defaults'); }); @@ -55,7 +55,7 @@ describe('launch_app_logs_sim tool', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Missing required session defaults'); - expect(result.content[0].text).toContain('Provide simulatorId and bundleId'); + expect(result.content[0].text).toContain('bundleId is required'); }); }); diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts index 11403f70..67162fcc 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts @@ -177,28 +177,66 @@ describe('launch_app_sim tool', () => { ]); }); - it('should surface error when simulatorId missing after lookup', async () => { - const result = await launch_app_simLogic( - { - simulatorId: undefined, - bundleId: 'com.example.testapp', - } as any, - async () => ({ + it('should display friendly name when simulatorName is provided alongside resolved simulatorId', async () => { + let callCount = 0; + const sequencedExecutor = async (command: string[]) => { + callCount++; + if (callCount === 1) { + return { + success: true, + output: '/path/to/app/container', + error: '', + process: {} as any, + }; + } + return { success: true, - output: '', + output: 'App launched successfully', error: '', process: {} as any, - }), + }; + }; + + const result = await launch_app_simLogic( + { + simulatorId: 'resolved-uuid', + simulatorName: 'iPhone 16', + bundleId: 'com.example.testapp', + }, + sequencedExecutor, ); expect(result).toEqual({ content: [ { type: 'text', - text: 'No simulator identifier provided', + text: 'App launched successfully in simulator "iPhone 16" (resolved-uuid).', + }, + ], + nextSteps: [ + { + tool: 'open_sim', + label: 'Open Simulator app to see it', + params: {}, + priority: 1, + }, + { + tool: 'start_sim_log_cap', + label: 'Capture structured logs (app continues running)', + params: { simulatorId: 'resolved-uuid', bundleId: 'com.example.testapp' }, + priority: 2, + }, + { + tool: 'start_sim_log_cap', + label: 'Capture console + structured logs (app restarts)', + params: { + simulatorId: 'resolved-uuid', + bundleId: 'com.example.testapp', + captureConsole: true, + }, + priority: 3, }, ], - isError: true, }); }); @@ -308,139 +346,5 @@ describe('launch_app_sim tool', () => { ], }); }); - - it('should launch using simulatorName by resolving UUID', async () => { - let callCount = 0; - const sequencedExecutor = async (command: string[]) => { - callCount++; - if (callCount === 1) { - return { - success: true, - output: JSON.stringify({ - devices: { - 'iOS 17.0': [ - { - name: 'iPhone 16', - udid: 'resolved-uuid', - isAvailable: true, - state: 'Shutdown', - }, - ], - }, - }), - error: '', - process: {} as any, - }; - } - if (callCount === 2) { - return { - success: true, - output: '/path/to/app/container', - error: '', - process: {} as any, - }; - } - return { - success: true, - output: 'App launched successfully', - error: '', - process: {} as any, - }; - }; - - const result = await launch_app_simLogic( - { - simulatorName: 'iPhone 16', - bundleId: 'com.example.testapp', - }, - sequencedExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'App launched successfully in simulator "iPhone 16" (resolved-uuid).', - }, - ], - nextSteps: [ - { - tool: 'open_sim', - label: 'Open Simulator app to see it', - params: {}, - priority: 1, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture structured logs (app continues running)', - params: { simulatorId: 'resolved-uuid', bundleId: 'com.example.testapp' }, - priority: 2, - }, - { - tool: 'start_sim_log_cap', - label: 'Capture console + structured logs (app restarts)', - params: { - simulatorId: 'resolved-uuid', - bundleId: 'com.example.testapp', - captureConsole: true, - }, - priority: 3, - }, - ], - }); - }); - - it('should return error when simulator name is not found', async () => { - const mockListExecutor = async () => ({ - success: true, - output: JSON.stringify({ devices: {} }), - error: '', - process: {} as any, - }); - - const result = await launch_app_simLogic( - { - simulatorName: 'Missing Simulator', - bundleId: 'com.example.testapp', - }, - mockListExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Simulator named "Missing Simulator" not found. Use list_sims to see available simulators.', - }, - ], - isError: true, - }); - }); - - it('should return error when simctl list fails', async () => { - const mockExecutor = createMockExecutor({ - success: false, - output: '', - error: 'simctl list failed', - }); - - const result = await launch_app_simLogic( - { - simulatorName: 'iPhone 16', - bundleId: 'com.example.testapp', - }, - mockExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Failed to list simulators: simctl list failed', - }, - ], - isError: true, - }); - }); }); }); diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts index f5ae1af2..19d68415 100644 --- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts @@ -83,104 +83,31 @@ describe('stop_app_sim tool', () => { content: [ { type: 'text', - text: '✅ App com.example.App stopped successfully in simulator test-uuid', + text: 'App com.example.App stopped successfully in simulator test-uuid', }, ], }); }); - it('should stop app successfully when resolving simulatorName', async () => { - let callCount = 0; - const sequencedExecutor = async (command: string[]) => { - callCount++; - if (callCount === 1) { - return { - success: true, - output: JSON.stringify({ - devices: { - 'iOS 17.0': [ - { name: 'iPhone 16', udid: 'resolved-uuid', isAvailable: true, state: 'Booted' }, - ], - }, - }), - error: '', - process: {} as any, - }; - } - return { - success: true, - output: '', - error: '', - process: {} as any, - }; - }; - - const result = await stop_app_simLogic( - { - simulatorName: 'iPhone 16', - bundleId: 'com.example.App', - }, - sequencedExecutor, - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: '✅ App com.example.App stopped successfully in simulator "iPhone 16" (resolved-uuid)', - }, - ], - }); - }); - - it('should surface error when simulator name is missing', async () => { - const result = await stop_app_simLogic( - { - simulatorName: 'Missing Simulator', - bundleId: 'com.example.App', - }, - async () => ({ - success: true, - output: JSON.stringify({ devices: {} }), - error: '', - process: {} as any, - }), - ); - - expect(result).toEqual({ - content: [ - { - type: 'text', - text: 'Simulator named "Missing Simulator" not found. Use list_sims to see available simulators.', - }, - ], - isError: true, - }); - }); - - it('should handle simulator list command failure', async () => { - const listExecutor = createMockExecutor({ - success: false, - output: '', - error: 'simctl list failed', - }); + it('should display friendly name when simulatorName is provided alongside resolved simulatorId', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); const result = await stop_app_simLogic( { + simulatorId: 'resolved-uuid', simulatorName: 'iPhone 16', bundleId: 'com.example.App', }, - listExecutor, + mockExecutor, ); expect(result).toEqual({ content: [ { type: 'text', - text: 'Failed to list simulators: simctl list failed', + text: 'App com.example.App stopped successfully in simulator "iPhone 16" (resolved-uuid)', }, ], - isError: true, }); }); diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts index 40e3a08e..8d1c332a 100644 --- a/src/mcp/tools/simulator/boot_sim.ts +++ b/src/mcp/tools/simulator/boot_sim.ts @@ -8,15 +8,33 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; -const bootSimSchemaObject = z.object({ - simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'), +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), }); -type BootSimParams = z.infer; +// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) +const internalSchemaObject = z.object({ + simulatorId: z.string(), + simulatorName: z.string().optional(), +}); + +type BootSimParams = z.infer; const publicSchemaObject = z.strictObject( - bootSimSchemaObject.omit({ + baseSchemaObject.omit({ simulatorId: true, + simulatorName: true, } as const).shape, ); @@ -85,12 +103,15 @@ export async function boot_simLogic( export const schema = getSessionAwareToolSchemaShape({ sessionAware: publicSchemaObject, - legacy: bootSimSchemaObject, + legacy: baseSchemaObject, }); export const handler = createSessionAwareTool({ - internalSchema: bootSimSchemaObject as unknown as z.ZodType, + internalSchema: internalSchemaObject as unknown as z.ZodType, logicFunction: boot_simLogic, getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], }); diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts index 510f9b74..fbe1e290 100644 --- a/src/mcp/tools/simulator/install_app_sim.ts +++ b/src/mcp/tools/simulator/install_app_sim.ts @@ -9,16 +9,35 @@ import { getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; -const installAppSimSchemaObject = z.object({ - simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'), +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + appPath: z.string().describe('Path to the .app bundle to install'), +}); + +// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) +const internalSchemaObject = z.object({ + simulatorId: z.string(), + simulatorName: z.string().optional(), appPath: z.string(), }); -type InstallAppSimParams = z.infer; +type InstallAppSimParams = z.infer; const publicSchemaObject = z.strictObject( - installAppSimSchemaObject.omit({ + baseSchemaObject.omit({ simulatorId: true, + simulatorName: true, } as const).shape, ); @@ -105,12 +124,15 @@ export async function install_app_simLogic( export const schema = getSessionAwareToolSchemaShape({ sessionAware: publicSchemaObject, - legacy: installAppSimSchemaObject, + legacy: baseSchemaObject, }); export const handler = createSessionAwareTool({ - internalSchema: installAppSimSchemaObject as unknown as z.ZodType, + internalSchema: internalSchemaObject as unknown as z.ZodType, logicFunction: install_app_simLogic, getExecutor: getDefaultCommandExecutor, - requirements: [{ allOf: ['simulatorId'], message: 'simulatorId is required' }], + requirements: [ + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + ], + exclusivePairs: [['simulatorId', 'simulatorName']], }); diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts index 7e998e02..be5732aa 100644 --- a/src/mcp/tools/simulator/launch_app_logs_sim.ts +++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts @@ -20,17 +20,37 @@ export type LogCaptureFunction = ( executor: CommandExecutor, ) => Promise<{ sessionId: string; logFilePath: string; processes: unknown[]; error?: string }>; -const launchAppLogsSimSchemaObject = z.object({ - simulatorId: z.string().describe('UUID of the simulator to use (obtained from list_sims)'), +const baseSchemaObject = z.object({ + simulatorId: z + .string() + .optional() + .describe( + 'UUID of the simulator to use (obtained from list_sims). Provide EITHER this OR simulatorName, not both', + ), + simulatorName: z + .string() + .optional() + .describe( + "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", + ), + bundleId: z.string().describe('Bundle identifier of the app to launch'), + args: z.array(z.string()).optional().describe('Optional arguments to pass to the app'), +}); + +// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) +const internalSchemaObject = z.object({ + simulatorId: z.string(), + simulatorName: z.string().optional(), bundleId: z.string(), args: z.array(z.string()).optional(), }); -type LaunchAppLogsSimParams = z.infer; +type LaunchAppLogsSimParams = z.infer; const publicSchemaObject = z.strictObject( - launchAppLogsSimSchemaObject.omit({ + baseSchemaObject.omit({ simulatorId: true, + simulatorName: true, bundleId: true, } as const).shape, ); @@ -77,17 +97,16 @@ export async function launch_app_logs_simLogic( export const schema = getSessionAwareToolSchemaShape({ sessionAware: publicSchemaObject, - legacy: launchAppLogsSimSchemaObject, + legacy: baseSchemaObject, }); export const handler = createSessionAwareTool({ - internalSchema: launchAppLogsSimSchemaObject as unknown as z.ZodType< - LaunchAppLogsSimParams, - unknown - >, + internalSchema: internalSchemaObject as unknown as z.ZodType, logicFunction: launch_app_logs_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ - { allOf: ['simulatorId', 'bundleId'], message: 'Provide simulatorId and bundleId' }, + { oneOf: ['simulatorId', 'simulatorName'], message: 'Provide simulatorId or simulatorName' }, + { allOf: ['bundleId'], message: 'bundleId is required' }, ], + exclusivePairs: [['simulatorId', 'simulatorName']], }); diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts index c913efb2..d24af0e5 100644 --- a/src/mcp/tools/simulator/launch_app_sim.ts +++ b/src/mcp/tools/simulator/launch_app_sim.ts @@ -3,7 +3,6 @@ import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, @@ -22,91 +21,28 @@ const baseSchemaObject = z.object({ .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), + bundleId: z.string().describe('Bundle identifier of the app to launch'), + args: z.array(z.string()).optional().describe('Optional arguments to pass to the app'), +}); + +// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) +const internalSchemaObject = z.object({ + simulatorId: z.string(), + simulatorName: z.string().optional(), bundleId: z.string(), args: z.array(z.string()).optional(), }); -const launchAppSimSchema = z.preprocess( - nullifyEmptyStrings, - baseSchemaObject - .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { - message: 'Either simulatorId or simulatorName is required.', - }) - .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { - message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', - }), -); - -export type LaunchAppSimParams = z.infer; +export type LaunchAppSimParams = z.infer; export async function launch_app_simLogic( params: LaunchAppSimParams, executor: CommandExecutor, ): Promise { - let simulatorId = params.simulatorId; - let simulatorDisplayName = simulatorId ?? ''; - - if (params.simulatorName && !simulatorId) { - log('info', `Looking up simulator by name: ${params.simulatorName}`); - - const simulatorListResult = await executor( - ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], - 'List Simulators', - false, - ); - if (!simulatorListResult.success) { - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${simulatorListResult.error}`, - }, - ], - isError: true, - }; - } - - const simulatorsData = JSON.parse(simulatorListResult.output) as { - devices: Record>; - }; - - let foundSimulator: { udid: string; name: string } | null = null; - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - const simulator = devices.find((device) => device.name === params.simulatorName); - if (simulator) { - foundSimulator = simulator; - break; - } - } - - if (!foundSimulator) { - return { - content: [ - { - type: 'text', - text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, - }, - ], - isError: true, - }; - } - - simulatorId = foundSimulator.udid; - simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; - } - - if (!simulatorId) { - return { - content: [ - { - type: 'text', - text: 'No simulator identifier provided', - }, - ], - isError: true, - }; - } + const simulatorId = params.simulatorId; + const simulatorDisplayName = params.simulatorName + ? `"${params.simulatorName}" (${simulatorId})` + : simulatorId; log('info', `Starting xcrun simctl launch request for simulator ${simulatorId}`); @@ -171,7 +107,7 @@ export async function launch_app_simLogic( content: [ { type: 'text', - text: `App launched successfully in simulator ${simulatorDisplayName || simulatorId}.`, + text: `App launched successfully in simulator ${simulatorDisplayName}.`, }, ], nextSteps: [ @@ -223,7 +159,7 @@ export const schema = getSessionAwareToolSchemaShape({ }); export const handler = createSessionAwareTool({ - internalSchema: launchAppSimSchema as unknown as z.ZodType, + internalSchema: internalSchemaObject as unknown as z.ZodType, logicFunction: launch_app_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts index eb337c18..26d4fd1b 100644 --- a/src/mcp/tools/simulator/stop_app_sim.ts +++ b/src/mcp/tools/simulator/stop_app_sim.ts @@ -3,7 +3,6 @@ import type { ToolResponse } from '../../../types/common.ts'; import { log } from '../../../utils/logging/index.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; -import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts'; import { createSessionAwareTool, getSessionAwareToolSchemaShape, @@ -22,90 +21,26 @@ const baseSchemaObject = z.object({ .describe( "Name of the simulator (e.g., 'iPhone 16'). Provide EITHER this OR simulatorId, not both", ), - bundleId: z.string(), + bundleId: z.string().describe('Bundle identifier of the app to stop'), }); -const stopAppSimSchema = z.preprocess( - nullifyEmptyStrings, - baseSchemaObject - .refine((val) => val.simulatorId !== undefined || val.simulatorName !== undefined, { - message: 'Either simulatorId or simulatorName is required.', - }) - .refine((val) => !(val.simulatorId !== undefined && val.simulatorName !== undefined), { - message: 'simulatorId and simulatorName are mutually exclusive. Provide only one.', - }), -); +// Internal schema requires simulatorId (factory resolves simulatorName → simulatorId) +const internalSchemaObject = z.object({ + simulatorId: z.string(), + simulatorName: z.string().optional(), + bundleId: z.string(), +}); -export type StopAppSimParams = z.infer; +export type StopAppSimParams = z.infer; export async function stop_app_simLogic( params: StopAppSimParams, executor: CommandExecutor, ): Promise { - let simulatorId = params.simulatorId; - let simulatorDisplayName = simulatorId ?? ''; - - if (params.simulatorName && !simulatorId) { - log('info', `Looking up simulator by name: ${params.simulatorName}`); - - const simulatorListResult = await executor( - ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], - 'List Simulators', - false, - ); - if (!simulatorListResult.success) { - return { - content: [ - { - type: 'text', - text: `Failed to list simulators: ${simulatorListResult.error}`, - }, - ], - isError: true, - }; - } - - const simulatorsData = JSON.parse(simulatorListResult.output) as { - devices: Record>; - }; - - let foundSimulator: { udid: string; name: string } | null = null; - for (const runtime in simulatorsData.devices) { - const devices = simulatorsData.devices[runtime]; - const simulator = devices.find((device) => device.name === params.simulatorName); - if (simulator) { - foundSimulator = simulator; - break; - } - } - - if (!foundSimulator) { - return { - content: [ - { - type: 'text', - text: `Simulator named "${params.simulatorName}" not found. Use list_sims to see available simulators.`, - }, - ], - isError: true, - }; - } - - simulatorId = foundSimulator.udid; - simulatorDisplayName = `"${params.simulatorName}" (${foundSimulator.udid})`; - } - - if (!simulatorId) { - return { - content: [ - { - type: 'text', - text: 'No simulator identifier provided', - }, - ], - isError: true, - }; - } + const simulatorId = params.simulatorId; + const simulatorDisplayName = params.simulatorName + ? `"${params.simulatorName}" (${simulatorId})` + : simulatorId; log('info', `Stopping app ${params.bundleId} in simulator ${simulatorId}`); @@ -129,7 +64,7 @@ export async function stop_app_simLogic( content: [ { type: 'text', - text: `✅ App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName || simulatorId}`, + text: `App ${params.bundleId} stopped successfully in simulator ${simulatorDisplayName}`, }, ], }; @@ -162,7 +97,7 @@ export const schema = getSessionAwareToolSchemaShape({ }); export const handler = createSessionAwareTool({ - internalSchema: stopAppSimSchema as unknown as z.ZodType, + internalSchema: internalSchemaObject as unknown as z.ZodType, logicFunction: stop_app_simLogic, getExecutor: getDefaultCommandExecutor, requirements: [ diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index 4d02148c..23417f1c 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -7,8 +7,10 @@ import { } from '../utils/config-store.ts'; import { sessionStore } from '../utils/session-store.ts'; import { getDefaultFileSystemExecutor } from '../utils/command.ts'; +import { getDefaultCommandExecutor } from '../utils/execution/index.ts'; import { log } from '../utils/logger.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; +import { resolveSimulatorNameToId } from '../utils/simulator-resolver.ts'; export type RuntimeKind = 'cli' | 'daemon' | 'mcp'; @@ -53,8 +55,25 @@ export async function bootstrapRuntime( const config = getConfig(); - const defaults = config.sessionDefaults ?? {}; + const defaults = { ...(config.sessionDefaults ?? {}) }; if (Object.keys(defaults).length > 0) { + // Auto-resolve simulatorName to simulatorId if only name is provided + if (defaults.simulatorName && !defaults.simulatorId) { + const executor = getDefaultCommandExecutor(); + const resolution = await resolveSimulatorNameToId(executor, defaults.simulatorName); + if (resolution.success) { + defaults.simulatorId = resolution.simulatorId; + log( + 'info', + `Resolved simulatorName "${defaults.simulatorName}" to simulatorId: ${resolution.simulatorId}`, + ); + } else { + log( + 'warning', + `Failed to resolve simulatorName "${defaults.simulatorName}": ${resolution.error}`, + ); + } + } sessionStore.setDefaults(defaults); } diff --git a/src/utils/simulator-resolver.ts b/src/utils/simulator-resolver.ts new file mode 100644 index 00000000..e24db724 --- /dev/null +++ b/src/utils/simulator-resolver.ts @@ -0,0 +1,99 @@ +/** + * Shared utility for resolving simulator names to UUIDs. + * Centralizes the lookup logic used across multiple tools. + */ + +import type { CommandExecutor } from './execution/index.ts'; +import { log } from './logger.ts'; + +export type SimulatorResolutionResult = + | { success: true; simulatorId: string; simulatorName: string } + | { success: false; error: string }; + +/** + * Resolves a simulator name to its UUID by querying simctl. + * + * @param executor - Command executor for running simctl + * @param simulatorName - The human-readable simulator name (e.g., "iPhone 16") + * @returns Resolution result with simulatorId on success, or error message on failure + */ +export async function resolveSimulatorNameToId( + executor: CommandExecutor, + simulatorName: string, +): Promise { + log('info', `Looking up simulator by name: ${simulatorName}`); + + const result = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List Simulators', + false, + ); + + if (!result.success) { + return { + success: false, + error: `Failed to list simulators: ${result.error}`, + }; + } + + let simulatorsData: { devices: Record> }; + try { + simulatorsData = JSON.parse(result.output) as typeof simulatorsData; + } catch (parseError) { + return { + success: false, + error: `Failed to parse simulator list: ${parseError}`, + }; + } + + for (const runtime in simulatorsData.devices) { + const devices = simulatorsData.devices[runtime]; + const simulator = devices.find((device) => device.name === simulatorName); + if (simulator) { + log('info', `Resolved simulator "${simulatorName}" to UUID: ${simulator.udid}`); + return { + success: true, + simulatorId: simulator.udid, + simulatorName: simulator.name, + }; + } + } + + return { + success: false, + error: `Simulator named "${simulatorName}" not found. Use list_sims to see available simulators.`, + }; +} + +/** + * Helper to resolve simulatorId from either simulatorId or simulatorName. + * If simulatorId is provided, returns it directly. + * If only simulatorName is provided, resolves it to simulatorId. + * + * @param executor - Command executor for running simctl + * @param simulatorId - Optional simulator UUID + * @param simulatorName - Optional simulator name + * @returns Resolution result with simulatorId, or error if neither provided or lookup fails + */ +export async function resolveSimulatorIdOrName( + executor: CommandExecutor, + simulatorId: string | undefined, + simulatorName: string | undefined, +): Promise { + if (simulatorId) { + return { + success: true, + simulatorId, + simulatorName: simulatorName ?? simulatorId, + }; + } + + if (simulatorName) { + return resolveSimulatorNameToId(executor, simulatorName); + } + + return { + success: false, + error: 'Either simulatorId or simulatorName must be provided.', + }; +} diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index 0071243a..fcfb2e89 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -180,6 +180,7 @@ function createSessionAwareHandler(opts: { } } + // Check requirements first (before expensive simulator resolution) for (const req of requirements) { if ('allOf' in req) { const missing = missingFromMerged(req.allOf, merged); diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 44f04143..683717de 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -115,22 +115,20 @@ describe('exposure', () => { expect(isToolExposedForRuntime(tool, ctx)).toBe(false); }); - it('should return false when hideWhenXcodeAgentMode predicate fails', () => { + it('should return false when hideWhenXcodeAgentMode predicate fails (running under Xcode)', () => { const tool = createTool({ predicates: ['hideWhenXcodeAgentMode'] }); const ctx = createContext({ runtime: 'mcp', runningUnderXcode: true, - xcodeToolsActive: true, }); expect(isToolExposedForRuntime(tool, ctx)).toBe(false); }); - it('should return true when hideWhenXcodeAgentMode predicate passes', () => { + it('should return true when hideWhenXcodeAgentMode predicate passes (not under Xcode)', () => { const tool = createTool({ predicates: ['hideWhenXcodeAgentMode'] }); const ctx = createContext({ runtime: 'mcp', - runningUnderXcode: true, - xcodeToolsActive: false, + runningUnderXcode: false, }); expect(isToolExposedForRuntime(tool, ctx)).toBe(true); }); diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index 1932deb8..c976022f 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -84,17 +84,12 @@ describe('predicate-registry', () => { describe('hideWhenXcodeAgentMode', () => { it('should return true when not running under Xcode', () => { - const ctx = createContext({ runningUnderXcode: false, xcodeToolsActive: false }); - expect(PREDICATES.hideWhenXcodeAgentMode(ctx)).toBe(true); - }); - - it('should return true when Xcode tools are not active', () => { - const ctx = createContext({ runningUnderXcode: true, xcodeToolsActive: false }); + const ctx = createContext({ runningUnderXcode: false }); expect(PREDICATES.hideWhenXcodeAgentMode(ctx)).toBe(true); }); - it('should return false when running under Xcode AND tools are active', () => { - const ctx = createContext({ runningUnderXcode: true, xcodeToolsActive: true }); + it('should return false when running under Xcode', () => { + const ctx = createContext({ runningUnderXcode: true }); expect(PREDICATES.hideWhenXcodeAgentMode(ctx)).toBe(false); }); }); diff --git a/src/visibility/predicate-registry.ts b/src/visibility/predicate-registry.ts index 1b7a6f82..6378af92 100644 --- a/src/visibility/predicate-registry.ts +++ b/src/visibility/predicate-registry.ts @@ -34,11 +34,10 @@ export const PREDICATES: Record = { requiresXcodeTools: (ctx: PredicateContext): boolean => ctx.xcodeToolsActive === true, /** - * Hide when running in Xcode agent mode (both under Xcode AND tools bridge active). + * Hide when running inside Xcode's coding agent. * Use for XcodeBuildMCP tools that conflict with Xcode's native equivalents. */ - hideWhenXcodeAgentMode: (ctx: PredicateContext): boolean => - !(ctx.runningUnderXcode && ctx.xcodeToolsActive), + hideWhenXcodeAgentMode: (ctx: PredicateContext): boolean => !ctx.runningUnderXcode, /** * Always visible - useful for explicit documentation in YAML. From 3da0e48803c367246ff936f673278c46e02c2874 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 18:43:04 +0000 Subject: [PATCH 07/23] Add automatic Xcode IDE state synchronization Sync session defaults (scheme, simulator, bundle ID) from Xcode's UserInterfaceState.xcuserstate file, both at startup and via FSEvents file watching for real-time updates. - Parse xcuserstate binary plists via bplist-parser to extract active scheme and run destination from NSKeyedArchiver object graph - Watch xcuserstate for changes using chokidar with debouncing and change detection to filter out UI-only writes - Resolve simulator name and bundle ID asynchronously (non-blocking) to avoid delaying session default updates - Add disableXcodeAutoSync config option: when true, disables file watcher and exposes manual sync_xcode_defaults tool instead - Add xcode-ide-state MCP resource for reading current IDE selection - Fix mutually exclusive session defaults (simulatorId preferred over simulatorName when both come from session defaults) - Use project/workspace path from config for monorepo disambiguation --- docs/investigations/xcode-ide-state-sync.md | 196 ++++++++++++ manifests/tools/sync_xcode_defaults.yaml | 15 + manifests/workflows/session-management.yaml | 1 + package-lock.json | 92 +++++- package.json | 5 +- src/core/resources.ts | 2 + .../__tests__/xcode-ide-state.test.ts | 65 ++++ src/mcp/resources/xcode-ide-state.ts | 75 +++++ .../__tests__/launch_app_logs_sim.test.ts | 2 +- .../ui-automation/__tests__/swipe.test.ts | 2 +- .../tools/ui-automation/__tests__/tap.test.ts | 2 +- .../__tests__/sync_xcode_defaults.test.ts | 172 +++++++++++ .../tools/xcode-ide/sync_xcode_defaults.ts | 116 ++++++++ src/server/bootstrap.ts | 71 ++++- src/utils/__tests__/log_capture.test.ts | 4 +- .../__tests__/nskeyedarchiver-parser.test.ts | 210 +++++++++++++ .../session-aware-tool-factory.test.ts | 45 +++ .../__tests__/typed-tool-factory.test.ts | 2 +- .../__tests__/xcode-state-reader.test.ts | 264 +++++++++++++++++ .../__tests__/xcode-state-watcher.test.ts | 51 ++++ src/utils/config-store.ts | 16 + src/utils/nskeyedarchiver-parser.ts | 241 +++++++++++++++ src/utils/runtime-config-schema.ts | 1 + src/utils/typed-tool-factory.ts | 17 ++ src/utils/xcode-state-reader.ts | 251 ++++++++++++++++ src/utils/xcode-state-watcher.ts | 280 ++++++++++++++++++ src/visibility/__tests__/exposure.test.ts | 52 +++- .../__tests__/predicate-registry.test.ts | 66 ++++- src/visibility/predicate-registry.ts | 7 + 29 files changed, 2275 insertions(+), 48 deletions(-) create mode 100644 docs/investigations/xcode-ide-state-sync.md create mode 100644 manifests/tools/sync_xcode_defaults.yaml create mode 100644 src/mcp/resources/__tests__/xcode-ide-state.test.ts create mode 100644 src/mcp/resources/xcode-ide-state.ts create mode 100644 src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts create mode 100644 src/mcp/tools/xcode-ide/sync_xcode_defaults.ts create mode 100644 src/utils/__tests__/nskeyedarchiver-parser.test.ts create mode 100644 src/utils/__tests__/xcode-state-reader.test.ts create mode 100644 src/utils/__tests__/xcode-state-watcher.test.ts create mode 100644 src/utils/nskeyedarchiver-parser.ts create mode 100644 src/utils/xcode-state-reader.ts create mode 100644 src/utils/xcode-state-watcher.ts diff --git a/docs/investigations/xcode-ide-state-sync.md b/docs/investigations/xcode-ide-state-sync.md new file mode 100644 index 00000000..76f66ada --- /dev/null +++ b/docs/investigations/xcode-ide-state-sync.md @@ -0,0 +1,196 @@ +# Investigation: Xcode IDE State Sync for Session Defaults + +## Problem Statement + +When XcodeBuildMCP runs inside Xcode's Coding Agent, users expect our tools to use the same scheme and simulator that are currently selected in Xcode's UI toolbar. Currently, users must manually set these via `session-set-defaults` or config files, which creates friction and potential mismatches. + +## Goal + +Auto-detect Xcode's currently selected scheme and simulator (run destination) and sync them to XcodeBuildMCP's session defaults, so tools automatically use the same targets as the IDE. + +## Why This Matters + +1. **Consistency** - When a user builds with Apple's tools in Xcode, then uses our tools to install/launch, they expect the same simulator to be targeted +2. **Reduced friction** - No need to manually configure session defaults when working in Xcode +3. **Avoiding errors** - Prevents "wrong simulator" issues where config says one thing but Xcode shows another + +## Technical Investigation + +### Where Xcode Stores UI State + +Xcode stores the active scheme and run destination in: + +``` +/xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate +``` + +For xcodeproj (without separate workspace): +``` +.xcodeproj/project.xcworkspace/xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate +``` + +### File Format: NSKeyedArchiver + +The xcuserstate file is a **binary plist** using Apple's `NSKeyedArchiver` serialization format. This is NOT a simple key-value plist - it's a serialized object graph with: + +- `$objects` array containing all archived objects +- `CF$UID` / `CFKeyedArchiverUID` references between objects +- Nested NSDictionary structures with `NS.keys` and `NS.objects` arrays + +Example structure (via `plutil -p`): +``` +{ + "$archiver" => "NSKeyedArchiver" + "$objects" => [ + 0 => "$null" + 1 => { "$class" => , "NS.keys" => [...], "NS.objects" => [...] } + ... + 407 => "ActiveScheme" + 415 => "ActiveRunDestination" + 730 => "IDERunContextRecentsSchemesKey" + 731 => "IDERunContextRecentsLastUsedRunDestinationBySchemeKey" + ... + ] +} +``` + +### Key Fields Identified + +| Key | Description | Location | +|-----|-------------|----------| +| `ActiveScheme` | Currently selected scheme | Index reference in $objects | +| `ActiveRunDestination` | Current run destination | Index reference in $objects | +| `IDERunContextRecentsSchemesKey` | Dictionary of recent schemes with timestamps | Index 730 (varies) | +| `IDERunContextRecentsLastUsedRunDestinationBySchemeKey` | Maps schemes to their last used destinations | Index 731 (varies) | + +### Run Destination Format + +The run destination (simulator/device) is stored in formats like: + +``` +E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443_iphonesimulator_arm64 +dvtdevice-iphonesimulator:E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443 +``` + +The UUID portion is the simulator identifier that can be used with `xcrun simctl`. + +### Parsing Challenges + +1. **NSKeyedArchiver complexity** - Values aren't stored directly; they're referenced by UID indices that must be followed through the object graph + +2. **No direct key lookup** - Can't simply ask "what is ActiveScheme?"; must find the key string, then trace UID references to find the value + +3. **Scheme detection is fragile** - The scheme name appears multiple times in different contexts; heuristics like "most frequent string" don't reliably identify the *active* scheme + +4. **Simulator detection works better** - The UUID pattern `[A-F0-9-]{36}_iphonesimulator_arm64` is distinctive and reliably identifies simulator destinations + +## Current Implementation (Partially Working) + +Location: `src/utils/xcode-state-reader.ts` + +### What Works +- Finding the xcuserstate file in workspace/project +- Extracting simulator UUID from destination pattern +- Looking up simulator name via `xcrun simctl list devices` + +### What's Fragile +- Scheme detection uses heuristic: "find most frequent scheme-like string" +- This fails when other strings appear more frequently +- Doesn't properly decode NSKeyedArchiver references + +## Options for Improvement + +### Option 1: Proper NSKeyedArchiver Decoding + +**Approach**: Write a proper decoder that follows UID references through the object graph. + +**Pros**: +- Reliable, correct parsing +- Would work for any NSKeyedArchiver file + +**Cons**: +- Complex implementation +- Need to handle various NS* class types +- Maintenance burden + +**Implementation**: Could use Python's `plistlib` with custom unarchiver, or implement UID resolution in TypeScript. + +### Option 2: Improved Heuristic with Known Schemes + +**Approach**: +1. Get list of valid scheme names from `*.xcscheme` files or `xcschememanagement.plist` +2. Search for those specific names near `IDERunContextRecentsSchemesKey` in plutil output +3. Use the one that appears in that context + +**Pros**: +- Simpler than full decoder +- More targeted than current "most frequent" approach + +**Cons**: +- Still a heuristic, could break with Xcode updates +- Requires reading multiple files + +### Option 3: AppleScript/Accessibility API + +**Approach**: Query Xcode directly for its current scheme and destination. + +```bash +osascript -e 'tell application "Xcode" to get name of active scheme of active workspace document' +``` + +**Pros**: +- Gets live state directly from Xcode +- Always accurate to what user sees + +**Cons**: +- Requires Xcode to grant automation permissions +- May not work in all contexts (sandboxing, permissions) +- AppleScript API for Xcode is limited/undocumented + +### Option 4: Partial Sync (Simulator Only) + +**Approach**: Only sync the simulator (which works reliably), skip scheme detection. + +**Pros**: +- Simple, reliable +- Simulator is often the more important value (scheme can be discovered via `list_schemes`) + +**Cons**: +- Incomplete solution +- Agent still needs to determine scheme somehow + +### Option 5: Use xcodebuild to Query + +**Approach**: Use `xcodebuild -showBuildSettings` or similar to get current build context. + +**Cons**: +- These commands don't report the *UI-selected* scheme/destination +- They require you to specify the scheme as a parameter + +## Related Files + +- `src/utils/xcode-state-reader.ts` - Current implementation +- `src/mcp/tools/xcode-ide/sync_xcode_defaults.ts` - Sync tool +- `src/server/bootstrap.ts` - Auto-sync at startup +- `manifests/tools/sync_xcode_defaults.yaml` - Tool manifest + +## Test Project Used + +``` +/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj +``` + +Xcuserstate location: +``` +MCPTest.xcodeproj/project.xcworkspace/xcuserdata/cameroncooke.xcuserdatad/UserInterfaceState.xcuserstate +``` + +## Open Questions + +1. When Xcode's Coding Agent launches the MCP server, what is the working directory? Is it reliably the project directory? + +2. Could we use the `discover_projs` tool output to locate the correct xcuserstate? + +3. Is there a simpler file that contains just the active scheme/destination without the full UI state? + +4. Would Apple's Coding Agent team expose this information via environment variables or a dedicated API? diff --git a/manifests/tools/sync_xcode_defaults.yaml b/manifests/tools/sync_xcode_defaults.yaml new file mode 100644 index 00000000..a0cc4de9 --- /dev/null +++ b/manifests/tools/sync_xcode_defaults.yaml @@ -0,0 +1,15 @@ +id: sync_xcode_defaults +module: mcp/tools/xcode-ide/sync_xcode_defaults +names: + mcp: sync_xcode_defaults +description: "Sync session defaults (scheme, simulator) from Xcode's current IDE selection." +availability: + mcp: true + cli: false + daemon: false +predicates: + - xcodeAutoSyncDisabled +annotations: + title: "Sync Xcode Defaults" + readOnlyHint: false + destructiveHint: false diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml index fb81c747..0c30d904 100644 --- a/manifests/workflows/session-management.yaml +++ b/manifests/workflows/session-management.yaml @@ -14,3 +14,4 @@ tools: - session_show_defaults - session_set_defaults - session_clear_defaults + - sync_xcode_defaults diff --git a/package-lock.json b/package-lock.json index 149c02aa..2d20ec31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@modelcontextprotocol/sdk": "^1.25.1", "@sentry/cli": "^3.1.0", "@sentry/node": "^10.37.0", + "bplist-parser": "^0.3.2", + "chokidar": "^5.0.0", "uuid": "^11.1.0", "yaml": "^2.4.5", "yargs": "^17.7.2", @@ -26,6 +28,7 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", "@smithery/cli": "^3.7.0", + "@types/chokidar": "^1.7.5", "@types/node": "^22.13.6", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.28.0", @@ -3527,6 +3530,17 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/chokidar": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/chokidar/-/chokidar-1.7.5.tgz", + "integrity": "sha512-PDkSRY7KltW3M60hSBlerxI8SFPXsO3AL/aRVsO4Kh9IHRW74Ih75gUuTd/aE4LSSFqypb10UIX3QzOJwBQMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/events": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3550,6 +3564,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4322,7 +4343,6 @@ "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, "license": "Unlicense", "engines": { "node": ">=0.6" @@ -4375,10 +4395,9 @@ } }, "node_modules/bplist-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", - "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", - "dev": true, + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", "license": "MIT", "dependencies": { "big-integer": "1.6.x" @@ -4562,16 +4581,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -8151,13 +8169,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -8717,6 +8734,19 @@ "plist": "^3.0.5" } }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/sirv": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", @@ -9420,6 +9450,36 @@ } } }, + "node_modules/tsup/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tsup/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/tsup/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", diff --git a/package.json b/package.json index aa39fac0..050fbe24 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "@modelcontextprotocol/sdk": "^1.25.1", "@sentry/cli": "^3.1.0", "@sentry/node": "^10.37.0", + "bplist-parser": "^0.3.2", + "chokidar": "^5.0.0", "uuid": "^11.1.0", "yaml": "^2.4.5", "yargs": "^17.7.2", @@ -82,6 +84,7 @@ "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.23.0", "@smithery/cli": "^3.7.0", + "@types/chokidar": "^1.7.5", "@types/node": "^22.13.6", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.28.0", @@ -101,4 +104,4 @@ "vitest": "^3.2.4", "xcode": "^3.0.1" } -} \ No newline at end of file +} diff --git a/src/core/resources.ts b/src/core/resources.ts index 92250105..5a7e984e 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -15,6 +15,7 @@ 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'; /** * Resource metadata interface @@ -40,6 +41,7 @@ const RESOURCES: ResourceMeta[] = [ doctorResource, sessionStatusResource, simulatorsResource, + xcodeIdeStateResource, ]; /** diff --git a/src/mcp/resources/__tests__/xcode-ide-state.test.ts b/src/mcp/resources/__tests__/xcode-ide-state.test.ts new file mode 100644 index 00000000..b7713a75 --- /dev/null +++ b/src/mcp/resources/__tests__/xcode-ide-state.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import xcodeIdeStateResource, { 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(); + + 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'); + } + if (parsed.simulatorId !== undefined) { + expect(typeof parsed.simulatorId).toBe('string'); + } + if (parsed.simulatorName !== undefined) { + expect(typeof parsed.simulatorName).toBe('string'); + } + if (parsed.error !== undefined) { + expect(typeof parsed.error).toBe('string'); + } + }); + + 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/xcode-ide-state.ts b/src/mcp/resources/xcode-ide-state.ts new file mode 100644 index 00000000..950969e7 --- /dev/null +++ b/src/mcp/resources/xcode-ide-state.ts @@ -0,0 +1,75 @@ +/** + * Xcode IDE State Resource + * + * 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. + */ + +import { log } from '../../utils/logging/index.ts'; +import { getDefaultCommandExecutor } from '../../utils/execution/index.ts'; +import { readXcodeIdeState } from '../../utils/xcode-state-reader.ts'; + +export interface XcodeIdeStateResponse { + detected: boolean; + scheme?: string; + simulatorId?: string; + simulatorName?: string; + error?: string; +} + +export async function xcodeIdeStateResourceLogic(): Promise<{ + contents: Array<{ text: string }>; +}> { + try { + log('info', 'Processing Xcode IDE state resource request'); + + const executor = getDefaultCommandExecutor(); + const cwd = process.cwd(); + + const state = await readXcodeIdeState({ executor, cwd }); + + const response: XcodeIdeStateResponse = { + detected: !state.error && (!!state.scheme || !!state.simulatorId), + scheme: state.scheme, + simulatorId: state.simulatorId, + simulatorName: state.simulatorName, + error: state.error, + }; + + return { + contents: [ + { + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in Xcode IDE state resource handler: ${errorMessage}`); + + const response: XcodeIdeStateResponse = { + detected: false, + error: errorMessage, + }; + + return { + contents: [ + { + text: JSON.stringify(response, null, 2), + }, + ], + }; + } +} + +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(); + }, +}; diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts index 1f5d0899..c67ccfcc 100644 --- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts +++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts @@ -9,7 +9,7 @@ import { schema, handler, launch_app_logs_simLogic, - LogCaptureFunction, + type LogCaptureFunction, } from '../launch_app_logs_sim.ts'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; diff --git a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts index fe3e7bde..8a6c9543 100644 --- a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts @@ -8,7 +8,7 @@ import { createMockExecutor, mockProcess } from '../../../../test-utils/mock-exe import { SystemError } from '../../../../utils/responses/index.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler, AxeHelpers, swipeLogic, SwipeParams } from '../swipe.ts'; +import { schema, handler, type AxeHelpers, swipeLogic, type SwipeParams } from '../swipe.ts'; // Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { diff --git a/src/mcp/tools/ui-automation/__tests__/tap.test.ts b/src/mcp/tools/ui-automation/__tests__/tap.test.ts index db0936cc..a5338132 100644 --- a/src/mcp/tools/ui-automation/__tests__/tap.test.ts +++ b/src/mcp/tools/ui-automation/__tests__/tap.test.ts @@ -7,7 +7,7 @@ import * as z from 'zod'; import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; -import { schema, handler, AxeHelpers, tapLogic } from '../tap.ts'; +import { schema, handler, type AxeHelpers, tapLogic } from '../tap.ts'; // Helper function to create mock axe helpers function createMockAxeHelpers(): AxeHelpers { diff --git a/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts new file mode 100644 index 00000000..117a101a --- /dev/null +++ b/src/mcp/tools/xcode-ide/__tests__/sync_xcode_defaults.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import { createCommandMatchingMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { schema, syncXcodeDefaultsLogic } from '../sync_xcode_defaults.ts'; + +// Path to the example project (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/cameroncooke.xcuserdatad/UserInterfaceState.xcuserstate', +); + +describe('sync_xcode_defaults tool', () => { + beforeEach(() => { + sessionStore.clear(); + }); + + describe('Export Field Validation', () => { + it('should have schema object', () => { + expect(schema).toBeDefined(); + expect(typeof schema).toBe('object'); + }); + }); + + describe('syncXcodeDefaultsLogic', () => { + it('returns error when no project found', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '' }, + }); + + const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to read Xcode IDE state'); + }); + + it('returns error when xcuserstate file not found', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '/test/project/MyApp.xcworkspace\n' }, + stat: { success: false, error: 'No such file' }, + }); + + const result = await syncXcodeDefaultsLogic({}, { executor, cwd: '/test/project' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to read Xcode IDE state'); + }); + }); + + describe('syncXcodeDefaultsLogic integration', () => { + // These tests use the actual example project fixture + + it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( + 'syncs scheme and simulator from example project', + async () => { + const simctlOutput = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', name: 'iPhone 16 Pro' }, + ], + }, + }); + + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'cameroncooke\n' }, + find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, + stat: { output: '1704067200\n' }, + 'xcrun simctl': { output: simctlOutput }, + xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = com.example.MCPTest\n' }, + }); + + const result = await syncXcodeDefaultsLogic( + {}, + { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, + ); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Synced session defaults from Xcode IDE'); + expect(result.content[0].text).toContain('Scheme: MCPTest'); + expect(result.content[0].text).toContain( + 'Simulator ID: E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', + ); + expect(result.content[0].text).toContain('Simulator Name: iPhone 16 Pro'); + expect(result.content[0].text).toContain('Bundle ID: com.example.MCPTest'); + + const defaults = sessionStore.getAll(); + expect(defaults.scheme).toBe('MCPTest'); + expect(defaults.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + expect(defaults.simulatorName).toBe('iPhone 16 Pro'); + expect(defaults.bundleId).toBe('com.example.MCPTest'); + }, + ); + + it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('syncs using configured projectPath', async () => { + const simctlOutput = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', name: 'iPhone 16 Pro' }, + ], + }, + }); + + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'cameroncooke\n' }, + 'test -f': { success: true }, + 'xcrun simctl': { output: simctlOutput }, + xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = com.example.MCPTest\n' }, + }); + + const result = await syncXcodeDefaultsLogic( + {}, + { + executor, + cwd: '/some/other/path', + projectPath: EXAMPLE_PROJECT_PATH, + }, + ); + + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Scheme: MCPTest'); + + const defaults = sessionStore.getAll(); + expect(defaults.scheme).toBe('MCPTest'); + expect(defaults.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + expect(defaults.bundleId).toBe('com.example.MCPTest'); + }); + + it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))('updates existing session defaults', async () => { + // Set some existing defaults + sessionStore.setDefaults({ + scheme: 'OldScheme', + simulatorId: 'OLD-SIM-UUID', + projectPath: '/some/project.xcodeproj', + }); + + const simctlOutput = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', name: 'iPhone 16 Pro' }, + ], + }, + }); + + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'cameroncooke\n' }, + find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, + stat: { output: '1704067200\n' }, + 'xcrun simctl': { output: simctlOutput }, + xcodebuild: { output: ' PRODUCT_BUNDLE_IDENTIFIER = com.example.MCPTest\n' }, + }); + + const result = await syncXcodeDefaultsLogic( + {}, + { executor, cwd: join(process.cwd(), 'example_projects/iOS') }, + ); + + expect(result.isError).toBe(false); + + const defaults = sessionStore.getAll(); + expect(defaults.scheme).toBe('MCPTest'); + expect(defaults.simulatorId).toBe('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + expect(defaults.simulatorName).toBe('iPhone 16 Pro'); + expect(defaults.bundleId).toBe('com.example.MCPTest'); + // Original projectPath should be preserved + expect(defaults.projectPath).toBe('/some/project.xcodeproj'); + }); + }); +}); diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts new file mode 100644 index 00000000..89b39b60 --- /dev/null +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -0,0 +1,116 @@ +/** + * Sync Xcode Defaults Tool + * + * Reads Xcode's IDE state (active scheme and run destination) and updates + * session defaults to match. This allows the agent to re-sync if the user + * changes their selection in Xcode mid-session. + * + * Only visible when running under Xcode's coding agent. + */ + +import type { ToolResponse } from '../../../types/common.ts'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; +import { sessionStore } from '../../../utils/session-store.ts'; +import { readXcodeIdeState } from '../../../utils/xcode-state-reader.ts'; +import { lookupBundleId } from '../../../utils/xcode-state-watcher.ts'; +import * as z from 'zod'; + +const schemaObj = z.object({}); + +type Params = z.infer; + +interface SyncXcodeDefaultsContext { + executor: CommandExecutor; + cwd: string; + projectPath?: string; + workspacePath?: string; +} + +export async function syncXcodeDefaultsLogic( + _params: Params, + ctx: SyncXcodeDefaultsContext, +): Promise { + const xcodeState = await readXcodeIdeState({ + executor: ctx.executor, + cwd: ctx.cwd, + projectPath: ctx.projectPath, + workspacePath: ctx.workspacePath, + }); + + if (xcodeState.error) { + return { + content: [ + { + type: 'text', + text: `Failed to read Xcode IDE state: ${xcodeState.error}`, + }, + ], + isError: true, + }; + } + + const synced: Record = {}; + const notices: string[] = []; + + if (xcodeState.scheme) { + synced.scheme = xcodeState.scheme; + notices.push(`Scheme: ${xcodeState.scheme}`); + } + + if (xcodeState.simulatorId) { + synced.simulatorId = xcodeState.simulatorId; + notices.push(`Simulator ID: ${xcodeState.simulatorId}`); + } + + if (xcodeState.simulatorName) { + synced.simulatorName = xcodeState.simulatorName; + notices.push(`Simulator Name: ${xcodeState.simulatorName}`); + } + + // Look up bundle ID if we have a scheme + if (xcodeState.scheme) { + const bundleId = await lookupBundleId( + ctx.executor, + xcodeState.scheme, + ctx.projectPath, + ctx.workspacePath, + ); + if (bundleId) { + synced.bundleId = bundleId; + notices.push(`Bundle ID: ${bundleId}`); + } + } + + if (Object.keys(synced).length === 0) { + return { + content: [ + { + type: 'text', + text: 'No scheme or simulator selection detected in Xcode IDE state.', + }, + ], + isError: false, + }; + } + + sessionStore.setDefaults(synced); + + return { + content: [ + { + type: 'text', + text: `Synced session defaults from Xcode IDE:\n- ${notices.join('\n- ')}`, + }, + ], + isError: false, + }; +} + +export const schema = schemaObj.shape; + +export const handler = createTypedToolWithContext(schemaObj, syncXcodeDefaultsLogic, () => ({ + executor: getDefaultCommandExecutor(), + cwd: process.cwd(), +})); diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index b2a71311..510b02bf 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -8,6 +8,9 @@ import { registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; +import { readXcodeIdeState } from '../utils/xcode-state-reader.ts'; +import { sessionStore } from '../utils/session-store.ts'; +import { startXcodeStateWatcher, lookupBundleId } from '../utils/xcode-state-watcher.ts'; import { getDefaultCommandExecutor } from '../utils/command.ts'; import type { PredicateContext } from '../visibility/predicate-types.ts'; @@ -59,9 +62,75 @@ export async function bootstrapServer( log('info', `🚀 Initializing server...`); // Detect if running under Xcode - const xcodeDetection = await detectXcodeRuntime(getDefaultCommandExecutor()); + const executor = getDefaultCommandExecutor(); + const xcodeDetection = await detectXcodeRuntime(executor); if (xcodeDetection.runningUnderXcode) { log('info', `[xcode] Running under Xcode agent environment`); + + // Get project/workspace path from config session defaults (for monorepo disambiguation) + const configSessionDefaults = result.runtime.config.sessionDefaults; + const projectPath = configSessionDefaults?.projectPath; + const workspacePath = configSessionDefaults?.workspacePath; + + // Sync session defaults from Xcode's IDE state + const xcodeState = await readXcodeIdeState({ + executor, + cwd: result.runtime.cwd, + projectPath, + workspacePath, + }); + + if (xcodeState.error) { + log('debug', `[xcode] Could not read Xcode IDE state: ${xcodeState.error}`); + } else { + const syncedDefaults: Record = {}; + if (xcodeState.scheme) { + syncedDefaults.scheme = xcodeState.scheme; + } + if (xcodeState.simulatorId) { + syncedDefaults.simulatorId = xcodeState.simulatorId; + } + if (xcodeState.simulatorName) { + syncedDefaults.simulatorName = xcodeState.simulatorName; + } + + if (Object.keys(syncedDefaults).length > 0) { + sessionStore.setDefaults(syncedDefaults); + log( + 'info', + `[xcode] Synced session defaults from Xcode: ${JSON.stringify(syncedDefaults)}`, + ); + } + + // Look up bundle ID asynchronously (non-blocking) + if (xcodeState.scheme) { + lookupBundleId(executor, xcodeState.scheme, projectPath, workspacePath) + .then((bundleId) => { + if (bundleId) { + sessionStore.setDefaults({ bundleId }); + log('info', `[xcode] Bundle ID resolved: "${bundleId}"`); + } + }) + .catch((e) => { + log('debug', `[xcode] Failed to lookup bundle ID: ${e}`); + }); + } + } + + // Start file watcher to auto-sync when user changes scheme/simulator in Xcode + if (!result.runtime.config.disableXcodeAutoSync) { + const watcherStarted = await startXcodeStateWatcher({ + executor, + cwd: result.runtime.cwd, + projectPath, + workspacePath, + }); + if (watcherStarted) { + log('info', `[xcode] Started file watcher for automatic sync`); + } + } else { + log('info', `[xcode] Automatic Xcode sync disabled via config`); + } } // Build predicate context for manifest-based registration diff --git a/src/utils/__tests__/log_capture.test.ts b/src/utils/__tests__/log_capture.test.ts index 77e2fec7..a95c8466 100644 --- a/src/utils/__tests__/log_capture.test.ts +++ b/src/utils/__tests__/log_capture.test.ts @@ -7,8 +7,8 @@ import { stopLogCapture, type SubsystemFilter, } from '../log_capture.ts'; -import { CommandExecutor } from '../CommandExecutor.ts'; -import { FileSystemExecutor } from '../FileSystemExecutor.ts'; +import type { CommandExecutor } from '../CommandExecutor.ts'; +import type { FileSystemExecutor } from '../FileSystemExecutor.ts'; type CallHistoryEntry = { command: string[]; diff --git a/src/utils/__tests__/nskeyedarchiver-parser.test.ts b/src/utils/__tests__/nskeyedarchiver-parser.test.ts new file mode 100644 index 00000000..13a62c11 --- /dev/null +++ b/src/utils/__tests__/nskeyedarchiver-parser.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { + parseXcuserstate, + parseXcuserstateBuffer, + isUID, + findStringIndex, + 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/cameroncooke.xcuserdatad/UserInterfaceState.xcuserstate', +); + +// Expected values for the MCPTest example project +const EXPECTED_MCPTEST = { + scheme: 'MCPTest', + simulatorId: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', + 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({}); + }); + }); + + 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({}); + }); + + it('returns empty result for invalid plist data', () => { + const result = parseXcuserstateBuffer(Buffer.from('not a plist')); + expect(result).toEqual({}); + }); + }); + + describe('helper functions', () => { + describe('isUID', () => { + it('returns true for valid UID objects', () => { + expect(isUID({ UID: 0 })).toBe(true); + expect(isUID({ UID: 123 })).toBe(true); + }); + + it('returns false for non-UID values', () => { + expect(isUID(null)).toBe(false); + expect(isUID(undefined)).toBe(false); + expect(isUID(123)).toBe(false); + expect(isUID('string')).toBe(false); + expect(isUID({ notUID: 123 })).toBe(false); + expect(isUID({ UID: 'string' })).toBe(false); + }); + }); + + describe('findStringIndex', () => { + it('finds string at correct index', () => { + const objects = ['$null', 'first', 'second', 'third']; + expect(findStringIndex(objects, 'first')).toBe(1); + expect(findStringIndex(objects, 'third')).toBe(3); + }); + + it('returns -1 for missing string', () => { + const objects = ['$null', 'first', 'second']; + expect(findStringIndex(objects, 'missing')).toBe(-1); + }); + }); + + describe('findDictWithKey', () => { + it('finds dictionary containing key index', () => { + const objects = [ + '$null', + 'KeyName', + { + 'NS.keys': [{ UID: 1 }], + 'NS.objects': [{ UID: 3 }], + }, + 'ValueName', + ]; + + const dict = findDictWithKey(objects, 1); + expect(dict).toBeDefined(); + expect(dict?.['NS.keys']).toHaveLength(1); + }); + + it('returns undefined when key not found', () => { + const objects = [ + '$null', + 'KeyName', + { + 'NS.keys': [{ UID: 1 }], + 'NS.objects': [{ UID: 3 }], + }, + ]; + + const dict = findDictWithKey(objects, 99); + expect(dict).toBeUndefined(); + }); + + it('skips non-dictionary objects', () => { + const objects = ['$null', 'string', 123, null, { noKeys: true }]; + const dict = findDictWithKey(objects, 1); + expect(dict).toBeUndefined(); + }); + }); + }); + + describe('edge cases', () => { + it('handles xcuserstate without ActiveScheme', () => { + // This would require a specially crafted test fixture + // For now, we just verify the function doesn't crash + const result = parseXcuserstateBuffer(Buffer.from('bplist00')); + expect(result).toEqual({}); + }); + + it('handles scheme object without IDENameString', () => { + // The parser should gracefully handle missing nested keys + // and return partial results + const result = parseXcuserstateBuffer(Buffer.from('invalid')); + expect(result.scheme).toBeUndefined(); + }); + }); +}); + +describe('Integration with real xcuserstate files', () => { + // Additional external test file (if available) + const HACKERNEWS_XCUSERSTATE = + '/Volumes/Developer/hackernews/ios/HackerNews.xcodeproj/project.xcworkspace/xcuserdata/cameroncooke.xcuserdatad/UserInterfaceState.xcuserstate'; + + it.skipIf(!existsSync(HACKERNEWS_XCUSERSTATE))('parses HackerNews project xcuserstate', () => { + const result = parseXcuserstate(HACKERNEWS_XCUSERSTATE); + // Scheme can vary based on user's current Xcode selection (could be any scheme in project) + expect(result.scheme).toBeDefined(); + expect(typeof result.scheme).toBe('string'); + expect(result.simulatorId).toMatch(/^[A-F0-9-]{36}$/); + expect(result.simulatorPlatform).toBe('iphonesimulator'); + }); +}); diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index 3bbe5d74..feed93e5 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -213,4 +213,49 @@ describe('createSessionAwareTool', () => { expect(msg).toContain('projectPath'); expect(msg).toContain('workspacePath'); }); + + it('prefers first key when both values of exclusive pair come from session defaults', async () => { + // Create handler that echoes which simulator param was used + const echoHandler = createSessionAwareTool({ + internalSchema: z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + simulatorId: z.string().optional(), + simulatorName: z.string().optional(), + }), + logicFunction: async (params) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + simulatorId: params.simulatorId, + simulatorName: params.simulatorName, + }), + }, + ], + isError: false, + }), + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + exclusivePairs: [['simulatorId', 'simulatorName']], + }); + + // Set both simulatorId and simulatorName in session defaults + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + simulatorId: 'SIM-123', + simulatorName: 'iPhone 16', + }); + + // Call with no args - both come from session defaults + const result = await echoHandler({}); + expect(result.isError).toBe(false); + + const content = result.content[0] as { type: 'text'; text: string }; + const parsed = JSON.parse(content.text); + // simulatorId should be kept (first in pair), simulatorName should be pruned + expect(parsed.simulatorId).toBe('SIM-123'); + expect(parsed.simulatorName).toBeUndefined(); + }); }); diff --git a/src/utils/__tests__/typed-tool-factory.test.ts b/src/utils/__tests__/typed-tool-factory.test.ts index 92326f96..f3930886 100644 --- a/src/utils/__tests__/typed-tool-factory.test.ts +++ b/src/utils/__tests__/typed-tool-factory.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; import * as z from 'zod'; import { createTypedTool } from '../typed-tool-factory.ts'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; -import { ToolResponse } from '../../types/common.ts'; +import type { ToolResponse } from '../../types/common.ts'; // Test schema and types const testSchema = z.object({ diff --git a/src/utils/__tests__/xcode-state-reader.test.ts b/src/utils/__tests__/xcode-state-reader.test.ts new file mode 100644 index 00000000..292c967e --- /dev/null +++ b/src/utils/__tests__/xcode-state-reader.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { + findXcodeStateFile, + lookupSimulatorName, + readXcodeIdeState, +} 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/cameroncooke.xcuserdatad/UserInterfaceState.xcuserstate', +); + +describe('findXcodeStateFile', () => { + it('returns undefined when no project/workspace found', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '' }, + }); + + const result = await findXcodeStateFile({ executor, cwd: '/test/project' }); + expect(result).toBeUndefined(); + }); + + it('finds xcuserstate in xcworkspace', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '/test/project/MyApp.xcworkspace\n' }, + stat: { output: '1704067200\n' }, // mtime + }); + + const result = await findXcodeStateFile({ executor, cwd: '/test/project' }); + expect(result).toBe( + '/test/project/MyApp.xcworkspace/xcuserdata/testuser.xcuserdatad/UserInterfaceState.xcuserstate', + ); + }); + + it('finds xcuserstate in xcodeproj when no workspace', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '/test/project/MyApp.xcodeproj\n' }, + stat: { output: '1704067200\n' }, + }); + + const result = await findXcodeStateFile({ executor, cwd: '/test/project' }); + expect(result).toBe( + '/test/project/MyApp.xcodeproj/project.xcworkspace/xcuserdata/testuser.xcuserdatad/UserInterfaceState.xcuserstate', + ); + }); + + it('returns first valid xcuserstate when multiple found', async () => { + // When multiple xcuserstate files exist with same mtime, returns first by sort order + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { + output: '/test/project/App.xcworkspace\n/test/project/Other.xcworkspace\n', + }, + stat: { output: '1704067200\n' }, + }); + + const result = await findXcodeStateFile({ executor, cwd: '/test/project' }); + // Should return one of them (implementation sorts by mtime then takes first) + expect(result).toMatch(/\.xcworkspace\/xcuserdata\/testuser\.xcuserdatad/); + }); + + it('returns undefined when xcuserstate file does not exist', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '/test/project/MyApp.xcworkspace\n' }, + stat: { success: false, error: 'No such file' }, + }); + + const result = await findXcodeStateFile({ executor, cwd: '/test/project' }); + expect(result).toBeUndefined(); + }); + + it('uses configured workspacePath directly', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + 'test -f': { success: true }, + }); + + const result = await findXcodeStateFile({ + executor, + cwd: '/test/project', + workspacePath: '/configured/path/MyApp.xcworkspace', + }); + + expect(result).toBe( + '/configured/path/MyApp.xcworkspace/xcuserdata/testuser.xcuserdatad/UserInterfaceState.xcuserstate', + ); + }); + + it('uses configured projectPath directly', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + 'test -f': { success: true }, + }); + + const result = await findXcodeStateFile({ + executor, + cwd: '/test/project', + projectPath: '/configured/path/MyApp.xcodeproj', + }); + + expect(result).toBe( + '/configured/path/MyApp.xcodeproj/project.xcworkspace/xcuserdata/testuser.xcuserdatad/UserInterfaceState.xcuserstate', + ); + }); +}); + +describe('lookupSimulatorName', () => { + it('returns simulator name for valid UUID', async () => { + const simctlOutput = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { udid: '2FCB5689-88F1-4CDF-9E7F-8E310CD41D72', name: 'iPhone 16' }, + { udid: 'OTHER-UUID', name: 'iPhone 15' }, + ], + }, + }); + + const executor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { output: simctlOutput }, + }); + + const result = await lookupSimulatorName( + { executor, cwd: '/test' }, + '2FCB5689-88F1-4CDF-9E7F-8E310CD41D72', + ); + + expect(result).toBe('iPhone 16'); + }); + + it('returns undefined for unknown UUID', async () => { + const simctlOutput = JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [{ udid: 'OTHER-UUID', name: 'iPhone 15' }], + }, + }); + + const executor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { output: simctlOutput }, + }); + + const result = await lookupSimulatorName({ executor, cwd: '/test' }, 'UNKNOWN-UUID'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when simctl fails', async () => { + const executor = createCommandMatchingMockExecutor({ + 'xcrun simctl': { success: false, error: 'simctl failed' }, + }); + + const result = await lookupSimulatorName( + { executor, cwd: '/test' }, + '2FCB5689-88F1-4CDF-9E7F-8E310CD41D72', + ); + + expect(result).toBeUndefined(); + }); +}); + +describe('readXcodeIdeState', () => { + it('returns error when no project found', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '' }, + }); + + const result = await readXcodeIdeState({ executor, cwd: '/test/project' }); + + expect(result.error).toBeDefined(); + expect(result.scheme).toBeUndefined(); + expect(result.simulatorId).toBeUndefined(); + }); + + it('returns error when xcuserstate not found', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '/test/project/MyApp.xcworkspace\n' }, + stat: { success: false, error: 'No such file' }, + }); + + const result = await readXcodeIdeState({ executor, cwd: '/test/project' }); + + 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: 'cameroncooke\n' }, + find: { output: `${EXAMPLE_PROJECT_PATH}\n` }, + stat: { output: '1704067200\n' }, + 'xcrun simctl': { + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', + name: 'iPhone 16 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('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + expect(result.simulatorName).toBe('iPhone 16 Pro'); + }, + ); + + it.skipIf(!existsSync(EXAMPLE_XCUSERSTATE))( + 'reads scheme using configured projectPath', + async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'cameroncooke\n' }, + 'test -f': { success: true }, + 'xcrun simctl': { + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + udid: 'E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443', + name: 'iPhone 16 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('E395B9FD-5A4A-4BE5-B61B-E48D1F5AE443'); + }, + ); +}); diff --git a/src/utils/__tests__/xcode-state-watcher.test.ts b/src/utils/__tests__/xcode-state-watcher.test.ts new file mode 100644 index 00000000..14adf67f --- /dev/null +++ b/src/utils/__tests__/xcode-state-watcher.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + startXcodeStateWatcher, + stopXcodeStateWatcher, + isWatcherRunning, + getWatchedPath, +} from '../xcode-state-watcher.ts'; +import { createCommandMatchingMockExecutor } from '../../test-utils/mock-executors.ts'; + +describe('xcode-state-watcher', () => { + afterEach(async () => { + await stopXcodeStateWatcher(); + }); + + describe('startXcodeStateWatcher', () => { + it('returns false when no xcuserstate file found', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + find: { output: '' }, + }); + + const result = await startXcodeStateWatcher({ + executor, + cwd: '/nonexistent', + }); + + expect(result).toBe(false); + expect(isWatcherRunning()).toBe(false); + }); + }); + + describe('stopXcodeStateWatcher', () => { + it('can be called when no watcher is running', async () => { + expect(isWatcherRunning()).toBe(false); + await stopXcodeStateWatcher(); + expect(isWatcherRunning()).toBe(false); + }); + }); + + describe('isWatcherRunning', () => { + it('returns false initially', () => { + expect(isWatcherRunning()).toBe(false); + }); + }); + + describe('getWatchedPath', () => { + it('returns null when no watcher is running', () => { + expect(getWatchedPath()).toBe(null); + }); + }); +}); diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index 74aca1d3..d1daa65f 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -14,6 +14,7 @@ export type RuntimeConfigOverrides = Partial<{ debug: boolean; experimentalWorkflowDiscovery: boolean; disableSessionDefaults: boolean; + disableXcodeAutoSync: boolean; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; dapRequestTimeoutMs: number; @@ -33,6 +34,7 @@ export type ResolvedRuntimeConfig = { debug: boolean; experimentalWorkflowDiscovery: boolean; disableSessionDefaults: boolean; + disableXcodeAutoSync: boolean; uiDebuggerGuardMode: UiDebuggerGuardMode; incrementalBuildsEnabled: boolean; dapRequestTimeoutMs: number; @@ -61,6 +63,7 @@ const DEFAULT_CONFIG: ResolvedRuntimeConfig = { debug: false, experimentalWorkflowDiscovery: false, disableSessionDefaults: false, + disableXcodeAutoSync: false, uiDebuggerGuardMode: 'error', incrementalBuildsEnabled: false, dapRequestTimeoutMs: 30_000, @@ -164,6 +167,12 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { parseBoolean(env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS), ); + setIfDefined( + config, + 'disableXcodeAutoSync', + parseBoolean(env.XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC), + ); + setIfDefined( config, 'uiDebuggerGuardMode', @@ -284,6 +293,13 @@ function resolveConfig(opts: { envConfig, fallback: DEFAULT_CONFIG.disableSessionDefaults, }), + disableXcodeAutoSync: resolveFromLayers({ + key: 'disableXcodeAutoSync', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.disableXcodeAutoSync, + }), uiDebuggerGuardMode: resolveFromLayers({ key: 'uiDebuggerGuardMode', overrides: opts.overrides, diff --git a/src/utils/nskeyedarchiver-parser.ts b/src/utils/nskeyedarchiver-parser.ts new file mode 100644 index 00000000..918c0d9f --- /dev/null +++ b/src/utils/nskeyedarchiver-parser.ts @@ -0,0 +1,241 @@ +/** + * NSKeyedArchiver Parser for Xcode xcuserstate files + * + * Parses binary plist files encoded with NSKeyedArchiver format + * to extract ActiveScheme and ActiveRunDestination values. + * + * Uses bplist-parser for robust binary plist parsing instead of + * relying on plutil output format which can change between macOS versions. + */ + +import { readFileSync } from 'fs'; +import { parseBuffer as bplistParseBuffer } from 'bplist-parser'; + +interface BplistUID { + UID: number; +} + +interface BplistResult { + $archiver?: string; + $objects?: unknown[]; + $top?: Record; + $version?: number; +} + +/** Parsed xcuserstate result */ +export interface XcodeStateResult { + scheme?: string; + simulatorId?: string; + simulatorPlatform?: string; + deviceLocation?: string; +} + +/** Represents a dictionary in the NSKeyedArchiver format */ +interface ArchivedDict { + 'NS.keys'?: BplistUID[]; + 'NS.objects'?: BplistUID[]; + [key: string]: unknown; +} + +/** + * Checks if a value is a bplist UID reference + */ +function isUID(value: unknown): value is BplistUID { + return ( + typeof value === 'object' && + value !== null && + 'UID' in value && + typeof (value as BplistUID).UID === 'number' + ); +} + +/** + * Resolves a UID to its value in the $objects array + */ +function resolveUID(objects: unknown[], uid: BplistUID | unknown): unknown { + if (!isUID(uid)) return uid; + const index = uid.UID; + if (index < 0 || index >= objects.length) return undefined; + return objects[index]; +} + +/** + * Finds the index of a string in the $objects array + */ +function findStringIndex(objects: unknown[], value: string): number { + return objects.findIndex((obj) => obj === value); +} + +/** + * Finds a dictionary that has the given key index in its NS.keys array + */ +function findDictWithKey(objects: unknown[], keyIndex: number): ArchivedDict | undefined { + for (const obj of objects) { + if (typeof obj !== 'object' || obj === null) continue; + const dict = obj as ArchivedDict; + const keys = dict['NS.keys']; + if (!Array.isArray(keys)) continue; + + const hasKey = keys.some((k) => isUID(k) && k.UID === keyIndex); + if (hasKey) return dict; + } + return undefined; +} + +/** + * Gets the value for a key in an NS.keys/NS.objects dictionary + */ +function getValueForKey(objects: unknown[], dict: ArchivedDict, keyIndex: number): unknown { + const keys = dict['NS.keys']; + const values = dict['NS.objects']; + + if (!Array.isArray(keys) || !Array.isArray(values)) return undefined; + + const keyPosition = keys.findIndex((k) => isUID(k) && k.UID === keyIndex); + if (keyPosition === -1 || keyPosition >= values.length) return undefined; + + return resolveUID(objects, values[keyPosition]); +} + +/** + * Main entry point: parses xcuserstate file and extracts Xcode state + * + * @param xcuserstatePath - Path to UserInterfaceState.xcuserstate file + * @returns Extracted scheme and simulator information + */ +export function parseXcuserstate(xcuserstatePath: string): XcodeStateResult { + const result: XcodeStateResult = {}; + + try { + const buffer = readFileSync(xcuserstatePath); + const [root] = bplistParseBuffer(buffer) as [BplistResult]; + + if (!root || root.$archiver !== 'NSKeyedArchiver' || !Array.isArray(root.$objects)) { + return result; + } + + const objects = root.$objects; + + // Find key indices + const activeSchemeIdx = findStringIndex(objects, 'ActiveScheme'); + const activeRunDestIdx = findStringIndex(objects, 'ActiveRunDestination'); + const ideNameStringIdx = findStringIndex(objects, 'IDENameString'); + const targetDeviceLocationIdx = findStringIndex(objects, 'targetDeviceLocation'); + + if (activeSchemeIdx === -1 && activeRunDestIdx === -1) { + return result; + } + + // Find the dictionary containing ActiveScheme key + const parentDict = findDictWithKey(objects, activeSchemeIdx); + if (!parentDict) { + return result; + } + + // Extract scheme name: ActiveScheme -> { IDENameString -> "SchemeName" } + if (activeSchemeIdx !== -1 && ideNameStringIdx !== -1) { + const schemeObj = getValueForKey(objects, parentDict, activeSchemeIdx); + if (typeof schemeObj === 'object' && schemeObj !== null) { + const schemeDict = schemeObj as ArchivedDict; + const schemeName = getValueForKey(objects, schemeDict, ideNameStringIdx); + if (typeof schemeName === 'string') { + result.scheme = schemeName; + } + } + } + + // Extract run destination: ActiveRunDestination -> { targetDeviceLocation -> "dvtdevice-..." } + if (activeRunDestIdx !== -1 && targetDeviceLocationIdx !== -1) { + const destObj = getValueForKey(objects, parentDict, activeRunDestIdx); + if (typeof destObj === 'object' && destObj !== null) { + const destDict = destObj as ArchivedDict; + const location = getValueForKey(objects, destDict, targetDeviceLocationIdx); + if (typeof location === 'string') { + result.deviceLocation = location; + + // Extract UUID from location string: "dvtdevice-iphonesimulator:UUID" + const match = location.match(/dvtdevice-([a-z]+):([A-F0-9-]{36})/i); + if (match) { + result.simulatorPlatform = match[1]; + result.simulatorId = match[2]; + } + } + } + } + } catch (error) { + // Return empty result on error - this is best-effort parsing + // that should never crash the server + console.error('Failed to parse xcuserstate:', error); + } + + return result; +} + +/** + * Parses xcuserstate from a Buffer (useful for testing with fixtures) + * + * @param buffer - Buffer containing the xcuserstate binary plist + * @returns Extracted scheme and simulator information + */ +export function parseXcuserstateBuffer(buffer: Buffer): XcodeStateResult { + const result: XcodeStateResult = {}; + + try { + const [root] = bplistParseBuffer(buffer) as [BplistResult]; + + if (!root || root.$archiver !== 'NSKeyedArchiver' || !Array.isArray(root.$objects)) { + return result; + } + + const objects = root.$objects; + + const activeSchemeIdx = findStringIndex(objects, 'ActiveScheme'); + const activeRunDestIdx = findStringIndex(objects, 'ActiveRunDestination'); + const ideNameStringIdx = findStringIndex(objects, 'IDENameString'); + const targetDeviceLocationIdx = findStringIndex(objects, 'targetDeviceLocation'); + + if (activeSchemeIdx === -1 && activeRunDestIdx === -1) { + return result; + } + + const parentDict = findDictWithKey(objects, activeSchemeIdx); + if (!parentDict) { + return result; + } + + if (activeSchemeIdx !== -1 && ideNameStringIdx !== -1) { + const schemeObj = getValueForKey(objects, parentDict, activeSchemeIdx); + if (typeof schemeObj === 'object' && schemeObj !== null) { + const schemeDict = schemeObj as ArchivedDict; + const schemeName = getValueForKey(objects, schemeDict, ideNameStringIdx); + if (typeof schemeName === 'string') { + result.scheme = schemeName; + } + } + } + + if (activeRunDestIdx !== -1 && targetDeviceLocationIdx !== -1) { + const destObj = getValueForKey(objects, parentDict, activeRunDestIdx); + if (typeof destObj === 'object' && destObj !== null) { + const destDict = destObj as ArchivedDict; + const location = getValueForKey(objects, destDict, targetDeviceLocationIdx); + if (typeof location === 'string') { + result.deviceLocation = location; + + const match = location.match(/dvtdevice-([a-z]+):([A-F0-9-]{36})/i); + if (match) { + result.simulatorPlatform = match[1]; + result.simulatorId = match[2]; + } + } + } + } + } catch (error) { + console.error('Failed to parse xcuserstate buffer:', error); + } + + return result; +} + +// Export helpers for testing +export { isUID, resolveUID, findStringIndex, findDictWithKey, getValueForKey }; diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index 6158b4a6..7ca8902a 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -8,6 +8,7 @@ export const runtimeConfigFileSchema = z debug: z.boolean().optional(), experimentalWorkflowDiscovery: z.boolean().optional(), disableSessionDefaults: z.boolean().optional(), + disableXcodeAutoSync: z.boolean().optional(), uiDebuggerGuardMode: z.enum(['error', 'warn', 'off']).optional(), incrementalBuildsEnabled: z.boolean().optional(), dapRequestTimeoutMs: z.number().int().positive().optional(), diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index fcfb2e89..f6ddfe95 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -180,6 +180,23 @@ function createSessionAwareHandler(opts: { } } + // When both values of an exclusive pair come from session defaults (not user args), + // prefer the first key in the pair. This ensures simulatorId is preferred over simulatorName. + for (const pair of exclusivePairs) { + const allFromDefaults = pair.every( + (k) => !Object.prototype.hasOwnProperty.call(sanitizedArgs, k), + ); + if (!allFromDefaults) continue; + + const presentKeys = pair.filter((k) => merged[k] != null); + if (presentKeys.length > 1) { + // Keep first key (preferred), remove others + for (let i = 1; i < presentKeys.length; i++) { + delete merged[presentKeys[i]]; + } + } + } + // Check requirements first (before expensive simulator resolution) for (const req of requirements) { if ('allOf' in req) { diff --git a/src/utils/xcode-state-reader.ts b/src/utils/xcode-state-reader.ts new file mode 100644 index 00000000..215d5f05 --- /dev/null +++ b/src/utils/xcode-state-reader.ts @@ -0,0 +1,251 @@ +/** + * 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 { log } from './logger.ts'; +import { parseXcuserstate } from './nskeyedarchiver-parser.ts'; +import type { CommandExecutor } from './execution/index.ts'; + +export interface XcodeStateResult { + scheme?: string; + simulatorId?: string; + simulatorName?: string; + error?: string; +} + +export interface XcodeStateReaderContext { + executor: CommandExecutor; + cwd: 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 in cwd and parent directories + * + * For each found project: + * - .xcworkspace: /xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate + * - .xcodeproj: /project.xcworkspace/xcuserdata/.xcuserdatad/UserInterfaceState.xcuserstate + */ +export async function findXcodeStateFile( + ctx: XcodeStateReaderContext, +): Promise { + const { executor, cwd, workspacePath, projectPath } = ctx; + + // Get current username + const userResult = await executor(['whoami'], 'Get username', false); + if (!userResult.success) { + log('warning', `[xcode-state] Failed to get username: ${userResult.error}`); + return undefined; + } + 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); + const testResult = await executor( + ['test', '-f', xcuserstatePath], + 'Check xcuserstate exists', + false, + ); + if (testResult.success) { + log('debug', `[xcode-state] Found xcuserstate from config: ${xcuserstatePath}`); + return xcuserstatePath; + } + log('debug', `[xcode-state] Configured path xcuserstate not found: ${xcuserstatePath}`); + } + + // Search for projects with increased depth (projects can be nested deeper) + const findResult = await executor( + [ + 'find', + cwd, + '-maxdepth', + '6', + '(', + '-name', + '*.xcworkspace', + '-o', + '-name', + '*.xcodeproj', + ')', + '-type', + 'd', + ], + 'Find Xcode project/workspace', + false, + ); + + if (!findResult.success || !findResult.output.trim()) { + log('debug', `[xcode-state] No Xcode project/workspace found in ${cwd}`); + return undefined; + } + + const paths = findResult.output.trim().split('\n').filter(Boolean); + + // 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; + if (!aIsWorkspace && bIsWorkspace) return 1; + 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', + false, + ); + + if (statResult.success) { + const mtime = parseInt(statResult.output.trim(), 10); + candidates.push({ path: xcuserstatePath, mtime }); + } + } + + if (candidates.length === 0) { + log('debug', `[xcode-state] No xcuserstate file found for user ${username}`); + 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( + 'debug', + `[xcode-state] Found ${candidates.length} xcuserstate files, using newest: ${candidates[0].path}`, + ); + } + + log('debug', `[xcode-state] Found xcuserstate: ${candidates[0].path}`); + 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`; + } +} + +/** + * Looks up a simulator name by its UUID. + */ +export async function lookupSimulatorName( + ctx: XcodeStateReaderContext, + simulatorId: string, +): Promise { + const { executor } = ctx; + + const result = await executor( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], + 'List simulators', + false, + ); + + if (!result.success) { + log('warning', `[xcode-state] Failed to list simulators: ${result.error}`); + return undefined; + } + + try { + const data = JSON.parse(result.output) as { + devices: Record>; + }; + + for (const runtime of Object.values(data.devices)) { + for (const device of runtime) { + if (device.udid === simulatorId) { + return device.name; + } + } + } + } catch (e) { + log('warning', `[xcode-state] Failed to parse simulator list: ${e}`); + } + + 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 = {}; + + if (state.scheme) { + result.scheme = state.scheme; + log('info', `[xcode-state] Detected active scheme: ${state.scheme}`); + } + + if (state.simulatorId) { + result.simulatorId = state.simulatorId; + + // Look up the simulator name + const name = await lookupSimulatorName(ctx, state.simulatorId); + if (name) { + result.simulatorName = name; + log('info', `[xcode-state] Detected active simulator: ${name} (${state.simulatorId})`); + } else { + log('info', `[xcode-state] Detected active destination: ${state.simulatorId}`); + } + } + + if (!result.scheme && !result.simulatorId) { + return { error: 'Could not extract active scheme or destination from Xcode state' }; + } + + return result; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + log('warning', `[xcode-state] Failed to read Xcode IDE state: ${message}`); + return { error: message }; + } +} diff --git a/src/utils/xcode-state-watcher.ts b/src/utils/xcode-state-watcher.ts new file mode 100644 index 00000000..e5812ed1 --- /dev/null +++ b/src/utils/xcode-state-watcher.ts @@ -0,0 +1,280 @@ +/** + * Xcode IDE State Watcher + * + * Watches Xcode's UserInterfaceState.xcuserstate file for changes and + * automatically syncs scheme/simulator selection to session defaults. + * + * Uses chokidar for reliable FSEvents-based file watching on macOS. + */ + +import { watch, type FSWatcher } from 'chokidar'; +import { log } from './logger.ts'; +import { parseXcuserstate } from './nskeyedarchiver-parser.ts'; +import { sessionStore } from './session-store.ts'; +import { findXcodeStateFile, lookupSimulatorName } from './xcode-state-reader.ts'; +import type { CommandExecutor } from './execution/index.ts'; +import { getDefaultCommandExecutor } from './execution/index.ts'; + +interface WatcherState { + watcher: FSWatcher | null; + watchedPath: string | null; + cachedScheme: string | null; + cachedSimulatorId: string | null; + debounceTimer: ReturnType | null; + executor: CommandExecutor | null; + cwd: string | null; + projectPath: string | null; + workspacePath: string | null; +} + +const state: WatcherState = { + watcher: null, + watchedPath: null, + cachedScheme: null, + cachedSimulatorId: null, + debounceTimer: null, + executor: null, + cwd: null, + projectPath: null, + workspacePath: null, +}; + +const DEBOUNCE_MS = 300; + +/** + * Look up bundle ID for a scheme using xcodebuild -showBuildSettings + */ +export async function lookupBundleId( + executor: CommandExecutor, + scheme: string, + projectPath?: string | null, + workspacePath?: string | null, +): Promise { + const args = ['xcodebuild', '-showBuildSettings', '-scheme', scheme, '-skipPackageUpdates']; + + if (workspacePath) { + args.push('-workspace', workspacePath); + } else if (projectPath) { + args.push('-project', projectPath); + } else { + // No project/workspace specified, let xcodebuild find it + } + + const result = await executor(args, 'Get bundle ID from build settings', false); + + if (!result.success) { + log('debug', `[xcode-watcher] Failed to get build settings: ${result.error}`); + return undefined; + } + + // Parse PRODUCT_BUNDLE_IDENTIFIER from output + const match = result.output.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*(.+)/); + if (match) { + return match[1].trim(); + } + + return undefined; +} + +/** + * Extract scheme and simulator ID from xcuserstate file + */ +function extractState(filePath: string): { scheme: string | null; simulatorId: string | null } { + try { + const result = parseXcuserstate(filePath); + return { + scheme: result.scheme ?? null, + simulatorId: result.simulatorId ?? null, + }; + } catch (e) { + log('warning', `[xcode-watcher] Failed to parse xcuserstate: ${e}`); + return { scheme: null, simulatorId: null }; + } +} + +/** + * Handle file change event (debounced) + */ +function handleFileChange(): void { + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + } + + state.debounceTimer = setTimeout(() => { + state.debounceTimer = null; + processFileChange().catch((e) => { + log('warning', `[xcode-watcher] Error processing file change: ${e}`); + }); + }, DEBOUNCE_MS); +} + +/** + * Process the file change and update session defaults + */ +async function processFileChange(): Promise { + if (!state.watchedPath) return; + + const newState = extractState(state.watchedPath); + + const schemeChanged = newState.scheme !== state.cachedScheme; + const simulatorChanged = newState.simulatorId !== state.cachedSimulatorId; + + if (!schemeChanged && !simulatorChanged) { + log('debug', '[xcode-watcher] File changed but scheme/simulator unchanged'); + return; + } + + const updates: Record = {}; + + if (schemeChanged && newState.scheme) { + updates.scheme = newState.scheme; + log('info', `[xcode-watcher] Scheme changed: "${state.cachedScheme}" -> "${newState.scheme}"`); + state.cachedScheme = newState.scheme; + } + + if (simulatorChanged && newState.simulatorId) { + updates.simulatorId = newState.simulatorId; + log( + 'info', + `[xcode-watcher] Simulator changed: "${state.cachedSimulatorId}" -> "${newState.simulatorId}"`, + ); + state.cachedSimulatorId = newState.simulatorId; + } + + // Update session defaults immediately with scheme/simulatorId + if (Object.keys(updates).length > 0) { + sessionStore.setDefaults(updates); + log('info', `[xcode-watcher] Session defaults updated: ${JSON.stringify(updates)}`); + } + + // Look up simulator name asynchronously (non-blocking) + if (simulatorChanged && newState.simulatorId && state.executor && state.cwd) { + lookupSimulatorName({ executor: state.executor, cwd: state.cwd }, newState.simulatorId) + .then((name) => { + if (name) { + sessionStore.setDefaults({ simulatorName: name }); + log('info', `[xcode-watcher] Simulator name resolved: "${name}"`); + } + }) + .catch((e) => { + log('debug', `[xcode-watcher] Failed to lookup simulator name: ${e}`); + }); + } + + // Look up bundle ID asynchronously when scheme changes (non-blocking) + if (schemeChanged && newState.scheme && state.executor) { + lookupBundleId(state.executor, newState.scheme, state.projectPath, state.workspacePath) + .then((bundleId) => { + if (bundleId) { + sessionStore.setDefaults({ bundleId }); + log('info', `[xcode-watcher] Bundle ID resolved: "${bundleId}"`); + } + }) + .catch((e) => { + log('debug', `[xcode-watcher] Failed to lookup bundle ID: ${e}`); + }); + } +} + +export interface StartWatcherOptions { + executor?: CommandExecutor; + cwd?: string; + workspacePath?: string; + projectPath?: string; +} + +/** + * Start watching the xcuserstate file for changes + */ +export async function startXcodeStateWatcher(options: StartWatcherOptions = {}): Promise { + if (state.watcher) { + log('debug', '[xcode-watcher] Watcher already running'); + return true; + } + + const executor = options.executor ?? getDefaultCommandExecutor(); + const cwd = options.cwd ?? process.cwd(); + + const xcuserstatePath = await findXcodeStateFile({ + executor, + cwd, + workspacePath: options.workspacePath, + projectPath: options.projectPath, + }); + + if (!xcuserstatePath) { + log('debug', '[xcode-watcher] No xcuserstate file found, watcher not started'); + return false; + } + + // Initialize cached state + const initialState = extractState(xcuserstatePath); + state.cachedScheme = initialState.scheme; + state.cachedSimulatorId = initialState.simulatorId; + state.watchedPath = xcuserstatePath; + state.executor = executor; + state.cwd = cwd; + state.projectPath = options.projectPath ?? null; + state.workspacePath = options.workspacePath ?? null; + + log( + 'info', + `[xcode-watcher] Starting watcher for ${xcuserstatePath} (scheme="${initialState.scheme}", sim="${initialState.simulatorId}")`, + ); + + state.watcher = watch(xcuserstatePath, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50, + }, + }); + + state.watcher.on('change', () => { + log('debug', '[xcode-watcher] File change detected'); + handleFileChange(); + }); + + state.watcher.on('error', (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + log('warning', `[xcode-watcher] Watcher error: ${message}`); + }); + + return true; +} + +/** + * Stop the xcuserstate watcher + */ +export async function stopXcodeStateWatcher(): Promise { + if (state.debounceTimer) { + clearTimeout(state.debounceTimer); + state.debounceTimer = null; + } + + if (state.watcher) { + await state.watcher.close(); + state.watcher = null; + state.watchedPath = null; + state.executor = null; + state.cwd = null; + state.projectPath = null; + state.workspacePath = null; + log('info', '[xcode-watcher] Watcher stopped'); + } +} + +/** + * Check if the watcher is currently running + */ +export function isWatcherRunning(): boolean { + return state.watcher !== null; +} + +/** + * Get the currently watched path + */ +export function getWatchedPath(): string | null { + return state.watchedPath; +} diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 683717de..0b7c893d 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -15,16 +15,29 @@ import type { ToolManifestEntry, WorkflowManifestEntry } from '../../core/manife import type { PredicateContext } from '../predicate-types.ts'; import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; -function createContext(overrides: Partial = {}): PredicateContext { - const defaultConfig: ResolvedRuntimeConfig = { +function createDefaultConfig( + overrides: Partial = {}, +): ResolvedRuntimeConfig { + return { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false, + disableSessionDefaults: false, + disableXcodeAutoSync: false, + uiDebuggerGuardMode: 'error', + incrementalBuildsEnabled: false, + dapRequestTimeoutMs: 30000, + dapLogEvents: false, + launchJsonWaitMs: 8000, + debuggerBackend: 'dap', + ...overrides, }; +} +function createContext(overrides: Partial = {}): PredicateContext { return { runtime: 'mcp', - config: defaultConfig, + config: createDefaultConfig(), runningUnderXcode: false, xcodeToolsActive: false, ...overrides, @@ -84,7 +97,7 @@ describe('exposure', () => { const workflow = createWorkflow({ predicates: ['debugEnabled'] }); const ctx = createContext({ runtime: 'mcp', - config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: false }), }); expect(isWorkflowEnabledForRuntime(workflow, ctx)).toBe(false); }); @@ -192,9 +205,18 @@ describe('exposure', () => { describe('getDefaultEnabledWorkflows', () => { it('should return only default-enabled workflows', () => { const workflows = [ - createWorkflow({ id: 'wf1', selection: { mcp: { defaultEnabled: true } } }), - createWorkflow({ id: 'wf2', selection: { mcp: { defaultEnabled: false } } }), - createWorkflow({ id: 'wf3', selection: { mcp: { defaultEnabled: true } } }), + createWorkflow({ + id: 'wf1', + selection: { mcp: { defaultEnabled: true, autoInclude: false } }, + }), + createWorkflow({ + id: 'wf2', + selection: { mcp: { defaultEnabled: false, autoInclude: false } }, + }), + createWorkflow({ + id: 'wf3', + selection: { mcp: { defaultEnabled: true, autoInclude: false } }, + }), ]; const defaultEnabled = getDefaultEnabledWorkflows(workflows); @@ -208,22 +230,22 @@ describe('exposure', () => { const workflows = [ createWorkflow({ id: 'wf1', - selection: { mcp: { autoInclude: true } }, + selection: { mcp: { defaultEnabled: false, autoInclude: true } }, predicates: [], }), createWorkflow({ id: 'wf2', - selection: { mcp: { autoInclude: true } }, + selection: { mcp: { defaultEnabled: false, autoInclude: true } }, predicates: ['debugEnabled'], }), createWorkflow({ id: 'wf3', - selection: { mcp: { autoInclude: false } }, + selection: { mcp: { defaultEnabled: false, autoInclude: false } }, }), ]; const ctx = createContext({ - config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: false }), }); const autoInclude = getAutoIncludeWorkflows(workflows, ctx); @@ -235,13 +257,13 @@ describe('exposure', () => { const workflows = [ createWorkflow({ id: 'doctor', - selection: { mcp: { autoInclude: true } }, + selection: { mcp: { defaultEnabled: false, autoInclude: true } }, predicates: ['debugEnabled'], }), ]; const ctx = createContext({ - config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: true }), }); const autoInclude = getAutoIncludeWorkflows(workflows, ctx); @@ -299,7 +321,7 @@ describe('exposure', () => { it('should include auto-include workflows when predicates pass', () => { const ctx = createContext({ - config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: true }), }); const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); expect(selected.map((w) => w.id)).toContain('doctor'); @@ -307,7 +329,7 @@ describe('exposure', () => { it('should not include auto-include workflows when predicates fail', () => { const ctx = createContext({ - config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: false }), }); const selected = selectWorkflowsForMcp(allWorkflows, ['device'], ctx); expect(selected.map((w) => w.id)).not.toContain('doctor'); diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index c976022f..acd413d6 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -8,16 +8,29 @@ import { import type { PredicateContext } from '../predicate-types.ts'; import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; -function createContext(overrides: Partial = {}): PredicateContext { - const defaultConfig: ResolvedRuntimeConfig = { +function createDefaultConfig( + overrides: Partial = {}, +): ResolvedRuntimeConfig { + return { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false, + disableSessionDefaults: false, + disableXcodeAutoSync: false, + uiDebuggerGuardMode: 'error', + incrementalBuildsEnabled: false, + dapRequestTimeoutMs: 30000, + dapLogEvents: false, + launchJsonWaitMs: 8000, + debuggerBackend: 'dap', + ...overrides, }; +} +function createContext(overrides: Partial = {}): PredicateContext { return { runtime: 'mcp', - config: defaultConfig, + config: createDefaultConfig(), runningUnderXcode: false, xcodeToolsActive: false, ...overrides, @@ -29,14 +42,14 @@ describe('predicate-registry', () => { describe('debugEnabled', () => { it('should return true when debug is enabled', () => { const ctx = createContext({ - config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: true }), }); expect(PREDICATES.debugEnabled(ctx)).toBe(true); }); it('should return false when debug is disabled', () => { const ctx = createContext({ - config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: false }), }); expect(PREDICATES.debugEnabled(ctx)).toBe(false); }); @@ -45,14 +58,14 @@ describe('predicate-registry', () => { describe('experimentalWorkflowDiscoveryEnabled', () => { it('should return true when experimental workflow discovery is enabled', () => { const ctx = createContext({ - config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: true }, + config: createDefaultConfig({ experimentalWorkflowDiscovery: true }), }); expect(PREDICATES.experimentalWorkflowDiscoveryEnabled(ctx)).toBe(true); }); it('should return false when experimental workflow discovery is disabled', () => { const ctx = createContext({ - config: { debug: false, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: false }), }); expect(PREDICATES.experimentalWorkflowDiscoveryEnabled(ctx)).toBe(false); }); @@ -94,6 +107,40 @@ describe('predicate-registry', () => { }); }); + describe('xcodeAutoSyncDisabled', () => { + it('should return true when running under Xcode AND auto-sync is disabled', () => { + const ctx = createContext({ + runningUnderXcode: true, + config: createDefaultConfig({ disableXcodeAutoSync: true }), + }); + expect(PREDICATES.xcodeAutoSyncDisabled(ctx)).toBe(true); + }); + + it('should return false when running under Xcode but auto-sync is enabled', () => { + const ctx = createContext({ + runningUnderXcode: true, + config: createDefaultConfig({ disableXcodeAutoSync: false }), + }); + expect(PREDICATES.xcodeAutoSyncDisabled(ctx)).toBe(false); + }); + + it('should return false when not running under Xcode even if auto-sync is disabled', () => { + const ctx = createContext({ + runningUnderXcode: false, + config: createDefaultConfig({ disableXcodeAutoSync: true }), + }); + expect(PREDICATES.xcodeAutoSyncDisabled(ctx)).toBe(false); + }); + + it('should return false when not running under Xcode and auto-sync is enabled', () => { + const ctx = createContext({ + runningUnderXcode: false, + config: createDefaultConfig({ disableXcodeAutoSync: false }), + }); + expect(PREDICATES.xcodeAutoSyncDisabled(ctx)).toBe(false); + }); + }); + describe('always', () => { it('should always return true', () => { const ctx = createContext(); @@ -122,7 +169,7 @@ describe('predicate-registry', () => { it('should return true when all predicates pass', () => { const ctx = createContext({ - config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: true }, + config: createDefaultConfig({ debug: true, experimentalWorkflowDiscovery: true }), }); expect(evalPredicates(['debugEnabled', 'experimentalWorkflowDiscoveryEnabled'], ctx)).toBe( true, @@ -131,7 +178,7 @@ describe('predicate-registry', () => { it('should return false when any predicate fails', () => { const ctx = createContext({ - config: { debug: true, enabledWorkflows: [], experimentalWorkflowDiscovery: false }, + config: createDefaultConfig({ debug: true }), }); expect(evalPredicates(['debugEnabled', 'experimentalWorkflowDiscoveryEnabled'], ctx)).toBe( false, @@ -154,6 +201,7 @@ describe('predicate-registry', () => { expect(names).toContain('runningUnderXcodeAgent'); expect(names).toContain('requiresXcodeTools'); expect(names).toContain('hideWhenXcodeAgentMode'); + expect(names).toContain('xcodeAutoSyncDisabled'); expect(names).toContain('always'); expect(names).toContain('never'); }); diff --git a/src/visibility/predicate-registry.ts b/src/visibility/predicate-registry.ts index 6378af92..e900395f 100644 --- a/src/visibility/predicate-registry.ts +++ b/src/visibility/predicate-registry.ts @@ -39,6 +39,13 @@ export const PREDICATES: Record = { */ hideWhenXcodeAgentMode: (ctx: PredicateContext): boolean => !ctx.runningUnderXcode, + /** + * Show only when Xcode auto-sync is disabled AND running under Xcode. + * Use for the manual sync tool that should appear when automatic sync is turned off. + */ + xcodeAutoSyncDisabled: (ctx: PredicateContext): boolean => + ctx.runningUnderXcode === true && ctx.config.disableXcodeAutoSync === true, + /** * Always visible - useful for explicit documentation in YAML. */ From 9b182d46bef04009cd51662832d118ee0046ee61 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 21:06:18 +0000 Subject: [PATCH 08/23] Fix xcode-ide workflow and bridge gating --- .../tools/xcode_tools_bridge_disconnect.yaml | 3 +- .../tools/xcode_tools_bridge_status.yaml | 3 +- manifests/tools/xcode_tools_bridge_sync.yaml | 3 +- manifests/workflows/xcode-ide.yaml | 2 +- .../manifest/__tests__/load-manifest.test.ts | 20 +++++++ src/integrations/xcode-tools-bridge/index.ts | 4 ++ .../xcode-tools-bridge/manager.ts | 9 ++- src/mcp/tools/doctor/doctor.ts | 55 ++++++++++--------- src/server/bootstrap.ts | 11 +++- src/utils/tool-registry.ts | 2 + src/visibility/__tests__/exposure.test.ts | 1 + .../__tests__/predicate-registry.test.ts | 14 +++++ src/visibility/predicate-registry.ts | 6 ++ src/visibility/predicate-types.ts | 3 + 14 files changed, 101 insertions(+), 35 deletions(-) diff --git a/manifests/tools/xcode_tools_bridge_disconnect.yaml b/manifests/tools/xcode_tools_bridge_disconnect.yaml index 55bbad8d..d1584902 100644 --- a/manifests/tools/xcode_tools_bridge_disconnect.yaml +++ b/manifests/tools/xcode_tools_bridge_disconnect.yaml @@ -7,7 +7,8 @@ availability: mcp: true cli: false daemon: false -predicates: [] +predicates: + - debugEnabled annotations: title: "Disconnect Xcode Tools Bridge" readOnlyHint: false diff --git a/manifests/tools/xcode_tools_bridge_status.yaml b/manifests/tools/xcode_tools_bridge_status.yaml index 137625a7..e92e8661 100644 --- a/manifests/tools/xcode_tools_bridge_status.yaml +++ b/manifests/tools/xcode_tools_bridge_status.yaml @@ -7,7 +7,8 @@ availability: mcp: true cli: false daemon: false -predicates: [] +predicates: + - debugEnabled annotations: title: "Xcode Tools Bridge Status" readOnlyHint: true diff --git a/manifests/tools/xcode_tools_bridge_sync.yaml b/manifests/tools/xcode_tools_bridge_sync.yaml index 15378d53..e806aa20 100644 --- a/manifests/tools/xcode_tools_bridge_sync.yaml +++ b/manifests/tools/xcode_tools_bridge_sync.yaml @@ -7,7 +7,8 @@ availability: mcp: true cli: false daemon: false -predicates: [] +predicates: + - debugEnabled annotations: title: "Sync Xcode Tools Bridge" readOnlyHint: false diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index 2dc19715..66ff19dd 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -10,7 +10,7 @@ selection: defaultEnabled: false autoInclude: true predicates: - - debugEnabled + - xcodeToolsAvailable - hideWhenXcodeAgentMode tools: - xcode_tools_bridge_status diff --git a/src/core/manifest/__tests__/load-manifest.test.ts b/src/core/manifest/__tests__/load-manifest.test.ts index 6054b565..dc55d4cc 100644 --- a/src/core/manifest/__tests__/load-manifest.test.ts +++ b/src/core/manifest/__tests__/load-manifest.test.ts @@ -83,6 +83,26 @@ describe('load-manifest', () => { expect(doctor?.predicates).toContain('debugEnabled'); expect(doctor?.selection?.mcp?.autoInclude).toBe(true); }); + + it('should have xcode-ide workflow gated by xcode tools availability', () => { + const manifest = loadManifest(); + const xcodeIde = manifest.workflows.get('xcode-ide'); + + expect(xcodeIde).toBeDefined(); + expect(xcodeIde?.predicates).toContain('xcodeToolsAvailable'); + expect(xcodeIde?.predicates).toContain('hideWhenXcodeAgentMode'); + expect(xcodeIde?.predicates).not.toContain('debugEnabled'); + }); + + it('should keep xcode bridge static tools gated by debugEnabled', () => { + const manifest = loadManifest(); + + expect(manifest.tools.get('xcode_tools_bridge_status')?.predicates).toContain('debugEnabled'); + expect(manifest.tools.get('xcode_tools_bridge_sync')?.predicates).toContain('debugEnabled'); + expect(manifest.tools.get('xcode_tools_bridge_disconnect')?.predicates).toContain( + 'debugEnabled', + ); + }); }); describe('getWorkflowTools', () => { diff --git a/src/integrations/xcode-tools-bridge/index.ts b/src/integrations/xcode-tools-bridge/index.ts index 7844d336..2c52dbb7 100644 --- a/src/integrations/xcode-tools-bridge/index.ts +++ b/src/integrations/xcode-tools-bridge/index.ts @@ -10,6 +10,10 @@ export function getXcodeToolsBridgeManager(server?: McpServer): XcodeToolsBridge return manager; } +export function peekXcodeToolsBridgeManager(): XcodeToolsBridgeManager | null { + return manager; +} + export async function shutdownXcodeToolsBridge(): Promise { await manager?.shutdown(); manager = null; diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index fe91bf7d..a3ad94ef 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -59,7 +59,7 @@ export class XcodeToolsBridgeManager { } async getStatus(): Promise { - const bridge = await findMcpBridge(); + const bridge = await getMcpBridgeAvailability(); const xcodeRunning = await isXcodeRunning(); const clientStatus = this.client.getStatus(); @@ -88,7 +88,7 @@ export class XcodeToolsBridgeManager { if (this.syncInFlight) return this.syncInFlight; this.syncInFlight = (async (): Promise => { - const bridge = await findMcpBridge(); + const bridge = await getMcpBridgeAvailability(); if (!bridge.available) { this.lastError = 'mcpbridge not available (xcrun --find mcpbridge failed)'; const existingCount = this.registry.getRegisteredCount(); @@ -175,7 +175,10 @@ export class XcodeToolsBridgeManager { } } -async function findMcpBridge(): Promise<{ available: boolean; path: string | null }> { +export async function getMcpBridgeAvailability(): Promise<{ + available: boolean; + path: string | null; +}> { try { const res = await execFileAsync('xcrun', ['--find', 'mcpbridge'], { timeout: 2000 }); const out = (res.stdout ?? '').toString().trim(); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 3e2fd83d..8282df24 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -14,8 +14,8 @@ import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getConfig } from '../../../utils/config-store.ts'; import { detectXcodeRuntime } from '../../../utils/xcode-process.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; -import { getServer } from '../../../server/server-state.ts'; -import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { peekXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { getMcpBridgeAvailability } from '../../../integrations/xcode-tools-bridge/manager.ts'; // Constants const LOG_PREFIX = '[Doctor]'; @@ -52,34 +52,35 @@ type XcodeToolsBridgeDoctorInfo = async function getXcodeToolsBridgeDoctorInfo( executor: CommandExecutor, + workflowEnabled: boolean, ): Promise { try { - const server = getServer(); - if (server) { - const manager = getXcodeToolsBridgeManager(server); - if (manager) { - const status = await manager.getStatus(); - return { - available: true, - workflowEnabled: status.workflowEnabled, - bridgePath: status.bridgePath, - xcodeRunning: status.xcodeRunning, - connected: status.connected, - bridgePid: status.bridgePid, - proxiedToolCount: status.proxiedToolCount, - lastError: status.lastError, - }; - } + const manager = peekXcodeToolsBridgeManager(); + if (manager) { + const status = await manager.getStatus(); + return { + available: true, + workflowEnabled: status.workflowEnabled, + bridgePath: status.bridgePath, + xcodeRunning: status.xcodeRunning, + connected: status.connected, + bridgePid: status.bridgePid, + proxiedToolCount: status.proxiedToolCount, + lastError: status.lastError, + }; } - const config = getConfig(); - const bridgePathResult = await executor(['xcrun', '--find', 'mcpbridge'], 'Check mcpbridge'); - const bridgePath = bridgePathResult.success ? bridgePathResult.output.trim() : ''; + const bridgeInfo = await getMcpBridgeAvailability(); + const bridgePath = bridgeInfo.available ? bridgeInfo.path : null; + const xcodeRunningResult = await executor(['pgrep', '-x', 'Xcode'], 'Check Xcode process'); + const xcodeRunning = xcodeRunningResult.success + ? xcodeRunningResult.output.trim().length > 0 + : null; return { available: true, - workflowEnabled: config.enabledWorkflows.includes('xcode-ide'), - bridgePath: bridgePath.length > 0 ? bridgePath : null, - xcodeRunning: null, + workflowEnabled, + bridgePath, + xcodeRunning, connected: false, bridgePid: null, proxiedToolCount: 0, @@ -121,6 +122,7 @@ export async function runDoctor( enabledWorkflows: [], registeredToolCount: 0, }; + const xcodeIdeWorkflowEnabled = runtimeRegistration.enabledWorkflows.includes('xcode-ide'); const runtimeNote = runtimeInfo ? null : 'Runtime registry unavailable.'; const xcodemakeEnabled = deps.features.isXcodemakeEnabled(); const xcodemakeAvailable = await deps.features.isXcodemakeAvailable(); @@ -128,7 +130,10 @@ export async function runDoctor( const lldbDapAvailable = await checkLldbDapAvailability(deps.commandExecutor); const selectedDebuggerBackend = getConfig().debuggerBackend; const dapSelected = selectedDebuggerBackend === 'dap'; - const xcodeToolsBridge = await getXcodeToolsBridgeDoctorInfo(deps.commandExecutor); + const xcodeToolsBridge = await getXcodeToolsBridgeDoctorInfo( + deps.commandExecutor, + xcodeIdeWorkflowEnabled, + ); const doctorInfo = { serverVersion: version, diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 510b02bf..bc9e6928 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -4,9 +4,10 @@ import { registerResources } from '../core/resources.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import { log, setLogLevel, type LogLevel } from '../utils/logger.ts'; import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; -import { registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; +import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; +import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/manager.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; import { readXcodeIdeState } from '../utils/xcode-state-reader.ts'; import { sessionStore } from '../utils/session-store.ts'; @@ -59,6 +60,8 @@ export async function bootstrapServer( } const enabledWorkflows = result.runtime.config.enabledWorkflows; + const mcpBridge = await getMcpBridgeAvailability(); + const xcodeToolsAvailable = mcpBridge.available; log('info', `🚀 Initializing server...`); // Detect if running under Xcode @@ -139,13 +142,15 @@ export async function bootstrapServer( config: result.runtime.config, runningUnderXcode: xcodeDetection.runningUnderXcode, xcodeToolsActive: false, // Will be updated after Xcode tools bridge sync + xcodeToolsAvailable, }; // Register workflows using manifest system await registerWorkflowsFromManifest(enabledWorkflows, ctx); - const xcodeIdeEnabled = enabledWorkflows.includes('xcode-ide'); - const xcodeToolsBridge = getXcodeToolsBridgeManager(server); + const resolvedWorkflows = getRegisteredWorkflows(); + const xcodeIdeEnabled = resolvedWorkflows.includes('xcode-ide'); + const xcodeToolsBridge = xcodeToolsAvailable ? getXcodeToolsBridgeManager(server) : null; xcodeToolsBridge?.setWorkflowEnabled(xcodeIdeEnabled); if (xcodeIdeEnabled && xcodeToolsBridge) { try { diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index b35a5f43..ab40d323 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -54,6 +54,7 @@ export function getMcpPredicateContext(): PredicateContext { config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: false, }; } @@ -153,6 +154,7 @@ export async function registerWorkflowsFromManifest( config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: false, }; await applyWorkflowSelectionFromManifest(workflowNames, effectiveCtx); } diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 0b7c893d..2b8d0ed1 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -40,6 +40,7 @@ function createContext(overrides: Partial = {}): PredicateCont config: createDefaultConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: false, ...overrides, }; } diff --git a/src/visibility/__tests__/predicate-registry.test.ts b/src/visibility/__tests__/predicate-registry.test.ts index acd413d6..b9896324 100644 --- a/src/visibility/__tests__/predicate-registry.test.ts +++ b/src/visibility/__tests__/predicate-registry.test.ts @@ -33,6 +33,7 @@ function createContext(overrides: Partial = {}): PredicateCont config: createDefaultConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: false, ...overrides, }; } @@ -95,6 +96,18 @@ describe('predicate-registry', () => { }); }); + describe('xcodeToolsAvailable', () => { + it('should return true when Xcode tools bridge is available', () => { + const ctx = createContext({ xcodeToolsAvailable: true }); + expect(PREDICATES.xcodeToolsAvailable(ctx)).toBe(true); + }); + + it('should return false when Xcode tools bridge is not available', () => { + const ctx = createContext({ xcodeToolsAvailable: false }); + expect(PREDICATES.xcodeToolsAvailable(ctx)).toBe(false); + }); + }); + describe('hideWhenXcodeAgentMode', () => { it('should return true when not running under Xcode', () => { const ctx = createContext({ runningUnderXcode: false }); @@ -200,6 +213,7 @@ describe('predicate-registry', () => { expect(names).toContain('experimentalWorkflowDiscoveryEnabled'); expect(names).toContain('runningUnderXcodeAgent'); expect(names).toContain('requiresXcodeTools'); + expect(names).toContain('xcodeToolsAvailable'); expect(names).toContain('hideWhenXcodeAgentMode'); expect(names).toContain('xcodeAutoSyncDisabled'); expect(names).toContain('always'); diff --git a/src/visibility/predicate-registry.ts b/src/visibility/predicate-registry.ts index e900395f..93ab37bf 100644 --- a/src/visibility/predicate-registry.ts +++ b/src/visibility/predicate-registry.ts @@ -33,6 +33,12 @@ export const PREDICATES: Record = { */ requiresXcodeTools: (ctx: PredicateContext): boolean => ctx.xcodeToolsActive === true, + /** + * Show only when xcrun mcpbridge is available on the host system. + * Use for workflows/tools that depend on Xcode's MCP bridge binary. + */ + xcodeToolsAvailable: (ctx: PredicateContext): boolean => ctx.xcodeToolsAvailable === true, + /** * Hide when running inside Xcode's coding agent. * Use for XcodeBuildMCP tools that conflict with Xcode's native equivalents. diff --git a/src/visibility/predicate-types.ts b/src/visibility/predicate-types.ts index b96c8581..52f472c0 100644 --- a/src/visibility/predicate-types.ts +++ b/src/visibility/predicate-types.ts @@ -26,6 +26,9 @@ export interface PredicateContext { /** Whether Xcode Tools bridge is active (MCP only; false otherwise) */ xcodeToolsActive: boolean; + + /** Whether the Xcode Tools bridge binary is available on this system */ + xcodeToolsAvailable?: boolean; } /** From c0200429d241a29f72e375c26ec4ff1759755c8c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 21:07:25 +0000 Subject: [PATCH 09/23] Use manifest metadata only for tool annotations --- src/core/manifest/import-tool-module.ts | 10 +++------- src/runtime/tool-catalog.ts | 6 +++--- src/utils/tool-registry.ts | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/core/manifest/import-tool-module.ts b/src/core/manifest/import-tool-module.ts index 32a8444a..a2371e90 100644 --- a/src/core/manifest/import-tool-module.ts +++ b/src/core/manifest/import-tool-module.ts @@ -7,7 +7,6 @@ import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; import type { ToolSchemaShape } from '../plugin-types.ts'; -import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import { getPackageRoot } from './load-manifest.ts'; /** @@ -17,7 +16,6 @@ import { getPackageRoot } from './load-manifest.ts'; export interface ImportedToolModule { schema: ToolSchemaShape; handler: (params: Record) => Promise; - annotations?: ToolAnnotations; } /** @@ -29,11 +27,11 @@ const moduleCache = new Map(); * Import a tool module by its manifest module path. * * Supports two module formats: - * 1. Legacy: `export default { name, schema, handler, annotations?, ... }` - * 2. New: Named exports `{ schema, handler, annotations? }` + * 1. Legacy: `export default { name, schema, handler, ... }` + * 2. New: Named exports `{ schema, handler }` * * @param moduleId - Extensionless module path (e.g., 'mcp/tools/simulator/build_sim') - * @returns Imported tool module with schema, handler, and optional annotations + * @returns Imported tool module with schema and handler */ export async function importToolModule(moduleId: string): Promise { // Check cache first @@ -74,7 +72,6 @@ function extractToolExports(mod: Record, moduleId: string): Imp return { schema: defaultExport.schema as ToolSchemaShape, handler: defaultExport.handler as (params: Record) => Promise, - annotations: defaultExport.annotations as ToolAnnotations | undefined, }; } } @@ -84,7 +81,6 @@ function extractToolExports(mod: Record, moduleId: string): Imp return { schema: mod.schema as ToolSchemaShape, handler: mod.handler as (params: Record) => Promise, - annotations: mod.annotations as ToolAnnotations | undefined, }; } diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index b597e223..b94c60de 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -154,14 +154,12 @@ export async function buildToolCatalogFromManifest(opts: { } const cliName = getEffectiveCliName(toolManifest); - // Prefer annotations from manifest, fall back to module for backward compatibility - const annotations = toolManifest.annotations ?? toolModule.annotations; tools.push({ cliName, mcpName: toolManifest.names.mcp, workflow: workflow.id, description: toolManifest.description, - annotations, + annotations: toolManifest.annotations, mcpSchema: toolModule.schema, cliSchema: toolModule.schema, stateful: toolManifest.routing?.stateful ?? false, @@ -190,6 +188,7 @@ export async function buildCliToolCatalogFromManifest(opts?: { config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: false, }; return buildToolCatalogFromManifest({ @@ -215,6 +214,7 @@ export async function buildDaemonToolCatalogFromManifest(opts?: { config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: false, }; return buildToolCatalogFromManifest({ diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index ab40d323..156d4763 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -111,7 +111,7 @@ export async function applyWorkflowSelectionFromManifest( { description: toolManifest.description ?? '', inputSchema: toolModule.schema, - annotations: toolModule.annotations, + annotations: toolManifest.annotations, }, async (args: unknown): Promise => { const response = await toolModule.handler(args as Record); From 6c31331dde4ebf21dad9221ad450690d425ce043 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 21:14:02 +0000 Subject: [PATCH 10/23] Fix docs:update to read YAML manifests directly --- docs/TOOLS-CLI.md | 149 ++++++++++++++++---------------- docs/TOOLS.md | 162 ++++++++++++++++++----------------- scripts/update-tools-docs.ts | 50 +++++++++-- 3 files changed, 200 insertions(+), 161 deletions(-) diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 8ef54bcd..3706e133 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -6,58 +6,80 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. ## Workflow Groups +### Build Utilities (`utilities`) +**Purpose**: Utility tools for cleaning build products and managing build artifacts. (1 tools) + +- `clean` - Defined in iOS Device Development workflow. + + + ### iOS Device Development (`device`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware. (14 tools) +**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (14 tools) - `build-device` - Build for device. -- `clean` - Defined in Project Utilities workflow. -- `discover-projs` - Defined in Project Discovery workflow. -- `get-app-bundle-id` - Defined in Project Discovery workflow. +- `clean` - Clean build products. +- `discover-projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. +- `get-app-bundle-id` - Extract bundle id from .app. - `get-device-app-path` - Get device built app path. - `install-app-device` - Install app on device. - `launch-app-device` - Launch app on device. - `list-devices` - List connected devices. -- `list-schemes` - Defined in Project Discovery workflow. -- `show-build-settings` - Defined in Project Discovery workflow. -- `start-device-log-cap` - Defined in Log Capture & Management workflow. +- `list-schemes` - List Xcode schemes. +- `show-build-settings` - Show build settings. +- `start-device-log-cap` - Start device log capture. - `stop-app-device` - Stop device app. -- `stop-device-log-cap` - Defined in Log Capture & Management workflow. +- `stop-device-log-cap` - Stop device app and return logs. - `test-device` - Test on device. ### iOS Simulator Development (`simulator`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (20 tools) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. (21 tools) -- `boot-sim` - Boot iOS simulator. +- `boot-sim` - Defined in Simulator Management workflow. - `build-run-sim` - Build and run iOS sim. - `build-sim` - Build for iOS sim. -- `clean` - Defined in Project Utilities workflow. -- `discover-projs` - Defined in Project Discovery workflow. -- `get-app-bundle-id` - Defined in Project Discovery workflow. +- `clean` - Defined in iOS Device Development workflow. +- `discover-projs` - Defined in iOS Device Development workflow. +- `get-app-bundle-id` - Defined in iOS Device Development workflow. - `get-sim-app-path` - Get sim built app path. - `install-app-sim` - Install app on sim. - `launch-app-logs-sim` - Launch sim app with logs. - `launch-app-sim` - Launch app on simulator. -- `list-schemes` - Defined in Project Discovery workflow. -- `list-sims` - List iOS simulators. -- `open-sim` - Open Simulator app. +- `list-schemes` - Defined in iOS Device Development workflow. +- `list-sims` - Defined in Simulator Management workflow. +- `open-sim` - Defined in Simulator Management workflow. - `record-sim-video` - Record sim video. -- `screenshot` - Defined in UI Automation workflow. -- `show-build-settings` - Defined in Project Discovery workflow. -- `snapshot-ui` - Defined in UI Automation workflow. +- `screenshot` - Capture screenshot. +- `show-build-settings` - Defined in iOS Device Development workflow. +- `snapshot-ui` - Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. +- `start-sim-log-cap` - Defined in Log Capture workflow. - `stop-app-sim` - Stop sim app. -- `stop-sim-log-cap` - Defined in Log Capture & Management workflow. +- `stop-sim-log-cap` - Defined in Log Capture workflow. - `test-sim` - Test on iOS sim. -### Log Capture & Management (`logging`) -**Purpose**: Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing. (4 tools) +### LLDB Debugging (`debugging`) +**Purpose**: Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks. (8 tools) -- `start-device-log-cap` - Start device log capture. +- `debug-attach-sim` - Attach LLDB to sim app. +- `debug-breakpoint-add` - Add breakpoint. +- `debug-breakpoint-remove` - Remove breakpoint. +- `debug-continue` - Continue debug session. +- `debug-detach` - Detach debugger. +- `debug-lldb-command` - Run LLDB command. +- `debug-stack` - Get backtrace. +- `debug-variables` - Get frame variables. + + + +### Log Capture (`logging`) +**Purpose**: Capture and retrieve logs from simulator and device apps. (4 tools) + +- `start-device-log-cap` - Defined in iOS Device Development workflow. - `start-sim-log-cap` - Start sim log capture. -- `stop-device-log-cap` - Stop device app and return logs. +- `stop-device-log-cap` - Defined in iOS Device Development workflow. - `stop-sim-log-cap` - Stop sim app and return logs. @@ -67,65 +89,51 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. - `build-macos` - Build macOS app. - `build-run-macos` - Build and run macOS app. -- `clean` - Defined in Project Utilities workflow. -- `discover-projs` - Defined in Project Discovery workflow. +- `clean` - Defined in iOS Device Development workflow. +- `discover-projs` - Defined in iOS Device Development workflow. - `get-mac-app-path` - Get macOS built app path. -- `get-mac-bundle-id` - Defined in Project Discovery workflow. +- `get-mac-bundle-id` - Extract bundle id from macOS .app. - `launch-mac-app` - Launch macOS app. -- `list-schemes` - Defined in Project Discovery workflow. -- `show-build-settings` - Defined in Project Discovery workflow. +- `list-schemes` - Defined in iOS Device Development workflow. +- `show-build-settings` - Defined in iOS Device Development workflow. - `stop-mac-app` - Stop macOS app. - `test-macos` - Test macOS target. +### MCP Doctor (`doctor`) +**Purpose**: Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration. (1 tools) + +- `doctor` - MCP environment info. + + + ### Project Discovery (`project-discovery`) **Purpose**: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. (5 tools) -- `discover-projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. -- `get-app-bundle-id` - Extract bundle id from .app. -- `get-mac-bundle-id` - Extract bundle id from macOS .app. -- `list-schemes` - List Xcode schemes. -- `show-build-settings` - Show build settings. +- `discover-projs` - Defined in iOS Device Development workflow. +- `get-app-bundle-id` - Defined in iOS Device Development workflow. +- `get-mac-bundle-id` - Defined in macOS Development workflow. +- `list-schemes` - Defined in iOS Device Development workflow. +- `show-build-settings` - Defined in iOS Device Development workflow. ### Project Scaffolding (`project-scaffolding`) -**Purpose**: Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures. (2 tools) +**Purpose**: Scaffold new iOS and macOS projects from templates. (2 tools) - `scaffold-ios-project` - Scaffold iOS project. - `scaffold-macos-project` - Scaffold macOS project. -### Project Utilities (`utilities`) -**Purpose**: Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files. (1 tools) - -- `clean` - Clean build products. - - - -### Simulator Debugging (`debugging`) -**Purpose**: Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands. (8 tools) - -- `debug-attach-sim` - Attach LLDB to sim app. -- `debug-breakpoint-add` - Add breakpoint. -- `debug-breakpoint-remove` - Remove breakpoint. -- `debug-continue` - Continue debug session. -- `debug-detach` - Detach debugger. -- `debug-lldb-command` - Run LLDB command. -- `debug-stack` - Get backtrace. -- `debug-variables` - Get frame variables. - - - ### Simulator Management (`simulator-management`) **Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (8 tools) -- `boot-sim` - Defined in iOS Simulator Development workflow. +- `boot-sim` - Boot iOS simulator. - `erase-sims` - Erase simulator. -- `list-sims` - Defined in iOS Simulator Development workflow. -- `open-sim` - Defined in iOS Simulator Development workflow. +- `list-sims` - List iOS simulators. +- `open-sim` - Open Simulator app. - `reset-sim-location` - Reset sim location. - `set-sim-appearance` - Set sim appearance. - `set-sim-location` - Set sim location. @@ -133,8 +141,8 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. -### Swift Package Manager (`swift-package`) -**Purpose**: Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support. (6 tools) +### Swift Package Development (`swift-package`) +**Purpose**: Build, test, run and manage Swift Package Manager projects. (6 tools) - `swift-package-build` - swift package target build. - `swift-package-clean` - swift package clean. @@ -145,13 +153,6 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. -### System Doctor (`doctor`) -**Purpose**: Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability. (1 tools) - -- `doctor` - MCP environment info. - - - ### UI Automation (`ui-automation`) **Purpose**: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. (11 tools) @@ -160,8 +161,8 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. - `key-press` - Press key by keycode. - `key-sequence` - Press a sequence of keys by their keycodes. - `long-press` - Long press at coords. -- `screenshot` - Capture screenshot. -- `snapshot-ui` - Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. +- `screenshot` - Defined in iOS Simulator Development workflow. +- `snapshot-ui` - Defined in iOS Simulator Development workflow. - `swipe` - Swipe between points. - `tap` - Tap coordinate or element. - `touch` - Touch down/up at coords. @@ -169,8 +170,8 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. -### Xcode IDE (mcpbridge) (`xcode-ide`) -**Purpose**: Proxy Xcode's built-in 'Xcode Tools' MCP service via `xcrun mcpbridge`. Registers dynamic `xcode_tools_*` tools when available. Bridge debug tools are only registered when `debug: true`. (3 tools) +### Xcode IDE Integration (`xcode-ide`) +**Purpose**: Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality. (3 tools) - `xcode-tools-bridge-disconnect` - Disconnect bridge and unregister proxied `xcode_tools_*` tools. - `xcode-tools-bridge-status` - Show xcrun mcpbridge availability and proxy tool sync status. @@ -181,9 +182,9 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. ## Summary Statistics - **Canonical Tools**: 71 -- **Total Tools**: 94 +- **Total Tools**: 95 - **Workflow Groups**: 13 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-04T09:25:59.573Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:12:18.351Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 4696d347..c99b8e1b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,61 +1,83 @@ # XcodeBuildMCP MCP Tools Reference -This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 75 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. +This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 76 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. ## Workflow Groups +### Build Utilities (`utilities`) +**Purpose**: Utility tools for cleaning build products and managing build artifacts. (1 tools) + +- `clean` - Defined in iOS Device Development workflow. + + + ### iOS Device Development (`device`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). Build, test, deploy, and debug apps on real hardware. (14 tools) +**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (14 tools) - `build_device` - Build for device. -- `clean` - Defined in Project Utilities workflow. -- `discover_projs` - Defined in Project Discovery workflow. -- `get_app_bundle_id` - Defined in Project Discovery workflow. +- `clean` - Clean build products. +- `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. +- `get_app_bundle_id` - Extract bundle id from .app. - `get_device_app_path` - Get device built app path. - `install_app_device` - Install app on device. - `launch_app_device` - Launch app on device. - `list_devices` - List connected devices. -- `list_schemes` - Defined in Project Discovery workflow. -- `show_build_settings` - Defined in Project Discovery workflow. -- `start_device_log_cap` - Defined in Log Capture & Management workflow. +- `list_schemes` - List Xcode schemes. +- `show_build_settings` - Show build settings. +- `start_device_log_cap` - Start device log capture. - `stop_app_device` - Stop device app. -- `stop_device_log_cap` - Defined in Log Capture & Management workflow. +- `stop_device_log_cap` - Stop device app and return logs. - `test_device` - Test on device. ### iOS Simulator Development (`simulator`) -**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. Build, test, deploy, and interact with iOS apps on simulators. (20 tools) +**Purpose**: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. (21 tools) -- `boot_sim` - Boot iOS simulator. +- `boot_sim` - Defined in Simulator Management workflow. - `build_run_sim` - Build and run iOS sim. - `build_sim` - Build for iOS sim. -- `clean` - Defined in Project Utilities workflow. -- `discover_projs` - Defined in Project Discovery workflow. -- `get_app_bundle_id` - Defined in Project Discovery workflow. +- `clean` - Defined in iOS Device Development workflow. +- `discover_projs` - Defined in iOS Device Development workflow. +- `get_app_bundle_id` - Defined in iOS Device Development workflow. - `get_sim_app_path` - Get sim built app path. - `install_app_sim` - Install app on sim. - `launch_app_logs_sim` - Launch sim app with logs. - `launch_app_sim` - Launch app on simulator. -- `list_schemes` - Defined in Project Discovery workflow. -- `list_sims` - List iOS simulators. -- `open_sim` - Open Simulator app. +- `list_schemes` - Defined in iOS Device Development workflow. +- `list_sims` - Defined in Simulator Management workflow. +- `open_sim` - Defined in Simulator Management workflow. - `record_sim_video` - Record sim video. -- `screenshot` - Defined in UI Automation workflow. -- `show_build_settings` - Defined in Project Discovery workflow. -- `snapshot_ui` - Defined in UI Automation workflow. +- `screenshot` - Capture screenshot. +- `show_build_settings` - Defined in iOS Device Development workflow. +- `snapshot_ui` - Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. +- `start_sim_log_cap` - Defined in Log Capture workflow. - `stop_app_sim` - Stop sim app. -- `stop_sim_log_cap` - Defined in Log Capture & Management workflow. +- `stop_sim_log_cap` - Defined in Log Capture workflow. - `test_sim` - Test on iOS sim. -### Log Capture & Management (`logging`) -**Purpose**: Log capture and management tools for iOS simulators and physical devices. Start, stop, and analyze application and system logs during development and testing. (4 tools) +### LLDB Debugging (`debugging`) +**Purpose**: Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks. (8 tools) -- `start_device_log_cap` - Start device log capture. +- `debug_attach_sim` - Attach LLDB to sim app. +- `debug_breakpoint_add` - Add breakpoint. +- `debug_breakpoint_remove` - Remove breakpoint. +- `debug_continue` - Continue debug session. +- `debug_detach` - Detach debugger. +- `debug_lldb_command` - Run LLDB command. +- `debug_stack` - Get backtrace. +- `debug_variables` - Get frame variables. + + + +### Log Capture (`logging`) +**Purpose**: Capture and retrieve logs from simulator and device apps. (4 tools) + +- `start_device_log_cap` - Defined in iOS Device Development workflow. - `start_sim_log_cap` - Start sim log capture. -- `stop_device_log_cap` - Stop device app and return logs. +- `stop_device_log_cap` - Defined in iOS Device Development workflow. - `stop_sim_log_cap` - Stop sim app and return logs. @@ -65,74 +87,61 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov - `build_macos` - Build macOS app. - `build_run_macos` - Build and run macOS app. -- `clean` - Defined in Project Utilities workflow. -- `discover_projs` - Defined in Project Discovery workflow. +- `clean` - Defined in iOS Device Development workflow. +- `discover_projs` - Defined in iOS Device Development workflow. - `get_mac_app_path` - Get macOS built app path. -- `get_mac_bundle_id` - Defined in Project Discovery workflow. +- `get_mac_bundle_id` - Extract bundle id from macOS .app. - `launch_mac_app` - Launch macOS app. -- `list_schemes` - Defined in Project Discovery workflow. -- `show_build_settings` - Defined in Project Discovery workflow. +- `list_schemes` - Defined in iOS Device Development workflow. +- `show_build_settings` - Defined in iOS Device Development workflow. - `stop_mac_app` - Stop macOS app. - `test_macos` - Test macOS target. +### MCP Doctor (`doctor`) +**Purpose**: Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration. (1 tools) + +- `doctor` - MCP environment info. + + + ### Project Discovery (`project-discovery`) **Purpose**: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. (5 tools) -- `discover_projs` - Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. -- `get_app_bundle_id` - Extract bundle id from .app. -- `get_mac_bundle_id` - Extract bundle id from macOS .app. -- `list_schemes` - List Xcode schemes. -- `show_build_settings` - Show build settings. +- `discover_projs` - Defined in iOS Device Development workflow. +- `get_app_bundle_id` - Defined in iOS Device Development workflow. +- `get_mac_bundle_id` - Defined in macOS Development workflow. +- `list_schemes` - Defined in iOS Device Development workflow. +- `show_build_settings` - Defined in iOS Device Development workflow. ### Project Scaffolding (`project-scaffolding`) -**Purpose**: Tools for creating new iOS and macOS projects from templates. Bootstrap new applications with best practices, standard configurations, and modern project structures. (2 tools) +**Purpose**: Scaffold new iOS and macOS projects from templates. (2 tools) - `scaffold_ios_project` - Scaffold iOS project. - `scaffold_macos_project` - Scaffold macOS project. -### Project Utilities (`utilities`) -**Purpose**: Essential project maintenance utilities for cleaning and managing existing projects. Provides clean operations for both .xcodeproj and .xcworkspace files. (1 tools) - -- `clean` - Clean build products. - - - -### session-management (`session-management`) -**Purpose**: Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup. (3 tools) +### Session Management (`session-management`) +**Purpose**: Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings. (4 tools) - `session_clear_defaults` - Clear session defaults. - `session_set_defaults` - Set the session defaults, should be called at least once to set tool defaults. - `session_show_defaults` - Show session defaults. - - - -### Simulator Debugging (`debugging`) -**Purpose**: Interactive iOS Simulator debugging tools: attach LLDB, manage breakpoints, inspect stack/variables, and run LLDB commands. (8 tools) - -- `debug_attach_sim` - Attach LLDB to sim app. -- `debug_breakpoint_add` - Add breakpoint. -- `debug_breakpoint_remove` - Remove breakpoint. -- `debug_continue` - Continue debug session. -- `debug_detach` - Detach debugger. -- `debug_lldb_command` - Run LLDB command. -- `debug_stack` - Get backtrace. -- `debug_variables` - Get frame variables. +- `sync_xcode_defaults` - Sync session defaults (scheme, simulator) from Xcode's current IDE selection. ### Simulator Management (`simulator-management`) **Purpose**: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. (8 tools) -- `boot_sim` - Defined in iOS Simulator Development workflow. +- `boot_sim` - Boot iOS simulator. - `erase_sims` - Erase simulator. -- `list_sims` - Defined in iOS Simulator Development workflow. -- `open_sim` - Defined in iOS Simulator Development workflow. +- `list_sims` - List iOS simulators. +- `open_sim` - Open Simulator app. - `reset_sim_location` - Reset sim location. - `set_sim_appearance` - Set sim appearance. - `set_sim_location` - Set sim location. @@ -140,8 +149,8 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov -### Swift Package Manager (`swift-package`) -**Purpose**: Swift Package Manager operations for building, testing, running, and managing Swift packages and dependencies. Complete SPM workflow support. (6 tools) +### Swift Package Development (`swift-package`) +**Purpose**: Build, test, run and manage Swift Package Manager projects. (6 tools) - `swift_package_build` - swift package target build. - `swift_package_clean` - swift package clean. @@ -152,13 +161,6 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov -### System Doctor (`doctor`) -**Purpose**: Debug tools and system doctor for troubleshooting XcodeBuildMCP server, development environment, and tool availability. (1 tools) - -- `doctor` - MCP environment info. - - - ### UI Automation (`ui-automation`) **Purpose**: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. (11 tools) @@ -167,8 +169,8 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov - `key_press` - Press key by keycode. - `key_sequence` - Press a sequence of keys by their keycodes. - `long_press` - Long press at coords. -- `screenshot` - Capture screenshot. -- `snapshot_ui` - Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. +- `screenshot` - Defined in iOS Simulator Development workflow. +- `snapshot_ui` - Defined in iOS Simulator Development workflow. - `swipe` - Swipe between points. - `tap` - Tap coordinate or element. - `touch` - Touch down/up at coords. @@ -177,14 +179,14 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ### Workflow Discovery (`workflow-discovery`) -**Purpose**: Manage the workflows that are enabled and disabled. (1 tools) +**Purpose**: Manage enabled workflows at runtime. (1 tools) -- `manage_workflows` - Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled. Available workflows: ${availableWorkflows} +- `manage-workflows` - Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled. -### Xcode IDE (mcpbridge) (`xcode-ide`) -**Purpose**: Proxy Xcode's built-in 'Xcode Tools' MCP service via `xcrun mcpbridge`. Registers dynamic `xcode_tools_*` tools when available. Bridge debug tools are only registered when `debug: true`. (3 tools) +### Xcode IDE Integration (`xcode-ide`) +**Purpose**: Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality. (3 tools) - `xcode_tools_bridge_disconnect` - Disconnect bridge and unregister proxied `xcode_tools_*` tools. - `xcode_tools_bridge_status` - Show xcrun mcpbridge availability and proxy tool sync status. @@ -194,10 +196,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ## Summary Statistics -- **Canonical Tools**: 75 -- **Total Tools**: 98 +- **Canonical Tools**: 76 +- **Total Tools**: 100 - **Workflow Groups**: 15 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-04T09:25:59.573Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:12:18.351Z UTC* diff --git a/scripts/update-tools-docs.ts b/scripts/update-tools-docs.ts index a692e6f9..32f9c500 100644 --- a/scripts/update-tools-docs.ts +++ b/scripts/update-tools-docs.ts @@ -18,6 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { loadManifest as loadYamlManifest } from '../src/core/manifest/load-manifest.ts'; +import { getEffectiveCliName } from '../src/core/manifest/schema.ts'; // Get project paths const __filename = fileURLToPath(import.meta.url); @@ -25,7 +27,6 @@ const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const docsPath = path.join(projectRoot, 'docs', 'TOOLS.md'); const docsCliPath = path.join(projectRoot, 'docs', 'TOOLS-CLI.md'); -const manifestPath = path.join(projectRoot, 'build', 'tools-manifest.json'); const cliExcludedWorkflows = new Set(['session-management', 'workflow-discovery']); type ToolsManifest = { @@ -142,14 +143,49 @@ function generateWorkflowSection( } function loadManifest(): ToolsManifest { - if (!fs.existsSync(manifestPath)) { - throw new Error( - `Missing tools manifest at ${path.relative(projectRoot, manifestPath)}. Run \"npm run build\" first.`, - ); + const manifest = loadYamlManifest(); + const workflowList = Array.from(manifest.workflows.values()); + const firstWorkflowByToolId = new Map(); + const docsTools: DocumentationTool[] = []; + + for (const workflow of workflowList) { + for (const toolId of workflow.tools) { + if (!firstWorkflowByToolId.has(toolId)) { + firstWorkflowByToolId.set(toolId, workflow.id); + } + + const tool = manifest.tools.get(toolId); + if (!tool) { + continue; + } + + const originWorkflow = firstWorkflowByToolId.get(toolId); + docsTools.push({ + name: tool.names.mcp, + description: tool.description, + isCanonical: originWorkflow === workflow.id, + originWorkflow, + workflow: workflow.id, + cliName: getEffectiveCliName(tool), + }); + } } - const raw = fs.readFileSync(manifestPath, 'utf-8'); - return JSON.parse(raw) as ToolsManifest; + return { + generatedAt: new Date().toISOString(), + stats: { + totalTools: docsTools.length, + canonicalTools: manifest.tools.size, + reExportTools: docsTools.length - manifest.tools.size, + workflowCount: workflowList.length, + }, + workflows: workflowList.map((workflow) => ({ + name: workflow.id, + displayName: workflow.title, + description: workflow.description, + })), + tools: docsTools, + }; } /** From bcbf0fe98d9bec483e68c8c446e696bef28a9457 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 21:33:24 +0000 Subject: [PATCH 11/23] chore: remove legacy npm tools scripts and analysis helpers --- .claude/commands/rp-build-cli.md | 59 +- .claude/commands/rp-investigate-cli.md | 35 +- .claude/commands/rp-oracle-export-cli.md | 27 +- .claude/commands/rp-refactor-cli.md | 52 +- .claude/commands/rp-reminder-cli.md | 22 +- .claude/commands/rp-review-cli.md | 80 ++- docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- package.json | 5 - scripts/analysis/tools-analysis.ts | 551 ---------------- scripts/analysis/tools-schema-audit.ts | 484 -------------- scripts/tools-cli.ts | 800 ----------------------- 12 files changed, 214 insertions(+), 1905 deletions(-) delete mode 100644 scripts/analysis/tools-analysis.ts delete mode 100644 scripts/analysis/tools-schema-audit.ts delete mode 100644 scripts/tools-cli.ts diff --git a/.claude/commands/rp-build-cli.md b/.claude/commands/rp-build-cli.md index 35ceca05..3a120aba 100644 --- a/.claude/commands/rp-build-cli.md +++ b/.claude/commands/rp-build-cli.md @@ -1,7 +1,7 @@ --- description: Build with rp-cli context builder → chat → implement repoprompt_managed: true -repoprompt_commands_version: 5 +repoprompt_skills_version: 6 repoprompt_variant: cli --- @@ -40,9 +40,12 @@ rp-cli -e 'select set src/ && context' Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. +**⚠️ TIMEOUT WARNING:** The `builder` and `chat` commands can take several minutes to complete. When invoking rp-cli, **set your command timeout to at least 2700 seconds (45 minutes)** to avoid premature termination. + --- ## The Workflow +0. **Verify workspace** – Confirm the target codebase is loaded 1. **Quick scan** – Understand how the task relates to the codebase 2. **Context builder** – Call `builder` with a clear prompt to get deep context + an architectural plan 3. **Refine with chat** – Use `chat` to clarify the plan if needed @@ -53,26 +56,51 @@ Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help ## CRITICAL REQUIREMENT ⚠️ **DO NOT START IMPLEMENTATION** until you have: -1. Completed Phase 1 (Quick Scan) -2. **Called `builder`** and received its plan +1. Completed Phase 0 (Workspace Verification) +2. Completed Phase 1 (Quick Scan) +3. **Called `builder`** and received its plan Skipping `builder` results in shallow implementations that miss architectural patterns, related code, and edge cases. The quick scan alone is NOT sufficient for implementation. --- +## Phase 0: Workspace Verification (REQUIRED) + +Before any exploration, confirm the target codebase is loaded: + +```bash +# First, list available windows to find the right one +rp-cli -e 'windows' + +# Then check roots in a specific window (REQUIRED - CLI cannot auto-bind) +rp-cli -w -e 'tree --type roots' +``` + +**Check the output:** +- If your target root appears in a window → note the window ID and proceed to Phase 1 +- If not → the codebase isn't loaded in any window + +**CLI Window Routing (CRITICAL):** +- CLI invocations are stateless—you MUST pass `-w ` to target the correct window +- Use `rp-cli -e 'windows'` to list all open windows and their workspaces +- Always include `-w ` in ALL subsequent commands +- Without `-w`, commands may target the wrong workspace + +--- + ## Phase 1: Quick Scan (LIMITED - 2-3 tool calls max) ⚠️ **This phase is intentionally brief.** Do NOT do extensive exploration here—that's what `builder` is for. Start by getting a lay of the land with the file tree: ```bash -rp-cli -e 'tree' +rp-cli -w -e 'tree' ``` Then use targeted searches to understand how the task maps to the codebase: ```bash -rp-cli -e 'search ""' -rp-cli -e 'structure RootName/likely/relevant/area/' +rp-cli -w -e 'search ""' +rp-cli -w -e 'structure RootName/likely/relevant/area/' ``` Use what you learn to **reformulate the user's prompt** with added clarity—reference specific modules, patterns, or terminology from the codebase. @@ -86,7 +114,7 @@ Use what you learn to **reformulate the user's prompt** with added clarity—ref Call `builder` with your informed prompt. Use `response_type: "plan"` to get an actionable architectural plan. ```bash -rp-cli -e 'builder "" --response-type plan' +rp-cli -w -e 'builder "" --response-type plan' ``` **What you get back:** @@ -139,21 +167,21 @@ Implement the plan directly. **Do not use `chat` with `mode:"edit"`** – you im **Primary tools:** ```bash # Modify existing files (search/replace) - JSON format required -rp-cli -e 'call apply_edits {"path":"Root/File.swift","search":"old","replace":"new"}' +rp-cli -w -e 'call apply_edits {"path":"Root/File.swift","search":"old","replace":"new"}' # Multiline edits -rp-cli -e 'call apply_edits {"path":"Root/File.swift","search":"old\ntext","replace":"new\ntext"}' +rp-cli -w -e 'call apply_edits {"path":"Root/File.swift","search":"old\ntext","replace":"new\ntext"}' # Create new files -rp-cli -e 'file create Root/NewFile.swift "content..."' +rp-cli -w -e 'file create Root/NewFile.swift "content..."' # Read specific sections during implementation -rp-cli -e 'read Root/File.swift --start-line 50 --limit 30' +rp-cli -w -e 'read Root/File.swift --start-line 50 --limit 30' ``` **Ask the chat when stuck:** ```bash -rp-cli -t '' -e 'chat "I'\''m implementing X but unsure about Y. What pattern should I follow?" --mode chat' +rp-cli -w -t '' -e 'chat "I'\''m implementing X but unsure about Y. What pattern should I follow?" --mode chat' ``` --- @@ -169,13 +197,13 @@ rp-cli -t '' -e 'chat "I'\''m implementing X but unsure about Y. What pa ```bash # Check current selection and tokens -rp-cli -e 'select get' +rp-cli -w -e 'select get' # Add a file if needed -rp-cli -e 'select add Root/path/to/file.swift' +rp-cli -w -e 'select add Root/path/to/file.swift' # Add a slice of a large file -rp-cli -e 'select add Root/large/file.swift:100-200' +rp-cli -w -e 'select add Root/large/file.swift:100-200' ``` **Chat sees only the selection:** If you need the chat's insight on a file, it must be selected first. @@ -193,6 +221,7 @@ rp-cli -e 'select add Root/large/file.swift:100-200' - 🚫 **CRITICAL:** Doing extensive exploration (5+ tool calls) before calling `builder` – the quick scan should be 2-3 calls max - 🚫 Reading full file contents during Phase 1 – save that for after `builder` builds context - 🚫 Convincing yourself you understand enough to skip `builder` – you don't +- 🚫 **CLI:** Forgetting to pass `-w ` – CLI invocations are stateless and require explicit window targeting --- diff --git a/.claude/commands/rp-investigate-cli.md b/.claude/commands/rp-investigate-cli.md index 39c79a90..b1a67521 100644 --- a/.claude/commands/rp-investigate-cli.md +++ b/.claude/commands/rp-investigate-cli.md @@ -1,7 +1,7 @@ --- description: Deep codebase investigation and architecture research with rp-cli commands repoprompt_managed: true -repoprompt_commands_version: 5 +repoprompt_skills_version: 6 repoprompt_variant: cli --- @@ -40,6 +40,8 @@ rp-cli -e 'select set src/ && context' Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. +**⚠️ TIMEOUT WARNING:** The `builder` and `chat` commands can take several minutes to complete. When invoking rp-cli, **set your command timeout to at least 2700 seconds (45 minutes)** to avoid premature termination. + --- ## Investigation Protocol @@ -49,6 +51,27 @@ Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help 3. **Question everything** - if something seems off, investigate it 4. **Use `builder` aggressively** - it's designed for deep exploration +### Phase 0: Workspace Verification (REQUIRED) + +Before any investigation, confirm the target codebase is loaded: + +```bash +# First, list available windows to find the right one +rp-cli -e 'windows' + +# Then check roots in a specific window (REQUIRED - CLI cannot auto-bind) +rp-cli -w -e 'tree --type roots' +``` + +**Check the output:** +- If your target root appears in a window → note the window ID and proceed to Phase 1 +- If not → the codebase isn't loaded in any window + +**CLI Window Routing (CRITICAL):** +- CLI invocations are stateless—you MUST pass `-w ` to target the correct window +- Use `rp-cli -e 'windows'` to list all open windows and their workspaces +- Always include `-w ` in ALL subsequent commands + ### Phase 1: Initial Assessment 1. Read any provided files/reports (traces, logs, error reports) @@ -62,7 +85,7 @@ Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help Use `builder` with detailed instructions: ```bash -rp-cli -e 'builder "Investigate: +rp-cli -w -e 'builder "Investigate: Symptoms observed: - @@ -82,10 +105,10 @@ Areas to explore: After `builder` returns, continue with targeted questions: ```bash -rp-cli -t '' -e 'chat "" --mode plan' +rp-cli -w -t '' -e 'chat "" --mode plan' ``` -> Pass `-t ` to target the same tab across separate CLI invocations. +> Pass `-w ` to target the correct window and `-t ` to target the same tab across separate CLI invocations. ### Phase 4: Evidence Gathering @@ -156,12 +179,14 @@ Create a findings report as you investigate: ## Anti-patterns to Avoid - 🚫 **CRITICAL:** Skipping `builder` and attempting to investigate by reading files manually – you'll miss critical context +- 🚫 Skipping Phase 0 (Workspace Verification) – you must confirm the target codebase is loaded first - 🚫 Doing extensive exploration (5+ tool calls) before calling `builder` – initial assessment should be brief - 🚫 Drawing conclusions before `builder` has built proper context - 🚫 Reading many full files during Phase 1 – save deep reading for after `builder` - 🚫 Assuming you understand the issue without systematic exploration via `builder` - 🚫 Using only chat follow-ups without an initial `builder` call +- 🚫 **CLI:** Forgetting to pass `-w ` – CLI invocations are stateless and require explicit window targeting --- -Now begin the investigation. Read any provided context, then **immediately** use `builder` to start systematic exploration. Do not attempt manual exploration first. \ No newline at end of file +Now begin the investigation. First run `rp-cli -e 'windows'` to find the correct window, then Read any provided context, then **immediately** use `builder` to start systematic exploration. Do not attempt manual exploration first. \ No newline at end of file diff --git a/.claude/commands/rp-oracle-export-cli.md b/.claude/commands/rp-oracle-export-cli.md index ae36f670..c258bca2 100644 --- a/.claude/commands/rp-oracle-export-cli.md +++ b/.claude/commands/rp-oracle-export-cli.md @@ -1,7 +1,7 @@ --- description: Export context for oracle consultation using rp-cli repoprompt_managed: true -repoprompt_commands_version: 5 +repoprompt_skills_version: 6 repoprompt_variant: cli --- @@ -22,10 +22,31 @@ You don't need to specify which files to include—just describe what you need h ## Workflow +### 0. Workspace Verification (REQUIRED) + +Before building context, confirm the target codebase is loaded: + +```bash +# First, list available windows to find the right one +rp-cli -e 'windows' + +# Then check roots in a specific window (REQUIRED - CLI cannot auto-bind) +rp-cli -w -e 'tree --type roots' +``` + +**Check the output:** +- If your target root appears in a window → note the window ID and proceed +- If not → the codebase isn't loaded in any window + +**CLI Window Routing (CRITICAL):** +- CLI invocations are stateless—you MUST pass `-w ` to target the correct window +- Use `rp-cli -e 'windows'` to list all open windows and their workspaces +- Always include `-w ` in ALL subsequent commands + ### 1. Build Context ```bash -rp-cli -e 'builder "" --response-type clarify' +rp-cli -w -e 'builder "" --response-type clarify' ``` Wait for context_builder to complete. It will explore the codebase and build optimal context. @@ -35,7 +56,7 @@ Wait for context_builder to complete. It will explore the codebase and build opt Confirm the export path with the user (default: `~/Downloads/oracle-prompt.md`), then export: ```bash -rp-cli -e 'prompt export ""' +rp-cli -w -e 'prompt export ""' ``` Report the export path and token count to the user. \ No newline at end of file diff --git a/.claude/commands/rp-refactor-cli.md b/.claude/commands/rp-refactor-cli.md index 359e6994..ecc554b9 100644 --- a/.claude/commands/rp-refactor-cli.md +++ b/.claude/commands/rp-refactor-cli.md @@ -1,7 +1,7 @@ --- description: Refactoring assistant using rp-cli to analyze and improve code organization repoprompt_managed: true -repoprompt_commands_version: 5 +repoprompt_skills_version: 6 repoprompt_variant: cli --- @@ -40,6 +40,8 @@ rp-cli -e 'select set src/ && context' Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. +**⚠️ TIMEOUT WARNING:** The `builder` and `chat` commands can take several minutes to complete. When invoking rp-cli, **set your command timeout to at least 2700 seconds (45 minutes)** to avoid premature termination. + --- ## Goal @@ -49,18 +51,42 @@ Analyze code for redundancies and complexity, then implement improvements. **Pre ## Protocol +0. **Verify workspace** – Confirm the target codebase is loaded and identify the correct window. 1. **Analyze** – Use `builder` with `response_type: "review"` to study recent changes and find refactor opportunities. 2. **Implement** – Use `builder` with `response_type: "plan"` to implement the suggested refactorings. --- +## Step 0: Workspace Verification (REQUIRED) + +Before any analysis, confirm the target codebase is loaded: + +```bash +# First, list available windows to find the right one +rp-cli -e 'windows' + +# Then check roots in a specific window (REQUIRED - CLI cannot auto-bind) +rp-cli -w -e 'tree --type roots' +``` + +**Check the output:** +- If your target root appears in a window → note the window ID and proceed to Step 1 +- If not → the codebase isn't loaded in any window + +**CLI Window Routing (CRITICAL):** +- CLI invocations are stateless—you MUST pass `-w ` to target the correct window +- Use `rp-cli -e 'windows'` to list all open windows and their workspaces +- Always include `-w ` in ALL subsequent commands + +--- + ## Step 1: Analyze for Refactoring Opportunities (via `builder` - REQUIRED) ⚠️ **Do NOT skip this step.** You MUST call `builder` with `response_type: "review"` to properly analyze the code. Use XML tags to structure the instructions: ```bash -rp-cli -e 'builder "Analyze for refactoring opportunities. Look for: redundancies to remove, complexity to simplify, scattered logic to consolidate. +rp-cli -w -e 'builder "Analyze for refactoring opportunities. Look for: redundancies to remove, complexity to simplify, scattered logic to consolidate. Target: . Goal: Preserve behavior while improving code organization. @@ -74,16 +100,16 @@ Review the findings. If areas were missed, run additional focused reviews with e After receiving analysis findings, you can ask clarifying questions in the same chat: ```bash -rp-cli -t '' -e 'chat "For the duplicate logic you identified, which location should be the canonical one?" --mode chat' +rp-cli -w -t '' -e 'chat "For the duplicate logic you identified, which location should be the canonical one?" --mode chat' ``` -> Pass `-t ` to target the same tab from the builder response. +> Pass `-w ` to target the correct window and `-t ` to target the same tab from the builder response. ## Step 2: Implement the Refactorings Once you have a clear list of refactoring opportunities, use `builder` with `response_type: "plan"` to implement: ```bash -rp-cli -e 'builder "Implement these refactorings: +rp-cli -w -e 'builder "Implement these refactorings: Refactorings to apply: 1. @@ -111,10 +137,12 @@ Preserve existing behavior. Make incremental changes. ## Anti-patterns to Avoid -- 🚫 **CRITICAL:** This workflow requires TWO \(builderName) calls – one for analysis (Step 1), one for implementation (Step 2). Do not skip either. -- 🚫 Skipping Step 1's \(builderName) call with `response_type: "review"` and attempting to analyze manually -- 🚫 Skipping Step 2's \(builderName) call with `response_type: "plan"` and implementing without a plan -- 🚫 Doing extensive exploration (5+ tool calls) before the first \(builderName) call – let the builder do the heavy lifting -- 🚫 Proposing refactorings without the analysis phase via \(builderName) -- 🚫 Implementing refactorings after only the analysis phase – you need the second \(builderName) call for implementation planning -- 🚫 Assuming you understand the code structure without \(builderName)'s architectural analysis \ No newline at end of file +- 🚫 **CRITICAL:** This workflow requires TWO `builder` calls – one for analysis (Step 1), one for implementation (Step 2). Do not skip either. +- 🚫 Skipping Step 0 (Workspace Verification) – you must confirm the target codebase is loaded first +- 🚫 Skipping Step 1's `builder` call with `response_type: "review"` and attempting to analyze manually +- 🚫 Skipping Step 2's `builder` call with `response_type: "plan"` and implementing without a plan +- 🚫 Doing extensive exploration (5+ tool calls) before the first `builder` call – let the builder do the heavy lifting +- 🚫 Proposing refactorings without the analysis phase via `builder` +- 🚫 Implementing refactorings after only the analysis phase – you need the second `builder` call for implementation planning +- 🚫 Assuming you understand the code structure without `builder`'s architectural analysis +- 🚫 **CLI:** Forgetting to pass `-w ` – CLI invocations are stateless and require explicit window targeting \ No newline at end of file diff --git a/.claude/commands/rp-reminder-cli.md b/.claude/commands/rp-reminder-cli.md index 30f0b472..db58a9b8 100644 --- a/.claude/commands/rp-reminder-cli.md +++ b/.claude/commands/rp-reminder-cli.md @@ -1,7 +1,7 @@ --- description: Reminder to use rp-cli repoprompt_managed: true -repoprompt_commands_version: 5 +repoprompt_skills_version: 6 repoprompt_variant: cli --- @@ -22,30 +22,30 @@ Continue your current workflow using rp-cli instead of built-in alternatives. ```bash # Search (path or content) -rp-cli -e 'search "keyword"' +rp-cli -w -e 'search "keyword"' # Read file (or slice) -rp-cli -e 'read Root/file.swift' -rp-cli -e 'read Root/file.swift --start-line 50 --limit 30' +rp-cli -w -e 'read Root/file.swift' +rp-cli -w -e 'read Root/file.swift --start-line 50 --limit 30' # Edit (search/replace) - JSON format required -rp-cli -e 'call apply_edits {"path":"Root/file.swift","search":"old","replace":"new"}' -rp-cli -e 'call apply_edits {"path":"Root/file.swift","search":"a\nb","replace":"c\nd"}' +rp-cli -w -e 'call apply_edits {"path":"Root/file.swift","search":"old","replace":"new"}' +rp-cli -w -e 'call apply_edits {"path":"Root/file.swift","search":"a\nb","replace":"c\nd"}' # File operations -rp-cli -e 'file create Root/new.swift "content..."' -rp-cli -e 'file delete /absolute/path.swift' -rp-cli -e 'file move Root/old.swift Root/new.swift' +rp-cli -w -e 'file create Root/new.swift "content..."' +rp-cli -w -e 'file delete /absolute/path.swift' +rp-cli -w -e 'file move Root/old.swift Root/new.swift' ``` ## Context Management ```bash # Check selection -rp-cli -e 'select get' +rp-cli -w -e 'select get' # Add files for chat context -rp-cli -e 'select add Root/path/file.swift' +rp-cli -w -e 'select add Root/path/file.swift' ``` Continue with your task using these tools. \ No newline at end of file diff --git a/.claude/commands/rp-review-cli.md b/.claude/commands/rp-review-cli.md index 54f83536..b7ac4b28 100644 --- a/.claude/commands/rp-review-cli.md +++ b/.claude/commands/rp-review-cli.md @@ -1,7 +1,7 @@ --- description: Code review workflow using rp-cli git tool and context_builder repoprompt_managed: true -repoprompt_commands_version: 5 +repoprompt_skills_version: 6 repoprompt_variant: cli --- @@ -40,40 +40,83 @@ rp-cli -e 'select set src/ && context' Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. +**⚠️ TIMEOUT WARNING:** The `builder` and `chat` commands can take several minutes to complete. When invoking rp-cli, **set your command timeout to at least 2700 seconds (45 minutes)** to avoid premature termination. + --- ## Protocol +0. **Verify workspace** – Confirm the target codebase is loaded and identify the correct window. 1. **Survey changes** – Check git state and recent commits to understand what's changed. -2. **Confirm scope** – If user wasn't explicit, confirm what to review (uncommitted, staged, branch, etc.). -3. **Deep review** – Run `builder` with `response_type: "review"`. +2. **Confirm scope (MANDATORY)** – You MUST confirm the comparison branch/scope with the user before proceeding. +3. **Deep review** – Run `builder` with `response_type: "review"`, explicitly specifying the confirmed comparison scope. 4. **Fill gaps** – If the review missed areas, run focused follow-up reviews explicitly describing what was/wasn't covered. --- +## Step 0: Workspace Verification (REQUIRED) + +Before any git operations, confirm the target codebase is loaded: + +```bash +# First, list available windows to find the right one +rp-cli -e 'windows' + +# Then check roots in a specific window (REQUIRED - CLI cannot auto-bind) +rp-cli -w -e 'tree --type roots' +``` + +**Check the output:** +- If your target root appears in a window → note the window ID and proceed to Step 1 +- If not → the codebase isn't loaded in any window + +**CLI Window Routing (CRITICAL):** +- CLI invocations are stateless—you MUST pass `-w ` to target the correct window +- Use `rp-cli -e 'windows'` to list all open windows and their workspaces +- Always include `-w ` in ALL subsequent commands + +--- + ## Step 1: Survey Changes ```bash -rp-cli -e 'git status' -rp-cli -e 'git log --count 10' -rp-cli -e 'git diff --detail files' +rp-cli -w -e 'git status' +rp-cli -w -e 'git log --count 10' +rp-cli -w -e 'git diff --detail files' ``` -## Step 2: Confirm Scope with User +## Step 2: Confirm Scope with User (MANDATORY - DO NOT SKIP) + +⚠️ **You MUST confirm the comparison scope with the user before calling `builder`.** Do not assume or proceed without explicit confirmation. -If the user didn't specify, ask them to confirm: -- `uncommitted` – All uncommitted changes (default) -- `staged` – Only staged changes -- `back:N` – Last N commits -- `main...HEAD` – Branch comparison +Ask the user to confirm: +- **Current branch**: What branch are you on? (from git status) +- **Comparison target**: What should changes be compared against? + - `uncommitted` – All uncommitted changes vs HEAD (default) + - `staged` – Only staged changes vs HEAD + - `back:N` – Last N commits + - `main` or `master` – Compare current branch against trunk + - `` – Compare against specific branch + +**Example prompt to user:** +> "You're on branch `feature/xyz`. What should I compare against? +> - `uncommitted` (default) - review all uncommitted changes +> - `main` - review all changes on this branch vs main +> - Other branch name?" + +**STOP and wait for user confirmation before proceeding to Step 3.** ## Step 3: Deep Review (via `builder` - REQUIRED) ⚠️ **Do NOT skip this step.** You MUST call `builder` with `response_type: "review"` for proper code review context. +**CRITICAL:** Include the confirmed comparison scope in your instructions so the context builder knows exactly what to review. + Use XML tags to structure the instructions: ```bash -rp-cli -e 'builder "Review the changes. Focus on correctness, security, API changes, error handling. +rp-cli -w -e 'builder "Review changes comparing against . Focus on correctness, security, API changes, error handling. -Changed files: +Comparison: (e.g., uncommitted, main, staged) +Current branch: +Changed files: Focus on directories containing changes." --response-type review' ``` @@ -82,16 +125,16 @@ rp-cli -e 'builder "Review the changes. Focus on correctness, secu After receiving review findings, you can ask clarifying questions in the same chat: ```bash -rp-cli -t '' -e 'chat "Can you explain the security concern in more detail? What'\''s the attack vector?" --mode chat' +rp-cli -w -t '' -e 'chat "Can you explain the security concern in more detail? What'\''s the attack vector?" --mode chat' ``` -> Pass `-t ` to target the same tab from the builder response. +> Pass `-w ` to target the correct window and `-t ` to target the same tab from the builder response. ## Step 4: Fill Gaps If the review omitted significant areas, run a focused follow-up. **You must explicitly describe what was already covered and what needs review now** (`builder` has no memory of previous runs): ```bash -rp-cli -e 'builder "Review in depth. +rp-cli -w -e 'builder "Review in depth. Previous review covered: . Not yet reviewed: . @@ -103,11 +146,14 @@ Not yet reviewed: . ## Anti-patterns to Avoid +- 🚫 **CRITICAL:** Skipping Step 2 (branch confirmation) – you MUST confirm the comparison scope with the user before calling `builder` - 🚫 **CRITICAL:** Skipping `builder` and attempting to review by reading files manually – you'll miss architectural context +- 🚫 Calling `builder` without specifying the confirmed comparison scope in the instructions - 🚫 Doing extensive file reading before calling `builder` – git status/log/diff is sufficient for Step 1 - 🚫 Providing review feedback without first calling `builder` with `response_type: "review"` - 🚫 Assuming the git diff alone is sufficient context for a thorough review - 🚫 Reading changed files manually instead of letting `builder` build proper review context +- 🚫 **CLI:** Forgetting to pass `-w ` – CLI invocations are stateless and require explicit window targeting --- diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index 3706e133..dc4f0ff4 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -187,4 +187,4 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:12:18.351Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:23:22.870Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c99b8e1b..006fabb8 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,4 +202,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:12:18.351Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:23:22.870Z UTC* diff --git a/package.json b/package.json index 050fbe24..66f8c782 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,6 @@ "verify:smithery-bundle": "bash scripts/verify-smithery-bundle.sh", "inspect": "npx @modelcontextprotocol/inspector node build/cli.js mcp", "doctor": "node build/doctor-cli.js", - "tools": "npx tsx scripts/tools-cli.ts", - "tools:list": "npx tsx scripts/tools-cli.ts list", - "tools:static": "npx tsx scripts/tools-cli.ts static", - "tools:count": "npx tsx scripts/tools-cli.ts count --static", - "tools:analysis": "npx tsx scripts/analysis/tools-analysis.ts", "docs:update": "npx tsx scripts/update-tools-docs.ts", "docs:update:dry-run": "npx tsx scripts/update-tools-docs.ts --dry-run --verbose", "test": "vitest run", diff --git a/scripts/analysis/tools-analysis.ts b/scripts/analysis/tools-analysis.ts deleted file mode 100644 index 036d5ad3..00000000 --- a/scripts/analysis/tools-analysis.ts +++ /dev/null @@ -1,551 +0,0 @@ -#!/usr/bin/env node - -/** - * XcodeBuildMCP Tools Analysis - * - * Core TypeScript module for analyzing XcodeBuildMCP tools using AST parsing. - * Provides reliable extraction of tool information without fallback strategies. - */ - -import { - createSourceFile, - forEachChild, - isExportAssignment, - isIdentifier, - isNoSubstitutionTemplateLiteral, - isObjectLiteralExpression, - isPropertyAssignment, - isStringLiteral, - isTemplateExpression, - isVariableDeclaration, - isVariableStatement, - type Node, - type ObjectLiteralExpression, - ScriptTarget, - type SourceFile, - SyntaxKind, -} from 'typescript'; -import * as fs from 'fs'; -import * as path from 'path'; -import { glob } from 'glob'; -import { fileURLToPath } from 'url'; - -// Get project root -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const projectRoot = path.resolve(__dirname, '..', '..'); -const toolsDir = path.join(projectRoot, 'src', 'mcp', 'tools'); - -export interface ToolInfo { - name: string; - workflow: string; - path: string; - relativePath: string; - description: string; - cliName?: string; - originWorkflow?: string; - stateful?: boolean; - isCanonical: boolean; -} - -export interface WorkflowInfo { - name: string; - displayName: string; - description: string; - tools: ToolInfo[]; - toolCount: number; - canonicalCount: number; - reExportCount: number; -} - -export interface AnalysisStats { - totalTools: number; - canonicalTools: number; - reExportTools: number; - workflowCount: number; -} - -export interface StaticAnalysisResult { - workflows: WorkflowInfo[]; - tools: ToolInfo[]; - stats: AnalysisStats; -} - -type ExtractStringOptions = { - allowFallback: boolean; -}; - -function extractStringValue( - sourceFile: SourceFile, - node: Node, - options: ExtractStringOptions = { allowFallback: false }, -): string | null { - if (isStringLiteral(node)) { - return node.text; - } - - if (isTemplateExpression(node) || isNoSubstitutionTemplateLiteral(node)) { - let text = node.getFullText(sourceFile).trim(); - if (text.startsWith('`') && text.endsWith('`')) { - text = text.slice(1, -1); - } - return text; - } - - if (!options.allowFallback) { - return null; - } - - const fullText = node.getFullText(sourceFile).trim(); - let cleaned = fullText; - if ( - (cleaned.startsWith('"') && cleaned.endsWith('"')) || - (cleaned.startsWith("'") && cleaned.endsWith("'")) - ) { - cleaned = cleaned.slice(1, -1); - } - return cleaned.replace(/\s+/g, ' ').trim(); -} - -function extractBooleanValue(node: Node): boolean | null { - if (node.kind === SyntaxKind.TrueKeyword) { - return true; - } - if (node.kind === SyntaxKind.FalseKeyword) { - return false; - } - return null; -} - -function getCodeLines(content: string): string[] { - const contentWithoutBlockComments = content.replace(/\/\*[\s\S]*?\*\//g, ''); - - return contentWithoutBlockComments - .split('\n') - .map((line) => line.split('//')[0].trim()) - .filter((line) => line.length > 0); -} - -/** - * Extract the description from a tool's default export using TypeScript AST - */ -function extractToolMetadata(sourceFile: SourceFile): { - description: string; - cliName?: string; - stateful?: boolean; -} { - let description: string | null = null; - let cliName: string | null = null; - let stateful: boolean | null = null; - - function visit(node: Node): void { - let objectExpression: ObjectLiteralExpression | null = null; - - // Look for export default { ... } - the standard TypeScript pattern - // isExportEquals is undefined for `export default` and true for `export = ` - if (isExportAssignment(node) && !node.isExportEquals) { - if (isObjectLiteralExpression(node.expression)) { - objectExpression = node.expression; - } - } - - if (objectExpression) { - // Found export default { ... }, now look for description and CLI metadata - for (const property of objectExpression.properties) { - if (!isPropertyAssignment(property) || !isIdentifier(property.name)) { - continue; - } - - if (property.name.text === 'description') { - description = extractStringValue(sourceFile, property.initializer, { - allowFallback: true, - }); - continue; - } - - if (property.name.text === 'cli' && isObjectLiteralExpression(property.initializer)) { - for (const cliProperty of property.initializer.properties) { - if (!isPropertyAssignment(cliProperty) || !isIdentifier(cliProperty.name)) { - continue; - } - - if (cliProperty.name.text === 'name') { - cliName = extractStringValue(sourceFile, cliProperty.initializer, { - allowFallback: true, - }); - continue; - } - - if (cliProperty.name.text === 'stateful') { - stateful = extractBooleanValue(cliProperty.initializer); - } - } - } - } - } - - forEachChild(node, visit); - } - - visit(sourceFile); - - if (description === null) { - throw new Error('Could not extract description from tool export default object'); - } - - return { - description, - cliName: cliName ?? undefined, - stateful: stateful ?? undefined, - }; -} - -/** - * Check if a file is a re-export by examining its content - */ -function isReExportFile(filePath: string): boolean { - const content = fs.readFileSync(filePath, 'utf-8'); - const cleanedLines = getCodeLines(content); - - // Should have exactly one line: export { default } from '...'; - if (cleanedLines.length !== 1) { - return false; - } - - const exportLine = cleanedLines[0]; - return /^export\s*{\s*default\s*}\s*from\s*['"][^'"]+['"];?\s*$/.test(exportLine); -} - -function getReExportTargetInfo(filePath: string): { filePath: string; workflow: string } | null { - const content = fs.readFileSync(filePath, 'utf-8'); - const cleanedLines = getCodeLines(content); - - if (cleanedLines.length !== 1) { - return null; - } - - const match = cleanedLines[0].match(/export\s*{\s*default\s*}\s*from\s*['"]([^'"]+)['"];?\s*$/); - if (!match) { - return null; - } - - let targetFilePath = path.resolve(path.dirname(filePath), match[1]); - if (!path.extname(targetFilePath)) { - targetFilePath += '.ts'; - } - - if (!targetFilePath.startsWith(toolsDir)) { - throw new Error( - `Re-export target for ${path.relative(projectRoot, filePath)} is outside tools directory: ${targetFilePath}`, - ); - } - - if (!fs.existsSync(targetFilePath)) { - throw new Error( - `Re-export target for ${path.relative(projectRoot, filePath)} does not exist: ${targetFilePath}`, - ); - } - - return { - filePath: targetFilePath, - workflow: path.basename(path.dirname(targetFilePath)), - }; -} - -/** - * Get workflow metadata from index.ts file if it exists - */ -async function getWorkflowMetadata( - workflowDir: string, -): Promise<{ displayName: string; description: string } | null> { - const indexPath = path.join(toolsDir, workflowDir, 'index.ts'); - - if (!fs.existsSync(indexPath)) { - return null; - } - - try { - const content = fs.readFileSync(indexPath, 'utf-8'); - const sourceFile = createSourceFile(indexPath, content, ScriptTarget.Latest, true); - - const workflowExport: { name?: string; description?: string } = {}; - - function visit(node: Node): void { - // Look for: export const workflow = { ... } - if ( - isVariableStatement(node) && - node.modifiers?.some((mod) => mod.kind === SyntaxKind.ExportKeyword) - ) { - for (const declaration of node.declarationList.declarations) { - if ( - isVariableDeclaration(declaration) && - isIdentifier(declaration.name) && - declaration.name.text === 'workflow' && - declaration.initializer && - isObjectLiteralExpression(declaration.initializer) - ) { - // Extract name and description properties - for (const property of declaration.initializer.properties) { - if (isPropertyAssignment(property) && isIdentifier(property.name)) { - const propertyName = property.name.text; - - if (propertyName === 'name') { - const value = extractStringValue(sourceFile, property.initializer); - if (value) { - workflowExport.name = value; - } - } else if (propertyName === 'description') { - const value = extractStringValue(sourceFile, property.initializer); - if (value) { - workflowExport.description = value; - } - } - } - } - } - } - } - - forEachChild(node, visit); - } - - visit(sourceFile); - - if (workflowExport.name && workflowExport.description) { - return { - displayName: workflowExport.name, - description: workflowExport.description, - }; - } - } catch (error) { - console.error(`Warning: Could not parse workflow metadata from ${indexPath}: ${error}`); - } - - return null; -} - -/** - * Get a human-readable workflow name from directory name - */ -function getWorkflowDisplayName(workflowDir: string): string { - const displayNames: Record = { - device: 'iOS Device Development', - doctor: 'System Doctor', - logging: 'Logging & Monitoring', - macos: 'macOS Development', - 'project-discovery': 'Project Discovery', - 'project-scaffolding': 'Project Scaffolding', - simulator: 'iOS Simulator Development', - 'simulator-management': 'Simulator Management', - 'swift-package': 'Swift Package Manager', - 'ui-testing': 'UI Testing & Automation', - utilities: 'Utilities', - }; - - return displayNames[workflowDir] || workflowDir; -} - -/** - * Get workflow description - */ -function getWorkflowDescription(workflowDir: string): string { - const descriptions: Record = { - device: 'Physical device development, testing, and deployment', - doctor: 'System health checks and environment validation', - logging: 'Log capture and monitoring across platforms', - macos: 'Native macOS application development and testing', - 'project-discovery': 'Project analysis and information gathering', - 'project-scaffolding': 'Create new projects from templates', - simulator: 'Simulator-based development, testing, and deployment', - 'simulator-management': 'Simulator environment and configuration management', - 'swift-package': 'Swift Package development and testing', - 'ui-testing': 'Automated UI interaction and testing', - utilities: 'General utility operations', - }; - - return descriptions[workflowDir] || `${workflowDir} related tools`; -} - -/** - * Perform static analysis of all tools in the project - */ -export async function getStaticToolAnalysis(): Promise { - // Find all workflow directories - const workflowDirs = fs - .readdirSync(toolsDir, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - .sort(); - - // Find all tool files - const files = await glob('**/*.ts', { - cwd: toolsDir, - ignore: [ - '**/__tests__/**', - '**/index.ts', - '**/*.test.ts', - '**/lib/**', - '**/shared/**', - '**/*-processes.ts', // Process management utilities - '**/*.deps.ts', // Dependency files - '**/*-utils.ts', // Utility files - '**/*-common.ts', // Common/shared code - '**/*-types.ts', // Type definition files - ], - absolute: true, - }); - - const allTools: ToolInfo[] = []; - const workflowMap = new Map(); - - let canonicalCount = 0; - let reExportCount = 0; - - // Initialize workflow map - for (const workflowDir of workflowDirs) { - workflowMap.set(workflowDir, []); - } - - // Process each tool file - for (const filePath of files) { - const toolName = path.basename(filePath, '.ts'); - const workflowDir = path.basename(path.dirname(filePath)); - const relativePath = path.relative(projectRoot, filePath); - - const isReExport = isReExportFile(filePath); - - let description = ''; - let cliName: string | undefined; - let originWorkflow: string | undefined; - let stateful: boolean | undefined; - - if (!isReExport) { - // Extract description from canonical tool using AST - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const sourceFile = createSourceFile(filePath, content, ScriptTarget.Latest, true); - - const metadata = extractToolMetadata(sourceFile); - description = metadata.description; - cliName = metadata.cliName; - stateful = metadata.stateful; - canonicalCount++; - } catch (error) { - throw new Error(`Failed to extract description from ${relativePath}: ${error}`); - } - } else { - const reExportInfo = getReExportTargetInfo(filePath); - if (!reExportInfo) { - throw new Error(`Failed to resolve re-export target for ${relativePath}`); - } - - originWorkflow = reExportInfo.workflow; - try { - const targetContent = fs.readFileSync(reExportInfo.filePath, 'utf-8'); - const targetSourceFile = createSourceFile( - reExportInfo.filePath, - targetContent, - ScriptTarget.Latest, - true, - ); - const metadata = extractToolMetadata(targetSourceFile); - description = metadata.description; - cliName = metadata.cliName; - stateful = metadata.stateful; - } catch (error) { - throw new Error( - `Failed to extract description for re-export ${relativePath}: ${error as Error}`, - ); - } - reExportCount++; - } - - const toolInfo: ToolInfo = { - name: toolName, - workflow: workflowDir, - path: filePath, - relativePath, - description, - cliName, - originWorkflow, - stateful, - isCanonical: !isReExport, - }; - - allTools.push(toolInfo); - - const workflowTools = workflowMap.get(workflowDir); - if (workflowTools) { - workflowTools.push(toolInfo); - } - } - - // Build workflow information - const workflows: WorkflowInfo[] = []; - - for (const workflowDir of workflowDirs) { - const workflowTools = workflowMap.get(workflowDir) ?? []; - const canonicalTools = workflowTools.filter((t) => t.isCanonical); - const reExportTools = workflowTools.filter((t) => !t.isCanonical); - - // Try to get metadata from index.ts, fall back to hardcoded names/descriptions - const metadata = await getWorkflowMetadata(workflowDir); - - const workflowInfo: WorkflowInfo = { - name: workflowDir, - displayName: metadata?.displayName ?? getWorkflowDisplayName(workflowDir), - description: metadata?.description ?? getWorkflowDescription(workflowDir), - tools: workflowTools.sort((a, b) => a.name.localeCompare(b.name)), - toolCount: workflowTools.length, - canonicalCount: canonicalTools.length, - reExportCount: reExportTools.length, - }; - - workflows.push(workflowInfo); - } - - const stats: AnalysisStats = { - totalTools: allTools.length, - canonicalTools: canonicalCount, - reExportTools: reExportCount, - workflowCount: workflows.length, - }; - - return { - workflows: workflows.sort((a, b) => a.displayName.localeCompare(b.displayName)), - tools: allTools.sort((a, b) => a.name.localeCompare(b.name)), - stats, - }; -} - -// CLI support - if run directly, perform analysis and output results -if (import.meta.url === `file://${process.argv[1]}`) { - async function main(): Promise { - try { - console.log('🔍 Performing static analysis...'); - const analysis = await getStaticToolAnalysis(); - - console.log('\n📊 Analysis Results:'); - console.log(` Workflows: ${analysis.stats.workflowCount}`); - console.log(` Total tools: ${analysis.stats.totalTools}`); - console.log(` Canonical tools: ${analysis.stats.canonicalTools}`); - console.log(` Re-export tools: ${analysis.stats.reExportTools}`); - - if (process.argv.includes('--json')) { - console.log('\n' + JSON.stringify(analysis, null, 2)); - } else { - console.log('\n📂 Workflows:'); - for (const workflow of analysis.workflows) { - console.log( - ` • ${workflow.displayName} (${workflow.canonicalCount} canonical, ${workflow.reExportCount} re-exports)`, - ); - } - } - } catch (error) { - console.error('❌ Analysis failed:', error); - process.exit(1); - } - } - - main(); -} diff --git a/scripts/analysis/tools-schema-audit.ts b/scripts/analysis/tools-schema-audit.ts deleted file mode 100644 index 87bf751c..00000000 --- a/scripts/analysis/tools-schema-audit.ts +++ /dev/null @@ -1,484 +0,0 @@ -#!/usr/bin/env node - -/** - * Tool schema audit analysis for XcodeBuildMCP. - * - * Static analysis of tool files to extract schema argument names and their - * `.describe()` strings without loading tool modules at runtime. - */ - -import { - createSourceFile, - forEachChild, - isAsExpression, - isCallExpression, - isExportAssignment, - isIdentifier, - isNoSubstitutionTemplateLiteral, - isObjectLiteralExpression, - isParenthesizedExpression, - isPropertyAccessExpression, - isPropertyAssignment, - isShorthandPropertyAssignment, - isSpreadAssignment, - isStringLiteral, - isTemplateExpression, - isVariableDeclaration, - isVariableStatement, - type Expression, - type Node, - type ObjectLiteralExpression, - ScriptTarget, - type SourceFile, -} from 'typescript'; -import * as fs from 'fs'; -import * as path from 'path'; -import { glob } from 'glob'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const projectRoot = path.resolve(__dirname, '..', '..'); -const toolsDir = path.join(projectRoot, 'src', 'mcp', 'tools'); - -export interface SchemaAuditArgument { - name: string; - description: string | null; -} - -export interface SchemaAuditTool { - name: string; - description: string | null; - args: SchemaAuditArgument[]; - relativePath: string; -} - -type SchemaShape = Map; - -function isReExportFile(filePath: string): boolean { - const content = fs.readFileSync(filePath, 'utf-8'); - const contentWithoutBlockComments = content.replace(/\/\*[\s\S]*?\*\//g, ''); - const cleanedLines = contentWithoutBlockComments - .split('\n') - .map((line) => line.split('//')[0].trim()) - .filter((line) => line.length > 0); - - if (cleanedLines.length !== 1) { - return false; - } - - const exportLine = cleanedLines[0]; - return /^export\s*{\s*default\s*}\s*from\s*['"][^'"]+['"];?\s*$/.test(exportLine); -} - -function extractStringLiteral( - expression: Expression | undefined, - sourceFile: SourceFile, -): string | null { - if (!expression) { - return null; - } - - if (isStringLiteral(expression)) { - return expression.text.trim(); - } - - if (isNoSubstitutionTemplateLiteral(expression)) { - return expression.text.trim(); - } - - if (isTemplateExpression(expression)) { - const raw = expression.getFullText(sourceFile).trim(); - if (raw.startsWith('`') && raw.endsWith('`')) { - return raw.slice(1, -1).trim(); - } - return raw; - } - - return null; -} - -function getPropertyName(node: Node): string | null { - if (isIdentifier(node)) { - return node.text; - } - if (isStringLiteral(node)) { - return node.text; - } - return null; -} - -function extractDescribeCall(expression: Expression, sourceFile: SourceFile): string | null { - if (isCallExpression(expression)) { - if ( - isPropertyAccessExpression(expression.expression) && - expression.expression.name.text === 'describe' - ) { - return extractStringLiteral(expression.arguments[0], sourceFile); - } - - const fromCallee = extractDescribeCall(expression.expression, sourceFile); - if (fromCallee) { - return fromCallee; - } - } - - if (isPropertyAccessExpression(expression)) { - return extractDescribeCall(expression.expression, sourceFile); - } - - if (isParenthesizedExpression(expression)) { - return extractDescribeCall(expression.expression, sourceFile); - } - - if (isAsExpression(expression)) { - return extractDescribeCall(expression.expression, sourceFile); - } - - return null; -} - -function resolveObjectLiteralShape( - objectLiteral: ObjectLiteralExpression, - context: SchemaResolveContext, -): SchemaShape { - const shape: SchemaShape = new Map(); - - for (const property of objectLiteral.properties) { - if (isPropertyAssignment(property)) { - const name = getPropertyName(property.name); - if (!name) { - continue; - } - - const description = extractDescribeCall(property.initializer, context.sourceFile); - shape.set(name, description ?? null); - continue; - } - - if (isShorthandPropertyAssignment(property)) { - const name = property.name.text; - if (!name) { - continue; - } - - shape.set(name, null); - continue; - } - - if (isSpreadAssignment(property)) { - const spreadShape = resolveSchemaShape(property.expression, context); - for (const [key, value] of spreadShape.entries()) { - shape.set(key, value); - } - } - } - - return shape; -} - -function isZodCall(expression: Expression, name: string): boolean { - if (!isCallExpression(expression)) { - return false; - } - if (!isPropertyAccessExpression(expression.expression)) { - return false; - } - if (!isIdentifier(expression.expression.expression)) { - return false; - } - - return expression.expression.expression.text === 'z' && expression.expression.name.text === name; -} - -function resolveOmitKeys(expression: Expression): Set { - if (isAsExpression(expression)) { - return resolveOmitKeys(expression.expression); - } - - if (!isObjectLiteralExpression(expression)) { - return new Set(); - } - - const keys = new Set(); - for (const property of expression.properties) { - if (isPropertyAssignment(property)) { - const name = getPropertyName(property.name); - if (name) { - keys.add(name); - } - } - } - return keys; -} - -function resolveSchemaShape(expression: Expression, context: SchemaResolveContext): SchemaShape { - if (isParenthesizedExpression(expression)) { - return resolveSchemaShape(expression.expression, context); - } - - if (isAsExpression(expression)) { - return resolveSchemaShape(expression.expression, context); - } - - if (isIdentifier(expression)) { - const name = expression.text; - if (context.memo.has(name)) { - return context.memo.get(name) ?? new Map(); - } - if (context.resolving.has(name)) { - return new Map(); - } - - const initializer = context.variableInitializers.get(name); - if (!initializer) { - return new Map(); - } - - context.resolving.add(name); - const resolved = resolveSchemaShape(initializer, context); - context.memo.set(name, resolved); - context.resolving.delete(name); - return resolved; - } - - if (isObjectLiteralExpression(expression)) { - return resolveObjectLiteralShape(expression, context); - } - - if (isPropertyAccessExpression(expression) && expression.name.text === 'shape') { - return resolveSchemaShape(expression.expression, context); - } - - if (isCallExpression(expression)) { - if (isZodCall(expression, 'object') || isZodCall(expression, 'strictObject')) { - const firstArg = expression.arguments[0]; - if (firstArg) { - return resolveSchemaShape(firstArg, context); - } - } - - if (isZodCall(expression, 'preprocess')) { - const schemaArg = expression.arguments[1]; - if (schemaArg) { - return resolveSchemaShape(schemaArg, context); - } - } - - if (isIdentifier(expression.expression) && expression.expression.text === 'getSessionAwareToolSchemaShape') { - const firstArg = expression.arguments[0]; - if (firstArg && isObjectLiteralExpression(firstArg)) { - let sessionAware: Expression | null = null; - let legacy: Expression | null = null; - - for (const property of firstArg.properties) { - if (isPropertyAssignment(property) && isIdentifier(property.name)) { - if (property.name.text === 'sessionAware') { - sessionAware = property.initializer; - } else if (property.name.text === 'legacy') { - legacy = property.initializer; - } - } - } - - if (sessionAware) { - return resolveSchemaShape(sessionAware, context); - } - if (legacy) { - return resolveSchemaShape(legacy, context); - } - } - } - - if (isPropertyAccessExpression(expression.expression)) { - const operation = expression.expression.name.text; - const baseExpression = expression.expression.expression; - const baseShape = resolveSchemaShape(baseExpression, context); - - if (operation === 'omit') { - const omitKeys = resolveOmitKeys(expression.arguments[0]); - for (const key of omitKeys) { - baseShape.delete(key); - } - return baseShape; - } - - if (operation === 'extend') { - const extensionArg = expression.arguments[0]; - if (extensionArg) { - const extensionShape = resolveSchemaShape(extensionArg, context); - for (const [key, value] of extensionShape.entries()) { - baseShape.set(key, value); - } - } - return baseShape; - } - - if (operation === 'passthrough') { - return baseShape; - } - } - } - - return new Map(); -} - -interface SchemaResolveContext { - sourceFile: SourceFile; - variableInitializers: Map; - memo: Map; - resolving: Set; -} - -function getExportDefaultObject(sourceFile: SourceFile): ObjectLiteralExpression | null { - let exportObject: ObjectLiteralExpression | null = null; - - function visit(node: Node): void { - if (isExportAssignment(node) && !node.isExportEquals) { - if (isObjectLiteralExpression(node.expression)) { - exportObject = node.expression; - return; - } - } - - forEachChild(node, visit); - } - - visit(sourceFile); - return exportObject; -} - -function extractToolMetadata( - sourceFile: SourceFile, - variableInitializers: Map, - fallbackName: string, -): { name: string; description: string | null; schemaExpression: Expression } { - const exportObject = getExportDefaultObject(sourceFile); - if (!exportObject) { - throw new Error('Export default object not found.'); - } - - let name: string | null = null; - let description: string | null = null; - let schemaExpression: Expression | null = null; - - for (const property of exportObject.properties) { - if (!isPropertyAssignment(property) || !isIdentifier(property.name)) { - continue; - } - - if (property.name.text === 'name') { - name = extractStringLiteral(property.initializer, sourceFile); - } else if (property.name.text === 'description') { - description = extractStringLiteral(property.initializer, sourceFile); - } else if (property.name.text === 'schema') { - schemaExpression = property.initializer; - } - } - - if (!schemaExpression) { - throw new Error('Tool schema not found.'); - } - - return { - name: name ?? fallbackName, - description, - schemaExpression, - }; -} - -function collectVariableInitializers(sourceFile: SourceFile): Map { - const map = new Map(); - - function visit(node: Node): void { - if (isVariableStatement(node)) { - for (const declaration of node.declarationList.declarations) { - if ( - isVariableDeclaration(declaration) && - isIdentifier(declaration.name) && - declaration.initializer - ) { - map.set(declaration.name.text, declaration.initializer); - } - } - } - - forEachChild(node, visit); - } - - visit(sourceFile); - return map; -} - -export async function getSchemaAuditTools(): Promise { - const files = await glob('**/*.ts', { - cwd: toolsDir, - ignore: [ - '**/__tests__/**', - '**/index.ts', - '**/*.test.ts', - '**/lib/**', - '**/*-processes.ts', - '**/*.deps.ts', - '**/*-utils.ts', - '**/*-common.ts', - '**/*-types.ts', - ], - absolute: true, - }); - - const tools: SchemaAuditTool[] = []; - - for (const filePath of files) { - if (isReExportFile(filePath)) { - continue; - } - - const content = fs.readFileSync(filePath, 'utf-8'); - const sourceFile = createSourceFile(filePath, content, ScriptTarget.Latest, true); - const variableInitializers = collectVariableInitializers(sourceFile); - - const toolNameFallback = path.basename(filePath, '.ts'); - const { name, description, schemaExpression } = extractToolMetadata( - sourceFile, - variableInitializers, - toolNameFallback, - ); - - const context: SchemaResolveContext = { - sourceFile, - variableInitializers, - memo: new Map(), - resolving: new Set(), - }; - - const shape = resolveSchemaShape(schemaExpression, context); - const args = Array.from(shape.entries()) - .map(([argName, argDescription]) => ({ - name: argName, - description: argDescription, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - tools.push({ - name, - description, - args, - relativePath: path.relative(projectRoot, filePath), - }); - } - - return tools.sort((a, b) => a.name.localeCompare(b.name)); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - async function main(): Promise { - const tools = await getSchemaAuditTools(); - console.log(JSON.stringify(tools, null, 2)); - } - - main().catch((error) => { - console.error('Schema audit failed:', error); - process.exit(1); - }); -} diff --git a/scripts/tools-cli.ts b/scripts/tools-cli.ts deleted file mode 100644 index 523bd4ed..00000000 --- a/scripts/tools-cli.ts +++ /dev/null @@ -1,800 +0,0 @@ -#!/usr/bin/env node - -/** - * XcodeBuildMCP Tools CLI - * - * A unified command-line tool that provides comprehensive information about - * XcodeBuildMCP tools and resources. Supports both runtime inspection - * (actual server state) and static analysis (source file analysis). - * - * Usage: - * npm run tools [command] [options] - * npx tsx src/cli/tools-cli.ts [command] [options] - * - * Commands: - * count, c Show tool and workflow counts - * list, l List all tools and resources - * static, s Show static source file analysis - * help, h Show this help message - * - * Options: - * --runtime, -r Use runtime inspection (respects env config) - * --static, -s Use static file analysis (development mode) - * --tools, -t Include tools in output - * --resources Include resources in output - * --workflows, -w Include workflow information - * --verbose, -v Show detailed information - * --json Output JSON format - * --help Show help for specific command - * - * Examples: - * npm run tools # Runtime summary with workflows - * npm run tools:count # Runtime tool count - * npm run tools:static # Static file analysis - * npm run tools:list # List runtime tools - * npx tsx src/cli/tools-cli.ts --json # JSON output - */ - -import { spawn } from 'child_process'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import * as fs from 'fs'; -import { getStaticToolAnalysis, type StaticAnalysisResult } from './analysis/tools-analysis.js'; -import { getSchemaAuditTools } from './analysis/tools-schema-audit.js'; -import type { SchemaAuditTool } from './analysis/tools-schema-audit.js'; - -// Get project paths -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// ANSI color codes -const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - magenta: '\x1b[35m', -} as const; - -// Types -interface CLIOptions { - runtime: boolean; - static: boolean; - tools: boolean; - resources: boolean; - workflows: boolean; - verbose: boolean; - json: boolean; - help: boolean; -} - -interface RuntimeTool { - name: string; - description: string; -} - -interface RuntimeResource { - uri: string; - name: string; - description: string; -} - -interface RuntimeData { - tools: RuntimeTool[]; - resources: RuntimeResource[]; - toolCount: number; - resourceCount: number; - mode: 'runtime'; -} - -// CLI argument parsing -const args = process.argv.slice(2); - -// Find the command (first non-flag argument) -let command = 'count'; // default -for (const arg of args) { - if (!arg.startsWith('-')) { - command = arg; - break; - } -} - -const options: CLIOptions = { - runtime: args.includes('--runtime') || args.includes('-r'), - static: args.includes('--static') || args.includes('-s'), - tools: args.includes('--tools') || args.includes('-t'), - resources: args.includes('--resources'), - workflows: args.includes('--workflows') || args.includes('-w'), - verbose: args.includes('--verbose') || args.includes('-v'), - json: args.includes('--json'), - help: args.includes('--help') || args.includes('-h'), -}; - -// Set sensible defaults for each command -if (!options.runtime && !options.static) { - if (command === 'static' || command === 's') { - options.static = true; - } else { - // Default to static analysis for development-friendly usage - options.static = true; - } -} - -// Set sensible content defaults -if (command === 'list' || command === 'l') { - if (!options.tools && !options.resources && !options.workflows) { - options.tools = true; // Default to showing tools for list command - } -} else if (!command || command === 'count' || command === 'c') { - // For no command or count, show comprehensive summary - if (!options.tools && !options.resources && !options.workflows) { - options.workflows = true; // Show workflows by default for summary - } -} - -// Help text -const helpText = { - main: ` -${colors.bright}${colors.blue}XcodeBuildMCP Tools CLI${colors.reset} - -A unified command-line tool for XcodeBuildMCP tool and resource information. - -${colors.bright}COMMANDS:${colors.reset} - count, c Show tool and workflow counts - list, l List all tools and resources - schema, audit Audit tool schemas (arguments and descriptions) - static, s Show static source file analysis - help, h Show this help message - -${colors.bright}OPTIONS:${colors.reset} - --runtime, -r Use runtime inspection (respects env config) - --static, -s Use static file analysis (default, development mode) - --tools, -t Include tools in output - --resources Include resources in output - --workflows, -w Include workflow information - --verbose, -v Show detailed information - --json Output JSON format - -${colors.bright}EXAMPLES:${colors.reset} - ${colors.cyan}npm run tools${colors.reset} # Static summary with workflows (default) - ${colors.cyan}npm run tools list${colors.reset} # List tools - ${colors.cyan}npm run tools schema${colors.reset} # Audit tool schemas - ${colors.cyan}npm run tools --runtime${colors.reset} # Runtime analysis (requires build) - ${colors.cyan}npm run tools static${colors.reset} # Static analysis summary - ${colors.cyan}npm run tools count --json${colors.reset} # JSON output - -${colors.bright}ANALYSIS MODES:${colors.reset} - ${colors.green}Runtime${colors.reset} Uses actual server inspection via Reloaderoo - - Respects XCODEBUILDMCP_ENABLED_WORKFLOWS environment variable - - Shows tools actually enabled at runtime - - Requires built server (npm run build) - - ${colors.yellow}Static${colors.reset} Scans source files directly using AST parsing - - Shows all tools in codebase regardless of config - - Development-time analysis with reliable description extraction - - No server build required -`, - - count: ` -${colors.bright}COUNT COMMAND${colors.reset} - -Shows tool and workflow counts using runtime or static analysis. - -${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts count [options] - -${colors.bright}Options:${colors.reset} - --runtime, -r Count tools from running server - --static, -s Count tools from source files - --workflows, -w Include workflow directory counts - --json Output JSON format - -${colors.bright}Examples:${colors.reset} - ${colors.cyan}npx tsx scripts/tools-cli.ts count${colors.reset} # Runtime count - ${colors.cyan}npx tsx scripts/tools-cli.ts count --static${colors.reset} # Static count - ${colors.cyan}npx tsx scripts/tools-cli.ts count --workflows${colors.reset} # Include workflows -`, - - list: ` -${colors.bright}LIST COMMAND${colors.reset} - -Lists tools and resources with optional details. - -${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts list [options] - -${colors.bright}Options:${colors.reset} - --runtime, -r List from running server - --static, -s List from source files - --tools, -t Show tool names - --resources Show resource URIs - --verbose, -v Show detailed information - --json Output JSON format - -${colors.bright}Examples:${colors.reset} - ${colors.cyan}npx tsx scripts/tools-cli.ts list --tools${colors.reset} # Runtime tool list - ${colors.cyan}npx tsx scripts/tools-cli.ts list --resources${colors.reset} # Runtime resource list - ${colors.cyan}npx tsx scripts/tools-cli.ts list --static --verbose${colors.reset} # Static detailed list -`, - - schema: ` -${colors.bright}SCHEMA COMMAND${colors.reset} - -Audits tool schemas and prints argument descriptions (when set). - -${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts schema [options] - -${colors.bright}Options:${colors.reset} - --json Output JSON format - -${colors.bright}Examples:${colors.reset} - ${colors.cyan}npx tsx scripts/tools-cli.ts schema${colors.reset} # Human-readable schema audit - ${colors.cyan}npx tsx scripts/tools-cli.ts schema --json${colors.reset} # JSON schema audit -`, - - audit: ` -${colors.bright}SCHEMA COMMAND${colors.reset} - -Audits tool schemas and prints argument descriptions (when set). - -${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts audit [options] - -${colors.bright}Options:${colors.reset} - --json Output JSON format - -${colors.bright}Examples:${colors.reset} - ${colors.cyan}npx tsx scripts/tools-cli.ts audit${colors.reset} # Human-readable schema audit - ${colors.cyan}npx tsx scripts/tools-cli.ts audit --json${colors.reset} # JSON schema audit -`, - - static: ` -${colors.bright}STATIC COMMAND${colors.reset} - -Performs detailed static analysis of source files using AST parsing. - -${colors.bright}Usage:${colors.reset} npx tsx scripts/tools-cli.ts static [options] - -${colors.bright}Options:${colors.reset} - --tools, -t Show canonical tool details - --workflows, -w Show workflow directory analysis - --verbose, -v Show detailed file information - --json Output JSON format - -${colors.bright}Examples:${colors.reset} - ${colors.cyan}npx tsx scripts/tools-cli.ts static${colors.reset} # Basic static analysis - ${colors.cyan}npx tsx scripts/tools-cli.ts static --verbose${colors.reset} # Detailed analysis - ${colors.cyan}npx tsx scripts/tools-cli.ts static --workflows${colors.reset} # Include workflow info -`, -}; - -if (options.help) { - console.log(helpText[command as keyof typeof helpText] || helpText.main); - process.exit(0); -} - -if (command === 'help' || command === 'h') { - const helpCommand = args[1]; - console.log(helpText[helpCommand as keyof typeof helpText] || helpText.main); - process.exit(0); -} - -/** - * Execute reloaderoo command and parse JSON response - */ -async function executeReloaderoo(reloaderooArgs: string[]): Promise { - const buildPath = path.resolve(__dirname, '..', 'build', 'index.js'); - - if (!fs.existsSync(buildPath)) { - throw new Error('Build not found. Please run "npm run build" first.'); - } - - const tempFile = `/tmp/reloaderoo-output-${Date.now()}.json`; - const command = `npx -y reloaderoo@latest inspect ${reloaderooArgs.join(' ')} -- node "${buildPath}"`; - - return new Promise((resolve, reject) => { - const child = spawn('bash', ['-c', `${command} > "${tempFile}"`], { - stdio: 'inherit', - }); - - child.on('close', (code) => { - try { - if (code !== 0) { - reject(new Error(`Command failed with code ${code}`)); - return; - } - - const content = fs.readFileSync(tempFile, 'utf8'); - - // Remove stderr log lines and find JSON - const lines = content.split('\n'); - const cleanLines: string[] = []; - - for (const line of lines) { - if ( - line.match(/^\[\d{4}-\d{2}-\d{2}T/) || - line.includes('[INFO]') || - line.includes('[DEBUG]') || - line.includes('[ERROR]') - ) { - continue; - } - - const trimmed = line.trim(); - if (trimmed) { - cleanLines.push(line); - } - } - - // Find JSON start - let jsonStartIndex = -1; - for (let i = 0; i < cleanLines.length; i++) { - if (cleanLines[i].trim().startsWith('{')) { - jsonStartIndex = i; - break; - } - } - - if (jsonStartIndex === -1) { - reject( - new Error(`No JSON response found in output.\nOutput: ${content.substring(0, 500)}...`), - ); - return; - } - - const jsonText = cleanLines.slice(jsonStartIndex).join('\n'); - const response = JSON.parse(jsonText); - resolve(response); - } catch (error) { - reject(new Error(`Failed to parse JSON response: ${(error as Error).message}`)); - } finally { - try { - fs.unlinkSync(tempFile); - } catch { - // Ignore cleanup errors - } - } - }); - - child.on('error', (error) => { - reject(new Error(`Failed to spawn process: ${error.message}`)); - }); - }); -} - -/** - * Get runtime server information - */ -async function getRuntimeInfo(): Promise { - try { - const toolsResponse = (await executeReloaderoo(['list-tools'])) as { - tools?: { name: string; description: string }[]; - }; - const resourcesResponse = (await executeReloaderoo(['list-resources'])) as { - resources?: { uri: string; name: string; description?: string; title?: string }[]; - }; - - let tools: RuntimeTool[] = []; - let toolCount = 0; - - if (toolsResponse.tools && Array.isArray(toolsResponse.tools)) { - toolCount = toolsResponse.tools.length; - tools = toolsResponse.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - })); - } - - let resources: RuntimeResource[] = []; - let resourceCount = 0; - - if (resourcesResponse.resources && Array.isArray(resourcesResponse.resources)) { - resourceCount = resourcesResponse.resources.length; - resources = resourcesResponse.resources.map((resource) => ({ - uri: resource.uri, - name: resource.name, - description: resource.title ?? resource.description ?? 'No description available', - })); - } - - return { - tools, - resources, - toolCount, - resourceCount, - mode: 'runtime', - }; - } catch (error) { - throw new Error(`Runtime analysis failed: ${(error as Error).message}`); - } -} - -/** - * Display summary information - */ -function displaySummary( - runtimeData: RuntimeData | null, - staticData: StaticAnalysisResult | null, -): void { - if (options.json) { - return; // JSON output handled separately - } - - console.log(`${colors.bright}${colors.blue}📊 XcodeBuildMCP Tools Summary${colors.reset}`); - console.log('═'.repeat(60)); - - if (runtimeData) { - console.log(`${colors.green}🚀 Runtime Analysis:${colors.reset}`); - console.log(` Tools: ${runtimeData.toolCount}`); - console.log(` Resources: ${runtimeData.resourceCount}`); - console.log(` Total: ${runtimeData.toolCount + runtimeData.resourceCount}`); - console.log(); - } - - if (staticData) { - console.log(`${colors.cyan}📁 Static Analysis:${colors.reset}`); - console.log(` Workflow directories: ${staticData.stats.workflowCount}`); - console.log(` Canonical tools: ${staticData.stats.canonicalTools}`); - console.log(` Re-export files: ${staticData.stats.reExportTools}`); - console.log(` Total tool files: ${staticData.stats.totalTools}`); - console.log(); - } -} - -/** - * Display workflow information - */ -function displayWorkflows(staticData: StaticAnalysisResult | null): void { - if (!options.workflows || !staticData || options.json) return; - - console.log(`${colors.bright}📂 Workflow Directories:${colors.reset}`); - console.log('─'.repeat(40)); - - for (const workflow of staticData.workflows) { - const totalTools = workflow.toolCount; - console.log(`${colors.green}• ${workflow.displayName}${colors.reset} (${totalTools} tools)`); - - if (options.verbose) { - const canonicalTools = workflow.tools.filter((t) => t.isCanonical).map((t) => t.name); - const reExportTools = workflow.tools.filter((t) => !t.isCanonical).map((t) => t.name); - - if (canonicalTools.length > 0) { - console.log(` ${colors.cyan}Canonical:${colors.reset} ${canonicalTools.join(', ')}`); - } - if (reExportTools.length > 0) { - console.log(` ${colors.yellow}Re-exports:${colors.reset} ${reExportTools.join(', ')}`); - } - } - } - console.log(); -} - -/** - * Display tool lists - */ -function displayTools( - runtimeData: RuntimeData | null, - staticData: StaticAnalysisResult | null, -): void { - if (!options.tools || options.json) return; - - if (runtimeData) { - console.log(`${colors.bright}🛠️ Runtime Tools (${runtimeData.toolCount}):${colors.reset}`); - console.log('─'.repeat(40)); - - if (runtimeData.tools.length === 0) { - console.log(' No tools available'); - } else { - runtimeData.tools.forEach((tool) => { - if (options.verbose && tool.description) { - console.log( - ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset}`, - ); - console.log(` ${tool.description}`); - } else { - console.log(` ${colors.green}•${colors.reset} ${tool.name}`); - } - }); - } - console.log(); - } - - if (staticData && options.static) { - const canonicalTools = staticData.tools.filter((tool) => tool.isCanonical); - console.log(`${colors.bright}📁 Static Tools (${canonicalTools.length}):${colors.reset}`); - console.log('─'.repeat(40)); - - if (canonicalTools.length === 0) { - console.log(' No tools found'); - } else { - canonicalTools - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach((tool) => { - if (options.verbose) { - console.log( - ` ${colors.green}•${colors.reset} ${colors.bright}${tool.name}${colors.reset} (${tool.workflow})`, - ); - console.log(` ${tool.description}`); - console.log(` ${colors.cyan}${tool.relativePath}${colors.reset}`); - } else { - console.log(` ${colors.green}•${colors.reset} ${tool.name}`); - } - }); - } - console.log(); - } -} - -/** - * Display resource lists - */ -function displayResources(runtimeData: RuntimeData | null): void { - if (!options.resources || !runtimeData || options.json) return; - - console.log(`${colors.bright}📚 Resources (${runtimeData.resourceCount}):${colors.reset}`); - console.log('─'.repeat(40)); - - if (runtimeData.resources.length === 0) { - console.log(' No resources available'); - } else { - runtimeData.resources.forEach((resource) => { - if (options.verbose) { - console.log( - ` ${colors.magenta}•${colors.reset} ${colors.bright}${resource.uri}${colors.reset}`, - ); - console.log(` ${resource.description}`); - } else { - console.log(` ${colors.magenta}•${colors.reset} ${resource.uri}`); - } - }); - } - console.log(); -} - -/** - * Output JSON format - matches the structure of human-readable output - */ -function outputJSON( - runtimeData: RuntimeData | null, - staticData: StaticAnalysisResult | null, -): void { - const output: Record = {}; - - // Add summary stats (equivalent to the summary table) - if (runtimeData) { - output.runtime = { - toolCount: runtimeData.toolCount, - resourceCount: runtimeData.resourceCount, - totalCount: runtimeData.toolCount + runtimeData.resourceCount, - }; - } - - if (staticData) { - output.static = { - workflowCount: staticData.stats.workflowCount, - canonicalTools: staticData.stats.canonicalTools, - reExportTools: staticData.stats.reExportTools, - totalTools: staticData.stats.totalTools, - }; - } - - // Add detailed data only if requested - if (options.workflows && staticData) { - output.workflows = staticData.workflows.map((w) => ({ - name: w.displayName, - toolCount: w.toolCount, - canonicalCount: w.canonicalCount, - reExportCount: w.reExportCount, - })); - } - - if (options.tools) { - if (runtimeData) { - output.runtimeTools = runtimeData.tools.map((t) => t.name); - } - if (staticData) { - output.staticTools = staticData.tools - .filter((t) => t.isCanonical) - .map((t) => t.name) - .sort(); - } - } - - if (options.resources && runtimeData) { - output.resources = runtimeData.resources.map((r) => r.uri); - } - - console.log(JSON.stringify(output, null, 2)); -} - -function isSchemaAuditCommand(commandName: string): boolean { - return commandName === 'schema' || commandName === 'audit'; -} - -function displaySchemaAudit(tools: SchemaAuditTool[]): void { - if (options.json) { - return; - } - - console.log(`${colors.bright}Tool Schema Audit:${colors.reset}`); - console.log('─'.repeat(60)); - - if (tools.length === 0) { - console.log('No tools found.'); - console.log(); - return; - } - - for (const tool of tools) { - const toolDescription = tool.description ?? 'No description provided'; - console.log(`Tool Name: ${tool.name}`); - console.log(`Tool Description: ${toolDescription}`); - console.log(`Arguments (${tool.args.length}):`); - - if (tool.args.length === 0) { - console.log(' (none)'); - } else { - for (const arg of tool.args) { - const argDescription = arg.description ?? 'No description provided'; - console.log(` Argument Name: ${arg.name}`); - console.log(` Argument Description: ${argDescription}`); - } - } - - console.log(); - } -} - -function outputSchemaAuditJSON(tools: SchemaAuditTool[]): void { - console.log( - JSON.stringify( - { - mode: 'schema-audit', - toolCount: tools.length, - tools: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - args: tool.args.map((arg) => ({ - name: arg.name, - description: arg.description, - })), - })), - }, - null, - 2, - ), - ); -} - -/** - * Main execution function - */ -async function main(): Promise { - try { - let runtimeData: RuntimeData | null = null; - let staticData: StaticAnalysisResult | null = null; - let schemaAuditTools: SchemaAuditTool[] | null = null; - const schemaAuditCommand = isSchemaAuditCommand(command); - - // Gather data based on options - if (options.runtime && !schemaAuditCommand) { - if (!options.json) { - console.log(`${colors.cyan}🔍 Gathering runtime information...${colors.reset}`); - } - runtimeData = await getRuntimeInfo(); - } - - if (options.static && !schemaAuditCommand) { - if (!options.json) { - console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}`); - } - staticData = await getStaticToolAnalysis(); - } - - // For default command or workflows option, always gather static data for workflow info - if (options.workflows && !staticData && !schemaAuditCommand) { - if (!options.json) { - console.log(`${colors.cyan}📁 Gathering workflow information...${colors.reset}`); - } - staticData = await getStaticToolAnalysis(); - } - - if (schemaAuditCommand) { - if (!options.json) { - console.log(`${colors.cyan}Gathering tool schema details...${colors.reset}`); - } - schemaAuditTools = await getSchemaAuditTools(); - } - - if (!options.json) { - console.log(); // Blank line after gathering - } - - // Handle JSON output - if (options.json) { - if (schemaAuditCommand) { - outputSchemaAuditJSON(schemaAuditTools ?? []); - } else { - outputJSON(runtimeData, staticData); - } - return; - } - - // Display based on command - switch (command) { - case 'count': - case 'c': - displaySummary(runtimeData, staticData); - displayWorkflows(staticData); - break; - - case 'list': - case 'l': - displaySummary(runtimeData, staticData); - displayTools(runtimeData, staticData); - displayResources(runtimeData); - break; - - case 'static': - case 's': - if (!staticData) { - console.log(`${colors.cyan}📁 Performing static analysis...${colors.reset}\n`); - staticData = await getStaticToolAnalysis(); - } - displaySummary(null, staticData); - displayWorkflows(staticData); - - if (options.verbose) { - displayTools(null, staticData); - const reExportTools = staticData.tools.filter((t) => !t.isCanonical); - console.log( - `${colors.bright}🔄 Re-export Files (${reExportTools.length}):${colors.reset}`, - ); - console.log('─'.repeat(40)); - reExportTools.forEach((file) => { - console.log(` ${colors.yellow}•${colors.reset} ${file.name} (${file.workflow})`); - console.log(` ${file.relativePath}`); - }); - } - break; - - case 'schema': - case 'audit': - if (!schemaAuditTools) { - schemaAuditTools = await getSchemaAuditTools(); - } - displaySchemaAudit(schemaAuditTools); - break; - - default: - // Default case (no command) - show runtime summary with workflows - displaySummary(runtimeData, staticData); - displayWorkflows(staticData); - break; - } - - if (!options.json) { - console.log(`${colors.green}✅ Analysis complete!${colors.reset}`); - } - } catch (error) { - if (options.json) { - console.error( - JSON.stringify( - { - success: false, - error: (error as Error).message, - timestamp: new Date().toISOString(), - }, - null, - 2, - ), - ); - } else { - console.error(`${colors.red}❌ Error: ${(error as Error).message}${colors.reset}`); - } - process.exit(1); - } -} - -// Run the CLI -main(); From 97099463383c0c7aaa90da006991fb26865a23ff Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 22:21:11 +0000 Subject: [PATCH 12/23] fix(cli): Show only CLI-exposed workflow commands Build CLI workflow groups from manifest-exposed CLI workflows instead of all manifest workflows. This removes empty workflow placeholders like xcode-ide when no CLI commands are available. Clarify xcode-ide help output to state bridge tools are MCP-only and point users to MCP server usage plus Xcode MCP Tools prerequisites. Co-Authored-By: Claude --- manifests/workflows/xcode-ide.yaml | 2 +- src/cli.ts | 15 ++++----------- src/cli/cli-tool-catalog.ts | 8 ++------ src/cli/register-tool-commands.ts | 17 +++++++++-------- src/cli/yargs-app.ts | 4 ++-- src/runtime/tool-catalog.ts | 10 ++++------ src/runtime/tool-invoker.ts | 6 +++--- src/runtime/types.ts | 4 +++- 8 files changed, 28 insertions(+), 38 deletions(-) diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index 66ff19dd..05e516b9 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -8,7 +8,7 @@ availability: selection: mcp: defaultEnabled: false - autoInclude: true + autoInclude: false predicates: - xcodeToolsAvailable - hideWhenXcodeAgentMode diff --git a/src/cli.ts b/src/cli.ts index 3cb61e6a..352a06bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,6 @@ import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts'; import { buildYargsApp } from './cli/yargs-app.ts'; import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts'; import { startMcpServer } from './server/start-mcp-server.ts'; -import { loadManifest } from './core/manifest/load-manifest.ts'; async function main(): Promise { if (process.argv.includes('mcp')) { @@ -20,8 +19,7 @@ async function main(): Promise { }, }); - // CLI uses its own catalog with ALL workflows enabled (except session-management) - // This is independent of the enabledWorkflows config which is for MCP + // CLI uses its own manifest-resolved catalog. const catalog = await buildCliToolCatalog(); // Compute workspace context for daemon routing @@ -40,13 +38,8 @@ async function main(): Promise { projectConfigPath: result.configPath, }); - const CLI_EXCLUDED_WORKFLOWS = new Set(['session-management', 'workflow-discovery']); - const manifest = loadManifest(); - const workflowNames = Array.from(manifest.workflows.keys()).filter( - (name) => !CLI_EXCLUDED_WORKFLOWS.has(name), - ); - - const enabledWorkflows = [...new Set(catalog.tools.map((tool) => tool.workflow))]; + const cliExposedWorkflowIds = [...new Set(catalog.tools.map((tool) => tool.workflow))]; + const workflowNames = cliExposedWorkflowIds; const yargsApp = buildYargsApp({ catalog, @@ -55,7 +48,7 @@ async function main(): Promise { workspaceRoot, workspaceKey, workflowNames, - enabledWorkflows, + cliExposedWorkflowIds, }); await yargsApp.parseAsync(); diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts index ff03effa..2f1ca73a 100644 --- a/src/cli/cli-tool-catalog.ts +++ b/src/cli/cli-tool-catalog.ts @@ -1,14 +1,10 @@ import { buildCliToolCatalogFromManifest } from '../runtime/tool-catalog.ts'; import type { ToolCatalog } from '../runtime/types.ts'; -const CLI_EXCLUDED_WORKFLOWS = ['session-management', 'workflow-discovery']; - /** * Build a tool catalog for CLI usage using the manifest system. - * CLI shows ALL workflows (not config-driven) except session-management and workflow-discovery. + * CLI visibility is determined by manifest availability and predicates. */ export async function buildCliToolCatalog(): Promise { - return buildCliToolCatalogFromManifest({ - excludeWorkflows: CLI_EXCLUDED_WORKFLOWS, - }); + return buildCliToolCatalogFromManifest(); } diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index adbdc160..e1dac53f 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -10,7 +10,7 @@ import { getWorkflowMetadataFromManifest } from '../core/manifest/load-manifest. export interface RegisterToolCommandsOptions { workspaceRoot: string; - enabledWorkflows?: string[]; + cliExposedWorkflowIds?: string[]; /** Workflows to register as command groups (even if currently empty) */ workflowNames?: string[]; } @@ -25,7 +25,7 @@ export function registerToolCommands( ): void { const invoker = new DefaultToolInvoker(catalog); const toolsByWorkflow = groupToolsByWorkflow(catalog); - const enabledWorkflows = opts.enabledWorkflows ?? [...toolsByWorkflow.keys()]; + const cliExposedWorkflowIds = opts.cliExposedWorkflowIds ?? [...toolsByWorkflow.keys()]; const workflowNames = opts.workflowNames ?? [...toolsByWorkflow.keys()]; const workflowMetadata = getWorkflowMetadataFromManifest(); @@ -46,15 +46,16 @@ export function registerToolCommands( // Register each tool as a subcommand under this workflow for (const tool of tools) { - registerToolSubcommand(yargs, tool, invoker, opts, enabledWorkflows); + registerToolSubcommand(yargs, tool, invoker, opts, cliExposedWorkflowIds); } if (tools.length === 0) { const hint = workflowName === 'xcode-ide' ? `No CLI commands are currently exposed for '${workflowName}'.\n` + - `Bridge debug tools are hidden unless XcodeBuildMCP debug mode is enabled.\n` + - `Set XCODEBUILDMCP_DEBUG=true to expose xcode_tools_bridge_{status,sync,disconnect}.` + `xcode-ide bridge tools are MCP-only and are not available as direct CLI subcommands.\n` + + `To use them, run the MCP server and connect with an MCP client.\n` + + `In Xcode, enable MCP Tools in Settings > Intelligence > Xcode Tools and click Allow if prompted.` : `No CLI commands are currently exposed for '${workflowName}'.`; yargs.epilogue(hint); @@ -67,7 +68,7 @@ export function registerToolCommands( if (tools.length === 0) { console.error( workflowName === 'xcode-ide' - ? `No CLI commands are currently exposed for '${workflowName}'. Set XCODEBUILDMCP_DEBUG=true to expose bridge debug commands.` + ? `No CLI commands are currently exposed for '${workflowName}'. xcode-ide bridge tools are MCP-only (not direct CLI subcommands). Run the MCP server with an MCP client, and in Xcode enable MCP Tools in Settings > Intelligence > Xcode Tools and click Allow if prompted.` : `No CLI commands are currently exposed for '${workflowName}'.`, ); } @@ -84,7 +85,7 @@ function registerToolSubcommand( tool: ToolDefinition, invoker: DefaultToolInvoker, opts: RegisterToolCommandsOptions, - enabledWorkflows: string[], + cliExposedWorkflowIds: string[], ): void { const yargsOptions = schemaToYargsOptions(tool.cliSchema); const unsupportedKeys = getUnsupportedSchemaKeys(tool.cliSchema); @@ -186,7 +187,7 @@ function registerToolSubcommand( // Invoke the tool const response = await invoker.invoke(tool.cliName, args, { runtime: 'cli', - enabledWorkflows, + cliExposedWorkflowIds, forceDaemon: Boolean(forceDaemon), disableDaemon: Boolean(noDaemon), socketPath, diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index ff47b130..76aada55 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -16,7 +16,7 @@ export interface YargsAppOptions { workspaceRoot: string; workspaceKey: string; workflowNames: string[]; - enabledWorkflows: string[]; + cliExposedWorkflowIds: string[]; } /** @@ -84,7 +84,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { registerToolsCommand(app); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, - enabledWorkflows: opts.enabledWorkflows, + cliExposedWorkflowIds: opts.cliExposedWorkflowIds, workflowNames: opts.workflowNames, }); // Daemon management is an advanced debugging tool - register last diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index b94c60de..0608c1d8 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -174,13 +174,12 @@ export async function buildToolCatalogFromManifest(opts: { /** * Build a CLI tool catalog from the manifest system. - * CLI shows ALL workflows (not config-driven) except excluded ones. + * CLI visibility is determined by manifest availability and predicates. */ export async function buildCliToolCatalogFromManifest(opts?: { excludeWorkflows?: string[]; }): Promise { - const defaultExclude = ['session-management', 'workflow-discovery']; - const excludeWorkflows = opts?.excludeWorkflows ?? defaultExclude; + const excludeWorkflows = opts?.excludeWorkflows ?? []; // CLI context: not running under Xcode, no Xcode tools active const ctx: PredicateContext = { @@ -200,13 +199,12 @@ export async function buildCliToolCatalogFromManifest(opts?: { /** * Build a daemon tool catalog from the manifest system. - * Daemon shows ALL workflows (not config-driven) except excluded ones. + * Daemon visibility is determined by manifest availability and predicates. */ export async function buildDaemonToolCatalogFromManifest(opts?: { excludeWorkflows?: string[]; }): Promise { - const defaultExclude = ['session-management', 'workflow-discovery']; - const excludeWorkflows = opts?.excludeWorkflows ?? defaultExclude; + const excludeWorkflows = opts?.excludeWorkflows ?? []; // Daemon context: not running under Xcode, no Xcode tools active const ctx: PredicateContext = { diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 1f873f8b..1b4fe776 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -91,10 +91,10 @@ export class DefaultToolInvoker implements ToolInvoker { } const client = new DaemonClient({ socketPath }); - const enabledWorkflows = opts.enabledWorkflows; + const cliExposedWorkflowIds = opts.cliExposedWorkflowIds ?? opts.enabledWorkflows; const envOverrides: Record = {}; - if (enabledWorkflows && enabledWorkflows.length > 0) { - envOverrides.XCODEBUILDMCP_ENABLED_WORKFLOWS = enabledWorkflows.join(','); + if (cliExposedWorkflowIds && cliExposedWorkflowIds.length > 0) { + envOverrides.XCODEBUILDMCP_ENABLED_WORKFLOWS = cliExposedWorkflowIds.join(','); } if (opts.logLevel) { envOverrides.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel; diff --git a/src/runtime/types.ts b/src/runtime/types.ts index e3b456ca..2002b73a 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -65,7 +65,9 @@ export interface ToolCatalog { export interface InvokeOptions { runtime: RuntimeKind; - /** If present, overrides enabled workflows */ + /** CLI-exposed workflow IDs used for daemon environment overrides */ + cliExposedWorkflowIds?: string[]; + /** @deprecated Use cliExposedWorkflowIds instead */ enabledWorkflows?: string[]; /** If true, route even stateless tools to daemon */ forceDaemon?: boolean; From f87be52eacbe65d9050389c3b2c9548fcfbc5731 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 22:49:08 +0000 Subject: [PATCH 13/23] fix(xcode-ide): decouple bridge tool handlers from MCP server Route xcode-ide status/sync/disconnect through a shared tool handler facade that uses the manager when a server instance exists and a standalone bridge client otherwise. This removes repeated branching logic across handlers and keeps CLI/debug bridge commands usable without requiring server bootstrap state. Co-Authored-By: Claude --- src/integrations/xcode-tools-bridge/core.ts | 70 ++++++++++++++ src/integrations/xcode-tools-bridge/index.ts | 21 +++++ .../xcode-tools-bridge/manager.ts | 65 ++----------- .../xcode-tools-bridge/standalone.ts | 83 ++++++++++++++++ src/mcp/tools/doctor/doctor.ts | 2 +- .../xcode-ide/__tests__/bridge_tools.test.ts | 94 +++++++++++++++++++ .../xcode_tools_bridge_disconnect.ts | 14 +-- .../xcode-ide/xcode_tools_bridge_status.ts | 14 +-- .../xcode-ide/xcode_tools_bridge_sync.ts | 14 +-- src/server/bootstrap.ts | 2 +- 10 files changed, 291 insertions(+), 88 deletions(-) create mode 100644 src/integrations/xcode-tools-bridge/core.ts create mode 100644 src/integrations/xcode-tools-bridge/standalone.ts create mode 100644 src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts diff --git a/src/integrations/xcode-tools-bridge/core.ts b/src/integrations/xcode-tools-bridge/core.ts new file mode 100644 index 00000000..c7f84fb8 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/core.ts @@ -0,0 +1,70 @@ +import { execFile } from 'node:child_process'; +import process from 'node:process'; +import { promisify } from 'node:util'; +import type { XcodeToolsBridgeClientStatus } from './client.ts'; + +const execFileAsync = promisify(execFile); + +export type XcodeToolsBridgeStatus = { + workflowEnabled: boolean; + bridgeAvailable: boolean; + bridgePath: string | null; + xcodeRunning: boolean | null; + connected: boolean; + bridgePid: number | null; + proxiedToolCount: number; + lastError: string | null; + xcodePid: string | null; + xcodeSessionId: string | null; +}; + +export interface BuildXcodeToolsBridgeStatusArgs { + workflowEnabled: boolean; + proxiedToolCount: number; + lastError: string | null; + clientStatus: XcodeToolsBridgeClientStatus; +} + +export async function buildXcodeToolsBridgeStatus( + args: BuildXcodeToolsBridgeStatusArgs, +): Promise { + const bridge = await getMcpBridgeAvailability(); + const xcodeRunning = await isXcodeRunning(); + + return { + workflowEnabled: args.workflowEnabled, + bridgeAvailable: bridge.available, + bridgePath: bridge.path, + xcodeRunning, + connected: args.clientStatus.connected, + bridgePid: args.clientStatus.bridgePid, + proxiedToolCount: args.proxiedToolCount, + lastError: args.lastError ?? args.clientStatus.lastError, + xcodePid: process.env.XCODEBUILDMCP_XCODE_PID ?? process.env.MCP_XCODE_PID ?? null, + xcodeSessionId: + process.env.XCODEBUILDMCP_XCODE_SESSION_ID ?? process.env.MCP_XCODE_SESSION_ID ?? null, + }; +} + +export async function getMcpBridgeAvailability(): Promise<{ + available: boolean; + path: string | null; +}> { + try { + const res = await execFileAsync('xcrun', ['--find', 'mcpbridge'], { timeout: 2000 }); + const out = (res.stdout ?? '').toString().trim(); + return out ? { available: true, path: out } : { available: false, path: null }; + } catch { + return { available: false, path: null }; + } +} + +export async function isXcodeRunning(): Promise { + try { + const res = await execFileAsync('pgrep', ['-x', 'Xcode'], { timeout: 1000 }); + const out = (res.stdout ?? '').toString().trim(); + return out.length > 0; + } catch { + return null; + } +} diff --git a/src/integrations/xcode-tools-bridge/index.ts b/src/integrations/xcode-tools-bridge/index.ts index 2c52dbb7..bb7765c1 100644 --- a/src/integrations/xcode-tools-bridge/index.ts +++ b/src/integrations/xcode-tools-bridge/index.ts @@ -1,7 +1,16 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ToolResponse } from '../../types/common.ts'; import { XcodeToolsBridgeManager } from './manager.ts'; +import { StandaloneXcodeToolsBridge } from './standalone.ts'; let manager: XcodeToolsBridgeManager | null = null; +let standalone: StandaloneXcodeToolsBridge | null = null; + +export interface XcodeToolsBridgeToolHandler { + statusTool(): Promise; + syncTool(): Promise; + disconnectTool(): Promise; +} export function getXcodeToolsBridgeManager(server?: McpServer): XcodeToolsBridgeManager | null { if (manager) return manager; @@ -14,7 +23,19 @@ export function peekXcodeToolsBridgeManager(): XcodeToolsBridgeManager | null { return manager; } +export function getXcodeToolsBridgeToolHandler( + server?: McpServer, +): XcodeToolsBridgeToolHandler | null { + if (server) { + return getXcodeToolsBridgeManager(server); + } + standalone ??= new StandaloneXcodeToolsBridge(); + return standalone; +} + export async function shutdownXcodeToolsBridge(): Promise { await manager?.shutdown(); + await standalone?.shutdown(); manager = null; + standalone = null; } diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index a3ad94ef..c190b7df 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -1,7 +1,4 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { execFile } from 'node:child_process'; -import process from 'node:process'; -import { promisify } from 'node:util'; import { log } from '../../utils/logger.ts'; import { createErrorResponse, @@ -10,21 +7,11 @@ import { } from '../../utils/responses/index.ts'; import { XcodeToolsBridgeClient } from './client.ts'; import { XcodeToolsProxyRegistry, type ProxySyncResult } from './registry.ts'; - -const execFileAsync = promisify(execFile); - -export type XcodeToolsBridgeStatus = { - workflowEnabled: boolean; - bridgeAvailable: boolean; - bridgePath: string | null; - xcodeRunning: boolean | null; - connected: boolean; - bridgePid: number | null; - proxiedToolCount: number; - lastError: string | null; - xcodePid: string | null; - xcodeSessionId: string | null; -}; +import { + buildXcodeToolsBridgeStatus, + getMcpBridgeAvailability, + type XcodeToolsBridgeStatus, +} from './core.ts'; export class XcodeToolsBridgeManager { private readonly server: McpServer; @@ -59,23 +46,12 @@ export class XcodeToolsBridgeManager { } async getStatus(): Promise { - const bridge = await getMcpBridgeAvailability(); - const xcodeRunning = await isXcodeRunning(); - const clientStatus = this.client.getStatus(); - - return { + return buildXcodeToolsBridgeStatus({ workflowEnabled: this.workflowEnabled, - bridgeAvailable: bridge.available, - bridgePath: bridge.path, - xcodeRunning, - connected: clientStatus.connected, - bridgePid: clientStatus.bridgePid, proxiedToolCount: this.registry.getRegisteredCount(), - lastError: this.lastError ?? clientStatus.lastError, - xcodePid: process.env.XCODEBUILDMCP_XCODE_PID ?? process.env.MCP_XCODE_PID ?? null, - xcodeSessionId: - process.env.XCODEBUILDMCP_XCODE_SESSION_ID ?? process.env.MCP_XCODE_SESSION_ID ?? null, - }; + lastError: this.lastError, + clientStatus: this.client.getStatus(), + }); } async syncTools(opts: { @@ -174,26 +150,3 @@ export class XcodeToolsBridgeManager { } } } - -export async function getMcpBridgeAvailability(): Promise<{ - available: boolean; - path: string | null; -}> { - try { - const res = await execFileAsync('xcrun', ['--find', 'mcpbridge'], { timeout: 2000 }); - const out = (res.stdout ?? '').toString().trim(); - return out ? { available: true, path: out } : { available: false, path: null }; - } catch { - return { available: false, path: null }; - } -} - -async function isXcodeRunning(): Promise { - try { - const res = await execFileAsync('pgrep', ['-x', 'Xcode'], { timeout: 1000 }); - const out = (res.stdout ?? '').toString().trim(); - return out.length > 0; - } catch { - return null; - } -} diff --git a/src/integrations/xcode-tools-bridge/standalone.ts b/src/integrations/xcode-tools-bridge/standalone.ts new file mode 100644 index 00000000..81e16fa6 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/standalone.ts @@ -0,0 +1,83 @@ +import { + createErrorResponse, + createTextResponse, + type ToolResponse, +} from '../../utils/responses/index.ts'; +import { XcodeToolsBridgeClient } from './client.ts'; +import { + buildXcodeToolsBridgeStatus, + getMcpBridgeAvailability, + type XcodeToolsBridgeStatus, +} from './core.ts'; + +export class StandaloneXcodeToolsBridge { + private readonly client: XcodeToolsBridgeClient; + private lastError: string | null = null; + + constructor() { + this.client = new XcodeToolsBridgeClient({ + onBridgeClosed: (): void => { + this.lastError = this.client.getStatus().lastError ?? this.lastError; + }, + }); + } + + async shutdown(): Promise { + await this.client.disconnect(); + } + + async getStatus(): Promise { + return buildXcodeToolsBridgeStatus({ + workflowEnabled: false, + proxiedToolCount: 0, + lastError: this.lastError, + clientStatus: this.client.getStatus(), + }); + } + + async statusTool(): Promise { + const status = await this.getStatus(); + return createTextResponse(JSON.stringify(status, null, 2)); + } + + async syncTool(): Promise { + try { + const bridge = await getMcpBridgeAvailability(); + if (!bridge.available) { + this.lastError = 'mcpbridge not available (xcrun --find mcpbridge failed)'; + return createErrorResponse('Bridge sync failed', this.lastError); + } + + await this.client.connectOnce(); + const remoteTools = await this.client.listTools(); + this.lastError = null; + + const sync = { + added: remoteTools.length, + updated: 0, + removed: 0, + total: remoteTools.length, + }; + const status = await this.getStatus(); + return createTextResponse(JSON.stringify({ sync, status }, null, 2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.lastError = message; + return createErrorResponse('Bridge sync failed', message); + } finally { + await this.client.disconnect(); + } + } + + async disconnectTool(): Promise { + try { + await this.client.disconnect(); + const status = await this.getStatus(); + return createTextResponse(JSON.stringify(status, null, 2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.lastError = message; + return createErrorResponse('Bridge disconnect failed', message); + } + } +} diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 8282df24..84364beb 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -15,7 +15,7 @@ import { getConfig } from '../../../utils/config-store.ts'; import { detectXcodeRuntime } from '../../../utils/xcode-process.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; import { peekXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; -import { getMcpBridgeAvailability } from '../../../integrations/xcode-tools-bridge/manager.ts'; +import { getMcpBridgeAvailability } from '../../../integrations/xcode-tools-bridge/core.ts'; // Constants const LOG_PREFIX = '[Doctor]'; diff --git a/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts new file mode 100644 index 00000000..c35b2e59 --- /dev/null +++ b/src/mcp/tools/xcode-ide/__tests__/bridge_tools.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../server/server-state.ts', () => ({ + getServer: vi.fn(), +})); + +vi.mock('../../../../integrations/xcode-tools-bridge/core.ts', () => ({ + buildXcodeToolsBridgeStatus: vi.fn(), + getMcpBridgeAvailability: vi.fn(), +})); + +const clientMocks = { + connectOnce: vi.fn(), + listTools: vi.fn(), + disconnect: vi.fn(), + getStatus: vi.fn(), +}; + +vi.mock('../../../../integrations/xcode-tools-bridge/client.ts', () => ({ + XcodeToolsBridgeClient: vi.fn().mockImplementation(() => clientMocks), +})); + +import { handler as statusHandler } from '../xcode_tools_bridge_status.ts'; +import { handler as syncHandler } from '../xcode_tools_bridge_sync.ts'; +import { handler as disconnectHandler } from '../xcode_tools_bridge_disconnect.ts'; +import { getServer } from '../../../../server/server-state.ts'; +import { shutdownXcodeToolsBridge } from '../../../../integrations/xcode-tools-bridge/index.ts'; +import { + buildXcodeToolsBridgeStatus, + getMcpBridgeAvailability, +} from '../../../../integrations/xcode-tools-bridge/core.ts'; + +describe('xcode-ide bridge tools (standalone fallback)', () => { + beforeEach(async () => { + await shutdownXcodeToolsBridge(); + + vi.mocked(getServer).mockReset(); + vi.mocked(buildXcodeToolsBridgeStatus).mockReset(); + vi.mocked(getMcpBridgeAvailability).mockReset(); + clientMocks.connectOnce.mockReset(); + clientMocks.listTools.mockReset(); + clientMocks.disconnect.mockReset(); + clientMocks.getStatus.mockReset(); + + vi.mocked(getServer).mockReturnValue(undefined); + clientMocks.getStatus.mockReturnValue({ + connected: false, + bridgePid: null, + lastError: null, + }); + vi.mocked(buildXcodeToolsBridgeStatus).mockResolvedValue({ + workflowEnabled: false, + bridgeAvailable: true, + bridgePath: '/usr/bin/mcpbridge', + xcodeRunning: true, + connected: false, + bridgePid: null, + proxiedToolCount: 0, + lastError: null, + xcodePid: null, + xcodeSessionId: null, + }); + vi.mocked(getMcpBridgeAvailability).mockResolvedValue({ + available: true, + path: '/usr/bin/mcpbridge', + }); + clientMocks.listTools.mockResolvedValue([{ name: 'toolA' }, { name: 'toolB' }]); + clientMocks.connectOnce.mockResolvedValue(undefined); + clientMocks.disconnect.mockResolvedValue(undefined); + }); + + it('status handler returns bridge status without MCP server instance', async () => { + const result = await statusHandler(); + const payload = JSON.parse(result.content[0].text as string); + expect(payload.bridgeAvailable).toBe(true); + expect(buildXcodeToolsBridgeStatus).toHaveBeenCalledOnce(); + }); + + it('sync handler uses direct bridge client when MCP server is not initialized', async () => { + const result = await syncHandler(); + const payload = JSON.parse(result.content[0].text as string); + expect(payload.sync.total).toBe(2); + expect(clientMocks.connectOnce).toHaveBeenCalledOnce(); + expect(clientMocks.listTools).toHaveBeenCalledOnce(); + expect(clientMocks.disconnect).toHaveBeenCalledOnce(); + }); + + it('disconnect handler succeeds without MCP server instance', async () => { + const result = await disconnectHandler(); + const payload = JSON.parse(result.content[0].text as string); + expect(payload.connected).toBe(false); + expect(clientMocks.disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts index 0d3daeaf..d39c0542 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_disconnect.ts @@ -1,20 +1,14 @@ import type { ToolResponse } from '../../../types/common.ts'; import { getServer } from '../../../server/server-state.ts'; -import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { getXcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; export const schema = {}; export const handler = async (): Promise => { - const server = getServer(); - if (!server) { - return createErrorResponse('Server not initialized', 'Unable to access server instance'); - } - - const manager = getXcodeToolsBridgeManager(server); - if (!manager) { + const bridge = getXcodeToolsBridgeToolHandler(getServer()); + if (!bridge) { return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); } - - return manager.disconnectTool(); + return bridge.disconnectTool(); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts index 96562748..0907ceca 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_status.ts @@ -1,20 +1,14 @@ import type { ToolResponse } from '../../../types/common.ts'; import { getServer } from '../../../server/server-state.ts'; -import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { getXcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; export const schema = {}; export const handler = async (): Promise => { - const server = getServer(); - if (!server) { - return createErrorResponse('Server not initialized', 'Unable to access server instance'); - } - - const manager = getXcodeToolsBridgeManager(server); - if (!manager) { + const bridge = getXcodeToolsBridgeToolHandler(getServer()); + if (!bridge) { return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); } - - return manager.statusTool(); + return bridge.statusTool(); }; diff --git a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts index d2d8f971..bf76cc9e 100644 --- a/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts +++ b/src/mcp/tools/xcode-ide/xcode_tools_bridge_sync.ts @@ -1,20 +1,14 @@ import type { ToolResponse } from '../../../types/common.ts'; import { getServer } from '../../../server/server-state.ts'; -import { getXcodeToolsBridgeManager } from '../../../integrations/xcode-tools-bridge/index.ts'; +import { getXcodeToolsBridgeToolHandler } from '../../../integrations/xcode-tools-bridge/index.ts'; import { createErrorResponse } from '../../../utils/responses/index.ts'; export const schema = {}; export const handler = async (): Promise => { - const server = getServer(); - if (!server) { - return createErrorResponse('Server not initialized', 'Unable to access server instance'); - } - - const manager = getXcodeToolsBridgeManager(server); - if (!manager) { + const bridge = getXcodeToolsBridgeToolHandler(getServer()); + if (!bridge) { return createErrorResponse('Bridge unavailable', 'Unable to initialize xcode tools bridge'); } - - return manager.syncTool(); + return bridge.syncTool(); }; diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index bc9e6928..2eef116e 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -7,7 +7,7 @@ import type { RuntimeConfigOverrides } from '../utils/config-store.ts'; import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/tool-registry.ts'; import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; -import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/manager.ts'; +import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; import { readXcodeIdeState } from '../utils/xcode-state-reader.ts'; import { sessionStore } from '../utils/session-store.ts'; From 10b8b4aa3fd2a8d6cd5674559144b510d947a2b2 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 5 Feb 2026 23:25:25 +0000 Subject: [PATCH 14/23] Wire xcode-tools dynamic tools to CLI mode --- .../tools/xcode_tools_bridge_disconnect.yaml | 2 +- .../tools/xcode_tools_bridge_status.yaml | 2 +- manifests/tools/xcode_tools_bridge_sync.yaml | 2 +- src/cli/commands/tools.ts | 13 ++- src/cli/register-tool-commands.ts | 7 +- src/runtime/tool-catalog.ts | 110 +++++++++++++++++- 6 files changed, 122 insertions(+), 14 deletions(-) diff --git a/manifests/tools/xcode_tools_bridge_disconnect.yaml b/manifests/tools/xcode_tools_bridge_disconnect.yaml index d1584902..e02c5e27 100644 --- a/manifests/tools/xcode_tools_bridge_disconnect.yaml +++ b/manifests/tools/xcode_tools_bridge_disconnect.yaml @@ -5,7 +5,7 @@ names: description: "Disconnect bridge and unregister proxied `xcode_tools_*` tools." availability: mcp: true - cli: false + cli: true daemon: false predicates: - debugEnabled diff --git a/manifests/tools/xcode_tools_bridge_status.yaml b/manifests/tools/xcode_tools_bridge_status.yaml index e92e8661..3187396a 100644 --- a/manifests/tools/xcode_tools_bridge_status.yaml +++ b/manifests/tools/xcode_tools_bridge_status.yaml @@ -5,7 +5,7 @@ names: description: "Show xcrun mcpbridge availability and proxy tool sync status." availability: mcp: true - cli: false + cli: true daemon: false predicates: - debugEnabled diff --git a/manifests/tools/xcode_tools_bridge_sync.yaml b/manifests/tools/xcode_tools_bridge_sync.yaml index e806aa20..43d2d37f 100644 --- a/manifests/tools/xcode_tools_bridge_sync.yaml +++ b/manifests/tools/xcode_tools_bridge_sync.yaml @@ -5,7 +5,7 @@ names: description: "One-shot connect + tools/list sync (manual retry; avoids background prompt spam)." availability: mcp: true - cli: false + cli: true daemon: false predicates: - debugEnabled diff --git a/src/cli/commands/tools.ts b/src/cli/commands/tools.ts index c67d6660..6b6fb36f 100644 --- a/src/cli/commands/tools.ts +++ b/src/cli/commands/tools.ts @@ -9,6 +9,7 @@ import { getEffectiveCliName } from '../../core/manifest/schema.ts'; import { isWorkflowEnabledForRuntime, isToolExposedForRuntime } from '../../visibility/exposure.ts'; import type { PredicateContext } from '../../visibility/predicate-types.ts'; import { getConfig } from '../../utils/config-store.ts'; +import { getMcpBridgeAvailability } from '../../integrations/xcode-tools-bridge/core.ts'; const CLI_EXCLUDED_WORKFLOWS = new Set(['session-management', 'workflow-discovery']); @@ -80,22 +81,24 @@ function toGroupedJsonTool(tool: ToolListItem): JsonTool { * Build CLI predicate context. * CLI is never running under Xcode and never has Xcode tools active. */ -function buildCliPredicateContext(): PredicateContext { +async function buildCliPredicateContext(): Promise { + const bridge = await getMcpBridgeAvailability(); return { runtime: 'cli', config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, + xcodeToolsAvailable: bridge.available, }; } /** * Build tool list from YAML manifest with predicate filtering. */ -function buildToolList(manifest: ResolvedManifest): ToolListItem[] { +async function buildToolList(manifest: ResolvedManifest): Promise { const tools: ToolListItem[] = []; const seenToolIds = new Set(); - const ctx = buildCliPredicateContext(); + const ctx = await buildCliPredicateContext(); // Get all CLI-available workflows that pass predicate checks const cliWorkflows = Array.from(manifest.workflows.values()).filter( @@ -191,9 +194,9 @@ export function registerToolsCommand(app: Argv): void { describe: 'Filter by workflow name', }); }, - (argv) => { + async (argv) => { const manifest = loadManifest(); - let tools = buildToolList(manifest); + let tools = await buildToolList(manifest); // Filter by workflow if specified if (argv.workflow) { diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index e1dac53f..178a1bfd 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -53,9 +53,8 @@ export function registerToolCommands( const hint = workflowName === 'xcode-ide' ? `No CLI commands are currently exposed for '${workflowName}'.\n` + - `xcode-ide bridge tools are MCP-only and are not available as direct CLI subcommands.\n` + - `To use them, run the MCP server and connect with an MCP client.\n` + - `In Xcode, enable MCP Tools in Settings > Intelligence > Xcode Tools and click Allow if prompted.` + `Open Xcode with the target project and enable MCP Tools in Settings > Intelligence > Xcode Tools.\n` + + `Click Allow if prompted.` : `No CLI commands are currently exposed for '${workflowName}'.`; yargs.epilogue(hint); @@ -68,7 +67,7 @@ export function registerToolCommands( if (tools.length === 0) { console.error( workflowName === 'xcode-ide' - ? `No CLI commands are currently exposed for '${workflowName}'. xcode-ide bridge tools are MCP-only (not direct CLI subcommands). Run the MCP server with an MCP client, and in Xcode enable MCP Tools in Settings > Intelligence > Xcode Tools and click Allow if prompted.` + ? `No CLI commands are currently exposed for '${workflowName}'. Open Xcode with the target project, enable MCP Tools in Settings > Intelligence > Xcode Tools, and click Allow if prompted.` : `No CLI commands are currently exposed for '${workflowName}'.`, ); } diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index 0608c1d8..04af24dd 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -1,8 +1,10 @@ import type { ToolCatalog, ToolDefinition, ToolResolution } from './types.ts'; import { toKebabCase } from './naming.ts'; +import type { ToolResponse } from '../types/common.ts'; import { loadManifest, type WorkflowManifestEntry } from '../core/manifest/load-manifest.ts'; import { getEffectiveCliName } from '../core/manifest/schema.ts'; import { importToolModule } from '../core/manifest/import-tool-module.ts'; +import type { ToolSchemaShape } from '../core/plugin-types.ts'; import type { PredicateContext, RuntimeKind } from '../visibility/predicate-types.ts'; import { isWorkflowAvailableForRuntime, @@ -12,6 +14,11 @@ import { } from '../visibility/exposure.ts'; import { getConfig } from '../utils/config-store.ts'; import { log } from '../utils/logging/index.ts'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { XcodeToolsBridgeClient } from '../integrations/xcode-tools-bridge/client.ts'; +import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts'; +import { jsonSchemaToZod } from '../integrations/xcode-tools-bridge/jsonschema-to-zod.ts'; +import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts'; function createCatalog(tools: ToolDefinition[]): ToolCatalog { // Build lookup maps for fast resolution @@ -91,6 +98,78 @@ export function groupToolsByWorkflow(catalog: ToolCatalog): Map; + required?: unknown[]; +}; + +function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape { + if (!inputSchema || typeof inputSchema !== 'object') { + return {}; + } + + const schema = inputSchema as JsonSchemaObject; + const properties = schema.properties; + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) { + return {}; + } + + const requiredFields = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === 'string') + : [], + ); + + const shape: ToolSchemaShape = {}; + for (const [name, propertySchema] of Object.entries(properties)) { + const zodSchema = jsonSchemaToZod(propertySchema); + shape[name] = requiredFields.has(name) ? zodSchema : zodSchema.optional(); + } + + return shape; +} + +function createCliXcodeProxyTool(remoteTool: Tool): ToolDefinition { + const mcpName = toLocalToolName(remoteTool.name); + const cliSchema = jsonSchemaToToolSchemaShape(remoteTool.inputSchema); + + return { + cliName: `xcode-ide-${toKebabCase(remoteTool.name)}`, + mcpName, + workflow: 'xcode-ide', + description: remoteTool.description ?? '', + annotations: remoteTool.annotations, + mcpSchema: cliSchema, + cliSchema, + stateful: false, + handler: async (params): Promise => { + const client = new XcodeToolsBridgeClient(); + await client.connectOnce(); + try { + const result = await client.callTool(remoteTool.name, params); + return result as unknown as ToolResponse; + } finally { + await client.disconnect(); + } + }, + }; +} + +async function loadCliXcodeProxyTools(): Promise { + const client = new XcodeToolsBridgeClient(); + try { + await client.connectOnce(); + const remoteTools = await client.listTools(); + return remoteTools.map((tool) => createCliXcodeProxyTool(tool)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('warning', `[xcode-ide] CLI bridge discovery failed: ${message}`); + return []; + } finally { + await client.disconnect(); + } +} + /** * Build a tool catalog from the YAML manifest system. */ @@ -180,6 +259,8 @@ export async function buildCliToolCatalogFromManifest(opts?: { excludeWorkflows?: string[]; }): Promise { const excludeWorkflows = opts?.excludeWorkflows ?? []; + const bridge = await getMcpBridgeAvailability(); + const xcodeToolsAvailable = bridge.available; // CLI context: not running under Xcode, no Xcode tools active const ctx: PredicateContext = { @@ -187,14 +268,39 @@ export async function buildCliToolCatalogFromManifest(opts?: { config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, - xcodeToolsAvailable: false, + xcodeToolsAvailable, }; - return buildToolCatalogFromManifest({ + const manifestCatalog = await buildToolCatalogFromManifest({ runtime: 'cli', ctx, excludeWorkflows, }); + + const excludeSet = new Set(excludeWorkflows.map((name) => name.toLowerCase())); + const manifest = loadManifest(); + const xcodeIdeWorkflow = manifest.workflows.get('xcode-ide'); + const xcodeIdeEnabled = + xcodeIdeWorkflow !== undefined && + !excludeSet.has('xcode-ide') && + isWorkflowEnabledForRuntime(xcodeIdeWorkflow, ctx); + + if (!xcodeIdeEnabled || !xcodeToolsAvailable) { + return manifestCatalog; + } + + const dynamicXcodeTools = await loadCliXcodeProxyTools(); + if (dynamicXcodeTools.length === 0) { + return manifestCatalog; + } + + const existingCliNames = new Set(manifestCatalog.tools.map((tool) => tool.cliName)); + const mergedTools = [ + ...manifestCatalog.tools, + ...dynamicXcodeTools.filter((tool) => !existingCliNames.has(tool.cliName)), + ]; + + return createCatalog(mergedTools); } /** From ff4d3f2f66b019a3b3fe32022f57a15c6c2a9381 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 11:46:36 +0000 Subject: [PATCH 15/23] ref(runtime): Simplify daemon routing and normalize manifests Remove legacy daemon routing knobs and CLI daemon flags so routing is driven by tool statefulness plus the explicit xcode-ide special case. Add idle shutdown with an activity lease registry so long-running tools signal lifecycle activity without hardcoded daemon imports. Clean manifest files by removing values that match schema defaults and regenerate generated tool documentation. Co-Authored-By: Claude --- CHANGELOG.md | 5 + docs/CLI.md | 24 +- docs/TOOLS-CLI.md | 2 +- docs/TOOLS.md | 2 +- docs/dev/MANIFEST_FORMAT.md | 28 +-- ...CODE_IDE_CLI_DYNAMIC_TOOLS_ARCHITECTURE.md | 213 ++++++++++++++++++ manifests/tools/boot_sim.yaml | 12 +- manifests/tools/build_device.yaml | 11 +- manifests/tools/build_macos.yaml | 11 +- manifests/tools/build_run_macos.yaml | 11 +- manifests/tools/build_run_sim.yaml | 11 +- manifests/tools/build_sim.yaml | 11 +- manifests/tools/button.yaml | 12 +- manifests/tools/clean.yaml | 11 +- manifests/tools/debug_attach_sim.yaml | 8 +- manifests/tools/debug_breakpoint_add.yaml | 8 +- manifests/tools/debug_breakpoint_remove.yaml | 8 +- manifests/tools/debug_continue.yaml | 8 +- manifests/tools/debug_detach.yaml | 8 +- manifests/tools/debug_lldb_command.yaml | 8 +- manifests/tools/debug_stack.yaml | 8 +- manifests/tools/debug_variables.yaml | 8 +- manifests/tools/discover_projs.yaml | 12 +- manifests/tools/doctor.yaml | 9 +- manifests/tools/erase_sims.yaml | 12 +- manifests/tools/gesture.yaml | 12 +- manifests/tools/get_app_bundle_id.yaml | 12 +- manifests/tools/get_device_app_path.yaml | 12 +- manifests/tools/get_mac_app_path.yaml | 12 +- manifests/tools/get_mac_bundle_id.yaml | 12 +- manifests/tools/get_sim_app_path.yaml | 12 +- manifests/tools/install_app_device.yaml | 12 +- manifests/tools/install_app_sim.yaml | 12 +- manifests/tools/key_press.yaml | 12 +- manifests/tools/key_sequence.yaml | 12 +- manifests/tools/launch_app_device.yaml | 12 +- manifests/tools/launch_app_logs_sim.yaml | 10 +- manifests/tools/launch_app_sim.yaml | 12 +- manifests/tools/launch_mac_app.yaml | 12 +- manifests/tools/list_devices.yaml | 12 +- manifests/tools/list_schemes.yaml | 12 +- manifests/tools/list_sims.yaml | 12 +- manifests/tools/long_press.yaml | 12 +- manifests/tools/manage_workflows.yaml | 5 +- manifests/tools/open_sim.yaml | 12 +- manifests/tools/record_sim_video.yaml | 10 +- manifests/tools/reset_sim_location.yaml | 12 +- manifests/tools/scaffold_ios_project.yaml | 11 +- manifests/tools/scaffold_macos_project.yaml | 11 +- manifests/tools/screenshot.yaml | 12 +- manifests/tools/session_clear_defaults.yaml | 7 +- manifests/tools/session_set_defaults.yaml | 7 +- manifests/tools/session_show_defaults.yaml | 7 +- manifests/tools/set_sim_appearance.yaml | 12 +- manifests/tools/set_sim_location.yaml | 12 +- manifests/tools/show_build_settings.yaml | 11 +- manifests/tools/sim_statusbar.yaml | 12 +- manifests/tools/snapshot_ui.yaml | 12 +- manifests/tools/start_device_log_cap.yaml | 10 +- manifests/tools/start_sim_log_cap.yaml | 10 +- manifests/tools/stop_app_device.yaml | 12 +- manifests/tools/stop_app_sim.yaml | 12 +- manifests/tools/stop_device_log_cap.yaml | 10 +- manifests/tools/stop_mac_app.yaml | 12 +- manifests/tools/stop_sim_log_cap.yaml | 10 +- manifests/tools/swift_package_build.yaml | 11 +- manifests/tools/swift_package_clean.yaml | 12 +- manifests/tools/swift_package_list.yaml | 10 +- manifests/tools/swift_package_run.yaml | 10 +- manifests/tools/swift_package_stop.yaml | 10 +- manifests/tools/swift_package_test.yaml | 11 +- manifests/tools/swipe.yaml | 12 +- manifests/tools/sync_xcode_defaults.yaml | 6 +- manifests/tools/tap.yaml | 12 +- manifests/tools/test_device.yaml | 11 +- manifests/tools/test_macos.yaml | 11 +- manifests/tools/test_sim.yaml | 11 +- manifests/tools/touch.yaml | 12 +- manifests/tools/type_text.yaml | 12 +- .../tools/xcode_tools_bridge_disconnect.yaml | 8 +- .../tools/xcode_tools_bridge_status.yaml | 8 +- manifests/tools/xcode_tools_bridge_sync.yaml | 8 +- manifests/workflows/debugging.yaml | 13 +- manifests/workflows/device.yaml | 13 +- manifests/workflows/doctor.yaml | 9 +- manifests/workflows/logging.yaml | 13 +- manifests/workflows/macos.yaml | 13 +- manifests/workflows/project-discovery.yaml | 13 +- manifests/workflows/project-scaffolding.yaml | 13 +- manifests/workflows/session-management.yaml | 7 +- manifests/workflows/simulator-management.yaml | 13 +- manifests/workflows/simulator.yaml | 10 +- manifests/workflows/swift-package.yaml | 13 +- manifests/workflows/ui-automation.yaml | 13 +- manifests/workflows/utilities.yaml | 13 +- manifests/workflows/workflow-discovery.yaml | 7 +- manifests/workflows/xcode-ide.yaml | 10 +- src/cli.ts | 44 +++- src/cli/cli-tool-catalog.ts | 181 ++++++++++++++- src/cli/daemon-client.ts | 30 ++- src/cli/daemon-control.ts | 11 +- src/cli/register-tool-commands.ts | 42 ++-- src/cli/yargs-app.ts | 11 - src/core/manifest/__tests__/schema.test.ts | 31 +-- src/core/manifest/schema.ts | 26 ++- src/core/plugin-types.ts | 2 - src/daemon.ts | 96 +++++++- .../__tests__/activity-registry.test.ts | 57 +++++ src/daemon/__tests__/idle-shutdown.test.ts | 66 ++++++ src/daemon/activity-registry.ts | 67 ++++++ src/daemon/daemon-server.ts | 92 ++++++++ src/daemon/idle-shutdown.ts | 32 +++ src/daemon/protocol.ts | 40 +++- .../xcode-tools-bridge/manager.ts | 26 +-- .../xcode-tools-bridge/standalone.ts | 40 +--- .../xcode-tools-bridge/tool-service.ts | 154 +++++++++++++ src/mcp/tools/logging/start_device_log_cap.ts | 2 + src/mcp/tools/logging/stop_device_log_cap.ts | 1 + .../__tests__/active-processes.test.ts | 54 +++++ .../tools/swift-package/active-processes.ts | 8 + .../tools/swift-package/swift_package_run.ts | 2 + src/runtime/__tests__/tool-invoker.test.ts | 198 ++++++++++++++++ src/runtime/tool-catalog.ts | 149 +++--------- src/runtime/tool-invoker.ts | 94 ++++---- src/runtime/types.ts | 8 +- src/utils/debugger/debugger-manager.ts | 24 +- src/utils/log-capture/device-log-sessions.ts | 1 + src/utils/log_capture.ts | 5 + src/utils/video_capture.ts | 9 + src/visibility/__tests__/exposure.test.ts | 34 ++- src/visibility/exposure.ts | 6 + 131 files changed, 1738 insertions(+), 1157 deletions(-) create mode 100644 docs/dev/XCODE_IDE_CLI_DYNAMIC_TOOLS_ARCHITECTURE.md create mode 100644 src/daemon/__tests__/activity-registry.test.ts create mode 100644 src/daemon/__tests__/idle-shutdown.test.ts create mode 100644 src/daemon/activity-registry.ts create mode 100644 src/daemon/idle-shutdown.ts create mode 100644 src/integrations/xcode-tools-bridge/tool-service.ts create mode 100644 src/runtime/__tests__/tool-invoker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b803f7c1..c239dcfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ ### Changed - Hide `xcode_tools_bridge_{status,sync,disconnect}` unless `debug: true` is enabled (these are troubleshooting tools). +- Simplified CLI daemon routing: stateless tools now always execute directly, stateful tools route through daemon with auto-start, and dynamic `xcode-ide` bridge tools remain an explicit daemon-backed special-case. +- Removed manifest daemon routing knobs `availability.daemon` and `routing.daemonAffinity`; manifests now use `availability.{mcp,cli}` plus optional `routing.stateful`. +- Removed hidden CLI daemon-routing flags; stateful routing is now automatic and only hidden `--socket` remains for advanced socket override workflows. +- Added daemon idle shutdown in CLI mode: per-workspace daemons now auto-exit after 10 minutes of inactivity when no active stateful sessions exist. +- Inverted idle activity tracking to a generic daemon activity registry so long-running tools report lifecycle activity without hardcoded daemon imports. ## [2.0.0] - 2026-02-02 diff --git a/docs/CLI.md b/docs/CLI.md index 89f4ca7a..cc2dcd8f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -121,6 +121,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for the full schema. | Variable | Description | |----------|-------------| | `XCODEBUILDMCP_SOCKET` | Override socket path for all commands | +| `XCODEBUILDMCP_DAEMON_IDLE_TIMEOUT_MS` | Daemon idle timeout in ms (default `600000`, set `0` to disable) | | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | Disable session defaults | ## CLI vs MCP Mode @@ -128,7 +129,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for the full schema. | Feature | CLI (`xcodebuildmcp `) | MCP (`xcodebuildmcp mcp`) | |---------|------------------------------|---------------------------| | Invocation | Direct terminal | MCP client (Claude, etc.) | -| Session state | Per-workspace daemon | In-process | +| Session state | Stateless direct + daemon for stateful tools | In-process | | Use case | Scripts, CI, manual | AI-assisted development | | Configuration | Same config.yaml | Same config.yaml | @@ -136,13 +137,18 @@ Both share the same underlying tool implementations. ## Per-Workspace Daemon -The CLI uses a per-workspace daemon architecture for stateful operations (log capture, video recording, debugging). Each workspace gets its own daemon instance. +The CLI uses a per-workspace daemon architecture only when needed: + +- Stateless tools run directly in the CLI process. +- Stateful tools route through the daemon (auto-started as needed). +- Dynamic `xcode-ide` bridge tools are a special-case daemon-backed path for persistent bridge sessions. ### How It Works - **Workspace identity**: The workspace root is determined by the location of `.xcodebuildmcp/config.yaml`, or falls back to the current directory. - **Socket location**: Each daemon runs on a Unix socket at `~/.xcodebuildmcp/daemons//daemon.sock` - **Auto-start**: The daemon starts automatically when you invoke a stateful tool - no manual setup required. +- **Auto-shutdown**: The daemon exits after 10 minutes of inactivity, but only when there are no active stateful sessions (log capture, debugging, video capture, background swift-package processes). ### Daemon Commands @@ -198,16 +204,6 @@ Daemons: Total: 2 (1 running, 1 stale) ``` -### Opting Out of Daemon - -If you want to disable daemon auto-start (stateful tools will error): - -```bash -xcodebuildmcp build-sim --no-daemon --scheme MyApp -``` - -This is useful for CI environments or when you want explicit control. - ## Stateful vs Stateless Tools ### Stateless Tools (run in-process) @@ -230,8 +226,6 @@ When you invoke a stateful tool, the daemon auto-starts if needed. | Option | Description | |--------|-------------| | `--socket ` | Override the daemon socket path (hidden) | -| `--daemon` | Force daemon execution for stateless tools (hidden) | -| `--no-daemon` | Disable daemon usage; stateful tools will fail | | `-h, --help` | Show help | | `-v, --version` | Show version | @@ -266,4 +260,4 @@ The socket directory (`~/.xcodebuildmcp/daemons/`) should have mode 0700. If you ```bash chmod 700 ~/.xcodebuildmcp chmod -R 700 ~/.xcodebuildmcp/daemons -``` \ No newline at end of file +``` diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index dc4f0ff4..1cf4501a 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -187,4 +187,4 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:23:22.870Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-06T11:40:14.287Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 006fabb8..6fe1465b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,4 +202,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-05T21:23:22.870Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-06T11:40:14.287Z UTC* diff --git a/docs/dev/MANIFEST_FORMAT.md b/docs/dev/MANIFEST_FORMAT.md index 388accba..5bdd9518 100644 --- a/docs/dev/MANIFEST_FORMAT.md +++ b/docs/dev/MANIFEST_FORMAT.md @@ -56,11 +56,9 @@ description: string # Tool description (shown in tool listings) availability: # Per-runtime availability flags mcp: boolean # Available via MCP server (default: true) cli: boolean # Available via CLI (default: true) - daemon: boolean # Available via daemon (default: true) predicates: string[] # Predicate names for visibility filtering (default: []) -routing: # Daemon routing hints +routing: # CLI daemon routing stateful: boolean # Tool maintains state (default: false) - daemonAffinity: enum # 'preferred' or 'required' (optional) annotations: # MCP tool annotations (hints for clients) title: string # Human-readable title (optional) readOnlyHint: boolean # Tool only reads data (optional) @@ -80,11 +78,7 @@ description: "List available iOS simulators." availability: mcp: true cli: true - daemon: true predicates: [] -routing: - stateful: false - daemonAffinity: preferred annotations: title: "List Simulators" readOnlyHint: true @@ -101,12 +95,8 @@ description: "Build for iOS sim." availability: mcp: true cli: true - daemon: true predicates: - hideWhenXcodeAgentMode # Hidden when Xcode provides equivalent tool -routing: - stateful: false - daemonAffinity: preferred ``` ### Example: MCP-Only Tool @@ -120,7 +110,6 @@ description: "Manage enabled workflows at runtime." availability: mcp: true cli: false # Not available in CLI - daemon: false # Not available via daemon predicates: - experimentalWorkflowDiscoveryEnabled ``` @@ -142,7 +131,6 @@ tools: string[] # Array of tool IDs belonging to this workflow availability: # Per-runtime availability flags mcp: boolean # Available via MCP server (default: true) cli: boolean # Available via CLI (default: true) - daemon: boolean # Available via daemon (default: true) selection: # MCP selection rules mcp: defaultEnabled: boolean # Enabled when config.enabledWorkflows is empty (default: false) @@ -159,7 +147,6 @@ description: "Complete iOS development workflow for simulators." availability: mcp: true cli: true - daemon: true selection: mcp: defaultEnabled: true # Enabled by default @@ -183,7 +170,6 @@ description: "Diagnostic tool for the MCP server environment." availability: mcp: true cli: true - daemon: true selection: mcp: defaultEnabled: false @@ -203,7 +189,6 @@ description: "Manage enabled workflows at runtime." availability: mcp: true cli: false - daemon: false selection: mcp: defaultEnabled: false @@ -227,10 +212,8 @@ tools: | `description` | string | No | - | Tool description | | `availability.mcp` | boolean | No | `true` | Available via MCP | | `availability.cli` | boolean | No | `true` | Available via CLI | -| `availability.daemon` | boolean | No | `true` | Available via daemon | | `predicates` | string[] | No | `[]` | Visibility predicates (all must pass) | | `routing.stateful` | boolean | No | `false` | Tool maintains state | -| `routing.daemonAffinity` | enum | No | - | `'preferred'` or `'required'` | | `annotations.title` | string | No | - | Human-readable title | | `annotations.readOnlyHint` | boolean | No | - | Tool only reads data | | `annotations.destructiveHint` | boolean | No | - | Tool may modify/delete data | @@ -247,7 +230,6 @@ tools: | `tools` | string[] | Yes | - | Tool IDs in this workflow | | `availability.mcp` | boolean | No | `true` | Available via MCP | | `availability.cli` | boolean | No | `true` | Available via CLI | -| `availability.daemon` | boolean | No | `true` | Available via daemon | | `selection.mcp.defaultEnabled` | boolean | No | `false` | Enabled when no workflows configured | | `selection.mcp.autoInclude` | boolean | No | `false` | Auto-include when predicates pass | | `predicates` | string[] | No | `[]` | Visibility predicates (all must pass) | @@ -402,11 +384,11 @@ The tool is defined once in `manifests/tools/clean.yaml` but referenced by both ## Daemon Routing -The `routing` field provides hints for daemon-based execution: +Daemon routing is intentionally simple: -- **`stateful: true`**: Tool maintains state across calls (e.g., debug sessions) -- **`daemonAffinity: 'preferred'`**: Prefer daemon execution but fall back to direct -- **`daemonAffinity: 'required'`**: Must run via daemon (fails if daemon unavailable) +- **`routing.stateful: true`**: CLI routes this tool through the daemon. +- **`routing` omitted or `stateful: false`**: CLI runs the tool directly. +- **Special-case**: dynamic `xcode-ide` bridge tools use daemon-backed routing for bridge session persistence. ## Validation diff --git a/docs/dev/XCODE_IDE_CLI_DYNAMIC_TOOLS_ARCHITECTURE.md b/docs/dev/XCODE_IDE_CLI_DYNAMIC_TOOLS_ARCHITECTURE.md new file mode 100644 index 00000000..2381ffc6 --- /dev/null +++ b/docs/dev/XCODE_IDE_CLI_DYNAMIC_TOOLS_ARCHITECTURE.md @@ -0,0 +1,213 @@ +# Xcode IDE Dynamic Tools in CLI: Plan and Architecture + +## Problem statement + +Today, `xcode-ide` dynamic tools can work in MCP mode, but CLI mode has sharp edges: + +- Each CLI invocation is a new process, so bridge connections are short-lived and repeated. +- Repeated bridge connects trigger repeated Xcode allow prompts. +- `--help` and exploratory commands can create extra connection churn. +- Static bridge tools and dynamic bridge-derived tools have different gating rules, but this is easy to misinterpret. + +The immediate user-visible outcome is: CLI appears inconsistent even when Xcode and bridge are available. + +## Goals + +- Keep MCP mode unchanged: no daemon requirement for MCP server flow. +- Make CLI dynamic xcode-ide tools reliable and predictable. +- Minimize repeated Xcode allow prompts in CLI workflows. +- Separate concerns so transport/client logic is reused across runtimes. +- Keep manifest-driven visibility semantics explicit and testable. + +## Non-goals + +- Replacing `xcrun mcpbridge`. +- Rewriting tool manifests or predicate system. +- Forcing daemon usage for all CLI tools. +- Reintroducing generic daemon affinity knobs for CLI routing. + +## Current behavior to preserve + +- Workflow/tool visibility remains manifest-driven. +- `availability.mcp: true, availability.cli: false` means MCP-only exposure. +- `availability.mcp: true, availability.cli: true` means exposed in both MCP and CLI. +- `xcode-ide` has: + - Static bridge tools (debug-gated). + - Dynamic tools resolved from `mcpbridge` `tools/list` (not debug-gated). + +## Why CLI currently behaves differently + +CLI is a process-per-command model. If each command independently connects to `mcpbridge`, then: + +- the bridge handshake repeats, +- Xcode trust prompts can repeat, +- command latency increases, +- and help/discovery flows can feel flaky. + +MCP mode avoids this by being long-running. + +## Clean architecture (least repetition) + +Use two layers plus runtime adapters: + +### Layer 1: Bridge client core (shared, runtime-agnostic) + +Responsibilities: + +- Discover bridge availability (`xcrun --find mcpbridge`). +- Open/close MCP client transport to `mcpbridge`. +- Call `tools/list`, `tools/call`. +- Surface normalized errors and bridge state events. + +Output: + +- `BridgeCapabilities` (available, connected, tool count). +- `BridgeToolCatalog` (dynamic tool metadata). +- `BridgeInvoker` (`invoke(name, args)`). + +This layer contains no CLI command wiring and no MCP server registration logic. + +### Layer 2: Xcode IDE tool service (shared domain layer) + +Responsibilities: + +- Build runtime-facing tool catalog from: + - static manifest-defined bridge tools, + - dynamic bridge-derived tools. +- Apply visibility and predicate checks. +- Expose `listTools(runtimeContext)` and `invokeTool(toolName, args)`. + +This is the single source of truth for xcode-ide tool behavior. + +### Adapters + +- MCP adapter: + - Binds service into long-running server lifecycle. + - Connect once at startup (if enabled), resync on bridge events. + - No daemon required. + +- CLI adapter: + - Uses same service API. + - Default path should be daemon-backed for xcode-ide dynamic tools. + +## CLI daemon strategy for xcode-ide only + +To remove repeated prompts, CLI should reuse one bridge session across commands. + +Recommended approach: + +- Introduce an xcode-ide bridge session in existing daemon runtime. +- Keep this as an explicit xcode-ide special-case bridge path (not generic affinity routing). +- CLI xcode-ide commands route to daemon when dynamic bridge tools are involved. +- Keep non-xcode-ide CLI commands unchanged. + +Lifecycle: + +1. CLI command asks daemon for xcode-ide tool catalog. +2. Daemon ensures single bridge session exists. +3. Daemon returns catalog or executes tool call over the same session. +4. Session remains warm for subsequent CLI commands. +5. Daemon exits automatically after idle timeout when no active stateful sessions remain. + +This preserves MCP independence while making CLI behavior consistent. + +## Visibility and registration rules + +Apply these rules explicitly: + +- Workflow appears in MCP/CLI when `workflow.availability[runtime]` is true. +- Daemon execution backend is not controlled by `availability` flags. +- Static tools appear only when: + - tool availability passes for MCP/CLI runtime, + - predicates pass, + - debug gate passes (for debug-only static tools). +- Dynamic tools appear only when: + - workflow is enabled for runtime, + - bridge is available and connected, + - `tools/list` returns entries. +- Dynamic tools are never controlled by static debug gates. + +## CLI UX behavior + +Expected behavior for `node build/cli.js xcode-ide ...`: + +- If workflow disabled for CLI: + - show actionable message explaining manifest controls this. +- If workflow enabled but Xcode bridge unavailable: + - show actionable setup guidance: + - open Xcode, + - enable `Settings > Intelligence > Xcode Tools`, + - accept allow prompt. +- If daemon session unavailable: + - auto-start daemon or print exact daemon start command (project decision). +- If bridge connected: + - dynamic tools are listed and invokable. + +## Implementation plan + +### Phase 1: Extract shared bridge client core + +- Isolate bridge transport/discovery/invocation into a runtime-agnostic module. +- Add typed status model and normalized error mapping. + +### Phase 2: Build shared xcode-ide tool service + +- Centralize static + dynamic catalog assembly. +- Centralize runtime visibility checks. +- Centralize invocation dispatch. + +### Phase 3: Rewire MCP adapter + +- Use shared service for registration and calls. +- Keep current long-running behavior. +- Ensure no daemon dependency in MCP code path. + +### Phase 4: Add CLI daemon-backed bridge path + +- Route xcode-ide dynamic tool list/call through daemon session. + +### Phase 5: Harden messages and observability + +- Standardize actionable operator messages for disabled/unavailable/not-authorized states. +- Log bridge session reuse metrics and reconnect causes. + +### Phase 6: Validate with manual tests + +Run manual tests across these scenarios: + +- MCP mode, workflow enabled, Xcode available. +- MCP mode, workflow enabled, Xcode unavailable. +- CLI mode, workflow disabled. +- CLI mode, workflow enabled, first-time allow prompt. +- CLI mode, workflow enabled, repeated commands without extra prompts. +- CLI mode, `--help` usage does not force repeated authorization churn. + +## Risks + +- Daemon session ownership bugs can create reconnect loops and prompt spam. +- Stale bridge session state can hide tools unexpectedly. +- Mixed one-shot/daemon modes can drift without a single service contract. + +Mitigations: + +- Single authoritative service interface for list/call. +- Explicit reconnect backoff and no tight retry loops. +- Structured logs with correlation IDs for CLI command -> daemon request -> bridge call. + +## Acceptance criteria + +- MCP mode unchanged and independent from daemon. +- CLI xcode-ide dynamic tools work when workflow `cli: true`. +- Repeated CLI xcode-ide commands do not trigger repeated allow prompts under normal operation. +- Static debug-gated tools remain debug-gated only. +- Dynamic tools appear based on bridge health, not debug gating. + +## Decision summary + +The cleanest architecture is to separate: + +- bridge transport/client core, +- xcode-ide tool service, +- runtime adapters (MCP and CLI). + +Then route CLI xcode-ide dynamic tools through a persistent daemon-held bridge session while keeping MCP flow long-running and daemon-free. diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml index f055dd6d..cd8caf1c 100644 --- a/manifests/tools/boot_sim.yaml +++ b/manifests/tools/boot_sim.yaml @@ -2,15 +2,7 @@ id: boot_sim module: mcp/tools/simulator/boot_sim names: mcp: boot_sim -description: "Boot iOS simulator." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Boot iOS simulator. annotations: - title: "Boot Simulator" + title: Boot Simulator destructiveHint: true diff --git a/manifests/tools/build_device.yaml b/manifests/tools/build_device.yaml index d5581bdf..3703f1b7 100644 --- a/manifests/tools/build_device.yaml +++ b/manifests/tools/build_device.yaml @@ -2,16 +2,9 @@ id: build_device module: mcp/tools/device/build_device names: mcp: build_device -description: "Build for device." -availability: - mcp: true - cli: true - daemon: true +description: Build for device. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Build Device" + title: Build Device destructiveHint: true diff --git a/manifests/tools/build_macos.yaml b/manifests/tools/build_macos.yaml index 952a6976..ba7318f5 100644 --- a/manifests/tools/build_macos.yaml +++ b/manifests/tools/build_macos.yaml @@ -2,16 +2,9 @@ id: build_macos module: mcp/tools/macos/build_macos names: mcp: build_macos -description: "Build macOS app." -availability: - mcp: true - cli: true - daemon: true +description: Build macOS app. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Build macOS" + title: Build macOS destructiveHint: true diff --git a/manifests/tools/build_run_macos.yaml b/manifests/tools/build_run_macos.yaml index 709d5c76..37049be6 100644 --- a/manifests/tools/build_run_macos.yaml +++ b/manifests/tools/build_run_macos.yaml @@ -2,16 +2,9 @@ id: build_run_macos module: mcp/tools/macos/build_run_macos names: mcp: build_run_macos -description: "Build and run macOS app." -availability: - mcp: true - cli: true - daemon: true +description: Build and run macOS app. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Build Run macOS" + title: Build Run macOS destructiveHint: true diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index b79366c5..9e8ae2dc 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -2,16 +2,9 @@ id: build_run_sim module: mcp/tools/simulator/build_run_sim names: mcp: build_run_sim -description: "Build and run iOS sim." -availability: - mcp: true - cli: true - daemon: true +description: Build and run iOS sim. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Build Run Simulator" + title: Build Run Simulator destructiveHint: true diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml index 27796d9c..18cf3341 100644 --- a/manifests/tools/build_sim.yaml +++ b/manifests/tools/build_sim.yaml @@ -2,16 +2,9 @@ id: build_sim module: mcp/tools/simulator/build_sim names: mcp: build_sim -description: "Build for iOS sim." -availability: - mcp: true - cli: true - daemon: true +description: Build for iOS sim. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Build Simulator" + title: Build Simulator destructiveHint: true diff --git a/manifests/tools/button.yaml b/manifests/tools/button.yaml index 60450abc..17c1deda 100644 --- a/manifests/tools/button.yaml +++ b/manifests/tools/button.yaml @@ -2,15 +2,7 @@ id: button module: mcp/tools/ui-automation/button names: mcp: button -description: "Press simulator hardware button." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Press simulator hardware button. annotations: - title: "Hardware Button" + title: Hardware Button destructiveHint: true diff --git a/manifests/tools/clean.yaml b/manifests/tools/clean.yaml index 34b92870..50c7cc7d 100644 --- a/manifests/tools/clean.yaml +++ b/manifests/tools/clean.yaml @@ -2,16 +2,9 @@ id: clean module: mcp/tools/utilities/clean names: mcp: clean -description: "Clean build products." -availability: - mcp: true - cli: true - daemon: true +description: Clean build products. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Clean" + title: Clean destructiveHint: true diff --git a/manifests/tools/debug_attach_sim.yaml b/manifests/tools/debug_attach_sim.yaml index 3f8bc840..782fa388 100644 --- a/manifests/tools/debug_attach_sim.yaml +++ b/manifests/tools/debug_attach_sim.yaml @@ -2,12 +2,6 @@ id: debug_attach_sim module: mcp/tools/debugging/debug_attach_sim names: mcp: debug_attach_sim -description: "Attach LLDB to sim app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Attach LLDB to sim app. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_breakpoint_add.yaml b/manifests/tools/debug_breakpoint_add.yaml index ff21c825..5e55b0c9 100644 --- a/manifests/tools/debug_breakpoint_add.yaml +++ b/manifests/tools/debug_breakpoint_add.yaml @@ -2,12 +2,6 @@ id: debug_breakpoint_add module: mcp/tools/debugging/debug_breakpoint_add names: mcp: debug_breakpoint_add -description: "Add breakpoint." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Add breakpoint. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_breakpoint_remove.yaml b/manifests/tools/debug_breakpoint_remove.yaml index 88683fd2..752ceb2b 100644 --- a/manifests/tools/debug_breakpoint_remove.yaml +++ b/manifests/tools/debug_breakpoint_remove.yaml @@ -2,12 +2,6 @@ id: debug_breakpoint_remove module: mcp/tools/debugging/debug_breakpoint_remove names: mcp: debug_breakpoint_remove -description: "Remove breakpoint." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Remove breakpoint. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_continue.yaml b/manifests/tools/debug_continue.yaml index 90e5bedf..a7cc8c53 100644 --- a/manifests/tools/debug_continue.yaml +++ b/manifests/tools/debug_continue.yaml @@ -2,12 +2,6 @@ id: debug_continue module: mcp/tools/debugging/debug_continue names: mcp: debug_continue -description: "Continue debug session." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Continue debug session. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_detach.yaml b/manifests/tools/debug_detach.yaml index 143781ea..6f0312f4 100644 --- a/manifests/tools/debug_detach.yaml +++ b/manifests/tools/debug_detach.yaml @@ -2,12 +2,6 @@ id: debug_detach module: mcp/tools/debugging/debug_detach names: mcp: debug_detach -description: "Detach debugger." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Detach debugger. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_lldb_command.yaml b/manifests/tools/debug_lldb_command.yaml index c3b199da..c6be3f0f 100644 --- a/manifests/tools/debug_lldb_command.yaml +++ b/manifests/tools/debug_lldb_command.yaml @@ -2,12 +2,6 @@ id: debug_lldb_command module: mcp/tools/debugging/debug_lldb_command names: mcp: debug_lldb_command -description: "Run LLDB command." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Run LLDB command. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_stack.yaml b/manifests/tools/debug_stack.yaml index 8c676aba..27cbb71b 100644 --- a/manifests/tools/debug_stack.yaml +++ b/manifests/tools/debug_stack.yaml @@ -2,12 +2,6 @@ id: debug_stack module: mcp/tools/debugging/debug_stack names: mcp: debug_stack -description: "Get backtrace." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Get backtrace. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/debug_variables.yaml b/manifests/tools/debug_variables.yaml index 21600d6e..8f37827f 100644 --- a/manifests/tools/debug_variables.yaml +++ b/manifests/tools/debug_variables.yaml @@ -2,12 +2,6 @@ id: debug_variables module: mcp/tools/debugging/debug_variables names: mcp: debug_variables -description: "Get frame variables." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Get frame variables. routing: stateful: true - daemonAffinity: required diff --git a/manifests/tools/discover_projs.yaml b/manifests/tools/discover_projs.yaml index d93da14b..2508289f 100644 --- a/manifests/tools/discover_projs.yaml +++ b/manifests/tools/discover_projs.yaml @@ -2,15 +2,7 @@ id: discover_projs module: mcp/tools/project-discovery/discover_projs names: mcp: discover_projs -description: "Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. annotations: - title: "Discover Projects" + title: Discover Projects readOnlyHint: true diff --git a/manifests/tools/doctor.yaml b/manifests/tools/doctor.yaml index 10105eb4..8878ee91 100644 --- a/manifests/tools/doctor.yaml +++ b/manifests/tools/doctor.yaml @@ -2,12 +2,7 @@ id: doctor module: mcp/tools/doctor/doctor names: mcp: doctor -description: "MCP environment info." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: MCP environment info. annotations: - title: "Doctor" + title: Doctor readOnlyHint: true diff --git a/manifests/tools/erase_sims.yaml b/manifests/tools/erase_sims.yaml index b330cb1d..6825b6d2 100644 --- a/manifests/tools/erase_sims.yaml +++ b/manifests/tools/erase_sims.yaml @@ -2,15 +2,7 @@ id: erase_sims module: mcp/tools/simulator-management/erase_sims names: mcp: erase_sims -description: "Erase simulator." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Erase simulator. annotations: - title: "Erase Simulators" + title: Erase Simulators destructiveHint: true diff --git a/manifests/tools/gesture.yaml b/manifests/tools/gesture.yaml index 88cc860e..55fe90cd 100644 --- a/manifests/tools/gesture.yaml +++ b/manifests/tools/gesture.yaml @@ -2,15 +2,7 @@ id: gesture module: mcp/tools/ui-automation/gesture names: mcp: gesture -description: "Simulator gesture preset." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Simulator gesture preset. annotations: - title: "Gesture" + title: Gesture destructiveHint: true diff --git a/manifests/tools/get_app_bundle_id.yaml b/manifests/tools/get_app_bundle_id.yaml index 44ecf6ed..e23176d4 100644 --- a/manifests/tools/get_app_bundle_id.yaml +++ b/manifests/tools/get_app_bundle_id.yaml @@ -2,15 +2,7 @@ id: get_app_bundle_id module: mcp/tools/project-discovery/get_app_bundle_id names: mcp: get_app_bundle_id -description: "Extract bundle id from .app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Extract bundle id from .app. annotations: - title: "Get App Bundle ID" + title: Get App Bundle ID readOnlyHint: true diff --git a/manifests/tools/get_device_app_path.yaml b/manifests/tools/get_device_app_path.yaml index cacac535..a3d34a71 100644 --- a/manifests/tools/get_device_app_path.yaml +++ b/manifests/tools/get_device_app_path.yaml @@ -2,15 +2,7 @@ id: get_device_app_path module: mcp/tools/device/get_device_app_path names: mcp: get_device_app_path -description: "Get device built app path." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Get device built app path. annotations: - title: "Get Device App Path" + title: Get Device App Path readOnlyHint: true diff --git a/manifests/tools/get_mac_app_path.yaml b/manifests/tools/get_mac_app_path.yaml index ae6c00b7..762d733b 100644 --- a/manifests/tools/get_mac_app_path.yaml +++ b/manifests/tools/get_mac_app_path.yaml @@ -2,15 +2,7 @@ id: get_mac_app_path module: mcp/tools/macos/get_mac_app_path names: mcp: get_mac_app_path -description: "Get macOS built app path." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Get macOS built app path. annotations: - title: "Get macOS App Path" + title: Get macOS App Path readOnlyHint: true diff --git a/manifests/tools/get_mac_bundle_id.yaml b/manifests/tools/get_mac_bundle_id.yaml index b1ff8679..96304790 100644 --- a/manifests/tools/get_mac_bundle_id.yaml +++ b/manifests/tools/get_mac_bundle_id.yaml @@ -2,15 +2,7 @@ id: get_mac_bundle_id module: mcp/tools/project-discovery/get_mac_bundle_id names: mcp: get_mac_bundle_id -description: "Extract bundle id from macOS .app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Extract bundle id from macOS .app. annotations: - title: "Get Mac Bundle ID" + title: Get Mac Bundle ID readOnlyHint: true diff --git a/manifests/tools/get_sim_app_path.yaml b/manifests/tools/get_sim_app_path.yaml index 52830811..f5ea81be 100644 --- a/manifests/tools/get_sim_app_path.yaml +++ b/manifests/tools/get_sim_app_path.yaml @@ -2,15 +2,7 @@ id: get_sim_app_path module: mcp/tools/simulator/get_sim_app_path names: mcp: get_sim_app_path -description: "Get sim built app path." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Get sim built app path. annotations: - title: "Get Simulator App Path" + title: Get Simulator App Path readOnlyHint: true diff --git a/manifests/tools/install_app_device.yaml b/manifests/tools/install_app_device.yaml index aa832ea7..414116cc 100644 --- a/manifests/tools/install_app_device.yaml +++ b/manifests/tools/install_app_device.yaml @@ -2,15 +2,7 @@ id: install_app_device module: mcp/tools/device/install_app_device names: mcp: install_app_device -description: "Install app on device." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Install app on device. annotations: - title: "Install App Device" + title: Install App Device destructiveHint: true diff --git a/manifests/tools/install_app_sim.yaml b/manifests/tools/install_app_sim.yaml index 94dedce1..ff65b40e 100644 --- a/manifests/tools/install_app_sim.yaml +++ b/manifests/tools/install_app_sim.yaml @@ -2,15 +2,7 @@ id: install_app_sim module: mcp/tools/simulator/install_app_sim names: mcp: install_app_sim -description: "Install app on sim." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Install app on sim. annotations: - title: "Install App Simulator" + title: Install App Simulator destructiveHint: true diff --git a/manifests/tools/key_press.yaml b/manifests/tools/key_press.yaml index 4898ce51..0983c576 100644 --- a/manifests/tools/key_press.yaml +++ b/manifests/tools/key_press.yaml @@ -2,15 +2,7 @@ id: key_press module: mcp/tools/ui-automation/key_press names: mcp: key_press -description: "Press key by keycode." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Press key by keycode. annotations: - title: "Key Press" + title: Key Press destructiveHint: true diff --git a/manifests/tools/key_sequence.yaml b/manifests/tools/key_sequence.yaml index 9c08a139..9f4f0a9d 100644 --- a/manifests/tools/key_sequence.yaml +++ b/manifests/tools/key_sequence.yaml @@ -2,15 +2,7 @@ id: key_sequence module: mcp/tools/ui-automation/key_sequence names: mcp: key_sequence -description: "Press a sequence of keys by their keycodes." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Press a sequence of keys by their keycodes. annotations: - title: "Key Sequence" + title: Key Sequence destructiveHint: true diff --git a/manifests/tools/launch_app_device.yaml b/manifests/tools/launch_app_device.yaml index b1f51597..9df7f65f 100644 --- a/manifests/tools/launch_app_device.yaml +++ b/manifests/tools/launch_app_device.yaml @@ -2,15 +2,7 @@ id: launch_app_device module: mcp/tools/device/launch_app_device names: mcp: launch_app_device -description: "Launch app on device." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Launch app on device. annotations: - title: "Launch App Device" + title: Launch App Device destructiveHint: true diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml index 1d8c8d2f..c0d9361a 100644 --- a/manifests/tools/launch_app_logs_sim.yaml +++ b/manifests/tools/launch_app_logs_sim.yaml @@ -2,15 +2,9 @@ id: launch_app_logs_sim module: mcp/tools/simulator/launch_app_logs_sim names: mcp: launch_app_logs_sim -description: "Launch sim app with logs." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Launch sim app with logs. routing: stateful: true - daemonAffinity: preferred annotations: - title: "Launch App Logs Simulator" + title: Launch App Logs Simulator destructiveHint: true diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index 6682e0fb..617591cc 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -2,15 +2,7 @@ id: launch_app_sim module: mcp/tools/simulator/launch_app_sim names: mcp: launch_app_sim -description: "Launch app on simulator." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Launch app on simulator. annotations: - title: "Launch App Simulator" + title: Launch App Simulator destructiveHint: true diff --git a/manifests/tools/launch_mac_app.yaml b/manifests/tools/launch_mac_app.yaml index b31f766e..8689c978 100644 --- a/manifests/tools/launch_mac_app.yaml +++ b/manifests/tools/launch_mac_app.yaml @@ -2,15 +2,7 @@ id: launch_mac_app module: mcp/tools/macos/launch_mac_app names: mcp: launch_mac_app -description: "Launch macOS app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Launch macOS app. annotations: - title: "Launch macOS App" + title: Launch macOS App destructiveHint: true diff --git a/manifests/tools/list_devices.yaml b/manifests/tools/list_devices.yaml index e7b5ef54..789730a7 100644 --- a/manifests/tools/list_devices.yaml +++ b/manifests/tools/list_devices.yaml @@ -2,15 +2,7 @@ id: list_devices module: mcp/tools/device/list_devices names: mcp: list_devices -description: "List connected devices." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: List connected devices. annotations: - title: "List Devices" + title: List Devices readOnlyHint: true diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml index a262733a..d078053d 100644 --- a/manifests/tools/list_schemes.yaml +++ b/manifests/tools/list_schemes.yaml @@ -2,15 +2,7 @@ id: list_schemes module: mcp/tools/project-discovery/list_schemes names: mcp: list_schemes -description: "List Xcode schemes." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: List Xcode schemes. annotations: - title: "List Schemes" + title: List Schemes readOnlyHint: true diff --git a/manifests/tools/list_sims.yaml b/manifests/tools/list_sims.yaml index 0f4fe2ed..d21514ba 100644 --- a/manifests/tools/list_sims.yaml +++ b/manifests/tools/list_sims.yaml @@ -2,15 +2,7 @@ id: list_sims module: mcp/tools/simulator/list_sims names: mcp: list_sims -description: "List iOS simulators." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: List iOS simulators. annotations: - title: "List Simulators" + title: List Simulators readOnlyHint: true diff --git a/manifests/tools/long_press.yaml b/manifests/tools/long_press.yaml index 5b2d2c86..730742eb 100644 --- a/manifests/tools/long_press.yaml +++ b/manifests/tools/long_press.yaml @@ -2,15 +2,7 @@ id: long_press module: mcp/tools/ui-automation/long_press names: mcp: long_press -description: "Long press at coords." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Long press at coords. annotations: - title: "Long Press" + title: Long Press destructiveHint: true diff --git a/manifests/tools/manage_workflows.yaml b/manifests/tools/manage_workflows.yaml index eb0533d0..56ec66b1 100644 --- a/manifests/tools/manage_workflows.yaml +++ b/manifests/tools/manage_workflows.yaml @@ -2,9 +2,6 @@ id: manage_workflows module: mcp/tools/workflow-discovery/manage_workflows names: mcp: manage-workflows -description: "Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled." +description: Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled. availability: - mcp: true cli: false - daemon: false -predicates: [] diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index fcb9d33c..d9a1364c 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -2,15 +2,7 @@ id: open_sim module: mcp/tools/simulator/open_sim names: mcp: open_sim -description: "Open Simulator app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Open Simulator app. annotations: - title: "Open Simulator" + title: Open Simulator destructiveHint: true diff --git a/manifests/tools/record_sim_video.yaml b/manifests/tools/record_sim_video.yaml index 123e591c..f867efd5 100644 --- a/manifests/tools/record_sim_video.yaml +++ b/manifests/tools/record_sim_video.yaml @@ -2,15 +2,9 @@ id: record_sim_video module: mcp/tools/simulator/record_sim_video names: mcp: record_sim_video -description: "Record sim video." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Record sim video. routing: stateful: true - daemonAffinity: preferred annotations: - title: "Record Simulator Video" + title: Record Simulator Video destructiveHint: true diff --git a/manifests/tools/reset_sim_location.yaml b/manifests/tools/reset_sim_location.yaml index 0c4dd4a5..4fb9420a 100644 --- a/manifests/tools/reset_sim_location.yaml +++ b/manifests/tools/reset_sim_location.yaml @@ -2,15 +2,7 @@ id: reset_sim_location module: mcp/tools/simulator-management/reset_sim_location names: mcp: reset_sim_location -description: "Reset sim location." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Reset sim location. annotations: - title: "Reset Simulator Location" + title: Reset Simulator Location destructiveHint: true diff --git a/manifests/tools/scaffold_ios_project.yaml b/manifests/tools/scaffold_ios_project.yaml index 66f306c8..05b31c36 100644 --- a/manifests/tools/scaffold_ios_project.yaml +++ b/manifests/tools/scaffold_ios_project.yaml @@ -2,16 +2,9 @@ id: scaffold_ios_project module: mcp/tools/project-scaffolding/scaffold_ios_project names: mcp: scaffold_ios_project -description: "Scaffold iOS project." -availability: - mcp: true - cli: true - daemon: true +description: Scaffold iOS project. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Scaffold iOS Project" + title: Scaffold iOS Project destructiveHint: true diff --git a/manifests/tools/scaffold_macos_project.yaml b/manifests/tools/scaffold_macos_project.yaml index c1ea93f8..f88cb70c 100644 --- a/manifests/tools/scaffold_macos_project.yaml +++ b/manifests/tools/scaffold_macos_project.yaml @@ -2,16 +2,9 @@ id: scaffold_macos_project module: mcp/tools/project-scaffolding/scaffold_macos_project names: mcp: scaffold_macos_project -description: "Scaffold macOS project." -availability: - mcp: true - cli: true - daemon: true +description: Scaffold macOS project. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Scaffold macOS Project" + title: Scaffold macOS Project destructiveHint: true diff --git a/manifests/tools/screenshot.yaml b/manifests/tools/screenshot.yaml index dcb0e85a..b10f8bd3 100644 --- a/manifests/tools/screenshot.yaml +++ b/manifests/tools/screenshot.yaml @@ -2,15 +2,7 @@ id: screenshot module: mcp/tools/ui-automation/screenshot names: mcp: screenshot -description: "Capture screenshot." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Capture screenshot. annotations: - title: "Screenshot" + title: Screenshot readOnlyHint: true diff --git a/manifests/tools/session_clear_defaults.yaml b/manifests/tools/session_clear_defaults.yaml index 3891ce51..e35d884d 100644 --- a/manifests/tools/session_clear_defaults.yaml +++ b/manifests/tools/session_clear_defaults.yaml @@ -2,12 +2,9 @@ id: session_clear_defaults module: mcp/tools/session-management/session_clear_defaults names: mcp: session_clear_defaults -description: "Clear session defaults." +description: Clear session defaults. availability: - mcp: true cli: false - daemon: false -predicates: [] annotations: - title: "Clear Session Defaults" + title: Clear Session Defaults destructiveHint: true diff --git a/manifests/tools/session_set_defaults.yaml b/manifests/tools/session_set_defaults.yaml index cae43dcf..0d86792a 100644 --- a/manifests/tools/session_set_defaults.yaml +++ b/manifests/tools/session_set_defaults.yaml @@ -2,12 +2,9 @@ id: session_set_defaults module: mcp/tools/session-management/session_set_defaults names: mcp: session_set_defaults -description: "Set the session defaults, should be called at least once to set tool defaults." +description: Set the session defaults, should be called at least once to set tool defaults. availability: - mcp: true cli: false - daemon: false -predicates: [] annotations: - title: "Set Session Defaults" + title: Set Session Defaults destructiveHint: true diff --git a/manifests/tools/session_show_defaults.yaml b/manifests/tools/session_show_defaults.yaml index 99bf5ca8..40414f40 100644 --- a/manifests/tools/session_show_defaults.yaml +++ b/manifests/tools/session_show_defaults.yaml @@ -2,12 +2,9 @@ id: session_show_defaults module: mcp/tools/session-management/session_show_defaults names: mcp: session_show_defaults -description: "Show session defaults." +description: Show session defaults. availability: - mcp: true cli: false - daemon: false -predicates: [] annotations: - title: "Show Session Defaults" + title: Show Session Defaults readOnlyHint: true diff --git a/manifests/tools/set_sim_appearance.yaml b/manifests/tools/set_sim_appearance.yaml index a7e2cc98..ba9f568d 100644 --- a/manifests/tools/set_sim_appearance.yaml +++ b/manifests/tools/set_sim_appearance.yaml @@ -2,15 +2,7 @@ id: set_sim_appearance module: mcp/tools/simulator-management/set_sim_appearance names: mcp: set_sim_appearance -description: "Set sim appearance." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Set sim appearance. annotations: - title: "Set Simulator Appearance" + title: Set Simulator Appearance destructiveHint: true diff --git a/manifests/tools/set_sim_location.yaml b/manifests/tools/set_sim_location.yaml index 0141cbad..80b757cf 100644 --- a/manifests/tools/set_sim_location.yaml +++ b/manifests/tools/set_sim_location.yaml @@ -2,15 +2,7 @@ id: set_sim_location module: mcp/tools/simulator-management/set_sim_location names: mcp: set_sim_location -description: "Set sim location." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Set sim location. annotations: - title: "Set Simulator Location" + title: Set Simulator Location destructiveHint: true diff --git a/manifests/tools/show_build_settings.yaml b/manifests/tools/show_build_settings.yaml index 45062a47..fabe3790 100644 --- a/manifests/tools/show_build_settings.yaml +++ b/manifests/tools/show_build_settings.yaml @@ -2,16 +2,9 @@ id: show_build_settings module: mcp/tools/project-discovery/show_build_settings names: mcp: show_build_settings -description: "Show build settings." -availability: - mcp: true - cli: true - daemon: true +description: Show build settings. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Show Build Settings" + title: Show Build Settings readOnlyHint: true diff --git a/manifests/tools/sim_statusbar.yaml b/manifests/tools/sim_statusbar.yaml index 405f5431..f6a92d45 100644 --- a/manifests/tools/sim_statusbar.yaml +++ b/manifests/tools/sim_statusbar.yaml @@ -2,15 +2,7 @@ id: sim_statusbar module: mcp/tools/simulator-management/sim_statusbar names: mcp: sim_statusbar -description: "Set sim status bar network." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Set sim status bar network. annotations: - title: "Simulator Statusbar" + title: Simulator Statusbar destructiveHint: true diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml index 743cd1d9..5ed6d410 100644 --- a/manifests/tools/snapshot_ui.yaml +++ b/manifests/tools/snapshot_ui.yaml @@ -2,15 +2,7 @@ id: snapshot_ui module: mcp/tools/ui-automation/snapshot_ui names: mcp: snapshot_ui -description: "Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. annotations: - title: "Snapshot UI" + title: Snapshot UI readOnlyHint: true diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml index 398ea424..2e24eafa 100644 --- a/manifests/tools/start_device_log_cap.yaml +++ b/manifests/tools/start_device_log_cap.yaml @@ -2,15 +2,9 @@ id: start_device_log_cap module: mcp/tools/logging/start_device_log_cap names: mcp: start_device_log_cap -description: "Start device log capture." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Start device log capture. routing: stateful: true - daemonAffinity: required annotations: - title: "Start Device Log Capture" + title: Start Device Log Capture destructiveHint: true diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml index c7f093dc..3e1f840d 100644 --- a/manifests/tools/start_sim_log_cap.yaml +++ b/manifests/tools/start_sim_log_cap.yaml @@ -2,15 +2,9 @@ id: start_sim_log_cap module: mcp/tools/logging/start_sim_log_cap names: mcp: start_sim_log_cap -description: "Start sim log capture." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Start sim log capture. routing: stateful: true - daemonAffinity: required annotations: - title: "Start Simulator Log Capture" + title: Start Simulator Log Capture destructiveHint: true diff --git a/manifests/tools/stop_app_device.yaml b/manifests/tools/stop_app_device.yaml index b2bb1a24..0d9b26bd 100644 --- a/manifests/tools/stop_app_device.yaml +++ b/manifests/tools/stop_app_device.yaml @@ -2,15 +2,7 @@ id: stop_app_device module: mcp/tools/device/stop_app_device names: mcp: stop_app_device -description: "Stop device app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Stop device app. annotations: - title: "Stop App Device" + title: Stop App Device destructiveHint: true diff --git a/manifests/tools/stop_app_sim.yaml b/manifests/tools/stop_app_sim.yaml index e9b77008..56ebaa48 100644 --- a/manifests/tools/stop_app_sim.yaml +++ b/manifests/tools/stop_app_sim.yaml @@ -2,15 +2,7 @@ id: stop_app_sim module: mcp/tools/simulator/stop_app_sim names: mcp: stop_app_sim -description: "Stop sim app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Stop sim app. annotations: - title: "Stop App Simulator" + title: Stop App Simulator destructiveHint: true diff --git a/manifests/tools/stop_device_log_cap.yaml b/manifests/tools/stop_device_log_cap.yaml index 296aad1e..5742b758 100644 --- a/manifests/tools/stop_device_log_cap.yaml +++ b/manifests/tools/stop_device_log_cap.yaml @@ -2,15 +2,9 @@ id: stop_device_log_cap module: mcp/tools/logging/stop_device_log_cap names: mcp: stop_device_log_cap -description: "Stop device app and return logs." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Stop device app and return logs. routing: stateful: true - daemonAffinity: required annotations: - title: "Stop Device and Return Logs" + title: Stop Device and Return Logs destructiveHint: true diff --git a/manifests/tools/stop_mac_app.yaml b/manifests/tools/stop_mac_app.yaml index 98c0f2e5..f12f94d5 100644 --- a/manifests/tools/stop_mac_app.yaml +++ b/manifests/tools/stop_mac_app.yaml @@ -2,15 +2,7 @@ id: stop_mac_app module: mcp/tools/macos/stop_mac_app names: mcp: stop_mac_app -description: "Stop macOS app." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Stop macOS app. annotations: - title: "Stop macOS App" + title: Stop macOS App destructiveHint: true diff --git a/manifests/tools/stop_sim_log_cap.yaml b/manifests/tools/stop_sim_log_cap.yaml index 622ce7d0..d39e9288 100644 --- a/manifests/tools/stop_sim_log_cap.yaml +++ b/manifests/tools/stop_sim_log_cap.yaml @@ -2,15 +2,9 @@ id: stop_sim_log_cap module: mcp/tools/logging/stop_sim_log_cap names: mcp: stop_sim_log_cap -description: "Stop sim app and return logs." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Stop sim app and return logs. routing: stateful: true - daemonAffinity: required annotations: - title: "Stop Simulator and Return Logs" + title: Stop Simulator and Return Logs destructiveHint: true diff --git a/manifests/tools/swift_package_build.yaml b/manifests/tools/swift_package_build.yaml index 5b72ffb0..7239dd54 100644 --- a/manifests/tools/swift_package_build.yaml +++ b/manifests/tools/swift_package_build.yaml @@ -2,16 +2,9 @@ id: swift_package_build module: mcp/tools/swift-package/swift_package_build names: mcp: swift_package_build -description: "swift package target build." -availability: - mcp: true - cli: true - daemon: true +description: swift package target build. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Swift Package Build" + title: Swift Package Build destructiveHint: true diff --git a/manifests/tools/swift_package_clean.yaml b/manifests/tools/swift_package_clean.yaml index eb6efeee..b1de0d1f 100644 --- a/manifests/tools/swift_package_clean.yaml +++ b/manifests/tools/swift_package_clean.yaml @@ -2,15 +2,7 @@ id: swift_package_clean module: mcp/tools/swift-package/swift_package_clean names: mcp: swift_package_clean -description: "swift package clean." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: swift package clean. annotations: - title: "Swift Package Clean" + title: Swift Package Clean destructiveHint: true diff --git a/manifests/tools/swift_package_list.yaml b/manifests/tools/swift_package_list.yaml index 2181b020..aaa307ff 100644 --- a/manifests/tools/swift_package_list.yaml +++ b/manifests/tools/swift_package_list.yaml @@ -2,15 +2,9 @@ id: swift_package_list module: mcp/tools/swift-package/swift_package_list names: mcp: swift_package_list -description: "List SwiftPM processes." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: List SwiftPM processes. routing: stateful: true - daemonAffinity: required annotations: - title: "Swift Package List" + title: Swift Package List readOnlyHint: true diff --git a/manifests/tools/swift_package_run.yaml b/manifests/tools/swift_package_run.yaml index 8587c327..d48ed382 100644 --- a/manifests/tools/swift_package_run.yaml +++ b/manifests/tools/swift_package_run.yaml @@ -2,15 +2,9 @@ id: swift_package_run module: mcp/tools/swift-package/swift_package_run names: mcp: swift_package_run -description: "swift package target run." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: swift package target run. routing: stateful: true - daemonAffinity: required annotations: - title: "Swift Package Run" + title: Swift Package Run destructiveHint: true diff --git a/manifests/tools/swift_package_stop.yaml b/manifests/tools/swift_package_stop.yaml index 4760bb4f..004cc854 100644 --- a/manifests/tools/swift_package_stop.yaml +++ b/manifests/tools/swift_package_stop.yaml @@ -2,15 +2,9 @@ id: swift_package_stop module: mcp/tools/swift-package/swift_package_stop names: mcp: swift_package_stop -description: "Stop SwiftPM run." -availability: - mcp: true - cli: true - daemon: true -predicates: [] +description: Stop SwiftPM run. routing: stateful: true - daemonAffinity: required annotations: - title: "Swift Package Stop" + title: Swift Package Stop destructiveHint: true diff --git a/manifests/tools/swift_package_test.yaml b/manifests/tools/swift_package_test.yaml index a7844d0d..c849176b 100644 --- a/manifests/tools/swift_package_test.yaml +++ b/manifests/tools/swift_package_test.yaml @@ -2,16 +2,9 @@ id: swift_package_test module: mcp/tools/swift-package/swift_package_test names: mcp: swift_package_test -description: "Run swift package target tests." -availability: - mcp: true - cli: true - daemon: true +description: Run swift package target tests. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Swift Package Test" + title: Swift Package Test destructiveHint: true diff --git a/manifests/tools/swipe.yaml b/manifests/tools/swipe.yaml index 036eab50..25721459 100644 --- a/manifests/tools/swipe.yaml +++ b/manifests/tools/swipe.yaml @@ -2,15 +2,7 @@ id: swipe module: mcp/tools/ui-automation/swipe names: mcp: swipe -description: "Swipe between points." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Swipe between points. annotations: - title: "Swipe" + title: Swipe destructiveHint: true diff --git a/manifests/tools/sync_xcode_defaults.yaml b/manifests/tools/sync_xcode_defaults.yaml index a0cc4de9..1679ddbe 100644 --- a/manifests/tools/sync_xcode_defaults.yaml +++ b/manifests/tools/sync_xcode_defaults.yaml @@ -2,14 +2,12 @@ id: sync_xcode_defaults module: mcp/tools/xcode-ide/sync_xcode_defaults names: mcp: sync_xcode_defaults -description: "Sync session defaults (scheme, simulator) from Xcode's current IDE selection." +description: Sync session defaults (scheme, simulator) from Xcode's current IDE selection. availability: - mcp: true cli: false - daemon: false predicates: - xcodeAutoSyncDisabled annotations: - title: "Sync Xcode Defaults" + title: Sync Xcode Defaults readOnlyHint: false destructiveHint: false diff --git a/manifests/tools/tap.yaml b/manifests/tools/tap.yaml index 9c838fa5..1a193cb9 100644 --- a/manifests/tools/tap.yaml +++ b/manifests/tools/tap.yaml @@ -2,15 +2,7 @@ id: tap module: mcp/tools/ui-automation/tap names: mcp: tap -description: "Tap coordinate or element." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Tap coordinate or element. annotations: - title: "Tap" + title: Tap destructiveHint: true diff --git a/manifests/tools/test_device.yaml b/manifests/tools/test_device.yaml index 43ab838f..6a778bc0 100644 --- a/manifests/tools/test_device.yaml +++ b/manifests/tools/test_device.yaml @@ -2,16 +2,9 @@ id: test_device module: mcp/tools/device/test_device names: mcp: test_device -description: "Test on device." -availability: - mcp: true - cli: true - daemon: true +description: Test on device. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Test Device" + title: Test Device destructiveHint: true diff --git a/manifests/tools/test_macos.yaml b/manifests/tools/test_macos.yaml index b34ca104..f8e69851 100644 --- a/manifests/tools/test_macos.yaml +++ b/manifests/tools/test_macos.yaml @@ -2,16 +2,9 @@ id: test_macos module: mcp/tools/macos/test_macos names: mcp: test_macos -description: "Test macOS target." -availability: - mcp: true - cli: true - daemon: true +description: Test macOS target. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Test macOS" + title: Test macOS destructiveHint: true diff --git a/manifests/tools/test_sim.yaml b/manifests/tools/test_sim.yaml index 9104255e..e716acd5 100644 --- a/manifests/tools/test_sim.yaml +++ b/manifests/tools/test_sim.yaml @@ -2,16 +2,9 @@ id: test_sim module: mcp/tools/simulator/test_sim names: mcp: test_sim -description: "Test on iOS sim." -availability: - mcp: true - cli: true - daemon: true +description: Test on iOS sim. predicates: - hideWhenXcodeAgentMode -routing: - stateful: false - daemonAffinity: preferred annotations: - title: "Test Simulator" + title: Test Simulator destructiveHint: true diff --git a/manifests/tools/touch.yaml b/manifests/tools/touch.yaml index fac3b280..44bd5d26 100644 --- a/manifests/tools/touch.yaml +++ b/manifests/tools/touch.yaml @@ -2,15 +2,7 @@ id: touch module: mcp/tools/ui-automation/touch names: mcp: touch -description: "Touch down/up at coords." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Touch down/up at coords. annotations: - title: "Touch" + title: Touch destructiveHint: true diff --git a/manifests/tools/type_text.yaml b/manifests/tools/type_text.yaml index 9391b774..d1a2b523 100644 --- a/manifests/tools/type_text.yaml +++ b/manifests/tools/type_text.yaml @@ -2,15 +2,7 @@ id: type_text module: mcp/tools/ui-automation/type_text names: mcp: type_text -description: "Type text." -availability: - mcp: true - cli: true - daemon: true -predicates: [] -routing: - stateful: false - daemonAffinity: preferred +description: Type text. annotations: - title: "Type Text" + title: Type Text destructiveHint: true diff --git a/manifests/tools/xcode_tools_bridge_disconnect.yaml b/manifests/tools/xcode_tools_bridge_disconnect.yaml index e02c5e27..66b42872 100644 --- a/manifests/tools/xcode_tools_bridge_disconnect.yaml +++ b/manifests/tools/xcode_tools_bridge_disconnect.yaml @@ -2,13 +2,9 @@ id: xcode_tools_bridge_disconnect module: mcp/tools/xcode-ide/xcode_tools_bridge_disconnect names: mcp: xcode_tools_bridge_disconnect -description: "Disconnect bridge and unregister proxied `xcode_tools_*` tools." -availability: - mcp: true - cli: true - daemon: false +description: Disconnect bridge and unregister proxied `xcode_tools_*` tools. predicates: - debugEnabled annotations: - title: "Disconnect Xcode Tools Bridge" + title: Disconnect Xcode Tools Bridge readOnlyHint: false diff --git a/manifests/tools/xcode_tools_bridge_status.yaml b/manifests/tools/xcode_tools_bridge_status.yaml index 3187396a..3a81325d 100644 --- a/manifests/tools/xcode_tools_bridge_status.yaml +++ b/manifests/tools/xcode_tools_bridge_status.yaml @@ -2,13 +2,9 @@ id: xcode_tools_bridge_status module: mcp/tools/xcode-ide/xcode_tools_bridge_status names: mcp: xcode_tools_bridge_status -description: "Show xcrun mcpbridge availability and proxy tool sync status." -availability: - mcp: true - cli: true - daemon: false +description: Show xcrun mcpbridge availability and proxy tool sync status. predicates: - debugEnabled annotations: - title: "Xcode Tools Bridge Status" + title: Xcode Tools Bridge Status readOnlyHint: true diff --git a/manifests/tools/xcode_tools_bridge_sync.yaml b/manifests/tools/xcode_tools_bridge_sync.yaml index 43d2d37f..6035a6c4 100644 --- a/manifests/tools/xcode_tools_bridge_sync.yaml +++ b/manifests/tools/xcode_tools_bridge_sync.yaml @@ -2,13 +2,9 @@ id: xcode_tools_bridge_sync module: mcp/tools/xcode-ide/xcode_tools_bridge_sync names: mcp: xcode_tools_bridge_sync -description: "One-shot connect + tools/list sync (manual retry; avoids background prompt spam)." -availability: - mcp: true - cli: true - daemon: false +description: One-shot connect + tools/list sync (manual retry; avoids background prompt spam). predicates: - debugEnabled annotations: - title: "Sync Xcode Tools Bridge" + title: Sync Xcode Tools Bridge readOnlyHint: false diff --git a/manifests/workflows/debugging.yaml b/manifests/workflows/debugging.yaml index 27d5848a..d3a92819 100644 --- a/manifests/workflows/debugging.yaml +++ b/manifests/workflows/debugging.yaml @@ -1,15 +1,6 @@ id: debugging -title: "LLDB Debugging" -description: "Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: LLDB Debugging +description: Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks. tools: - debug_attach_sim - debug_breakpoint_add diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index d8669d34..7a5aaa86 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -1,15 +1,6 @@ id: device -title: "iOS Device Development" -description: "Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro)." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: iOS Device Development +description: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). tools: - build_device - test_device diff --git a/manifests/workflows/doctor.yaml b/manifests/workflows/doctor.yaml index ad1172a0..cc2b1879 100644 --- a/manifests/workflows/doctor.yaml +++ b/manifests/workflows/doctor.yaml @@ -1,13 +1,8 @@ id: doctor -title: "MCP Doctor" -description: "Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration." -availability: - mcp: true - cli: true - daemon: true +title: MCP Doctor +description: Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration. selection: mcp: - defaultEnabled: false autoInclude: true predicates: - debugEnabled diff --git a/manifests/workflows/logging.yaml b/manifests/workflows/logging.yaml index 6c406b66..4d2ad6d3 100644 --- a/manifests/workflows/logging.yaml +++ b/manifests/workflows/logging.yaml @@ -1,15 +1,6 @@ id: logging -title: "Log Capture" -description: "Capture and retrieve logs from simulator and device apps." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: Log Capture +description: Capture and retrieve logs from simulator and device apps. tools: - start_sim_log_cap - stop_sim_log_cap diff --git a/manifests/workflows/macos.yaml b/manifests/workflows/macos.yaml index a2ddfa7e..c6cec69a 100644 --- a/manifests/workflows/macos.yaml +++ b/manifests/workflows/macos.yaml @@ -1,15 +1,6 @@ id: macos -title: "macOS Development" -description: "Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: macOS Development +description: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. tools: - build_macos - build_run_macos diff --git a/manifests/workflows/project-discovery.yaml b/manifests/workflows/project-discovery.yaml index c21ba3d4..279a74ba 100644 --- a/manifests/workflows/project-discovery.yaml +++ b/manifests/workflows/project-discovery.yaml @@ -1,15 +1,6 @@ id: project-discovery -title: "Project Discovery" -description: "Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: Project Discovery +description: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. tools: - discover_projs - list_schemes diff --git a/manifests/workflows/project-scaffolding.yaml b/manifests/workflows/project-scaffolding.yaml index 2c3e0a0b..2ffdf8cb 100644 --- a/manifests/workflows/project-scaffolding.yaml +++ b/manifests/workflows/project-scaffolding.yaml @@ -1,15 +1,6 @@ id: project-scaffolding -title: "Project Scaffolding" -description: "Scaffold new iOS and macOS projects from templates." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: Project Scaffolding +description: Scaffold new iOS and macOS projects from templates. tools: - scaffold_ios_project - scaffold_macos_project diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml index 0c30d904..b2ea01ba 100644 --- a/manifests/workflows/session-management.yaml +++ b/manifests/workflows/session-management.yaml @@ -1,15 +1,12 @@ id: session-management -title: "Session Management" -description: "Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings." +title: Session Management +description: Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings. availability: - mcp: true cli: false - daemon: false selection: mcp: defaultEnabled: true autoInclude: true -predicates: [] tools: - session_show_defaults - session_set_defaults diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index 917828c4..ca55174e 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -1,15 +1,6 @@ id: simulator-management -title: "Simulator Management" -description: "Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: Simulator Management +description: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. tools: - boot_sim - list_sims diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml index 782ce81f..bd44ded9 100644 --- a/manifests/workflows/simulator.yaml +++ b/manifests/workflows/simulator.yaml @@ -1,15 +1,9 @@ id: simulator -title: "iOS Simulator Development" -description: "Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators." -availability: - mcp: true - cli: true - daemon: true +title: iOS Simulator Development +description: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. selection: mcp: defaultEnabled: true - autoInclude: false -predicates: [] tools: - list_sims - boot_sim diff --git a/manifests/workflows/swift-package.yaml b/manifests/workflows/swift-package.yaml index c85a9d0a..173ae76e 100644 --- a/manifests/workflows/swift-package.yaml +++ b/manifests/workflows/swift-package.yaml @@ -1,15 +1,6 @@ id: swift-package -title: "Swift Package Development" -description: "Build, test, run and manage Swift Package Manager projects." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: Swift Package Development +description: Build, test, run and manage Swift Package Manager projects. tools: - swift_package_build - swift_package_test diff --git a/manifests/workflows/ui-automation.yaml b/manifests/workflows/ui-automation.yaml index 29c43968..9f471b3e 100644 --- a/manifests/workflows/ui-automation.yaml +++ b/manifests/workflows/ui-automation.yaml @@ -1,15 +1,6 @@ id: ui-automation -title: "UI Automation" -description: "UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: UI Automation +description: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. tools: - tap - touch diff --git a/manifests/workflows/utilities.yaml b/manifests/workflows/utilities.yaml index 2c2e64c7..dfcc802c 100644 --- a/manifests/workflows/utilities.yaml +++ b/manifests/workflows/utilities.yaml @@ -1,14 +1,5 @@ id: utilities -title: "Build Utilities" -description: "Utility tools for cleaning build products and managing build artifacts." -availability: - mcp: true - cli: true - daemon: true -selection: - mcp: - defaultEnabled: false - autoInclude: false -predicates: [] +title: Build Utilities +description: Utility tools for cleaning build products and managing build artifacts. tools: - clean diff --git a/manifests/workflows/workflow-discovery.yaml b/manifests/workflows/workflow-discovery.yaml index b8316984..099034f6 100644 --- a/manifests/workflows/workflow-discovery.yaml +++ b/manifests/workflows/workflow-discovery.yaml @@ -1,13 +1,10 @@ id: workflow-discovery -title: "Workflow Discovery" -description: "Manage enabled workflows at runtime." +title: Workflow Discovery +description: Manage enabled workflows at runtime. availability: - mcp: true cli: false - daemon: false selection: mcp: - defaultEnabled: false autoInclude: true predicates: - experimentalWorkflowDiscoveryEnabled diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index 05e516b9..c13fc489 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -1,14 +1,8 @@ id: xcode-ide -title: "Xcode IDE Integration" -description: "Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality." +title: Xcode IDE Integration +description: Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality. availability: - mcp: true cli: false - daemon: false -selection: - mcp: - defaultEnabled: false - autoInclude: false predicates: - xcodeToolsAvailable - hideWhenXcodeAgentMode diff --git a/src/cli.ts b/src/cli.ts index 352a06bf..3a37ef11 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,30 @@ import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts'; import { buildYargsApp } from './cli/yargs-app.ts'; import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts'; import { startMcpServer } from './server/start-mcp-server.ts'; +import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts'; + +function findTopLevelCommand(argv: string[]): string | undefined { + const flagsWithValue = new Set(['--socket', '--log-level', '--style']); + let skipNext = false; + + for (const token of argv) { + if (skipNext) { + skipNext = false; + continue; + } + + if (token.startsWith('-')) { + if (flagsWithValue.has(token)) { + skipNext = true; + } + continue; + } + + return token; + } + + return undefined; +} async function main(): Promise { if (process.argv.includes('mcp')) { @@ -19,9 +43,6 @@ async function main(): Promise { }, }); - // CLI uses its own manifest-resolved catalog. - const catalog = await buildCliToolCatalog(); - // Compute workspace context for daemon routing const workspaceRoot = resolveWorkspaceRoot({ cwd: result.runtime.cwd, @@ -38,7 +59,22 @@ async function main(): Promise { projectConfigPath: result.configPath, }); - const cliExposedWorkflowIds = [...new Set(catalog.tools.map((tool) => tool.workflow))]; + const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({ + excludeWorkflows: ['session-management', 'workflow-discovery'], + }); + const topLevelCommand = findTopLevelCommand(process.argv.slice(2)); + const discoveryMode = + topLevelCommand === 'xcode-ide' || topLevelCommand === 'tools' ? 'quick' : 'none'; + + // CLI uses a manifest-resolved catalog plus daemon-backed xcode-ide dynamic tools. + const catalog = await buildCliToolCatalog({ + socketPath: defaultSocketPath, + workspaceRoot, + cliExposedWorkflowIds, + logLevel: result.runtime.config.debug ? 'info' : undefined, + discoveryMode, + }); + const workflowNames = cliExposedWorkflowIds; const yargsApp = buildYargsApp({ diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts index 2f1ca73a..0952c112 100644 --- a/src/cli/cli-tool-catalog.ts +++ b/src/cli/cli-tool-catalog.ts @@ -1,10 +1,183 @@ -import { buildCliToolCatalogFromManifest } from '../runtime/tool-catalog.ts'; -import type { ToolCatalog } from '../runtime/types.ts'; +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolSchemaShape } from '../core/plugin-types.ts'; +import { startDaemonBackground } from './daemon-control.ts'; +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 { 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'; + +interface BuildCliToolCatalogOptions { + socketPath: string; + workspaceRoot: string; + cliExposedWorkflowIds: string[]; + logLevel?: string; + discoveryMode?: 'none' | 'quick'; +} + +type JsonSchemaObject = { + properties?: Record; + required?: unknown[]; +}; + +function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape { + if (!inputSchema || typeof inputSchema !== 'object') { + return {}; + } + + const schema = inputSchema as JsonSchemaObject; + const properties = schema.properties; + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) { + return {}; + } + + const requiredFields = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === 'string') + : [], + ); + + const shape: ToolSchemaShape = {}; + for (const [name, propertySchema] of Object.entries(properties)) { + const zodSchema = jsonSchemaToZod(propertySchema); + shape[name] = requiredFields.has(name) ? zodSchema : zodSchema.optional(); + } + + return shape; +} + +function buildDaemonEnvOverrides(opts: BuildCliToolCatalogOptions): Record { + const env: Record = {}; + + if (opts.logLevel) { + env.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel; + } + + return env; +} + +async function invokeRemoteToolOneShot( + remoteToolName: string, + args: Record, +): Promise { + const service = new XcodeIdeToolService(); + service.setWorkflowEnabled(true); + try { + const response = await service.invokeTool(remoteToolName, args); + return response as unknown as ToolResponse; + } finally { + await service.disconnect(); + } +} + +type DynamicBridgeTool = { + name: string; + description?: string; + inputSchema?: unknown; + annotations?: ToolAnnotations; +}; + +function createCliXcodeProxyTool(remoteTool: DynamicBridgeTool): ToolDefinition { + const cliSchema = jsonSchemaToToolSchemaShape(remoteTool.inputSchema); + + return { + cliName: `xcode-ide-${toKebabCase(remoteTool.name)}`, + mcpName: toLocalToolName(remoteTool.name), + workflow: 'xcode-ide', + description: remoteTool.description ?? '', + annotations: remoteTool.annotations, + mcpSchema: cliSchema, + cliSchema, + stateful: false, + xcodeIdeRemoteToolName: remoteTool.name, + handler: async (params): Promise => { + return invokeRemoteToolOneShot(remoteTool.name, params); + }, + }; +} + +async function loadDaemonBackedXcodeProxyTools( + opts: BuildCliToolCatalogOptions, +): Promise { + const discoveryMode = opts.discoveryMode ?? 'none'; + const quickMode = discoveryMode === 'quick'; + const daemonClient = new DaemonClient({ + socketPath: opts.socketPath, + timeout: quickMode ? 400 : 250, + }); + + try { + const isRunning = await daemonClient.isRunning(); + if (!isRunning) { + if (!quickMode) { + return []; + } + + // Fast path for CLI help/discovery: fire-and-forget daemon startup to avoid + // blocking command rendering while still warming a long-lived bridge session. + try { + startDaemonBackground({ + socketPath: opts.socketPath, + workspaceRoot: opts.workspaceRoot, + env: buildDaemonEnvOverrides(opts), + }); + } catch (startError) { + const message = startError instanceof Error ? startError.message : String(startError); + log('warning', `[xcode-ide] Failed to start daemon in background: ${message}`); + } + return []; + } + + const tools = await daemonClient.listXcodeIdeTools({ + refresh: false, + prefetch: quickMode, + }); + + return tools.map( + (tool): ToolDefinition => + createCliXcodeProxyTool({ + name: tool.remoteName, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (quickMode) { + log('warning', `[xcode-ide] CLI daemon-backed bridge discovery failed: ${message}`); + } else { + log('debug', `[xcode-ide] CLI cached bridge discovery skipped: ${message}`); + } + return []; + } +} /** * Build a tool catalog for CLI usage using the manifest system. * CLI visibility is determined by manifest availability and predicates. */ -export async function buildCliToolCatalog(): Promise { - return buildCliToolCatalogFromManifest(); +export async function buildCliToolCatalog(opts: BuildCliToolCatalogOptions): Promise { + const manifestCatalog = await buildCliToolCatalogFromManifest(); + + if (!opts.cliExposedWorkflowIds.includes('xcode-ide')) { + return manifestCatalog; + } + + const dynamicTools = await loadDaemonBackedXcodeProxyTools(opts); + if (dynamicTools.length === 0) { + return manifestCatalog; + } + + const existingCliNames = new Set(manifestCatalog.tools.map((tool) => tool.cliName)); + const mergedTools = [ + ...manifestCatalog.tools, + ...dynamicTools.filter((tool) => !existingCliNames.has(tool.cliName)), + ]; + + return createToolCatalog(mergedTools); } diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts index db4dc604..6c25a1f7 100644 --- a/src/cli/daemon-client.ts +++ b/src/cli/daemon-client.ts @@ -7,8 +7,14 @@ import { type DaemonResponse, type DaemonMethod, type ToolInvokeParams, + type ToolInvokeResult, type DaemonStatusResult, type ToolListItem, + type XcodeIdeListParams, + type XcodeIdeListResult, + type XcodeIdeToolListItem, + type XcodeIdeInvokeParams, + type XcodeIdeInvokeResult, } from '../daemon/protocol.ts'; import type { ToolResponse } from '../types/common.ts'; import { getSocketPath } from '../daemon/socket-path.ts'; @@ -119,13 +125,35 @@ export class DaemonClient { * Invoke a tool. */ async invokeTool(tool: string, args: Record): Promise { - const result = await this.request<{ response: ToolResponse }>('tool.invoke', { + const result = await this.request('tool.invoke', { tool, args, } satisfies ToolInvokeParams); return result.response; } + /** + * List dynamic xcode-ide bridge tools from the daemon-managed bridge session. + */ + async listXcodeIdeTools(params?: XcodeIdeListParams): Promise { + const result = await this.request('xcode-ide.list', params); + return result.tools; + } + + /** + * Invoke a dynamic xcode-ide bridge tool through the daemon-managed bridge session. + */ + async invokeXcodeIdeTool( + remoteTool: string, + args: Record, + ): Promise { + const result = await this.request('xcode-ide.invoke', { + remoteTool, + args, + } satisfies XcodeIdeInvokeParams); + return result.response as ToolResponse; + } + /** * Check if daemon is running by attempting to connect. */ diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts index a08b7220..eb5ad843 100644 --- a/src/cli/daemon-control.ts +++ b/src/cli/daemon-control.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; import { DaemonClient } from './daemon-client.ts'; /** @@ -17,10 +18,16 @@ export const DEFAULT_POLL_INTERVAL_MS = 100; * Get the path to the daemon executable. */ export function getDaemonExecutablePath(): string { - // In the built output, daemon.js is in the same directory as cli.js + // In the built output, this file is build/cli/daemon-control.js and daemon is build/daemon.js. const currentFile = fileURLToPath(import.meta.url); const buildDir = dirname(currentFile); - return resolve(buildDir, 'daemon.js'); + const candidateJs = resolve(buildDir, '..', 'daemon.js'); + if (existsSync(candidateJs)) { + return candidateJs; + } + + // Fallback for source/dev layouts. + return resolve(buildDir, '..', 'daemon.ts'); } export interface StartDaemonBackgroundOptions { diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index 178a1bfd..b634b37a 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -15,6 +15,17 @@ export interface RegisterToolCommandsOptions { workflowNames?: string[]; } +function buildXcodeIdeNoCommandsMessage(workflowName: string): string { + return ( + `No CLI commands are currently exposed for '${workflowName}'.\n\n` + + `If you're expecting Xcode IDE tools here:\n` + + `1. Make sure Xcode MCP Tools is enabled in:\n` + + ` Settings > Intelligence > Xcode Tools\n\n` + + `If Xcode showed an authorization prompt, make sure you clicked Allow.\n\n` + + `Then run this command again.` + ); +} + /** * Register all tool commands from the catalog with yargs, grouped by workflow. */ @@ -39,10 +50,7 @@ export function registerToolCommands( workflowDescription, (yargs) => { // Hide root-level options from workflow help - yargs - .option('no-daemon', { hidden: true }) - .option('log-level', { hidden: true }) - .option('style', { hidden: true }); + yargs.option('log-level', { hidden: true }).option('style', { hidden: true }); // Register each tool as a subcommand under this workflow for (const tool of tools) { @@ -52,9 +60,7 @@ export function registerToolCommands( if (tools.length === 0) { const hint = workflowName === 'xcode-ide' - ? `No CLI commands are currently exposed for '${workflowName}'.\n` + - `Open Xcode with the target project and enable MCP Tools in Settings > Intelligence > Xcode Tools.\n` + - `Click Allow if prompted.` + ? buildXcodeIdeNoCommandsMessage(workflowName) : `No CLI commands are currently exposed for '${workflowName}'.`; yargs.epilogue(hint); @@ -67,7 +73,7 @@ export function registerToolCommands( if (tools.length === 0) { console.error( workflowName === 'xcode-ide' - ? `No CLI commands are currently exposed for '${workflowName}'. Open Xcode with the target project, enable MCP Tools in Settings > Intelligence > Xcode Tools, and click Allow if prompted.` + ? buildXcodeIdeNoCommandsMessage(workflowName) : `No CLI commands are currently exposed for '${workflowName}'.`, ); } @@ -97,10 +103,7 @@ function registerToolSubcommand( tool.description ?? `Run the ${tool.mcpName} tool`, (subYargs) => { // Hide root-level options from tool help - subYargs - .option('no-daemon', { hidden: true }) - .option('log-level', { hidden: true }) - .option('style', { hidden: true }); + subYargs.option('log-level', { hidden: true }).option('style', { hidden: true }); // Register schema-derived options (tool arguments) const toolArgNames: string[] = []; @@ -144,8 +147,6 @@ function registerToolSubcommand( const outputFormat = (argv.output as OutputFormat) ?? 'text'; const outputStyle = (argv.style as OutputStyle) ?? 'normal'; const socketPath = argv.socket as string; - const forceDaemon = argv.daemon as boolean | undefined; - const noDaemon = argv.noDaemon as boolean | undefined; const logLevel = argv['log-level'] as string | undefined; // Parse JSON args if provided @@ -162,16 +163,7 @@ function registerToolSubcommand( // Convert CLI argv to tool params (kebab-case -> camelCase) // Filter out internal CLI options before converting - const internalKeys = new Set([ - 'json', - 'output', - 'style', - 'socket', - 'daemon', - 'noDaemon', - '_', - '$0', - ]); + const internalKeys = new Set(['json', 'output', 'style', 'socket', '_', '$0']); const flagArgs: Record = {}; for (const [key, value] of Object.entries(argv as Record)) { if (!internalKeys.has(key)) { @@ -187,8 +179,6 @@ function registerToolSubcommand( const response = await invoker.invoke(tool.cliName, args, { runtime: 'cli', cliExposedWorkflowIds, - forceDaemon: Boolean(forceDaemon), - disableDaemon: Boolean(noDaemon), socketPath, workspaceRoot: opts.workspaceRoot, logLevel, diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 76aada55..e59af5b4 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -39,17 +39,6 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { default: opts.defaultSocketPath, hidden: true, }) - .option('daemon', { - type: 'boolean', - describe: 'Force daemon execution even for stateless tools', - default: false, - hidden: true, - }) - .option('no-daemon', { - type: 'boolean', - describe: 'Disable daemon usage and auto-start (stateful tools will fail)', - default: false, - }) .option('log-level', { type: 'string', describe: 'Set log verbosity level', diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts index c9cb0de0..253a2d98 100644 --- a/src/core/manifest/__tests__/schema.test.ts +++ b/src/core/manifest/__tests__/schema.test.ts @@ -15,9 +15,9 @@ describe('schema', () => { module: 'mcp/tools/simulator/build_sim', names: { mcp: 'build_sim' }, description: 'Build iOS app for simulator', - availability: { mcp: true, cli: true, daemon: true }, + availability: { mcp: true, cli: true }, predicates: [], - routing: { stateful: false, daemonAffinity: 'preferred' }, + routing: { stateful: false }, }; const result = toolManifestEntrySchema.safeParse(input); @@ -38,7 +38,7 @@ describe('schema', () => { const result = toolManifestEntrySchema.safeParse(input); expect(result.success).toBe(true); if (result.success) { - expect(result.data.availability).toEqual({ mcp: true, cli: true, daemon: true }); + expect(result.data.availability).toEqual({ mcp: true, cli: true }); expect(result.data.predicates).toEqual([]); } }); @@ -67,23 +67,26 @@ describe('schema', () => { } }); - it('should accept daemonAffinity values', () => { - const inputPreferred = { + it('should reject availability.daemon', () => { + const input = { id: 'tool1', module: 'mcp/tools/test/tool1', names: { mcp: 'tool1' }, - routing: { stateful: false, daemonAffinity: 'preferred' }, + availability: { mcp: true, cli: true, daemon: true }, }; - const inputRequired = { + expect(toolManifestEntrySchema.safeParse(input).success).toBe(false); + }); + + it('should reject routing.daemonAffinity', () => { + const input = { id: 'tool2', module: 'mcp/tools/test/tool2', names: { mcp: 'tool2' }, routing: { stateful: true, daemonAffinity: 'required' }, }; - expect(toolManifestEntrySchema.safeParse(inputPreferred).success).toBe(true); - expect(toolManifestEntrySchema.safeParse(inputRequired).success).toBe(true); + expect(toolManifestEntrySchema.safeParse(input).success).toBe(false); }); }); @@ -93,7 +96,7 @@ describe('schema', () => { id: 'simulator', title: 'iOS Simulator Development', description: 'Build and test iOS apps on simulators', - availability: { mcp: true, cli: true, daemon: true }, + availability: { mcp: true, cli: true }, selection: { mcp: { defaultEnabled: true, @@ -124,7 +127,7 @@ describe('schema', () => { const result = workflowManifestEntrySchema.safeParse(input); expect(result.success).toBe(true); if (result.success) { - expect(result.data.availability).toEqual({ mcp: true, cli: true, daemon: true }); + expect(result.data.availability).toEqual({ mcp: true, cli: true }); expect(result.data.predicates).toEqual([]); } }); @@ -147,7 +150,7 @@ describe('schema', () => { id: 'session-management', title: 'Session Management', description: 'Manage session defaults', - availability: { mcp: true, cli: false, daemon: false }, + availability: { mcp: true, cli: false }, selection: { mcp: { defaultEnabled: true, @@ -196,7 +199,7 @@ describe('schema', () => { id: 'build_sim', module: 'mcp/tools/simulator/build_sim', names: { mcp: 'build_sim', cli: 'build-simulator' }, - availability: { mcp: true, cli: true, daemon: true }, + availability: { mcp: true, cli: true }, predicates: [], }; @@ -208,7 +211,7 @@ describe('schema', () => { id: 'build_sim', module: 'mcp/tools/simulator/build_sim', names: { mcp: 'build_sim' }, - availability: { mcp: true, cli: true, daemon: true }, + availability: { mcp: true, cli: true }, predicates: [], }; diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index f88055b9..97b055cb 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -8,21 +8,23 @@ import { z } from 'zod'; /** * Availability flags for different runtimes. */ -export const availabilitySchema = z.object({ - mcp: z.boolean().default(true), - cli: z.boolean().default(true), - daemon: z.boolean().default(true), -}); +export const availabilitySchema = z + .object({ + mcp: z.boolean().default(true), + cli: z.boolean().default(true), + }) + .strict(); export type Availability = z.infer; /** - * Routing hints for daemon affinity. + * Routing hints for daemon-backed CLI execution. */ -export const routingSchema = z.object({ - stateful: z.boolean().default(false), - daemonAffinity: z.enum(['preferred', 'required']).optional(), -}); +export const routingSchema = z + .object({ + stateful: z.boolean().default(false), + }) + .strict(); export type Routing = z.infer; @@ -73,7 +75,7 @@ export const toolManifestEntrySchema = z.object({ description: z.string().optional(), /** Per-runtime availability flags */ - availability: availabilitySchema.default({ mcp: true, cli: true, daemon: true }), + availability: availabilitySchema.default({ mcp: true, cli: true }), /** Predicate names for visibility filtering (all must pass) */ predicates: z.array(z.string()).default([]), @@ -123,7 +125,7 @@ export const workflowManifestEntrySchema = z.object({ description: z.string(), /** Per-runtime availability flags */ - availability: availabilitySchema.default({ mcp: true, cli: true, daemon: true }), + availability: availabilitySchema.default({ mcp: true, cli: true }), /** MCP selection rules */ selection: workflowSelectionSchema.optional(), diff --git a/src/core/plugin-types.ts b/src/core/plugin-types.ts index 02b82e22..ad617ac5 100644 --- a/src/core/plugin-types.ts +++ b/src/core/plugin-types.ts @@ -11,8 +11,6 @@ export interface PluginCliMeta { readonly schema?: ToolSchemaShape; /** Mark tool as requiring daemon routing */ readonly stateful?: boolean; - /** Prefer daemon routing when available (without forcing auto-start) */ - readonly daemonAffinity?: 'preferred' | 'required'; } export interface PluginMeta { diff --git a/src/daemon.ts b/src/daemon.ts index 00e347ea..02151fa8 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -21,6 +21,13 @@ import { } from './daemon/daemon-registry.ts'; import { log, setLogFile, setLogLevel, type LogLevel } from './utils/logger.ts'; import { version } from './version.ts'; +import { + DAEMON_IDLE_TIMEOUT_ENV_KEY, + DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS, + resolveDaemonIdleTimeoutMs, + getDaemonRuntimeActivitySnapshot, + hasActiveRuntimeSessions, +} from './daemon/idle-shutdown.ts'; async function checkExistingDaemon(socketPath: string): Promise { return new Promise((resolve) => { @@ -169,10 +176,18 @@ async function main(): Promise { const excludedWorkflows = ['session-management', 'workflow-discovery']; - // Get all workflows from manifest (for reporting purposes) + // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows. + // CLI exposure is controlled at CLI catalog/command registration time. + // Get all workflows from manifest (for reporting purposes and filtering). const manifest = loadManifest(); const allWorkflowIds = Array.from(manifest.workflows.keys()); - const daemonWorkflows = allWorkflowIds.filter((wf) => !excludedWorkflows.includes(wf)); + const daemonWorkflows = allWorkflowIds.filter((workflowId) => { + if (excludedWorkflows.includes(workflowId)) { + return false; + } + return true; + }); + const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide'); // Build tool catalog using manifest system const catalog = await buildDaemonToolCatalogFromManifest({ @@ -182,9 +197,48 @@ async function main(): Promise { log('info', `[Daemon] Loaded ${catalog.tools.length} tools`); const startedAt = new Date().toISOString(); + const idleTimeoutMs = resolveDaemonIdleTimeoutMs(); + const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim(); + if (configuredIdleTimeout) { + const parsedIdleTimeout = Number(configuredIdleTimeout); + if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) { + log( + 'warn', + `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`, + ); + } + } + + if (idleTimeoutMs === 0) { + log('info', '[Daemon] Idle shutdown disabled'); + } else { + log( + 'info', + `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`, + ); + } + + let isShuttingDown = false; + let inFlightRequests = 0; + let lastActivityAt = Date.now(); + let idleCheckTimer: NodeJS.Timeout | null = null; + + const markActivity = (): void => { + lastActivityAt = Date.now(); + }; // Unified shutdown handler const shutdown = (): void => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + if (idleCheckTimer) { + clearInterval(idleCheckTimer); + idleCheckTimer = null; + } + log('info', '[Daemon] Shutting down...'); // Close the server @@ -216,9 +270,47 @@ async function main(): Promise { catalog, workspaceRoot, workspaceKey, + xcodeIdeWorkflowEnabled, requestShutdown: shutdown, + onRequestStarted: () => { + inFlightRequests += 1; + markActivity(); + }, + onRequestFinished: () => { + inFlightRequests = Math.max(0, inFlightRequests - 1); + markActivity(); + }, }); + if (idleTimeoutMs > 0) { + idleCheckTimer = setInterval(() => { + if (isShuttingDown) { + return; + } + + const idleForMs = Date.now() - lastActivityAt; + if (idleForMs < idleTimeoutMs) { + return; + } + + if (inFlightRequests > 0) { + return; + } + + const sessionSnapshot = getDaemonRuntimeActivitySnapshot(); + if (hasActiveRuntimeSessions(sessionSnapshot)) { + return; + } + + log( + 'info', + `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`, + ); + shutdown(); + }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS); + idleCheckTimer.unref?.(); + } + server.listen(socketPath, () => { log('info', `[Daemon] Listening on ${socketPath}`); diff --git a/src/daemon/__tests__/activity-registry.test.ts b/src/daemon/__tests__/activity-registry.test.ts new file mode 100644 index 00000000..b747e9ab --- /dev/null +++ b/src/daemon/__tests__/activity-registry.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + acquireDaemonActivity, + clearDaemonActivityRegistry, + getDaemonActivitySnapshot, +} from '../activity-registry.ts'; + +describe('daemon activity registry', () => { + beforeEach(() => { + clearDaemonActivityRegistry(); + }); + + afterEach(() => { + clearDaemonActivityRegistry(); + }); + + it('tracks acquired activity by category', () => { + const releaseFirst = acquireDaemonActivity('logging.simulator'); + const releaseSecond = acquireDaemonActivity('logging.simulator'); + + expect(getDaemonActivitySnapshot()).toEqual({ + activeOperationCount: 2, + byCategory: { + 'logging.simulator': 2, + }, + }); + + releaseFirst(); + expect(getDaemonActivitySnapshot()).toEqual({ + activeOperationCount: 1, + byCategory: { + 'logging.simulator': 1, + }, + }); + + releaseSecond(); + expect(getDaemonActivitySnapshot()).toEqual({ + activeOperationCount: 0, + byCategory: {}, + }); + }); + + it('treats release as idempotent', () => { + const release = acquireDaemonActivity('video.capture'); + release(); + release(); + + expect(getDaemonActivitySnapshot()).toEqual({ + activeOperationCount: 0, + byCategory: {}, + }); + }); + + it('rejects empty activity keys', () => { + expect(() => acquireDaemonActivity(' ')).toThrow('activityKey must be a non-empty string'); + }); +}); diff --git a/src/daemon/__tests__/idle-shutdown.test.ts b/src/daemon/__tests__/idle-shutdown.test.ts new file mode 100644 index 00000000..7d72e31b --- /dev/null +++ b/src/daemon/__tests__/idle-shutdown.test.ts @@ -0,0 +1,66 @@ +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'; + +describe('daemon idle shutdown', () => { + beforeEach(() => { + clearDaemonActivityRegistry(); + }); + + afterEach(() => { + clearDaemonActivityRegistry(); + }); + + describe('resolveDaemonIdleTimeoutMs', () => { + it('uses default timeout when env is not set', () => { + expect(resolveDaemonIdleTimeoutMs({})).toBe(DEFAULT_DAEMON_IDLE_TIMEOUT_MS); + }); + + it('uses configured timeout when env has a valid value', () => { + expect(resolveDaemonIdleTimeoutMs({ [DAEMON_IDLE_TIMEOUT_ENV_KEY]: '15000' })).toBe(15000); + }); + + it('falls back to default timeout when env has an invalid value', () => { + expect(resolveDaemonIdleTimeoutMs({ [DAEMON_IDLE_TIMEOUT_ENV_KEY]: '-1' })).toBe( + DEFAULT_DAEMON_IDLE_TIMEOUT_MS, + ); + expect(resolveDaemonIdleTimeoutMs({ [DAEMON_IDLE_TIMEOUT_ENV_KEY]: 'NaN' })).toBe( + DEFAULT_DAEMON_IDLE_TIMEOUT_MS, + ); + }); + }); + + describe('hasActiveRuntimeSessions', () => { + it('returns false when active operation count is zero', () => { + expect(hasActiveRuntimeSessions({ activeOperationCount: 0, byCategory: {} })).toBe(false); + }); + + it('returns true when active operation count is positive', () => { + expect( + hasActiveRuntimeSessions({ + activeOperationCount: 1, + byCategory: { 'video.capture': 1 }, + }), + ).toBe(true); + }); + }); + + describe('getDaemonRuntimeActivitySnapshot', () => { + it('reports category counters for active daemon activity', () => { + const release = acquireDaemonActivity('swift-package.background-process'); + + const snapshot = getDaemonRuntimeActivitySnapshot(); + expect(snapshot.activeOperationCount).toBe(1); + expect(snapshot.byCategory).toEqual({ + 'swift-package.background-process': 1, + }); + release(); + }); + }); +}); diff --git a/src/daemon/activity-registry.ts b/src/daemon/activity-registry.ts new file mode 100644 index 00000000..17185856 --- /dev/null +++ b/src/daemon/activity-registry.ts @@ -0,0 +1,67 @@ +const activityCounts = new Map(); + +function normalizeActivityKey(activityKey: string): string { + return activityKey.trim(); +} + +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; + if (current <= 1) { + activityCounts.delete(activityKey); + return; + } + activityCounts.set(activityKey, current - 1); +} + +/** + * Acquire a long-running daemon activity lease. + * Call the returned release function once the activity has finished. + */ +export function acquireDaemonActivity(activityKey: string): () => void { + const normalizedKey = normalizeActivityKey(activityKey); + if (!normalizedKey) { + throw new Error('activityKey must be a non-empty string'); + } + + incrementActivity(normalizedKey); + + let released = false; + return (): void => { + if (released) { + return; + } + released = true; + decrementActivity(normalizedKey); + }; +} + +export interface DaemonActivitySnapshot { + activeOperationCount: number; + byCategory: Record; +} + +export function getDaemonActivitySnapshot(): DaemonActivitySnapshot { + const byCategory = Object.fromEntries( + Array.from(activityCounts.entries()).sort(([left], [right]) => left.localeCompare(right)), + ); + const activeOperationCount = Array.from(activityCounts.values()).reduce( + (accumulator, count) => accumulator + count, + 0, + ); + return { + activeOperationCount, + byCategory, + }; +} + +/** + * Test helper to reset shared process-local activity state. + */ +export function clearDaemonActivityRegistry(): void { + activityCounts.clear(); +} diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts index a68caa0a..685acdf6 100644 --- a/src/daemon/daemon-server.ts +++ b/src/daemon/daemon-server.ts @@ -7,10 +7,16 @@ import type { ToolInvokeParams, DaemonStatusResult, ToolListItem, + XcodeIdeListParams, + XcodeIdeListResult, + XcodeIdeInvokeParams, + XcodeIdeInvokeResult, } from './protocol.ts'; import { DAEMON_PROTOCOL_VERSION } from './protocol.ts'; import { DefaultToolInvoker } from '../runtime/tool-invoker.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'; export interface DaemonServerContext { socketPath: string; @@ -20,8 +26,13 @@ export interface DaemonServerContext { catalog: ToolCatalog; workspaceRoot: string; workspaceKey: string; + xcodeIdeWorkflowEnabled: boolean; /** Callback to request graceful shutdown (used instead of direct process.exit) */ requestShutdown: () => void; + /** Callback invoked whenever a daemon request starts processing. */ + onRequestStarted?: () => void; + /** Callback invoked after a daemon request has finished processing. */ + onRequestFinished?: () => void; } /** @@ -29,6 +40,15 @@ export interface DaemonServerContext { */ export function startDaemonServer(ctx: DaemonServerContext): net.Server { const invoker = new DefaultToolInvoker(ctx.catalog); + const xcodeIdeService = new XcodeIdeToolService(); + xcodeIdeService.setWorkflowEnabled(ctx.xcodeIdeWorkflowEnabled); + if (ctx.xcodeIdeWorkflowEnabled) { + // Warm dynamic tool cache in the background so CLI discovery can stay fast. + void xcodeIdeService.listTools({ refresh: true }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + log('debug', `[Daemon] Initial xcode-ide bridge prefetch failed: ${message}`); + }); + } const server = net.createServer((socket) => { log('info', '[Daemon] Client connected'); @@ -41,6 +61,7 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { id: req?.id ?? 'unknown', }; + ctx.onRequestStarted?.(); try { if (!req || typeof req !== 'object') { return writeFrame(socket, { @@ -111,6 +132,72 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { return writeFrame(socket, { ...base, result: { response } }); } + case 'xcode-ide.list': { + if (!ctx.xcodeIdeWorkflowEnabled) { + return writeFrame(socket, { + ...base, + error: { + code: 'NOT_FOUND', + message: + 'xcode-ide workflow is not enabled for this daemon session (set XCODEBUILDMCP_ENABLED_WORKFLOWS to include xcode-ide)', + }, + }); + } + + const params = (req.params ?? {}) as XcodeIdeListParams; + const refresh = params.refresh === true; + if (params.prefetch === true && !refresh) { + void xcodeIdeService.listTools({ refresh: true }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + log('debug', `[Daemon] xcode-ide prefetch failed: ${message}`); + }); + } + const tools = await xcodeIdeService.listTools({ + refresh, + }); + const result: XcodeIdeListResult = { + tools: tools.map((tool) => ({ + remoteName: tool.name, + localName: toLocalToolName(tool.name), + description: tool.description ?? '', + inputSchema: tool.inputSchema, + annotations: tool.annotations, + })), + }; + return writeFrame(socket, { ...base, result }); + } + + case 'xcode-ide.invoke': { + if (!ctx.xcodeIdeWorkflowEnabled) { + return writeFrame(socket, { + ...base, + error: { + code: 'NOT_FOUND', + message: + 'xcode-ide workflow is not enabled for this daemon session (set XCODEBUILDMCP_ENABLED_WORKFLOWS to include xcode-ide)', + }, + }); + } + + const params = req.params as XcodeIdeInvokeParams; + if (!params?.remoteTool) { + return writeFrame(socket, { + ...base, + error: { + code: 'BAD_REQUEST', + message: 'Missing remoteTool parameter', + }, + }); + } + + const response = await xcodeIdeService.invokeTool( + params.remoteTool, + params.args ?? {}, + ); + const result: XcodeIdeInvokeResult = { response }; + return writeFrame(socket, { ...base, result }); + } + default: return writeFrame(socket, { ...base, @@ -126,6 +213,8 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { message: error instanceof Error ? error.message : String(error), }, }); + } finally { + ctx.onRequestFinished?.(); } }, (err) => { @@ -145,6 +234,9 @@ export function startDaemonServer(ctx: DaemonServerContext): net.Server { server.on('error', (err) => { log('error', `[Daemon] Server error: ${err.message}`); }); + server.on('close', () => { + void xcodeIdeService.disconnect(); + }); return server; } diff --git a/src/daemon/idle-shutdown.ts b/src/daemon/idle-shutdown.ts new file mode 100644 index 00000000..fee5cc05 --- /dev/null +++ b/src/daemon/idle-shutdown.ts @@ -0,0 +1,32 @@ +import { getDaemonActivitySnapshot, 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, +): number { + const raw = env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim(); + if (!raw) { + return fallbackMs; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallbackMs; + } + + return Math.floor(parsed); +} + +export function getDaemonRuntimeActivitySnapshot(): DaemonRuntimeActivitySnapshot { + return getDaemonActivitySnapshot(); +} + +export function hasActiveRuntimeSessions(snapshot: DaemonRuntimeActivitySnapshot): boolean { + return snapshot.activeOperationCount > 0; +} diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 0d100e2e..61fd9802 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -1,6 +1,15 @@ +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ToolResponse } from '../types/common.ts'; + export const DAEMON_PROTOCOL_VERSION = 1 as const; -export type DaemonMethod = 'daemon.status' | 'daemon.stop' | 'tool.list' | 'tool.invoke'; +export type DaemonMethod = + | 'daemon.status' + | 'daemon.stop' + | 'tool.list' + | 'tool.invoke' + | 'xcode-ide.list' + | 'xcode-ide.invoke'; export interface DaemonRequest { v: typeof DAEMON_PROTOCOL_VERSION; @@ -35,7 +44,7 @@ export interface ToolInvokeParams { } export interface ToolInvokeResult { - response: unknown; + response: ToolResponse; } export interface DaemonStatusResult { @@ -57,3 +66,30 @@ export interface ToolListItem { description: string; stateful: boolean; } + +export interface XcodeIdeListParams { + refresh?: boolean; + /** Trigger a background refresh while still returning cached tools immediately. */ + prefetch?: boolean; +} + +export interface XcodeIdeToolListItem { + remoteName: string; + localName: string; + description: string; + inputSchema?: unknown; + annotations?: ToolAnnotations; +} + +export interface XcodeIdeListResult { + tools: XcodeIdeToolListItem[]; +} + +export interface XcodeIdeInvokeParams { + remoteTool: string; + args: Record; +} + +export interface XcodeIdeInvokeResult { + response: unknown; +} diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index c190b7df..3d6acc9e 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -5,18 +5,18 @@ import { createTextResponse, type ToolResponse, } from '../../utils/responses/index.ts'; -import { XcodeToolsBridgeClient } from './client.ts'; import { XcodeToolsProxyRegistry, type ProxySyncResult } from './registry.ts'; import { buildXcodeToolsBridgeStatus, getMcpBridgeAvailability, type XcodeToolsBridgeStatus, } from './core.ts'; +import { XcodeIdeToolService } from './tool-service.ts'; export class XcodeToolsBridgeManager { private readonly server: McpServer; - private readonly client: XcodeToolsBridgeClient; private readonly registry: XcodeToolsProxyRegistry; + private readonly service: XcodeIdeToolService; private workflowEnabled = false; private lastError: string | null = null; @@ -25,32 +25,29 @@ export class XcodeToolsBridgeManager { constructor(server: McpServer) { this.server = server; this.registry = new XcodeToolsProxyRegistry(server); - this.client = new XcodeToolsBridgeClient({ - onToolsListChanged: (): void => { + this.service = new XcodeIdeToolService({ + onToolCatalogInvalidated: (): void => { void this.syncTools({ reason: 'listChanged' }); }, - onBridgeClosed: (): void => { - this.registry.clear(); - this.lastError = this.client.getStatus().lastError ?? this.lastError; - }, }); } setWorkflowEnabled(enabled: boolean): void { this.workflowEnabled = enabled; + this.service.setWorkflowEnabled(enabled); } async shutdown(): Promise { this.registry.clear(); - await this.client.disconnect(); + await this.service.disconnect(); } async getStatus(): Promise { return buildXcodeToolsBridgeStatus({ workflowEnabled: this.workflowEnabled, proxiedToolCount: this.registry.getRegisteredCount(), - lastError: this.lastError, - clientStatus: this.client.getStatus(), + lastError: this.lastError ?? this.service.getLastError(), + clientStatus: this.service.getClientStatus(), }); } @@ -74,11 +71,10 @@ export class XcodeToolsBridgeManager { } try { - await this.client.connectOnce(); - const remoteTools = await this.client.listTools(); + const remoteTools = await this.service.listTools({ refresh: true }); const sync = this.registry.sync(remoteTools, async (remoteName, args) => { - return this.client.callTool(remoteName, args); + return this.service.invokeTool(remoteName, args); }); if (opts.reason !== 'listChanged') { @@ -111,7 +107,7 @@ export class XcodeToolsBridgeManager { async disconnect(): Promise { this.registry.clear(); this.server.sendToolListChanged(); - await this.client.disconnect(); + await this.service.disconnect(); } async statusTool(): Promise { diff --git a/src/integrations/xcode-tools-bridge/standalone.ts b/src/integrations/xcode-tools-bridge/standalone.ts index 81e16fa6..ce260ae1 100644 --- a/src/integrations/xcode-tools-bridge/standalone.ts +++ b/src/integrations/xcode-tools-bridge/standalone.ts @@ -3,35 +3,27 @@ import { createTextResponse, type ToolResponse, } from '../../utils/responses/index.ts'; -import { XcodeToolsBridgeClient } from './client.ts'; -import { - buildXcodeToolsBridgeStatus, - getMcpBridgeAvailability, - type XcodeToolsBridgeStatus, -} from './core.ts'; +import { buildXcodeToolsBridgeStatus, type XcodeToolsBridgeStatus } from './core.ts'; +import { XcodeIdeToolService } from './tool-service.ts'; export class StandaloneXcodeToolsBridge { - private readonly client: XcodeToolsBridgeClient; - private lastError: string | null = null; + private readonly service: XcodeIdeToolService; constructor() { - this.client = new XcodeToolsBridgeClient({ - onBridgeClosed: (): void => { - this.lastError = this.client.getStatus().lastError ?? this.lastError; - }, - }); + this.service = new XcodeIdeToolService(); + this.service.setWorkflowEnabled(true); } async shutdown(): Promise { - await this.client.disconnect(); + await this.service.disconnect(); } async getStatus(): Promise { return buildXcodeToolsBridgeStatus({ workflowEnabled: false, proxiedToolCount: 0, - lastError: this.lastError, - clientStatus: this.client.getStatus(), + lastError: this.service.getLastError(), + clientStatus: this.service.getClientStatus(), }); } @@ -42,15 +34,7 @@ export class StandaloneXcodeToolsBridge { async syncTool(): Promise { try { - const bridge = await getMcpBridgeAvailability(); - if (!bridge.available) { - this.lastError = 'mcpbridge not available (xcrun --find mcpbridge failed)'; - return createErrorResponse('Bridge sync failed', this.lastError); - } - - await this.client.connectOnce(); - const remoteTools = await this.client.listTools(); - this.lastError = null; + const remoteTools = await this.service.listTools({ refresh: true }); const sync = { added: remoteTools.length, @@ -62,21 +46,19 @@ export class StandaloneXcodeToolsBridge { return createTextResponse(JSON.stringify({ sync, status }, null, 2)); } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.lastError = message; return createErrorResponse('Bridge sync failed', message); } finally { - await this.client.disconnect(); + await this.service.disconnect(); } } async disconnectTool(): Promise { try { - await this.client.disconnect(); + await this.service.disconnect(); const status = await this.getStatus(); return createTextResponse(JSON.stringify(status, null, 2)); } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.lastError = message; return createErrorResponse('Bridge disconnect failed', message); } } diff --git a/src/integrations/xcode-tools-bridge/tool-service.ts b/src/integrations/xcode-tools-bridge/tool-service.ts new file mode 100644 index 00000000..b2c894b4 --- /dev/null +++ b/src/integrations/xcode-tools-bridge/tool-service.ts @@ -0,0 +1,154 @@ +import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { + XcodeToolsBridgeClient, + type XcodeToolsBridgeClientOptions, + type XcodeToolsBridgeClientStatus, +} from './client.ts'; +import { getMcpBridgeAvailability } from './core.ts'; + +export interface BridgeCapabilities { + available: boolean; + path: string | null; + connected: boolean; + bridgePid: number | null; + lastError: string | null; + toolCount: number; +} + +export interface XcodeIdeToolServiceOptions { + onToolCatalogInvalidated?: () => void; + clientOptions?: XcodeToolsBridgeClientOptions; +} + +export interface ListBridgeToolsOptions { + refresh?: boolean; +} + +export class XcodeIdeToolService { + private readonly client: XcodeToolsBridgeClient; + private readonly options: XcodeIdeToolServiceOptions; + + private workflowEnabled = false; + private toolCatalog = new Map(); + private lastError: string | null = null; + private listInFlight: Promise | null = null; + + constructor(options: XcodeIdeToolServiceOptions = {}) { + this.options = options; + this.client = new XcodeToolsBridgeClient({ + ...this.options.clientOptions, + onToolsListChanged: (): void => { + this.toolCatalog.clear(); + this.options.onToolCatalogInvalidated?.(); + }, + onBridgeClosed: (): void => { + this.toolCatalog.clear(); + this.lastError = this.client.getStatus().lastError ?? this.lastError; + this.options.onToolCatalogInvalidated?.(); + }, + }); + } + + setWorkflowEnabled(enabled: boolean): void { + this.workflowEnabled = enabled; + } + + isWorkflowEnabled(): boolean { + return this.workflowEnabled; + } + + getClientStatus(): XcodeToolsBridgeClientStatus { + return this.client.getStatus(); + } + + getLastError(): string | null { + return this.lastError ?? this.client.getStatus().lastError; + } + + getCachedTools(): Tool[] { + return [...this.toolCatalog.values()]; + } + + async getCapabilities(): Promise { + const bridge = await getMcpBridgeAvailability(); + const clientStatus = this.client.getStatus(); + return { + available: bridge.available, + path: bridge.path, + connected: clientStatus.connected, + bridgePid: clientStatus.bridgePid, + lastError: this.getLastError(), + toolCount: this.toolCatalog.size, + }; + } + + async listTools(opts: ListBridgeToolsOptions = {}): Promise { + if (opts.refresh === false) { + return this.getCachedTools(); + } + return this.refreshTools(); + } + + async invokeTool(name: string, args: Record): Promise { + await this.ensureConnected(); + try { + const response = await this.client.callTool(name, args); + this.lastError = null; + return response; + } catch (error) { + this.lastError = toErrorMessage(error); + throw error; + } + } + + async disconnect(): Promise { + this.toolCatalog.clear(); + this.listInFlight = null; + await this.client.disconnect(); + } + + private async refreshTools(): Promise { + if (this.listInFlight) { + return this.listInFlight; + } + + this.listInFlight = (async (): Promise => { + await this.ensureConnected(); + const tools = await this.client.listTools(); + this.toolCatalog = new Map(tools.map((tool) => [tool.name, tool])); + this.lastError = null; + return tools; + })(); + + try { + return await this.listInFlight; + } catch (error) { + this.toolCatalog.clear(); + this.lastError = toErrorMessage(error); + throw error; + } finally { + this.listInFlight = null; + } + } + + private async ensureConnected(): Promise { + if (!this.workflowEnabled) { + const message = 'xcode-ide workflow is not enabled'; + this.lastError = message; + throw new Error(message); + } + + const bridge = await getMcpBridgeAvailability(); + if (!bridge.available) { + const message = 'mcpbridge not available (xcrun --find mcpbridge failed)'; + this.lastError = message; + throw new Error(message); + } + + await this.client.connectOnce(); + } +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 76d0315e..843503ed 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -25,6 +25,7 @@ import { } from '../../../utils/log-capture/device-log-sessions.ts'; import type { WriteStream } from 'fs'; import { getConfig } from '../../../utils/config-store.ts'; +import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; /** * Log file retention policy for device logs: @@ -431,6 +432,7 @@ export async function startDeviceLogCapture( // For testing purposes, we'll simulate process management // In actual usage, the process would be managed by the executor result + session.releaseActivity = acquireDaemonActivity('logging.device'); activeDeviceLogSessions.set(logSessionId, session); log('info', `Device log capture started with session ID: ${logSessionId}`); diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index 8ecb1b51..4553cefd 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -66,6 +66,7 @@ export async function stop_device_log_capLogic( } const logFilePath = session.logFilePath; + session.releaseActivity?.(); activeDeviceLogSessions.delete(logSessionId); // Check file access diff --git a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts index e10114a5..06eb57ba 100644 --- a/src/mcp/tools/swift-package/__tests__/active-processes.test.ts +++ b/src/mcp/tools/swift-package/__tests__/active-processes.test.ts @@ -12,11 +12,16 @@ import { clearAllProcesses, type ProcessInfo, } from '../active-processes.ts'; +import { + clearDaemonActivityRegistry, + getDaemonActivitySnapshot, +} from '../../../../daemon/activity-registry.ts'; describe('active-processes module', () => { // Clear the map before each test beforeEach(() => { clearAllProcesses(); + clearDaemonActivityRegistry(); }); describe('activeProcesses Map', () => { @@ -127,6 +132,25 @@ describe('active-processes module', () => { expect(activeProcesses.size).toBe(0); expect(activeProcesses.get(54321)).toBe(undefined); }); + + it('should release daemon activity when removing process', () => { + let releaseCalls = 0; + + addProcess(321, { + process: { + kill: () => {}, + on: () => {}, + pid: 321, + }, + startedAt: new Date('2023-03-20T09:15:00.000Z'), + releaseActivity: () => { + releaseCalls += 1; + }, + }); + + removeProcess(321); + expect(releaseCalls).toBe(1); + }); }); describe('clearAllProcesses function', () => { @@ -152,6 +176,36 @@ describe('active-processes module', () => { expect(activeProcesses.size).toBe(0); }); + it('should release daemon activity for all tracked processes', () => { + const calls = { first: 0, second: 0 }; + addProcess(1111, { + process: { + kill: () => {}, + on: () => {}, + pid: 1111, + }, + startedAt: new Date(), + releaseActivity: () => { + calls.first += 1; + }, + }); + addProcess(2222, { + process: { + kill: () => {}, + on: () => {}, + pid: 2222, + }, + startedAt: new Date(), + releaseActivity: () => { + calls.second += 1; + }, + }); + + clearAllProcesses(); + expect(calls).toEqual({ first: 1, second: 1 }); + expect(getDaemonActivitySnapshot().activeOperationCount).toBe(0); + }); + it('should work on already empty map', () => { expect(activeProcesses.size).toBe(0); clearAllProcesses(); diff --git a/src/mcp/tools/swift-package/active-processes.ts b/src/mcp/tools/swift-package/active-processes.ts index c125705c..7dd450c3 100644 --- a/src/mcp/tools/swift-package/active-processes.ts +++ b/src/mcp/tools/swift-package/active-processes.ts @@ -13,6 +13,7 @@ export interface ProcessInfo { startedAt: Date; executableName?: string; packagePath?: string; + releaseActivity?: () => void; } // Global map to track active processes @@ -24,13 +25,20 @@ export const getProcess = (pid: number): ProcessInfo | undefined => { }; export const addProcess = (pid: number, processInfo: ProcessInfo): void => { + const existing = activeProcesses.get(pid); + existing?.releaseActivity?.(); activeProcesses.set(pid, processInfo); }; export const removeProcess = (pid: number): boolean => { + const existing = activeProcesses.get(pid); + existing?.releaseActivity?.(); return activeProcesses.delete(pid); }; export const clearAllProcesses = (): void => { + for (const processInfo of activeProcesses.values()) { + processInfo.releaseActivity?.(); + } activeProcesses.clear(); }; diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts index 38dbd1ab..451733a7 100644 --- a/src/mcp/tools/swift-package/swift_package_run.ts +++ b/src/mcp/tools/swift-package/swift_package_run.ts @@ -11,6 +11,7 @@ import { createSessionAwareTool, getSessionAwareToolSchemaShape, } from '../../../utils/typed-tool-factory.ts'; +import { acquireDaemonActivity } from '../../../daemon/activity-registry.ts'; // Define schema as ZodObject const baseSchemaObject = z.object({ @@ -115,6 +116,7 @@ export async function swift_package_runLogic( startedAt: new Date(), executableName: params.executableName, packagePath: resolvedPath, + releaseActivity: acquireDaemonActivity('swift-package.background-process'), }); return { diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts new file mode 100644 index 00000000..8fbe7589 --- /dev/null +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import type { ToolResponse } from '../../types/common.ts'; +import type { ToolDefinition } from '../types.ts'; +import { createToolCatalog } from '../tool-catalog.ts'; +import { DefaultToolInvoker } from '../tool-invoker.ts'; +import { ensureDaemonRunning } from '../../cli/daemon-control.ts'; + +const daemonClientMock = { + isRunning: vi.fn<() => Promise>(), + invokeXcodeIdeTool: + vi.fn<(name: string, args: Record) => Promise>(), + invokeTool: vi.fn<(name: string, args: Record) => Promise>(), + listTools: vi.fn<() => Promise>>(), +}; + +vi.mock('../../cli/daemon-client.ts', () => ({ + DaemonClient: vi.fn().mockImplementation(() => daemonClientMock), +})); + +vi.mock('../../cli/daemon-control.ts', () => ({ + ensureDaemonRunning: vi.fn(), + DEFAULT_DAEMON_STARTUP_TIMEOUT_MS: 5000, +})); + +function textResponse(text: string): ToolResponse { + return { + content: [{ type: 'text', text }], + }; +} + +function makeTool(opts: { + cliName: string; + workflow: string; + stateful: boolean; + handler: ToolDefinition['handler']; + xcodeIdeRemoteToolName?: string; +}): ToolDefinition { + return { + cliName: opts.cliName, + mcpName: opts.cliName.replace(/-/g, '_'), + workflow: opts.workflow, + description: `${opts.cliName} tool`, + mcpSchema: { value: z.string().optional() }, + cliSchema: { value: z.string().optional() }, + stateful: opts.stateful, + xcodeIdeRemoteToolName: opts.xcodeIdeRemoteToolName, + handler: opts.handler, + }; +} + +describe('DefaultToolInvoker CLI routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + daemonClientMock.isRunning.mockResolvedValue(true); + daemonClientMock.invokeXcodeIdeTool.mockResolvedValue(textResponse('daemon-xcode-ide-result')); + daemonClientMock.invokeTool.mockResolvedValue(textResponse('daemon-result')); + daemonClientMock.listTools.mockResolvedValue([]); + }); + + it('uses direct invocation for stateless tools', async () => { + const directHandler = vi.fn().mockResolvedValue(textResponse('direct-result')); + const catalog = createToolCatalog([ + makeTool({ + cliName: 'list-sims', + workflow: 'simulator', + stateful: false, + handler: directHandler, + }), + ]); + const invoker = new DefaultToolInvoker(catalog); + + const response = await invoker.invoke( + 'list-sims', + { value: 'hello' }, + { + runtime: 'cli', + socketPath: '/tmp/xcodebuildmcp.sock', + }, + ); + + expect(directHandler).toHaveBeenCalledWith({ value: 'hello' }); + expect(daemonClientMock.isRunning).not.toHaveBeenCalled(); + expect(daemonClientMock.invokeTool).not.toHaveBeenCalled(); + expect(response.content[0].text).toBe('direct-result'); + }); + + it('routes stateful tools through daemon and auto-starts when needed', async () => { + daemonClientMock.isRunning.mockResolvedValue(false); + const directHandler = vi.fn().mockResolvedValue(textResponse('direct-result')); + const catalog = createToolCatalog([ + makeTool({ + cliName: 'start-sim-log-cap', + workflow: 'logging', + stateful: true, + handler: directHandler, + }), + ]); + const invoker = new DefaultToolInvoker(catalog); + + const response = await invoker.invoke( + 'start-sim-log-cap', + { value: 'hello' }, + { + runtime: 'cli', + socketPath: '/tmp/xcodebuildmcp.sock', + workspaceRoot: '/repo', + }, + ); + + expect(ensureDaemonRunning).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/tmp/xcodebuildmcp.sock', + workspaceRoot: '/repo', + env: undefined, + }), + ); + expect(daemonClientMock.invokeTool).toHaveBeenCalledWith('start-sim-log-cap', { + value: 'hello', + }); + expect(directHandler).not.toHaveBeenCalled(); + expect(response.content[0].text).toBe('daemon-result'); + }); +}); + +describe('DefaultToolInvoker xcode-ide dynamic routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + daemonClientMock.isRunning.mockResolvedValue(true); + daemonClientMock.invokeXcodeIdeTool.mockResolvedValue(textResponse('daemon-result')); + daemonClientMock.invokeTool.mockResolvedValue(textResponse('daemon-generic')); + daemonClientMock.listTools.mockResolvedValue([]); + }); + + it('routes dynamic xcode-ide tools through daemon xcode-ide invoke API', async () => { + daemonClientMock.isRunning.mockResolvedValue(false); + const directHandler = vi.fn().mockResolvedValue(textResponse('direct-result')); + const catalog = createToolCatalog([ + makeTool({ + cliName: 'xcode-ide-alpha', + workflow: 'xcode-ide', + stateful: false, + xcodeIdeRemoteToolName: 'Alpha', + handler: directHandler, + }), + ]); + const invoker = new DefaultToolInvoker(catalog); + + const response = await invoker.invoke( + 'xcode-ide-alpha', + { value: 'hello' }, + { + runtime: 'cli', + socketPath: '/tmp/xcodebuildmcp.sock', + workspaceRoot: '/repo', + cliExposedWorkflowIds: ['simulator', 'xcode-ide'], + }, + ); + + expect(ensureDaemonRunning).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/tmp/xcodebuildmcp.sock', + workspaceRoot: '/repo', + env: undefined, + }), + ); + expect(daemonClientMock.invokeXcodeIdeTool).toHaveBeenCalledWith('Alpha', { value: 'hello' }); + expect(directHandler).not.toHaveBeenCalled(); + expect(response.content[0].text).toBe('daemon-result'); + }); + + it('fails for dynamic xcode-ide tools when socket path is missing', async () => { + const directHandler = vi.fn().mockResolvedValue(textResponse('direct-result')); + const catalog = createToolCatalog([ + makeTool({ + cliName: 'xcode-ide-alpha', + workflow: 'xcode-ide', + stateful: false, + xcodeIdeRemoteToolName: 'Alpha', + handler: directHandler, + }), + ]); + const invoker = new DefaultToolInvoker(catalog); + + const response = await invoker.invoke( + 'xcode-ide-alpha', + { value: 'hello' }, + { + runtime: 'cli', + }, + ); + + expect(response.isError).toBe(true); + expect(response.content[0].text).toContain('No socket path configured'); + expect(directHandler).not.toHaveBeenCalled(); + expect(daemonClientMock.invokeXcodeIdeTool).not.toHaveBeenCalled(); + }); +}); diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index 04af24dd..5571445b 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -1,26 +1,20 @@ import type { ToolCatalog, ToolDefinition, ToolResolution } from './types.ts'; import { toKebabCase } from './naming.ts'; -import type { ToolResponse } from '../types/common.ts'; import { loadManifest, type WorkflowManifestEntry } from '../core/manifest/load-manifest.ts'; import { getEffectiveCliName } from '../core/manifest/schema.ts'; import { importToolModule } from '../core/manifest/import-tool-module.ts'; -import type { ToolSchemaShape } from '../core/plugin-types.ts'; import type { PredicateContext, RuntimeKind } from '../visibility/predicate-types.ts'; import { isWorkflowAvailableForRuntime, isToolAvailableForRuntime, - isToolExposedForRuntime, isWorkflowEnabledForRuntime, + isToolExposedForRuntime, } from '../visibility/exposure.ts'; import { getConfig } from '../utils/config-store.ts'; import { log } from '../utils/logging/index.ts'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { XcodeToolsBridgeClient } from '../integrations/xcode-tools-bridge/client.ts'; import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts'; -import { jsonSchemaToZod } from '../integrations/xcode-tools-bridge/jsonschema-to-zod.ts'; -import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts'; -function createCatalog(tools: ToolDefinition[]): ToolCatalog { +export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { // Build lookup maps for fast resolution const byCliName = new Map(); const byMcpName = new Map(); @@ -98,78 +92,6 @@ export function groupToolsByWorkflow(catalog: ToolCatalog): Map; - required?: unknown[]; -}; - -function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape { - if (!inputSchema || typeof inputSchema !== 'object') { - return {}; - } - - const schema = inputSchema as JsonSchemaObject; - const properties = schema.properties; - if (!properties || typeof properties !== 'object' || Array.isArray(properties)) { - return {}; - } - - const requiredFields = new Set( - Array.isArray(schema.required) - ? schema.required.filter((name): name is string => typeof name === 'string') - : [], - ); - - const shape: ToolSchemaShape = {}; - for (const [name, propertySchema] of Object.entries(properties)) { - const zodSchema = jsonSchemaToZod(propertySchema); - shape[name] = requiredFields.has(name) ? zodSchema : zodSchema.optional(); - } - - return shape; -} - -function createCliXcodeProxyTool(remoteTool: Tool): ToolDefinition { - const mcpName = toLocalToolName(remoteTool.name); - const cliSchema = jsonSchemaToToolSchemaShape(remoteTool.inputSchema); - - return { - cliName: `xcode-ide-${toKebabCase(remoteTool.name)}`, - mcpName, - workflow: 'xcode-ide', - description: remoteTool.description ?? '', - annotations: remoteTool.annotations, - mcpSchema: cliSchema, - cliSchema, - stateful: false, - handler: async (params): Promise => { - const client = new XcodeToolsBridgeClient(); - await client.connectOnce(); - try { - const result = await client.callTool(remoteTool.name, params); - return result as unknown as ToolResponse; - } finally { - await client.disconnect(); - } - }, - }; -} - -async function loadCliXcodeProxyTools(): Promise { - const client = new XcodeToolsBridgeClient(); - try { - await client.connectOnce(); - const remoteTools = await client.listTools(); - return remoteTools.map((tool) => createCliXcodeProxyTool(tool)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log('warning', `[xcode-ide] CLI bridge discovery failed: ${message}`); - return []; - } finally { - await client.disconnect(); - } -} - /** * Build a tool catalog from the YAML manifest system. */ @@ -242,13 +164,12 @@ export async function buildToolCatalogFromManifest(opts: { mcpSchema: toolModule.schema, cliSchema: toolModule.schema, stateful: toolManifest.routing?.stateful ?? false, - daemonAffinity: toolManifest.routing?.daemonAffinity, handler: toolModule.handler as ToolDefinition['handler'], }); } } - return createCatalog(tools); + return createToolCatalog(tools); } /** @@ -258,49 +179,26 @@ export async function buildToolCatalogFromManifest(opts: { export async function buildCliToolCatalogFromManifest(opts?: { excludeWorkflows?: string[]; }): Promise { - const excludeWorkflows = opts?.excludeWorkflows ?? []; - const bridge = await getMcpBridgeAvailability(); - const xcodeToolsAvailable = bridge.available; - - // CLI context: not running under Xcode, no Xcode tools active - const ctx: PredicateContext = { - runtime: 'cli', - config: getConfig(), - runningUnderXcode: false, - xcodeToolsActive: false, - xcodeToolsAvailable, - }; - - const manifestCatalog = await buildToolCatalogFromManifest({ + const ctx = await buildCliPredicateContext(); + return buildToolCatalogFromManifest({ runtime: 'cli', ctx, - excludeWorkflows, + excludeWorkflows: opts?.excludeWorkflows, }); +} - const excludeSet = new Set(excludeWorkflows.map((name) => name.toLowerCase())); +export async function listCliWorkflowIdsFromManifest(opts?: { + excludeWorkflows?: string[]; +}): Promise { const manifest = loadManifest(); - const xcodeIdeWorkflow = manifest.workflows.get('xcode-ide'); - const xcodeIdeEnabled = - xcodeIdeWorkflow !== undefined && - !excludeSet.has('xcode-ide') && - isWorkflowEnabledForRuntime(xcodeIdeWorkflow, ctx); - - if (!xcodeIdeEnabled || !xcodeToolsAvailable) { - return manifestCatalog; - } - - const dynamicXcodeTools = await loadCliXcodeProxyTools(); - if (dynamicXcodeTools.length === 0) { - return manifestCatalog; - } - - const existingCliNames = new Set(manifestCatalog.tools.map((tool) => tool.cliName)); - const mergedTools = [ - ...manifestCatalog.tools, - ...dynamicXcodeTools.filter((tool) => !existingCliNames.has(tool.cliName)), - ]; - - return createCatalog(mergedTools); + const excludeSet = new Set(opts?.excludeWorkflows?.map((name) => name.toLowerCase()) ?? []); + const ctx = await buildCliPredicateContext(); + + return Array.from(manifest.workflows.values()) + .filter((workflow) => !excludeSet.has(workflow.id.toLowerCase())) + .filter((workflow) => isWorkflowEnabledForRuntime(workflow, ctx)) + .map((workflow) => workflow.id) + .sort((a, b) => a.localeCompare(b)); } /** @@ -327,3 +225,14 @@ export async function buildDaemonToolCatalogFromManifest(opts?: { excludeWorkflows, }); } + +async function buildCliPredicateContext(): Promise { + const bridge = await getMcpBridgeAvailability(); + return { + runtime: 'cli', + config: getConfig(), + runningUnderXcode: false, + xcodeToolsActive: false, + xcodeToolsAvailable: bridge.available, + }; +} diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 1b4fe776..1b834e70 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -30,6 +30,16 @@ function enrichNextStepsForCli(response: ToolResponse, catalog: ToolCatalog): To }; } +function buildDaemonEnvOverrides(opts: InvokeOptions): Record | undefined { + const envOverrides: Record = {}; + + if (opts.logLevel) { + envOverrides.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel; + } + + return Object.keys(envOverrides).length > 0 ? envOverrides : undefined; +} + export class DefaultToolInvoker implements ToolInvoker { constructor(private catalog: ToolCatalog) {} @@ -56,31 +66,56 @@ export class DefaultToolInvoker implements ToolInvoker { const tool = resolved.tool; - const daemonAffinity = tool.daemonAffinity; - const mustUseDaemon = - tool.stateful || daemonAffinity === 'required' || Boolean(opts.forceDaemon); - const prefersDaemon = daemonAffinity === 'preferred'; + const xcodeIdeRemoteToolName = tool.xcodeIdeRemoteToolName; + const isDynamicXcodeIdeTool = + tool.workflow === 'xcode-ide' && typeof xcodeIdeRemoteToolName === 'string'; - if (opts.runtime === 'cli') { - // Check for conflicting options - if (opts.disableDaemon && opts.forceDaemon) { + if (opts.runtime === 'cli' && isDynamicXcodeIdeTool) { + const socketPath = opts.socketPath; + if (!socketPath) { return createErrorResponse( - 'Conflicting options', - `Cannot use both --daemon and --no-daemon flags together.`, + 'Socket path required', + `No socket path configured for daemon communication.`, ); } - if (mustUseDaemon) { - // Check if daemon is disabled - if (opts.disableDaemon) { + const envOverrideValue = buildDaemonEnvOverrides(opts); + const client = new DaemonClient({ socketPath }); + + const isRunning = await client.isRunning(); + if (!isRunning) { + try { + await ensureDaemonRunning({ + socketPath, + workspaceRoot: opts.workspaceRoot, + startupTimeoutMs: opts.daemonStartupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS, + env: envOverrideValue, + }); + } catch (error) { return createErrorResponse( - 'Daemon required', - `Tool '${tool.cliName}' is stateful and requires the daemon.\n` + - `Remove the --no-daemon flag, or start the daemon manually:\n` + + 'Daemon auto-start failed', + (error instanceof Error ? error.message : String(error)) + + `\n\nYou can try starting the daemon manually:\n` + ` xcodebuildmcp daemon start`, ); } + } + + try { + const response = await client.invokeXcodeIdeTool(xcodeIdeRemoteToolName, args); + return enrichNextStepsForCli(response, this.catalog); + } catch (error) { + return createErrorResponse( + 'Xcode IDE invocation failed', + error instanceof Error ? error.message : String(error), + ); + } + } + + const mustUseDaemon = tool.stateful; + if (opts.runtime === 'cli') { + if (mustUseDaemon) { // Route through daemon with auto-start const socketPath = opts.socketPath; if (!socketPath) { @@ -91,15 +126,7 @@ export class DefaultToolInvoker implements ToolInvoker { } const client = new DaemonClient({ socketPath }); - const cliExposedWorkflowIds = opts.cliExposedWorkflowIds ?? opts.enabledWorkflows; - const envOverrides: Record = {}; - if (cliExposedWorkflowIds && cliExposedWorkflowIds.length > 0) { - envOverrides.XCODEBUILDMCP_ENABLED_WORKFLOWS = cliExposedWorkflowIds.join(','); - } - if (opts.logLevel) { - envOverrides.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel; - } - const envOverrideValue = Object.keys(envOverrides).length > 0 ? envOverrides : undefined; + const envOverrideValue = buildDaemonEnvOverrides(opts); // Check if daemon is running; auto-start if not const isRunning = await client.isRunning(); @@ -131,25 +158,6 @@ export class DefaultToolInvoker implements ToolInvoker { ); } } - - if (prefersDaemon && !opts.disableDaemon && opts.socketPath) { - const client = new DaemonClient({ socketPath: opts.socketPath, timeout: 1000 }); - try { - const isRunning = await client.isRunning(); - if (isRunning) { - const tools = await client.listTools(); - const hasTool = tools.some((item) => item.name === tool.cliName); - if (hasTool) { - const response = await client.invokeTool(tool.cliName, args); - return opts.runtime === 'cli' - ? enrichNextStepsForCli(response, this.catalog) - : response; - } - } - } catch { - // Fall back to direct invocation - } - } } // Direct invocation (CLI stateless or daemon internal) diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 2002b73a..4e0e347f 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -34,9 +34,9 @@ export interface ToolDefinition { stateful: boolean; /** - * Daemon routing preference for CLI (optional). + * For daemon-backed xcode-ide dynamic tools, identifies the remote bridge tool. */ - daemonAffinity?: 'preferred' | 'required'; + xcodeIdeRemoteToolName?: string; /** * Shared handler (same used by MCP). No duplication. @@ -69,12 +69,8 @@ export interface InvokeOptions { cliExposedWorkflowIds?: string[]; /** @deprecated Use cliExposedWorkflowIds instead */ enabledWorkflows?: string[]; - /** If true, route even stateless tools to daemon */ - forceDaemon?: boolean; /** Socket path override */ socketPath?: string; - /** If true, disable daemon usage entirely (stateful tools will error) */ - disableDaemon?: boolean; /** Timeout in ms for daemon startup when auto-starting (default: 5000) */ daemonStartupTimeoutMs?: number; /** Workspace root for daemon auto-start context */ diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index e1f681f4..a93092e9 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -10,15 +10,19 @@ import type { DebuggerBackendKind, } from './types.ts'; import { getConfig } from '../config-store.ts'; +import { acquireDaemonActivity } from '../../daemon/activity-registry.ts'; export type DebuggerBackendFactory = (kind: DebuggerBackendKind) => Promise; +type DebugSessionEntry = { + info: DebugSessionInfo; + backend: DebuggerBackend; + releaseActivity: () => void; +}; + export class DebuggerManager { private readonly backendFactory: DebuggerBackendFactory; - private readonly sessions = new Map< - string, - { info: DebugSessionInfo; backend: DebuggerBackend } - >(); + private readonly sessions = new Map(); private currentSessionId: string | null = null; constructor(options: { backendFactory?: DebuggerBackendFactory } = {}) { @@ -55,11 +59,15 @@ export class DebuggerManager { lastUsedAt: now, }; - this.sessions.set(info.id, { info, backend }); + this.sessions.set(info.id, { + info, + backend, + releaseActivity: acquireDaemonActivity('debug.session'), + }); return info; } - getSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } | null { + getSession(id?: string): DebugSessionEntry | null { const resolvedId = id ?? this.currentSessionId; if (!resolvedId) return null; return this.sessions.get(resolvedId) ?? null; @@ -104,6 +112,7 @@ export class DebuggerManager { await session.backend.detach(); } finally { await session.backend.dispose(); + session.releaseActivity(); this.sessions.delete(session.info.id); if (this.currentSessionId === session.info.id) { this.currentSessionId = null; @@ -120,6 +129,7 @@ export class DebuggerManager { // Best-effort cleanup; detach can fail if the process exited. } finally { await session.backend.dispose(); + session.releaseActivity(); } }), ); @@ -189,7 +199,7 @@ export class DebuggerManager { this.touch(session.info.id); } - private requireSession(id?: string): { info: DebugSessionInfo; backend: DebuggerBackend } { + private requireSession(id?: string): DebugSessionEntry { const session = this.getSession(id); if (!session) { throw new Error('No active debug session. Provide debugSessionId or attach first.'); diff --git a/src/utils/log-capture/device-log-sessions.ts b/src/utils/log-capture/device-log-sessions.ts index 48b7e927..938df930 100644 --- a/src/utils/log-capture/device-log-sessions.ts +++ b/src/utils/log-capture/device-log-sessions.ts @@ -8,6 +8,7 @@ export interface DeviceLogSession { bundleId: string; logStream?: fs.WriteStream; hasEnded: boolean; + releaseActivity?: () => void; } export const activeDeviceLogSessions = new Map(); diff --git a/src/utils/log_capture.ts b/src/utils/log_capture.ts index be521114..3cf3d585 100644 --- a/src/utils/log_capture.ts +++ b/src/utils/log_capture.ts @@ -7,6 +7,7 @@ import { log } from '../utils/logger.ts'; import type { CommandExecutor } from './command.ts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from './command.ts'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; +import { acquireDaemonActivity } from '../daemon/activity-registry.ts'; /** * Log file retention policy: @@ -22,6 +23,7 @@ export interface LogSession { simulatorUuid: string; bundleId: string; logStream: Writable; + releaseActivity?: () => void; } /** @@ -209,12 +211,14 @@ export async function startLogCapture( }); } + const releaseActivity = acquireDaemonActivity('logging.simulator'); activeLogSessions.set(logSessionId, { processes, logFilePath, simulatorUuid, bundleId, logStream, + releaseActivity, }); log('info', `Log capture started with session ID: ${logSessionId}`); @@ -250,6 +254,7 @@ export async function stopLogCapture( } logStream.end(); await finished(logStream); + session.releaseActivity?.(); activeLogSessions.delete(logSessionId); log( 'info', diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts index 4fb25e28..b3c83771 100644 --- a/src/utils/video_capture.ts +++ b/src/utils/video_capture.ts @@ -9,6 +9,7 @@ import type { ChildProcess } from 'child_process'; import { log } from './logging/index.ts'; import { getAxePath, getBundledAxeEnvironment } from './axe-helpers.ts'; import type { CommandExecutor } from './execution/index.ts'; +import { acquireDaemonActivity } from '../daemon/activity-registry.ts'; type Session = { process: unknown; @@ -16,6 +17,7 @@ type Session = { startedAt: number; buffer: string; ended: boolean; + releaseActivity?: () => void; }; const sessions = new Map(); @@ -38,6 +40,7 @@ function ensureSignalHandlersAttached(): void { } catch { // ignore } finally { + sess.releaseActivity?.(); sessions.delete(simulatorUuid); } } @@ -64,6 +67,10 @@ function createSessionId(simulatorUuid: string): string { return `${simulatorUuid}:${Date.now()}`; } +export function listActiveVideoCaptureSessionIds(): string[] { + return Array.from(sessions.keys()).sort(); +} + /** * Start recording video for a simulator using AXe. */ @@ -116,6 +123,7 @@ export async function startSimulatorVideoCapture( startedAt: Date.now(), buffer: '', ended: false, + releaseActivity: acquireDaemonActivity('video.capture'), }; try { @@ -230,6 +238,7 @@ export async function stopSimulatorVideoCapture( const combinedOutput = session.buffer; const parsedPath = parseLastAbsoluteMp4Path(combinedOutput) ?? undefined; + session.releaseActivity?.(); sessions.delete(simulatorUuid); log( diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 2b8d0ed1..ab43b0ad 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -50,7 +50,7 @@ function createTool(overrides: Partial = {}): ToolManifestEnt id: 'test_tool', module: 'mcp/tools/test/test_tool', names: { mcp: 'test_tool' }, - availability: { mcp: true, cli: true, daemon: true }, + availability: { mcp: true, cli: true }, predicates: [], ...overrides, }; @@ -61,7 +61,7 @@ function createWorkflow(overrides: Partial = {}): Workflo id: 'test-workflow', title: 'Test Workflow', description: 'A test workflow', - availability: { mcp: true, cli: true, daemon: true }, + availability: { mcp: true, cli: true }, predicates: [], tools: ['test_tool'], ...overrides, @@ -71,14 +71,19 @@ function createWorkflow(overrides: Partial = {}): Workflo describe('exposure', () => { describe('isWorkflowAvailableForRuntime', () => { it('should return true when workflow is available for runtime', () => { - const workflow = createWorkflow({ availability: { mcp: true, cli: false, daemon: false } }); + const workflow = createWorkflow({ availability: { mcp: true, cli: false } }); expect(isWorkflowAvailableForRuntime(workflow, 'mcp')).toBe(true); }); it('should return false when workflow is not available for runtime', () => { - const workflow = createWorkflow({ availability: { mcp: true, cli: false, daemon: false } }); + const workflow = createWorkflow({ availability: { mcp: true, cli: false } }); expect(isWorkflowAvailableForRuntime(workflow, 'cli')).toBe(false); }); + + it('should ignore manifest availability in daemon runtime', () => { + const workflow = createWorkflow({ availability: { mcp: false, cli: false } }); + expect(isWorkflowAvailableForRuntime(workflow, 'daemon')).toBe(true); + }); }); describe('isWorkflowEnabledForRuntime', () => { @@ -89,7 +94,7 @@ describe('exposure', () => { }); it('should return false when not available', () => { - const workflow = createWorkflow({ availability: { mcp: false, cli: true, daemon: true } }); + const workflow = createWorkflow({ availability: { mcp: false, cli: true } }); const ctx = createContext({ runtime: 'mcp' }); expect(isWorkflowEnabledForRuntime(workflow, ctx)).toBe(false); }); @@ -106,14 +111,19 @@ describe('exposure', () => { describe('isToolAvailableForRuntime', () => { it('should return true when tool is available for runtime', () => { - const tool = createTool({ availability: { mcp: true, cli: false, daemon: false } }); + const tool = createTool({ availability: { mcp: true, cli: false } }); expect(isToolAvailableForRuntime(tool, 'mcp')).toBe(true); }); it('should return false when tool is not available for runtime', () => { - const tool = createTool({ availability: { mcp: false, cli: true, daemon: true } }); + const tool = createTool({ availability: { mcp: false, cli: true } }); expect(isToolAvailableForRuntime(tool, 'mcp')).toBe(false); }); + + it('should ignore manifest availability in daemon runtime', () => { + const tool = createTool({ availability: { mcp: false, cli: false } }); + expect(isToolAvailableForRuntime(tool, 'daemon')).toBe(true); + }); }); describe('isToolExposedForRuntime', () => { @@ -124,7 +134,7 @@ describe('exposure', () => { }); it('should return false when not available', () => { - const tool = createTool({ availability: { mcp: false, cli: true, daemon: true } }); + const tool = createTool({ availability: { mcp: false, cli: true } }); const ctx = createContext({ runtime: 'mcp' }); expect(isToolExposedForRuntime(tool, ctx)).toBe(false); }); @@ -158,7 +168,7 @@ describe('exposure', () => { it('should return false when workflow is not enabled', () => { const workflow = createWorkflow({ - availability: { mcp: false, cli: true, daemon: true }, + availability: { mcp: false, cli: true }, }); const tool = createTool(); const ctx = createContext({ runtime: 'mcp' }); @@ -167,7 +177,7 @@ describe('exposure', () => { it('should return false when tool is not exposed', () => { const workflow = createWorkflow(); - const tool = createTool({ availability: { mcp: false, cli: true, daemon: true } }); + const tool = createTool({ availability: { mcp: false, cli: true } }); const ctx = createContext({ runtime: 'mcp' }); expect(isToolInWorkflowExposed(tool, workflow, ctx)).toBe(false); }); @@ -177,7 +187,7 @@ describe('exposure', () => { it('should filter out tools that are not exposed', () => { const tools = [ createTool({ id: 'tool1' }), - createTool({ id: 'tool2', availability: { mcp: false, cli: true, daemon: true } }), + createTool({ id: 'tool2', availability: { mcp: false, cli: true } }), createTool({ id: 'tool3' }), ]; const ctx = createContext({ runtime: 'mcp' }); @@ -192,7 +202,7 @@ describe('exposure', () => { it('should filter out workflows that are not enabled', () => { const workflows = [ createWorkflow({ id: 'wf1' }), - createWorkflow({ id: 'wf2', availability: { mcp: false, cli: true, daemon: true } }), + createWorkflow({ id: 'wf2', availability: { mcp: false, cli: true } }), createWorkflow({ id: 'wf3' }), ]; const ctx = createContext({ runtime: 'mcp' }); diff --git a/src/visibility/exposure.ts b/src/visibility/exposure.ts index 6abdfc77..89fc6cea 100644 --- a/src/visibility/exposure.ts +++ b/src/visibility/exposure.ts @@ -16,6 +16,9 @@ export function isWorkflowAvailableForRuntime( workflow: WorkflowManifestEntry, runtime: RuntimeKind, ): boolean { + if (runtime === 'daemon') { + return true; + } return workflow.availability[runtime]; } @@ -41,6 +44,9 @@ export function isWorkflowEnabledForRuntime( * This checks the availability flag only, not predicates. */ export function isToolAvailableForRuntime(tool: ToolManifestEntry, runtime: RuntimeKind): boolean { + if (runtime === 'daemon') { + return true; + } return tool.availability[runtime]; } From 2d480e541e5c8a1d0ff335cb7de01cb7b34ff2dc Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 12:15:06 +0000 Subject: [PATCH 16/23] fix(xcode-ide): Bound IDE state fallback to workspace root Limit parent-directory fallback discovery for xcuserstate to a configured search root instead of traversing to filesystem root. Pass the resolved workspace root from server bootstrap so Xcode IDE sync and watcher startup only search within the active workspace context. Add regression coverage for nested cwd discovery and boundary enforcement. Co-Authored-By: Claude --- CHANGELOG.md | 3 + src/server/bootstrap.ts | 7 + .../__tests__/xcode-state-reader.test.ts | 35 +++++ src/utils/xcode-state-reader.ts | 126 ++++++++++++++---- src/utils/xcode-state-watcher.ts | 2 + 5 files changed, 150 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c239dcfe..955a6dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ - Added daemon idle shutdown in CLI mode: per-workspace daemons now auto-exit after 10 minutes of inactivity when no active stateful sessions exist. - Inverted idle activity tracking to a generic daemon activity registry so long-running tools report lifecycle activity without hardcoded daemon imports. +### Fixed +- Fix Xcode IDE state discovery fallback to check parent directories only up to the resolved workspace root boundary when started from a nested working directory without explicit `projectPath`/`workspacePath`. + ## [2.0.0] - 2026-02-02 ### Breaking diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 2eef116e..e5c6d791 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -8,6 +8,7 @@ import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/ import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts'; import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts'; import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts'; +import { resolveWorkspaceRoot } from '../daemon/socket-path.ts'; import { detectXcodeRuntime } from '../utils/xcode-process.ts'; import { readXcodeIdeState } from '../utils/xcode-state-reader.ts'; import { sessionStore } from '../utils/session-store.ts'; @@ -60,6 +61,10 @@ export async function bootstrapServer( } const enabledWorkflows = result.runtime.config.enabledWorkflows; + const workspaceRoot = resolveWorkspaceRoot({ + cwd: result.runtime.cwd, + projectConfigPath: result.configPath, + }); const mcpBridge = await getMcpBridgeAvailability(); const xcodeToolsAvailable = mcpBridge.available; log('info', `🚀 Initializing server...`); @@ -79,6 +84,7 @@ export async function bootstrapServer( const xcodeState = await readXcodeIdeState({ executor, cwd: result.runtime.cwd, + searchRoot: workspaceRoot, projectPath, workspacePath, }); @@ -125,6 +131,7 @@ export async function bootstrapServer( const watcherStarted = await startXcodeStateWatcher({ executor, cwd: result.runtime.cwd, + searchRoot: workspaceRoot, projectPath, workspacePath, }); diff --git a/src/utils/__tests__/xcode-state-reader.test.ts b/src/utils/__tests__/xcode-state-reader.test.ts index 292c967e..7960b386 100644 --- a/src/utils/__tests__/xcode-state-reader.test.ts +++ b/src/utils/__tests__/xcode-state-reader.test.ts @@ -78,6 +78,41 @@ describe('findXcodeStateFile', () => { expect(result).toBeUndefined(); }); + it('finds project in parent directory when cwd is nested within searchRoot', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + 'find /test/project/subdir -maxdepth 6': { output: '' }, + 'find /test/project -maxdepth 1': { output: '/test/project/MyApp.xcodeproj\n' }, + stat: { output: '1704067200\n' }, + }); + + const result = await findXcodeStateFile({ + executor, + cwd: '/test/project/subdir', + searchRoot: '/test/project', + }); + + expect(result).toBe( + '/test/project/MyApp.xcodeproj/project.xcworkspace/xcuserdata/testuser.xcuserdatad/UserInterfaceState.xcuserstate', + ); + }); + + it('does not search above searchRoot boundary', async () => { + const executor = createCommandMatchingMockExecutor({ + whoami: { output: 'testuser\n' }, + 'find /test/project/subdir -maxdepth 6': { output: '' }, + 'find /test/project -maxdepth 1': { output: '' }, + }); + + const result = await findXcodeStateFile({ + executor, + cwd: '/test/project/subdir', + searchRoot: '/test/project', + }); + + expect(result).toBeUndefined(); + }); + it('uses configured workspacePath directly', async () => { const executor = createCommandMatchingMockExecutor({ whoami: { output: 'testuser\n' }, diff --git a/src/utils/xcode-state-reader.ts b/src/utils/xcode-state-reader.ts index 215d5f05..d9c4994a 100644 --- a/src/utils/xcode-state-reader.ts +++ b/src/utils/xcode-state-reader.ts @@ -8,6 +8,7 @@ * running under Xcode's coding agent. */ +import { dirname, resolve, sep } from 'node:path'; import { log } from './logger.ts'; import { parseXcuserstate } from './nskeyedarchiver-parser.ts'; import type { CommandExecutor } from './execution/index.ts'; @@ -22,6 +23,8 @@ 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 */ @@ -33,16 +36,79 @@ export interface XcodeStateReaderContext { * * Search order: * 1. Use configured workspacePath/projectPath if provided - * 2. Search for .xcworkspace/.xcodeproj in cwd and parent directories + * 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', + root, + '-maxdepth', + String(maxDepth), + '(', + '-name', + '*.xcworkspace', + '-o', + '-name', + '*.xcodeproj', + ')', + '-type', + 'd', + ]; +} + +function isPathWithinBoundary(path: string, boundary: string): boolean { + return path === boundary || path.startsWith(`${boundary}${sep}`); +} + +function listParentDirectories(startPath: string, boundaryPath: string): string[] { + const parents: string[] = []; + const start = resolve(startPath); + const boundary = resolve(boundaryPath); + + if (!isPathWithinBoundary(start, boundary)) { + return parents; + } + + let current = start; + while (true) { + const parent = dirname(current); + if (parent === current) { + break; + } + + if (!isPathWithinBoundary(parent, boundary)) { + break; + } + + parents.push(parent); + if (parent === boundary) { + break; + } + + current = parent; + } + + return parents; +} + +function collectFindPaths(output: string): string[] { + return output + .trim() + .split('\n') + .map((path) => path.trim()) + .filter(Boolean); +} + export async function findXcodeStateFile( ctx: XcodeStateReaderContext, ): Promise { - const { executor, cwd, workspacePath, projectPath } = ctx; + const { executor, cwd, searchRoot, workspacePath, projectPath } = ctx; // Get current username const userResult = await executor(['whoami'], 'Get username', false); @@ -68,33 +134,47 @@ export async function findXcodeStateFile( log('debug', `[xcode-state] Configured path xcuserstate not found: ${xcuserstatePath}`); } - // Search for projects with increased depth (projects can be nested deeper) - const findResult = await executor( - [ - 'find', - cwd, - '-maxdepth', - '6', - '(', - '-name', - '*.xcworkspace', - '-o', - '-name', - '*.xcodeproj', - ')', - '-type', - 'd', - ], - 'Find Xcode project/workspace', + 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', false, ); + if (descendantsResult.success && descendantsResult.output.trim()) { + for (const path of collectFindPaths(descendantsResult.output)) { + discoveredPaths.add(path); + } + } - if (!findResult.success || !findResult.output.trim()) { - log('debug', `[xcode-state] No Xcode project/workspace found in ${cwd}`); + // 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( + buildFindProjectsCommand(parentDir, 1), + 'Find Xcode project/workspace in parent directory', + false, + ); + if (!parentResult.success || !parentResult.output.trim()) { + continue; + } + for (const path of collectFindPaths(parentResult.output)) { + discoveredPaths.add(path); + } + } + + if (discoveredPaths.size === 0) { + log( + 'debug', + `[xcode-state] No Xcode project/workspace found in ${cwd} (boundary: ${parentSearchBoundary})`, + ); return undefined; } - const paths = findResult.output.trim().split('\n').filter(Boolean); + const paths = [...discoveredPaths]; // Filter out nested workspaces inside xcodeproj and sort const filteredPaths = paths diff --git a/src/utils/xcode-state-watcher.ts b/src/utils/xcode-state-watcher.ts index e5812ed1..cf94017c 100644 --- a/src/utils/xcode-state-watcher.ts +++ b/src/utils/xcode-state-watcher.ts @@ -179,6 +179,7 @@ async function processFileChange(): Promise { export interface StartWatcherOptions { executor?: CommandExecutor; cwd?: string; + searchRoot?: string; workspacePath?: string; projectPath?: string; } @@ -198,6 +199,7 @@ export async function startXcodeStateWatcher(options: StartWatcherOptions = {}): const xcuserstatePath = await findXcodeStateFile({ executor, cwd, + searchRoot: options.searchRoot, workspacePath: options.workspacePath, projectPath: options.projectPath, }); From 0246a2c9e285f50eab71ba95516bcf53618c8436 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 13:10:26 +0000 Subject: [PATCH 17/23] fix: require mcp subcommand in QA docs and harden Swift escaping --- .claude/agents/xcodebuild-mcp-qa-tester.md | 10 +++++----- src/mcp/tools/ui-automation/screenshot.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude/agents/xcodebuild-mcp-qa-tester.md b/.claude/agents/xcodebuild-mcp-qa-tester.md index 6e3afdfc..3babda00 100644 --- a/.claude/agents/xcodebuild-mcp-qa-tester.md +++ b/.claude/agents/xcodebuild-mcp-qa-tester.md @@ -76,7 +76,7 @@ After testing `list_sims` tool, update the report: ## Detailed Test Results ### Tool: list_sims ✅ PASSED -**Command:** `npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/cli.js` +**Command:** `npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/cli.js mcp` **Verification:** Command returned JSON array with 6 simulator objects **Validation Summary:** Successfully discovered 6 available simulators with UUIDs, names, and boot status **Timestamp:** 2025-01-29 14:30:15 @@ -87,8 +87,8 @@ After testing `list_sims` tool, update the report: ### Pre-Testing Setup - Always start by building the project: `npm run build` - Verify Reloaderoo is available: `npx reloaderoo@latest --help` -- Check server connectivity: `npx reloaderoo@latest inspect ping -- node build/cli.js` -- Get server information: `npx reloaderoo@latest inspect server-info -- node build/cli.js` +- Check server connectivity: `npx reloaderoo@latest inspect ping -- node build/cli.js mcp` +- Get server information: `npx reloaderoo@latest inspect server-info -- node build/cli.js mcp` ### Systematic Testing Workflow 1. **Create Initial Report**: Generate test report with all checkboxes unchecked @@ -108,7 +108,7 @@ After testing `list_sims` tool, update the report: ### Tool Testing Process For each tool: -1. Execute test with `npx reloaderoo@latest inspect call-tool --params '' -- node build/cli.js` +1. Execute test with `npx reloaderoo@latest inspect call-tool --params '' -- node build/cli.js mcp` 2. Verify response format and content 3. **IMMEDIATELY** update test report with result 4. Check the box and add detailed verification summary @@ -116,7 +116,7 @@ For each tool: ### Resource Testing Process For each resource: -1. Execute test with `npx reloaderoo@latest inspect read-resource "" -- node build/cli.js` +1. Execute test with `npx reloaderoo@latest inspect read-resource "" -- node build/cli.js mcp` 2. Verify resource accessibility and content format 3. **IMMEDIATELY** update test report with result 4. Check the box and add detailed verification summary diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index 9b54a16f..468c66f0 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -43,14 +43,22 @@ interface SimctlDeviceList { devices: Record; } +function escapeSwiftStringLiteral(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + /** * Generates Swift code to detect simulator window dimensions via CoreGraphics. * Filters by device name to handle multiple open simulators correctly. * Returns "width,height" of the matching simulator window. */ function getWindowDetectionSwiftCode(deviceName: string): string { - // Escape the device name for use in Swift string - const escapedDeviceName = deviceName.replace(/"/g, '\\"'); + const escapedDeviceName = escapeSwiftStringLiteral(deviceName); // Use hasPrefix + boundary check to avoid matching "iPhone 15" when looking for "iPhone 15 Pro" // Window titles are formatted like "iPhone 15 Pro – iOS 17.2" return ` From 78e31b974d1ddb8d815815ad72f95ec35d68e8c9 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 13:40:40 +0000 Subject: [PATCH 18/23] fix: harden signal shutdown error handling --- src/server/start-mcp-server.ts | 47 ++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts index f9105bb2..b43c3ef4 100644 --- a/src/server/start-mcp-server.ts +++ b/src/server/start-mcp-server.ts @@ -35,18 +35,45 @@ export async function startMcpServer(): Promise { await startServer(server); - process.on('SIGTERM', async () => { - await shutdownXcodeToolsBridge(); - await getDefaultDebuggerManager().disposeAll(); - await server.close(); - process.exit(0); + let shuttingDown = false; + const shutdown = async (signal: NodeJS.Signals): Promise => { + if (shuttingDown) return; + shuttingDown = true; + + log('info', `Received ${signal}; shutting down MCP server`); + + let exitCode = 0; + + try { + await shutdownXcodeToolsBridge(); + } catch (error) { + exitCode = 1; + log('error', `Failed to shutdown Xcode tools bridge: ${String(error)}`); + } + + try { + await getDefaultDebuggerManager().disposeAll(); + } catch (error) { + exitCode = 1; + log('error', `Failed to dispose debugger sessions: ${String(error)}`); + } + + try { + await server.close(); + } catch (error) { + exitCode = 1; + log('error', `Failed to close MCP server: ${String(error)}`); + } + + process.exit(exitCode); + }; + + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); }); - process.on('SIGINT', async () => { - await shutdownXcodeToolsBridge(); - await getDefaultDebuggerManager().disposeAll(); - await server.close(); - process.exit(0); + process.once('SIGINT', () => { + void shutdown('SIGINT'); }); log('info', `XcodeBuildMCP server (version ${version}) started successfully`); From e2c5d6e7b0c33048be6f11e5b8ceff694ff8fe70 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 16:11:30 +0000 Subject: [PATCH 19/23] fix: deduplicate tool catalog entries and correct screenshot device matching - Deduplicate tools in buildToolCatalogFromManifest when the same tool appears in multiple workflows, preventing ambiguous resolution via kebab-case MCP name lookup and non-deterministic Map overwrites - Fix screenshot window title matching to use the actual Simulator title separator (en-dash/hyphen) instead of bare space, preventing false-positives like "iPhone 15" matching "iPhone 15 Pro" - Remove dead tool-visibility.ts superseded by the predicate system --- src/mcp/tools/ui-automation/screenshot.ts | 9 +++++---- src/runtime/tool-catalog.ts | 4 ++++ src/utils/tool-visibility.ts | 14 -------------- 3 files changed, 9 insertions(+), 18 deletions(-) delete mode 100644 src/utils/tool-visibility.ts diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts index 468c66f0..c129fa25 100644 --- a/src/mcp/tools/ui-automation/screenshot.ts +++ b/src/mcp/tools/ui-automation/screenshot.ts @@ -59,8 +59,8 @@ function escapeSwiftStringLiteral(value: string): string { */ function getWindowDetectionSwiftCode(deviceName: string): string { const escapedDeviceName = escapeSwiftStringLiteral(deviceName); - // Use hasPrefix + boundary check to avoid matching "iPhone 15" when looking for "iPhone 15 Pro" - // Window titles are formatted like "iPhone 15 Pro – iOS 17.2" + // Match by title separator (en-dash) to avoid "iPhone 15" matching "iPhone 15 Pro" + // Window titles are formatted like "iPhone 15 Pro \u{2013} iOS 17.2" return ` import Cocoa import CoreGraphics @@ -71,8 +71,9 @@ if let wins = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: An if let o = w[kCGWindowOwnerName as String] as? String, o == "Simulator", let b = w[kCGWindowBounds as String] as? [String: Any], let n = w[kCGWindowName as String] as? String { - // Check for exact match: name starts with deviceName followed by separator or end - let isMatch = n == deviceName || n.hasPrefix(deviceName + " ") + // Check for exact match: name equals deviceName or is followed by the title separator + // Window titles use en-dash: "iPhone 15 Pro \u{2013} iOS 17.2" + let isMatch = n == deviceName || n.hasPrefix(deviceName + " \\u{2013}") || n.hasPrefix(deviceName + " -") if isMatch { print("\\(b["Width"] as? Int ?? 0),\\(b["Height"] as? Int ?? 0)") break diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index 5571445b..b1f8cb74 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -130,9 +130,12 @@ export async function buildToolCatalogFromManifest(opts: { // Cache imported modules to avoid re-importing the same tool const moduleCache = new Map>>(); const tools: ToolDefinition[] = []; + const seenToolIds = new Set(); for (const workflow of filteredWorkflows) { for (const toolId of workflow.tools) { + if (seenToolIds.has(toolId)) continue; + const toolManifest = manifest.tools.get(toolId); if (!toolManifest) continue; @@ -154,6 +157,7 @@ export async function buildToolCatalogFromManifest(opts: { } } + seenToolIds.add(toolId); const cliName = getEffectiveCliName(toolManifest); tools.push({ cliName, diff --git a/src/utils/tool-visibility.ts b/src/utils/tool-visibility.ts deleted file mode 100644 index 9585a0f9..00000000 --- a/src/utils/tool-visibility.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getConfig } from './config-store.ts'; - -const XCODE_IDE_WORKFLOW = 'xcode-ide'; -const XCODE_IDE_DEBUG_TOOLS = new Set([ - 'xcode_tools_bridge_status', - 'xcode_tools_bridge_sync', - 'xcode_tools_bridge_disconnect', -]); - -export function shouldExposeTool(workflowDirectoryName: string, toolName: string): boolean { - if (workflowDirectoryName !== XCODE_IDE_WORKFLOW) return true; - if (!XCODE_IDE_DEBUG_TOOLS.has(toolName)) return true; - return getConfig().debug; -} From cedc0d2f090de7299b41afe889b17d4383275867 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 21:27:54 +0000 Subject: [PATCH 20/23] refactor(cli): use manifest-declared CLI names instead of prefix stripping - Add explicit cli: names to 6 swift-package manifests - Remove getBaseToolName() from output.ts and register-tool-commands.ts - Remove global CLI name uniqueness check from loadManifest --- manifests/tools/swift_package_build.yaml | 1 + manifests/tools/swift_package_clean.yaml | 1 + manifests/tools/swift_package_list.yaml | 1 + manifests/tools/swift_package_run.yaml | 1 + manifests/tools/swift_package_stop.yaml | 1 + manifests/tools/swift_package_test.yaml | 1 + src/cli/output.ts | 34 +++++------------------- src/cli/register-tool-commands.ts | 15 +---------- src/core/manifest/load-manifest.ts | 15 ----------- 9 files changed, 13 insertions(+), 57 deletions(-) diff --git a/manifests/tools/swift_package_build.yaml b/manifests/tools/swift_package_build.yaml index 7239dd54..14d328f8 100644 --- a/manifests/tools/swift_package_build.yaml +++ b/manifests/tools/swift_package_build.yaml @@ -2,6 +2,7 @@ id: swift_package_build module: mcp/tools/swift-package/swift_package_build names: mcp: swift_package_build + cli: build description: swift package target build. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/swift_package_clean.yaml b/manifests/tools/swift_package_clean.yaml index b1de0d1f..4d291324 100644 --- a/manifests/tools/swift_package_clean.yaml +++ b/manifests/tools/swift_package_clean.yaml @@ -2,6 +2,7 @@ id: swift_package_clean module: mcp/tools/swift-package/swift_package_clean names: mcp: swift_package_clean + cli: clean description: swift package clean. annotations: title: Swift Package Clean diff --git a/manifests/tools/swift_package_list.yaml b/manifests/tools/swift_package_list.yaml index aaa307ff..974a3855 100644 --- a/manifests/tools/swift_package_list.yaml +++ b/manifests/tools/swift_package_list.yaml @@ -2,6 +2,7 @@ id: swift_package_list module: mcp/tools/swift-package/swift_package_list names: mcp: swift_package_list + cli: list description: List SwiftPM processes. routing: stateful: true diff --git a/manifests/tools/swift_package_run.yaml b/manifests/tools/swift_package_run.yaml index d48ed382..627b90de 100644 --- a/manifests/tools/swift_package_run.yaml +++ b/manifests/tools/swift_package_run.yaml @@ -2,6 +2,7 @@ id: swift_package_run module: mcp/tools/swift-package/swift_package_run names: mcp: swift_package_run + cli: run description: swift package target run. routing: stateful: true diff --git a/manifests/tools/swift_package_stop.yaml b/manifests/tools/swift_package_stop.yaml index 004cc854..dd55bd1f 100644 --- a/manifests/tools/swift_package_stop.yaml +++ b/manifests/tools/swift_package_stop.yaml @@ -2,6 +2,7 @@ id: swift_package_stop module: mcp/tools/swift-package/swift_package_stop names: mcp: swift_package_stop + cli: stop description: Stop SwiftPM run. routing: stateful: true diff --git a/manifests/tools/swift_package_test.yaml b/manifests/tools/swift_package_test.yaml index c849176b..a4165287 100644 --- a/manifests/tools/swift_package_test.yaml +++ b/manifests/tools/swift_package_test.yaml @@ -2,6 +2,7 @@ id: swift_package_test module: mcp/tools/swift-package/swift_package_test names: mcp: swift_package_test + cli: test description: Run swift package target tests. predicates: - hideWhenXcodeAgentMode diff --git a/src/cli/output.ts b/src/cli/output.ts index 1ea740b3..dca2ddd7 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -52,18 +52,6 @@ function printToolResponseText(response: ToolResponse): void { } } -/** - * Get the base tool name without workflow prefix. - * For disambiguated tools, strips the workflow prefix. - */ -function getBaseToolName(cliName: string, workflow: string): string { - const prefix = `${workflow}-`; - if (cliName.startsWith(prefix)) { - return cliName.slice(prefix.length); - } - return cliName; -} - /** * Format a tool list for display. */ @@ -74,7 +62,6 @@ export function formatToolList( const lines: string[] = []; if (options.grouped) { - // Group by workflow - show subcommand names const byWorkflow = new Map(); for (const tool of tools) { const existing = byWorkflow.get(tool.workflow) ?? []; @@ -85,37 +72,28 @@ export function formatToolList( for (const workflow of sortedWorkflows) { lines.push(`\n${workflow}:`); const workflowTools = byWorkflow.get(workflow) ?? []; - // Sort by base name (without prefix) - const sortedTools = workflowTools.sort((a, b) => { - const aBase = getBaseToolName(a.cliName, a.workflow); - const bBase = getBaseToolName(b.cliName, b.workflow); - return aBase.localeCompare(bBase); - }); + const sortedTools = workflowTools.sort((a, b) => a.cliName.localeCompare(b.cliName)); for (const tool of sortedTools) { - // Show subcommand name (without workflow prefix) - const toolName = getBaseToolName(tool.cliName, tool.workflow); const statefulMarker = tool.stateful ? ' [stateful]' : ''; if (options.verbose && tool.description) { - lines.push(` ${toolName}${statefulMarker}`); + lines.push(` ${tool.cliName}${statefulMarker}`); lines.push(` ${tool.description}`); } else { const desc = tool.description ? ` - ${truncate(tool.description, 60)}` : ''; - lines.push(` ${toolName}${statefulMarker}${desc}`); + lines.push(` ${tool.cliName}${statefulMarker}${desc}`); } } } } else { - // Flat list - show full workflow-scoped command const sortedTools = [...tools].sort((a, b) => { - const aFull = `${a.workflow} ${getBaseToolName(a.cliName, a.workflow)}`; - const bFull = `${b.workflow} ${getBaseToolName(b.cliName, b.workflow)}`; + const aFull = `${a.workflow} ${a.cliName}`; + const bFull = `${b.workflow} ${b.cliName}`; return aFull.localeCompare(bFull); }); for (const tool of sortedTools) { - const toolName = getBaseToolName(tool.cliName, tool.workflow); - const fullCommand = `${tool.workflow} ${toolName}`; + const fullCommand = `${tool.workflow} ${tool.cliName}`; const statefulMarker = tool.stateful ? ' [stateful]' : ''; if (options.verbose && tool.description) { lines.push(`${fullCommand}${statefulMarker}`); diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index b634b37a..fba35867 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -95,8 +95,7 @@ function registerToolSubcommand( const yargsOptions = schemaToYargsOptions(tool.cliSchema); const unsupportedKeys = getUnsupportedSchemaKeys(tool.cliSchema); - // Use the base CLI name without workflow prefix since it's already scoped - const commandName = getBaseToolName(tool); + const commandName = tool.cliName; yargs.command( commandName, @@ -188,15 +187,3 @@ function registerToolSubcommand( }, ); } - -/** - * Get the base tool name without any workflow prefix. - * For tools that were disambiguated with workflow prefix, strip it. - */ -function getBaseToolName(tool: ToolDefinition): string { - const prefix = `${tool.workflow}-`; - if (tool.cliName.startsWith(prefix)) { - return tool.cliName.slice(prefix.length); - } - return tool.cliName; -} diff --git a/src/core/manifest/load-manifest.ts b/src/core/manifest/load-manifest.ts index 52a49acc..ad789d36 100644 --- a/src/core/manifest/load-manifest.ts +++ b/src/core/manifest/load-manifest.ts @@ -13,7 +13,6 @@ import { type ToolManifestEntry, type WorkflowManifestEntry, type ResolvedManifest, - getEffectiveCliName, } from './schema.ts'; // Re-export types for consumers @@ -226,20 +225,6 @@ export function loadManifest(): ResolvedManifest { mcpNames.set(tool.names.mcp, toolId); } - // Validate CLI name uniqueness (after derivation) - const cliNames = new Map(); // cliName -> toolId - for (const [toolId, tool] of tools) { - const cliName = getEffectiveCliName(tool); - const existing = cliNames.get(cliName); - if (existing) { - throw new ManifestValidationError( - `Duplicate CLI name '${cliName}' used by tools '${existing}' and '${toolId}'. ` + - `Set explicit 'names.cli' in one of the tool manifests to resolve.`, - ); - } - cliNames.set(cliName, toolId); - } - return { tools, workflows }; } From b7683cdd7c622ac5cbed92eec2a9f6727bdaf397 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 22:17:53 +0000 Subject: [PATCH 21/23] feat(cli): add explicit CLI names to all tool manifests Add cli: names to all 70 tool manifests using human-friendly names scoped by workflow namespace. Simple names (build, test, list) where unique within a workflow; platform-qualified names (start-device-log-capture, get-macos-bundle-id) only where tools coexist in the same workflow and would otherwise clash. Move tool catalog deduplication from the builder to the index maps in createToolCatalog so shared tools still appear in every workflow they belong to (for CLI grouping) while preventing ambiguous resolution in the lookup indices. --- manifests/tools/boot_sim.yaml | 1 + manifests/tools/build_device.yaml | 1 + manifests/tools/build_macos.yaml | 1 + manifests/tools/build_run_macos.yaml | 1 + manifests/tools/build_run_sim.yaml | 1 + manifests/tools/build_sim.yaml | 1 + manifests/tools/button.yaml | 1 + manifests/tools/clean.yaml | 1 + manifests/tools/debug_attach_sim.yaml | 1 + manifests/tools/debug_breakpoint_add.yaml | 1 + manifests/tools/debug_breakpoint_remove.yaml | 1 + manifests/tools/debug_continue.yaml | 1 + manifests/tools/debug_detach.yaml | 1 + manifests/tools/debug_lldb_command.yaml | 1 + manifests/tools/debug_stack.yaml | 1 + manifests/tools/debug_variables.yaml | 1 + manifests/tools/discover_projs.yaml | 1 + manifests/tools/doctor.yaml | 1 + manifests/tools/erase_sims.yaml | 1 + manifests/tools/gesture.yaml | 1 + manifests/tools/get_app_bundle_id.yaml | 1 + manifests/tools/get_device_app_path.yaml | 1 + manifests/tools/get_mac_app_path.yaml | 1 + manifests/tools/get_mac_bundle_id.yaml | 1 + manifests/tools/get_sim_app_path.yaml | 1 + manifests/tools/install_app_device.yaml | 1 + manifests/tools/install_app_sim.yaml | 1 + manifests/tools/key_press.yaml | 1 + manifests/tools/key_sequence.yaml | 1 + manifests/tools/launch_app_device.yaml | 1 + manifests/tools/launch_app_logs_sim.yaml | 1 + manifests/tools/launch_app_sim.yaml | 1 + manifests/tools/launch_mac_app.yaml | 1 + manifests/tools/list_devices.yaml | 1 + manifests/tools/list_schemes.yaml | 1 + manifests/tools/list_sims.yaml | 1 + manifests/tools/long_press.yaml | 1 + manifests/tools/manage_workflows.yaml | 3 ++- manifests/tools/open_sim.yaml | 1 + manifests/tools/record_sim_video.yaml | 1 + manifests/tools/reset_sim_location.yaml | 1 + manifests/tools/scaffold_ios_project.yaml | 1 + manifests/tools/scaffold_macos_project.yaml | 1 + manifests/tools/screenshot.yaml | 1 + manifests/tools/session_clear_defaults.yaml | 3 +-- manifests/tools/session_set_defaults.yaml | 3 +-- manifests/tools/session_show_defaults.yaml | 3 +-- manifests/tools/set_sim_appearance.yaml | 1 + manifests/tools/set_sim_location.yaml | 1 + manifests/tools/show_build_settings.yaml | 1 + manifests/tools/sim_statusbar.yaml | 1 + manifests/tools/snapshot_ui.yaml | 1 + manifests/tools/start_device_log_cap.yaml | 1 + manifests/tools/start_sim_log_cap.yaml | 1 + manifests/tools/stop_app_device.yaml | 1 + manifests/tools/stop_app_sim.yaml | 1 + manifests/tools/stop_device_log_cap.yaml | 1 + manifests/tools/stop_mac_app.yaml | 1 + manifests/tools/stop_sim_log_cap.yaml | 1 + manifests/tools/swipe.yaml | 1 + manifests/tools/sync_xcode_defaults.yaml | 3 +-- manifests/tools/tap.yaml | 1 + manifests/tools/test_device.yaml | 1 + manifests/tools/test_macos.yaml | 1 + manifests/tools/test_sim.yaml | 1 + manifests/tools/touch.yaml | 1 + manifests/tools/type_text.yaml | 1 + .../tools/xcode_tools_bridge_disconnect.yaml | 1 + manifests/tools/xcode_tools_bridge_status.yaml | 1 + manifests/tools/xcode_tools_bridge_sync.yaml | 1 + src/runtime/tool-catalog.ts | 15 ++++++++------- 71 files changed, 79 insertions(+), 16 deletions(-) diff --git a/manifests/tools/boot_sim.yaml b/manifests/tools/boot_sim.yaml index cd8caf1c..dbb03c8a 100644 --- a/manifests/tools/boot_sim.yaml +++ b/manifests/tools/boot_sim.yaml @@ -2,6 +2,7 @@ id: boot_sim module: mcp/tools/simulator/boot_sim names: mcp: boot_sim + cli: boot description: Boot iOS simulator. annotations: title: Boot Simulator diff --git a/manifests/tools/build_device.yaml b/manifests/tools/build_device.yaml index 3703f1b7..4a472acf 100644 --- a/manifests/tools/build_device.yaml +++ b/manifests/tools/build_device.yaml @@ -2,6 +2,7 @@ id: build_device module: mcp/tools/device/build_device names: mcp: build_device + cli: build description: Build for device. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/build_macos.yaml b/manifests/tools/build_macos.yaml index ba7318f5..b97f2aa2 100644 --- a/manifests/tools/build_macos.yaml +++ b/manifests/tools/build_macos.yaml @@ -2,6 +2,7 @@ id: build_macos module: mcp/tools/macos/build_macos names: mcp: build_macos + cli: build description: Build macOS app. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/build_run_macos.yaml b/manifests/tools/build_run_macos.yaml index 37049be6..b39101ca 100644 --- a/manifests/tools/build_run_macos.yaml +++ b/manifests/tools/build_run_macos.yaml @@ -2,6 +2,7 @@ id: build_run_macos module: mcp/tools/macos/build_run_macos names: mcp: build_run_macos + cli: build-and-run description: Build and run macOS app. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/build_run_sim.yaml b/manifests/tools/build_run_sim.yaml index 9e8ae2dc..c23b640f 100644 --- a/manifests/tools/build_run_sim.yaml +++ b/manifests/tools/build_run_sim.yaml @@ -2,6 +2,7 @@ id: build_run_sim module: mcp/tools/simulator/build_run_sim names: mcp: build_run_sim + cli: build-and-run description: Build and run iOS sim. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/build_sim.yaml b/manifests/tools/build_sim.yaml index 18cf3341..9ed21b85 100644 --- a/manifests/tools/build_sim.yaml +++ b/manifests/tools/build_sim.yaml @@ -2,6 +2,7 @@ id: build_sim module: mcp/tools/simulator/build_sim names: mcp: build_sim + cli: build description: Build for iOS sim. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/button.yaml b/manifests/tools/button.yaml index 17c1deda..e99ddb2e 100644 --- a/manifests/tools/button.yaml +++ b/manifests/tools/button.yaml @@ -2,6 +2,7 @@ id: button module: mcp/tools/ui-automation/button names: mcp: button + cli: button description: Press simulator hardware button. annotations: title: Hardware Button diff --git a/manifests/tools/clean.yaml b/manifests/tools/clean.yaml index 50c7cc7d..010eeb9a 100644 --- a/manifests/tools/clean.yaml +++ b/manifests/tools/clean.yaml @@ -2,6 +2,7 @@ id: clean module: mcp/tools/utilities/clean names: mcp: clean + cli: clean description: Clean build products. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/debug_attach_sim.yaml b/manifests/tools/debug_attach_sim.yaml index 782fa388..01750bba 100644 --- a/manifests/tools/debug_attach_sim.yaml +++ b/manifests/tools/debug_attach_sim.yaml @@ -2,6 +2,7 @@ id: debug_attach_sim module: mcp/tools/debugging/debug_attach_sim names: mcp: debug_attach_sim + cli: attach description: Attach LLDB to sim app. routing: stateful: true diff --git a/manifests/tools/debug_breakpoint_add.yaml b/manifests/tools/debug_breakpoint_add.yaml index 5e55b0c9..c0cf9597 100644 --- a/manifests/tools/debug_breakpoint_add.yaml +++ b/manifests/tools/debug_breakpoint_add.yaml @@ -2,6 +2,7 @@ id: debug_breakpoint_add module: mcp/tools/debugging/debug_breakpoint_add names: mcp: debug_breakpoint_add + cli: add-breakpoint description: Add breakpoint. routing: stateful: true diff --git a/manifests/tools/debug_breakpoint_remove.yaml b/manifests/tools/debug_breakpoint_remove.yaml index 752ceb2b..d7a2775e 100644 --- a/manifests/tools/debug_breakpoint_remove.yaml +++ b/manifests/tools/debug_breakpoint_remove.yaml @@ -2,6 +2,7 @@ id: debug_breakpoint_remove module: mcp/tools/debugging/debug_breakpoint_remove names: mcp: debug_breakpoint_remove + cli: remove-breakpoint description: Remove breakpoint. routing: stateful: true diff --git a/manifests/tools/debug_continue.yaml b/manifests/tools/debug_continue.yaml index a7cc8c53..6683cb7b 100644 --- a/manifests/tools/debug_continue.yaml +++ b/manifests/tools/debug_continue.yaml @@ -2,6 +2,7 @@ id: debug_continue module: mcp/tools/debugging/debug_continue names: mcp: debug_continue + cli: continue description: Continue debug session. routing: stateful: true diff --git a/manifests/tools/debug_detach.yaml b/manifests/tools/debug_detach.yaml index 6f0312f4..44466a99 100644 --- a/manifests/tools/debug_detach.yaml +++ b/manifests/tools/debug_detach.yaml @@ -2,6 +2,7 @@ id: debug_detach module: mcp/tools/debugging/debug_detach names: mcp: debug_detach + cli: detach description: Detach debugger. routing: stateful: true diff --git a/manifests/tools/debug_lldb_command.yaml b/manifests/tools/debug_lldb_command.yaml index c6be3f0f..0642b0cb 100644 --- a/manifests/tools/debug_lldb_command.yaml +++ b/manifests/tools/debug_lldb_command.yaml @@ -2,6 +2,7 @@ id: debug_lldb_command module: mcp/tools/debugging/debug_lldb_command names: mcp: debug_lldb_command + cli: lldb-command description: Run LLDB command. routing: stateful: true diff --git a/manifests/tools/debug_stack.yaml b/manifests/tools/debug_stack.yaml index 27cbb71b..8b002069 100644 --- a/manifests/tools/debug_stack.yaml +++ b/manifests/tools/debug_stack.yaml @@ -2,6 +2,7 @@ id: debug_stack module: mcp/tools/debugging/debug_stack names: mcp: debug_stack + cli: stack description: Get backtrace. routing: stateful: true diff --git a/manifests/tools/debug_variables.yaml b/manifests/tools/debug_variables.yaml index 8f37827f..287075f6 100644 --- a/manifests/tools/debug_variables.yaml +++ b/manifests/tools/debug_variables.yaml @@ -2,6 +2,7 @@ id: debug_variables module: mcp/tools/debugging/debug_variables names: mcp: debug_variables + cli: variables description: Get frame variables. routing: stateful: true diff --git a/manifests/tools/discover_projs.yaml b/manifests/tools/discover_projs.yaml index 2508289f..4c2cf4c1 100644 --- a/manifests/tools/discover_projs.yaml +++ b/manifests/tools/discover_projs.yaml @@ -2,6 +2,7 @@ id: discover_projs module: mcp/tools/project-discovery/discover_projs names: mcp: discover_projs + cli: discover-projects description: Scans a directory (defaults to workspace root) to find Xcode project (.xcodeproj) and workspace (.xcworkspace) files. annotations: title: Discover Projects diff --git a/manifests/tools/doctor.yaml b/manifests/tools/doctor.yaml index 8878ee91..d7e08ed1 100644 --- a/manifests/tools/doctor.yaml +++ b/manifests/tools/doctor.yaml @@ -2,6 +2,7 @@ id: doctor module: mcp/tools/doctor/doctor names: mcp: doctor + cli: doctor description: MCP environment info. annotations: title: Doctor diff --git a/manifests/tools/erase_sims.yaml b/manifests/tools/erase_sims.yaml index 6825b6d2..4ecd376d 100644 --- a/manifests/tools/erase_sims.yaml +++ b/manifests/tools/erase_sims.yaml @@ -2,6 +2,7 @@ id: erase_sims module: mcp/tools/simulator-management/erase_sims names: mcp: erase_sims + cli: erase description: Erase simulator. annotations: title: Erase Simulators diff --git a/manifests/tools/gesture.yaml b/manifests/tools/gesture.yaml index 55fe90cd..abbab2c7 100644 --- a/manifests/tools/gesture.yaml +++ b/manifests/tools/gesture.yaml @@ -2,6 +2,7 @@ id: gesture module: mcp/tools/ui-automation/gesture names: mcp: gesture + cli: gesture description: Simulator gesture preset. annotations: title: Gesture diff --git a/manifests/tools/get_app_bundle_id.yaml b/manifests/tools/get_app_bundle_id.yaml index e23176d4..3e8fa28d 100644 --- a/manifests/tools/get_app_bundle_id.yaml +++ b/manifests/tools/get_app_bundle_id.yaml @@ -2,6 +2,7 @@ id: get_app_bundle_id module: mcp/tools/project-discovery/get_app_bundle_id names: mcp: get_app_bundle_id + cli: get-app-bundle-id description: Extract bundle id from .app. annotations: title: Get App Bundle ID diff --git a/manifests/tools/get_device_app_path.yaml b/manifests/tools/get_device_app_path.yaml index a3d34a71..be830454 100644 --- a/manifests/tools/get_device_app_path.yaml +++ b/manifests/tools/get_device_app_path.yaml @@ -2,6 +2,7 @@ id: get_device_app_path module: mcp/tools/device/get_device_app_path names: mcp: get_device_app_path + cli: get-app-path description: Get device built app path. annotations: title: Get Device App Path diff --git a/manifests/tools/get_mac_app_path.yaml b/manifests/tools/get_mac_app_path.yaml index 762d733b..fdfabc47 100644 --- a/manifests/tools/get_mac_app_path.yaml +++ b/manifests/tools/get_mac_app_path.yaml @@ -2,6 +2,7 @@ id: get_mac_app_path module: mcp/tools/macos/get_mac_app_path names: mcp: get_mac_app_path + cli: get-app-path description: Get macOS built app path. annotations: title: Get macOS App Path diff --git a/manifests/tools/get_mac_bundle_id.yaml b/manifests/tools/get_mac_bundle_id.yaml index 96304790..c397834a 100644 --- a/manifests/tools/get_mac_bundle_id.yaml +++ b/manifests/tools/get_mac_bundle_id.yaml @@ -2,6 +2,7 @@ id: get_mac_bundle_id module: mcp/tools/project-discovery/get_mac_bundle_id names: mcp: get_mac_bundle_id + cli: get-macos-bundle-id description: Extract bundle id from macOS .app. annotations: title: Get Mac Bundle ID diff --git a/manifests/tools/get_sim_app_path.yaml b/manifests/tools/get_sim_app_path.yaml index f5ea81be..923df057 100644 --- a/manifests/tools/get_sim_app_path.yaml +++ b/manifests/tools/get_sim_app_path.yaml @@ -2,6 +2,7 @@ id: get_sim_app_path module: mcp/tools/simulator/get_sim_app_path names: mcp: get_sim_app_path + cli: get-app-path description: Get sim built app path. annotations: title: Get Simulator App Path diff --git a/manifests/tools/install_app_device.yaml b/manifests/tools/install_app_device.yaml index 414116cc..b6368485 100644 --- a/manifests/tools/install_app_device.yaml +++ b/manifests/tools/install_app_device.yaml @@ -2,6 +2,7 @@ id: install_app_device module: mcp/tools/device/install_app_device names: mcp: install_app_device + cli: install description: Install app on device. annotations: title: Install App Device diff --git a/manifests/tools/install_app_sim.yaml b/manifests/tools/install_app_sim.yaml index ff65b40e..2cc6fc9a 100644 --- a/manifests/tools/install_app_sim.yaml +++ b/manifests/tools/install_app_sim.yaml @@ -2,6 +2,7 @@ id: install_app_sim module: mcp/tools/simulator/install_app_sim names: mcp: install_app_sim + cli: install description: Install app on sim. annotations: title: Install App Simulator diff --git a/manifests/tools/key_press.yaml b/manifests/tools/key_press.yaml index 0983c576..d5dd227a 100644 --- a/manifests/tools/key_press.yaml +++ b/manifests/tools/key_press.yaml @@ -2,6 +2,7 @@ id: key_press module: mcp/tools/ui-automation/key_press names: mcp: key_press + cli: key-press description: Press key by keycode. annotations: title: Key Press diff --git a/manifests/tools/key_sequence.yaml b/manifests/tools/key_sequence.yaml index 9f4f0a9d..4b0235de 100644 --- a/manifests/tools/key_sequence.yaml +++ b/manifests/tools/key_sequence.yaml @@ -2,6 +2,7 @@ id: key_sequence module: mcp/tools/ui-automation/key_sequence names: mcp: key_sequence + cli: key-sequence description: Press a sequence of keys by their keycodes. annotations: title: Key Sequence diff --git a/manifests/tools/launch_app_device.yaml b/manifests/tools/launch_app_device.yaml index 9df7f65f..7df14a6c 100644 --- a/manifests/tools/launch_app_device.yaml +++ b/manifests/tools/launch_app_device.yaml @@ -2,6 +2,7 @@ id: launch_app_device module: mcp/tools/device/launch_app_device names: mcp: launch_app_device + cli: launch description: Launch app on device. annotations: title: Launch App Device diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml index c0d9361a..14d69f01 100644 --- a/manifests/tools/launch_app_logs_sim.yaml +++ b/manifests/tools/launch_app_logs_sim.yaml @@ -2,6 +2,7 @@ id: launch_app_logs_sim module: mcp/tools/simulator/launch_app_logs_sim names: mcp: launch_app_logs_sim + cli: launch-app-with-logs description: Launch sim app with logs. routing: stateful: true diff --git a/manifests/tools/launch_app_sim.yaml b/manifests/tools/launch_app_sim.yaml index 617591cc..b78adc16 100644 --- a/manifests/tools/launch_app_sim.yaml +++ b/manifests/tools/launch_app_sim.yaml @@ -2,6 +2,7 @@ id: launch_app_sim module: mcp/tools/simulator/launch_app_sim names: mcp: launch_app_sim + cli: launch-app description: Launch app on simulator. annotations: title: Launch App Simulator diff --git a/manifests/tools/launch_mac_app.yaml b/manifests/tools/launch_mac_app.yaml index 8689c978..f094a82e 100644 --- a/manifests/tools/launch_mac_app.yaml +++ b/manifests/tools/launch_mac_app.yaml @@ -2,6 +2,7 @@ id: launch_mac_app module: mcp/tools/macos/launch_mac_app names: mcp: launch_mac_app + cli: launch description: Launch macOS app. annotations: title: Launch macOS App diff --git a/manifests/tools/list_devices.yaml b/manifests/tools/list_devices.yaml index 789730a7..47f48d0e 100644 --- a/manifests/tools/list_devices.yaml +++ b/manifests/tools/list_devices.yaml @@ -2,6 +2,7 @@ id: list_devices module: mcp/tools/device/list_devices names: mcp: list_devices + cli: list description: List connected devices. annotations: title: List Devices diff --git a/manifests/tools/list_schemes.yaml b/manifests/tools/list_schemes.yaml index d078053d..0eff0718 100644 --- a/manifests/tools/list_schemes.yaml +++ b/manifests/tools/list_schemes.yaml @@ -2,6 +2,7 @@ id: list_schemes module: mcp/tools/project-discovery/list_schemes names: mcp: list_schemes + cli: list-schemes description: List Xcode schemes. annotations: title: List Schemes diff --git a/manifests/tools/list_sims.yaml b/manifests/tools/list_sims.yaml index d21514ba..4ec0ccfb 100644 --- a/manifests/tools/list_sims.yaml +++ b/manifests/tools/list_sims.yaml @@ -2,6 +2,7 @@ id: list_sims module: mcp/tools/simulator/list_sims names: mcp: list_sims + cli: list description: List iOS simulators. annotations: title: List Simulators diff --git a/manifests/tools/long_press.yaml b/manifests/tools/long_press.yaml index 730742eb..2dbf4089 100644 --- a/manifests/tools/long_press.yaml +++ b/manifests/tools/long_press.yaml @@ -2,6 +2,7 @@ id: long_press module: mcp/tools/ui-automation/long_press names: mcp: long_press + cli: long-press description: Long press at coords. annotations: title: Long Press diff --git a/manifests/tools/manage_workflows.yaml b/manifests/tools/manage_workflows.yaml index 56ec66b1..4f83dce4 100644 --- a/manifests/tools/manage_workflows.yaml +++ b/manifests/tools/manage_workflows.yaml @@ -2,6 +2,7 @@ id: manage_workflows module: mcp/tools/workflow-discovery/manage_workflows names: mcp: manage-workflows + cli: manage-workflows description: Workflows are groups of tools exposed by XcodeBuildMCP. By default, not all workflows (and therefore tools) are enabled; only simulator tools are enabled by default. Some workflows are mandatory and can't be disabled. availability: - cli: false + cli: true diff --git a/manifests/tools/open_sim.yaml b/manifests/tools/open_sim.yaml index d9a1364c..0f88dd7b 100644 --- a/manifests/tools/open_sim.yaml +++ b/manifests/tools/open_sim.yaml @@ -2,6 +2,7 @@ id: open_sim module: mcp/tools/simulator/open_sim names: mcp: open_sim + cli: open description: Open Simulator app. annotations: title: Open Simulator diff --git a/manifests/tools/record_sim_video.yaml b/manifests/tools/record_sim_video.yaml index f867efd5..78cd2647 100644 --- a/manifests/tools/record_sim_video.yaml +++ b/manifests/tools/record_sim_video.yaml @@ -2,6 +2,7 @@ id: record_sim_video module: mcp/tools/simulator/record_sim_video names: mcp: record_sim_video + cli: record-video description: Record sim video. routing: stateful: true diff --git a/manifests/tools/reset_sim_location.yaml b/manifests/tools/reset_sim_location.yaml index 4fb9420a..1ce2fb35 100644 --- a/manifests/tools/reset_sim_location.yaml +++ b/manifests/tools/reset_sim_location.yaml @@ -2,6 +2,7 @@ id: reset_sim_location module: mcp/tools/simulator-management/reset_sim_location names: mcp: reset_sim_location + cli: reset-location description: Reset sim location. annotations: title: Reset Simulator Location diff --git a/manifests/tools/scaffold_ios_project.yaml b/manifests/tools/scaffold_ios_project.yaml index 05b31c36..af49dc28 100644 --- a/manifests/tools/scaffold_ios_project.yaml +++ b/manifests/tools/scaffold_ios_project.yaml @@ -2,6 +2,7 @@ id: scaffold_ios_project module: mcp/tools/project-scaffolding/scaffold_ios_project names: mcp: scaffold_ios_project + cli: scaffold-ios description: Scaffold iOS project. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/scaffold_macos_project.yaml b/manifests/tools/scaffold_macos_project.yaml index f88cb70c..3c90b339 100644 --- a/manifests/tools/scaffold_macos_project.yaml +++ b/manifests/tools/scaffold_macos_project.yaml @@ -2,6 +2,7 @@ id: scaffold_macos_project module: mcp/tools/project-scaffolding/scaffold_macos_project names: mcp: scaffold_macos_project + cli: scaffold-macos description: Scaffold macOS project. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/screenshot.yaml b/manifests/tools/screenshot.yaml index b10f8bd3..75dc871a 100644 --- a/manifests/tools/screenshot.yaml +++ b/manifests/tools/screenshot.yaml @@ -2,6 +2,7 @@ id: screenshot module: mcp/tools/ui-automation/screenshot names: mcp: screenshot + cli: screenshot description: Capture screenshot. annotations: title: Screenshot diff --git a/manifests/tools/session_clear_defaults.yaml b/manifests/tools/session_clear_defaults.yaml index e35d884d..b0eccb98 100644 --- a/manifests/tools/session_clear_defaults.yaml +++ b/manifests/tools/session_clear_defaults.yaml @@ -2,9 +2,8 @@ id: session_clear_defaults module: mcp/tools/session-management/session_clear_defaults names: mcp: session_clear_defaults + cli: clear-defaults description: Clear session defaults. -availability: - cli: false annotations: title: Clear Session Defaults destructiveHint: true diff --git a/manifests/tools/session_set_defaults.yaml b/manifests/tools/session_set_defaults.yaml index 0d86792a..4f567b0d 100644 --- a/manifests/tools/session_set_defaults.yaml +++ b/manifests/tools/session_set_defaults.yaml @@ -2,9 +2,8 @@ id: session_set_defaults module: mcp/tools/session-management/session_set_defaults names: mcp: session_set_defaults + cli: set-defaults description: Set the session defaults, should be called at least once to set tool defaults. -availability: - cli: false annotations: title: Set Session Defaults destructiveHint: true diff --git a/manifests/tools/session_show_defaults.yaml b/manifests/tools/session_show_defaults.yaml index 40414f40..3d49d90d 100644 --- a/manifests/tools/session_show_defaults.yaml +++ b/manifests/tools/session_show_defaults.yaml @@ -2,9 +2,8 @@ id: session_show_defaults module: mcp/tools/session-management/session_show_defaults names: mcp: session_show_defaults + cli: show-defaults description: Show session defaults. -availability: - cli: false annotations: title: Show Session Defaults readOnlyHint: true diff --git a/manifests/tools/set_sim_appearance.yaml b/manifests/tools/set_sim_appearance.yaml index ba9f568d..d55a21f0 100644 --- a/manifests/tools/set_sim_appearance.yaml +++ b/manifests/tools/set_sim_appearance.yaml @@ -2,6 +2,7 @@ id: set_sim_appearance module: mcp/tools/simulator-management/set_sim_appearance names: mcp: set_sim_appearance + cli: set-appearance description: Set sim appearance. annotations: title: Set Simulator Appearance diff --git a/manifests/tools/set_sim_location.yaml b/manifests/tools/set_sim_location.yaml index 80b757cf..b89db124 100644 --- a/manifests/tools/set_sim_location.yaml +++ b/manifests/tools/set_sim_location.yaml @@ -2,6 +2,7 @@ id: set_sim_location module: mcp/tools/simulator-management/set_sim_location names: mcp: set_sim_location + cli: set-location description: Set sim location. annotations: title: Set Simulator Location diff --git a/manifests/tools/show_build_settings.yaml b/manifests/tools/show_build_settings.yaml index fabe3790..d2ec7862 100644 --- a/manifests/tools/show_build_settings.yaml +++ b/manifests/tools/show_build_settings.yaml @@ -2,6 +2,7 @@ id: show_build_settings module: mcp/tools/project-discovery/show_build_settings names: mcp: show_build_settings + cli: show-build-settings description: Show build settings. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/sim_statusbar.yaml b/manifests/tools/sim_statusbar.yaml index f6a92d45..690c5a3d 100644 --- a/manifests/tools/sim_statusbar.yaml +++ b/manifests/tools/sim_statusbar.yaml @@ -2,6 +2,7 @@ id: sim_statusbar module: mcp/tools/simulator-management/sim_statusbar names: mcp: sim_statusbar + cli: statusbar description: Set sim status bar network. annotations: title: Simulator Statusbar diff --git a/manifests/tools/snapshot_ui.yaml b/manifests/tools/snapshot_ui.yaml index 5ed6d410..2808e101 100644 --- a/manifests/tools/snapshot_ui.yaml +++ b/manifests/tools/snapshot_ui.yaml @@ -2,6 +2,7 @@ id: snapshot_ui module: mcp/tools/ui-automation/snapshot_ui names: mcp: snapshot_ui + cli: snapshot-ui description: Print view hierarchy with precise view coordinates (x, y, width, height) for visible elements. annotations: title: Snapshot UI diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml index 2e24eafa..35b81796 100644 --- a/manifests/tools/start_device_log_cap.yaml +++ b/manifests/tools/start_device_log_cap.yaml @@ -2,6 +2,7 @@ id: start_device_log_cap module: mcp/tools/logging/start_device_log_cap names: mcp: start_device_log_cap + cli: start-device-log-capture description: Start device log capture. routing: stateful: true diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml index 3e1f840d..36056e15 100644 --- a/manifests/tools/start_sim_log_cap.yaml +++ b/manifests/tools/start_sim_log_cap.yaml @@ -2,6 +2,7 @@ id: start_sim_log_cap module: mcp/tools/logging/start_sim_log_cap names: mcp: start_sim_log_cap + cli: start-simulator-log-capture description: Start sim log capture. routing: stateful: true diff --git a/manifests/tools/stop_app_device.yaml b/manifests/tools/stop_app_device.yaml index 0d9b26bd..df943e69 100644 --- a/manifests/tools/stop_app_device.yaml +++ b/manifests/tools/stop_app_device.yaml @@ -2,6 +2,7 @@ id: stop_app_device module: mcp/tools/device/stop_app_device names: mcp: stop_app_device + cli: stop description: Stop device app. annotations: title: Stop App Device diff --git a/manifests/tools/stop_app_sim.yaml b/manifests/tools/stop_app_sim.yaml index 56ebaa48..5f44eb1d 100644 --- a/manifests/tools/stop_app_sim.yaml +++ b/manifests/tools/stop_app_sim.yaml @@ -2,6 +2,7 @@ id: stop_app_sim module: mcp/tools/simulator/stop_app_sim names: mcp: stop_app_sim + cli: stop description: Stop sim app. annotations: title: Stop App Simulator diff --git a/manifests/tools/stop_device_log_cap.yaml b/manifests/tools/stop_device_log_cap.yaml index 5742b758..a3149a6e 100644 --- a/manifests/tools/stop_device_log_cap.yaml +++ b/manifests/tools/stop_device_log_cap.yaml @@ -2,6 +2,7 @@ id: stop_device_log_cap module: mcp/tools/logging/stop_device_log_cap names: mcp: stop_device_log_cap + cli: stop-device-log-capture description: Stop device app and return logs. routing: stateful: true diff --git a/manifests/tools/stop_mac_app.yaml b/manifests/tools/stop_mac_app.yaml index f12f94d5..b2641369 100644 --- a/manifests/tools/stop_mac_app.yaml +++ b/manifests/tools/stop_mac_app.yaml @@ -2,6 +2,7 @@ id: stop_mac_app module: mcp/tools/macos/stop_mac_app names: mcp: stop_mac_app + cli: stop description: Stop macOS app. annotations: title: Stop macOS App diff --git a/manifests/tools/stop_sim_log_cap.yaml b/manifests/tools/stop_sim_log_cap.yaml index d39e9288..52fc2232 100644 --- a/manifests/tools/stop_sim_log_cap.yaml +++ b/manifests/tools/stop_sim_log_cap.yaml @@ -2,6 +2,7 @@ id: stop_sim_log_cap module: mcp/tools/logging/stop_sim_log_cap names: mcp: stop_sim_log_cap + cli: stop-simulator-log-capture description: Stop sim app and return logs. routing: stateful: true diff --git a/manifests/tools/swipe.yaml b/manifests/tools/swipe.yaml index 25721459..b9d17e71 100644 --- a/manifests/tools/swipe.yaml +++ b/manifests/tools/swipe.yaml @@ -2,6 +2,7 @@ id: swipe module: mcp/tools/ui-automation/swipe names: mcp: swipe + cli: swipe description: Swipe between points. annotations: title: Swipe diff --git a/manifests/tools/sync_xcode_defaults.yaml b/manifests/tools/sync_xcode_defaults.yaml index 1679ddbe..06c4d318 100644 --- a/manifests/tools/sync_xcode_defaults.yaml +++ b/manifests/tools/sync_xcode_defaults.yaml @@ -2,9 +2,8 @@ id: sync_xcode_defaults module: mcp/tools/xcode-ide/sync_xcode_defaults names: mcp: sync_xcode_defaults + cli: sync-xcode-defaults description: Sync session defaults (scheme, simulator) from Xcode's current IDE selection. -availability: - cli: false predicates: - xcodeAutoSyncDisabled annotations: diff --git a/manifests/tools/tap.yaml b/manifests/tools/tap.yaml index 1a193cb9..f5ccd719 100644 --- a/manifests/tools/tap.yaml +++ b/manifests/tools/tap.yaml @@ -2,6 +2,7 @@ id: tap module: mcp/tools/ui-automation/tap names: mcp: tap + cli: tap description: Tap coordinate or element. annotations: title: Tap diff --git a/manifests/tools/test_device.yaml b/manifests/tools/test_device.yaml index 6a778bc0..1564fe55 100644 --- a/manifests/tools/test_device.yaml +++ b/manifests/tools/test_device.yaml @@ -2,6 +2,7 @@ id: test_device module: mcp/tools/device/test_device names: mcp: test_device + cli: test description: Test on device. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/test_macos.yaml b/manifests/tools/test_macos.yaml index f8e69851..c28f7772 100644 --- a/manifests/tools/test_macos.yaml +++ b/manifests/tools/test_macos.yaml @@ -2,6 +2,7 @@ id: test_macos module: mcp/tools/macos/test_macos names: mcp: test_macos + cli: test description: Test macOS target. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/test_sim.yaml b/manifests/tools/test_sim.yaml index e716acd5..d4b10d0c 100644 --- a/manifests/tools/test_sim.yaml +++ b/manifests/tools/test_sim.yaml @@ -2,6 +2,7 @@ id: test_sim module: mcp/tools/simulator/test_sim names: mcp: test_sim + cli: test description: Test on iOS sim. predicates: - hideWhenXcodeAgentMode diff --git a/manifests/tools/touch.yaml b/manifests/tools/touch.yaml index 44bd5d26..88d480c6 100644 --- a/manifests/tools/touch.yaml +++ b/manifests/tools/touch.yaml @@ -2,6 +2,7 @@ id: touch module: mcp/tools/ui-automation/touch names: mcp: touch + cli: touch description: Touch down/up at coords. annotations: title: Touch diff --git a/manifests/tools/type_text.yaml b/manifests/tools/type_text.yaml index d1a2b523..54058179 100644 --- a/manifests/tools/type_text.yaml +++ b/manifests/tools/type_text.yaml @@ -2,6 +2,7 @@ id: type_text module: mcp/tools/ui-automation/type_text names: mcp: type_text + cli: type-text description: Type text. annotations: title: Type Text diff --git a/manifests/tools/xcode_tools_bridge_disconnect.yaml b/manifests/tools/xcode_tools_bridge_disconnect.yaml index 66b42872..30c2d151 100644 --- a/manifests/tools/xcode_tools_bridge_disconnect.yaml +++ b/manifests/tools/xcode_tools_bridge_disconnect.yaml @@ -2,6 +2,7 @@ id: xcode_tools_bridge_disconnect module: mcp/tools/xcode-ide/xcode_tools_bridge_disconnect names: mcp: xcode_tools_bridge_disconnect + cli: bridge-disconnect description: Disconnect bridge and unregister proxied `xcode_tools_*` tools. predicates: - debugEnabled diff --git a/manifests/tools/xcode_tools_bridge_status.yaml b/manifests/tools/xcode_tools_bridge_status.yaml index 3a81325d..7166f140 100644 --- a/manifests/tools/xcode_tools_bridge_status.yaml +++ b/manifests/tools/xcode_tools_bridge_status.yaml @@ -2,6 +2,7 @@ id: xcode_tools_bridge_status module: mcp/tools/xcode-ide/xcode_tools_bridge_status names: mcp: xcode_tools_bridge_status + cli: bridge-status description: Show xcrun mcpbridge availability and proxy tool sync status. predicates: - debugEnabled diff --git a/manifests/tools/xcode_tools_bridge_sync.yaml b/manifests/tools/xcode_tools_bridge_sync.yaml index 6035a6c4..a8595996 100644 --- a/manifests/tools/xcode_tools_bridge_sync.yaml +++ b/manifests/tools/xcode_tools_bridge_sync.yaml @@ -2,6 +2,7 @@ id: xcode_tools_bridge_sync module: mcp/tools/xcode-ide/xcode_tools_bridge_sync names: mcp: xcode_tools_bridge_sync + cli: bridge-sync description: One-shot connect + tools/list sync (manual retry; avoids background prompt spam). predicates: - debugEnabled diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index b1f8cb74..d75a1d0a 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -15,16 +15,21 @@ import { log } from '../utils/logging/index.ts'; import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts'; export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { - // Build lookup maps for fast resolution + // Build lookup maps for fast resolution, deduplicating by mcpName so that + // tools shared across multiple workflows don't cause ambiguous resolution. const byCliName = new Map(); const byMcpName = new Map(); const byMcpKebab = new Map(); + const seenMcpNames = new Set(); for (const tool of tools) { + const mcpKey = tool.mcpName.toLowerCase(); + if (seenMcpNames.has(mcpKey)) continue; + seenMcpNames.add(mcpKey); + byCliName.set(tool.cliName, tool); - byMcpName.set(tool.mcpName.toLowerCase(), tool); + byMcpName.set(mcpKey, tool); - // Also index by the kebab-case of MCP name (for aliases) const mcpKebab = toKebabCase(tool.mcpName); const existing = byMcpKebab.get(mcpKebab) ?? []; byMcpKebab.set(mcpKebab, [...existing, tool]); @@ -130,12 +135,9 @@ export async function buildToolCatalogFromManifest(opts: { // Cache imported modules to avoid re-importing the same tool const moduleCache = new Map>>(); const tools: ToolDefinition[] = []; - const seenToolIds = new Set(); for (const workflow of filteredWorkflows) { for (const toolId of workflow.tools) { - if (seenToolIds.has(toolId)) continue; - const toolManifest = manifest.tools.get(toolId); if (!toolManifest) continue; @@ -157,7 +159,6 @@ export async function buildToolCatalogFromManifest(opts: { } } - seenToolIds.add(toolId); const cliName = getEffectiveCliName(toolManifest); tools.push({ cliName, From af358e204975f6ecf5eef599c5f13396d3742c0f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 22:48:51 +0000 Subject: [PATCH 22/23] =?UTF-8?q?=F0=9F=90=9B=20fix(cli):=20resolve=20corr?= =?UTF-8?q?ect=20tool=20via=20direct=20invocation=20instead=20of=20flat=20?= =?UTF-8?q?catalog=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI tool subcommands like `simulator list` were resolving to the wrong handler because multiple workflows share the same CLI name (e.g. "list", "build", "test"). The flat byCliName map meant last-inserted won. Instead of re-resolving by name, pass the ToolDefinition directly from the workflow-scoped yargs handler via a new invokeDirect() method. For daemon wire communication, use mcpName (unique within the catalog) instead of the ambiguous cliName. Also skip the bridge availability check (xcrun --find mcpbridge) in CLI mode since xcode-ide has availability.cli: false, avoiding an unwanted Xcode authorization prompt. --- src/cli/register-tool-commands.ts | 2 +- src/runtime/__tests__/tool-invoker.test.ts | 2 +- src/runtime/tool-catalog.ts | 7 +++--- src/runtime/tool-invoker.ts | 25 +++++++++++++++++++--- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts index fba35867..8f020506 100644 --- a/src/cli/register-tool-commands.ts +++ b/src/cli/register-tool-commands.ts @@ -175,7 +175,7 @@ function registerToolSubcommand( const args = { ...toolParams, ...jsonArgs }; // Invoke the tool - const response = await invoker.invoke(tool.cliName, args, { + const response = await invoker.invokeDirect(tool, args, { runtime: 'cli', cliExposedWorkflowIds, socketPath, diff --git a/src/runtime/__tests__/tool-invoker.test.ts b/src/runtime/__tests__/tool-invoker.test.ts index 8fbe7589..21918e9d 100644 --- a/src/runtime/__tests__/tool-invoker.test.ts +++ b/src/runtime/__tests__/tool-invoker.test.ts @@ -115,7 +115,7 @@ describe('DefaultToolInvoker CLI routing', () => { env: undefined, }), ); - expect(daemonClientMock.invokeTool).toHaveBeenCalledWith('start-sim-log-cap', { + expect(daemonClientMock.invokeTool).toHaveBeenCalledWith('start_sim_log_cap', { value: 'hello', }); expect(directHandler).not.toHaveBeenCalled(); diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts index d75a1d0a..87247a53 100644 --- a/src/runtime/tool-catalog.ts +++ b/src/runtime/tool-catalog.ts @@ -12,7 +12,6 @@ import { } from '../visibility/exposure.ts'; import { getConfig } from '../utils/config-store.ts'; import { log } from '../utils/logging/index.ts'; -import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts'; export function createToolCatalog(tools: ToolDefinition[]): ToolCatalog { // Build lookup maps for fast resolution, deduplicating by mcpName so that @@ -232,12 +231,14 @@ export async function buildDaemonToolCatalogFromManifest(opts?: { } async function buildCliPredicateContext(): Promise { - const bridge = await getMcpBridgeAvailability(); + // Skip bridge availability check in CLI mode — xcode-ide workflow has + // availability.cli: false so the bridge result is unused, and the + // xcrun --find mcpbridge call triggers an unwanted Xcode auth prompt. return { runtime: 'cli', config: getConfig(), runningUnderXcode: false, xcodeToolsActive: false, - xcodeToolsAvailable: bridge.available, + xcodeToolsAvailable: false, }; } diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts index 1b834e70..739cbe6a 100644 --- a/src/runtime/tool-invoker.ts +++ b/src/runtime/tool-invoker.ts @@ -1,4 +1,4 @@ -import type { ToolCatalog, ToolInvoker, InvokeOptions } from './types.ts'; +import type { ToolCatalog, ToolDefinition, ToolInvoker, InvokeOptions } from './types.ts'; import type { ToolResponse } from '../types/common.ts'; import { createErrorResponse } from '../utils/responses/index.ts'; import { DaemonClient } from '../cli/daemon-client.ts'; @@ -64,8 +64,27 @@ export class DefaultToolInvoker implements ToolInvoker { ); } - const tool = resolved.tool; + return this.executeTool(resolved.tool, args, opts); + } + /** + * Invoke a tool directly, bypassing catalog resolution. + * Used by CLI where the correct ToolDefinition is already known + * from workflow-scoped yargs routing. + */ + async invokeDirect( + tool: ToolDefinition, + args: Record, + opts: InvokeOptions, + ): Promise { + return this.executeTool(tool, args, opts); + } + + private async executeTool( + tool: ToolDefinition, + args: Record, + opts: InvokeOptions, + ): Promise { const xcodeIdeRemoteToolName = tool.xcodeIdeRemoteToolName; const isDynamicXcodeIdeTool = tool.workflow === 'xcode-ide' && typeof xcodeIdeRemoteToolName === 'string'; @@ -149,7 +168,7 @@ export class DefaultToolInvoker implements ToolInvoker { } try { - const response = await client.invokeTool(tool.cliName, args); + const response = await client.invokeTool(tool.mcpName, args); return opts.runtime === 'cli' ? enrichNextStepsForCli(response, this.catalog) : response; } catch (error) { return createErrorResponse( From 13807c9a062ac470f25d17e3322ab90a43c3b41c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 6 Feb 2026 23:01:16 +0000 Subject: [PATCH 23/23] =?UTF-8?q?=F0=9F=93=9D=20docs:=20correct=20hideWhen?= =?UTF-8?q?XcodeAgentMode=20predicate=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs incorrectly stated the predicate depends on both runningUnderXcode AND xcodeToolsActive. In reality it only checks runningUnderXcode — Xcode's coding agent always provides native equivalents, independent of the Xcode Tools bridge (which is for external agents connecting to Xcode's MCP server). --- docs/dev/MANIFEST_FORMAT.md | 2 +- docs/dev/TOOL_REGISTRY_REFACTOR.md | 33 +++++++++++++----------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/docs/dev/MANIFEST_FORMAT.md b/docs/dev/MANIFEST_FORMAT.md index 5bdd9518..0ebc32c3 100644 --- a/docs/dev/MANIFEST_FORMAT.md +++ b/docs/dev/MANIFEST_FORMAT.md @@ -301,7 +301,7 @@ Predicates control visibility based on runtime context. All predicates in the ar | `experimentalWorkflowDiscoveryEnabled` | Show only when experimental workflow discovery is enabled | | `runningUnderXcodeAgent` | Show only when running under Xcode's coding agent | | `requiresXcodeTools` | Show only when Xcode Tools bridge is active | -| `hideWhenXcodeAgentMode` | Hide when running under Xcode agent AND Xcode Tools bridge is active | +| `hideWhenXcodeAgentMode` | Hide when running inside Xcode's coding agent (tools conflict with Xcode's native equivalents) | | `always` | Always visible (explicit documentation) | | `never` | Never visible (temporarily disable) | diff --git a/docs/dev/TOOL_REGISTRY_REFACTOR.md b/docs/dev/TOOL_REGISTRY_REFACTOR.md index 96fada97..b2a4a9d0 100644 --- a/docs/dev/TOOL_REGISTRY_REFACTOR.md +++ b/docs/dev/TOOL_REGISTRY_REFACTOR.md @@ -311,9 +311,10 @@ type PredicateContext = { - `debugEnabled`: true if config debug mode is enabled - `experimentalWorkflowDiscoveryEnabled`: true if experimental workflow discovery is enabled -- `hideWhenXcodeAgentMode`: hides tool/workflow when: - - running under Xcode agent, AND - - Xcode Tools bridge is active (proxied tools are available) +- `hideWhenXcodeAgentMode`: hides tool/workflow when running inside Xcode's coding agent. + Xcode provides native equivalents for these tools, so XcodeBuildMCP hides its versions + to avoid conflicts. This is independent of the Xcode Tools bridge (which is for external + agents connecting to Xcode's MCP server). This predicate powers the policy described in `XCODE_IDE_TOOL_CONFLICTS.md`. @@ -345,12 +346,9 @@ These tools: - can trigger `tools/listChanged` updates ### How dynamic tools influence static tool visibility -When dynamic tools are active (`xcodeToolsActive`), conflict-tagged XcodeBuildMCP tools are hidden via `hideWhenXcodeAgentMode`. +When running inside Xcode's coding agent (`runningUnderXcode`), conflict-tagged XcodeBuildMCP tools are hidden via `hideWhenXcodeAgentMode` because Xcode provides native equivalents. -This behavior is: -- scoped to MCP runtime -- driven by bridge status -- re-applied whenever bridge status changes +This is distinct from the Xcode Tools bridge, which allows external agents (Cursor, Claude Code, etc.) to access Xcode capabilities via its MCP server. --- @@ -437,8 +435,8 @@ At runtime the loader imports: ### Phase 4: migrate MCP registration to manifest-driven - Replace generated loader usage with manifest selection + import-based registration -- Wire in Xcode bridge status → `xcodeToolsActive` updates -- Apply conflict filtering via `hideWhenXcodeAgentMode` +- Wire in Xcode bridge status → `xcodeToolsActive` updates for bridge-dependent predicates +- Apply Xcode agent conflict filtering via `hideWhenXcodeAgentMode` (based on `runningUnderXcode`) ### Phase 5: migrate daemon to manifest-driven - Daemon builds catalog from manifest @@ -478,9 +476,7 @@ routing: daemonAffinity: preferred ``` -When: -- runningUnderXcode=true AND xcodeToolsActive=true -then `hideWhenXcodeAgentMode` fails and tool is not registered/listed in MCP (but remains in CLI outside Xcode). +When `runningUnderXcode=true`, `hideWhenXcodeAgentMode` fails and the tool is not registered/listed in MCP (but remains in CLI outside Xcode). This check is independent of `xcodeToolsActive` — Xcode's coding agent always provides native equivalents for these tools. --- @@ -657,8 +653,9 @@ export const PREDICATES: Record = { debugEnabled: (ctx) => ctx.config.debug, experimentalWorkflowDiscoveryEnabled: (ctx) => ctx.config.experimentalWorkflowDiscovery, - // Key for XCODE_IDE_TOOL_CONFLICTS.md - hideWhenXcodeAgentMode: (ctx) => !(ctx.runningUnderXcode && ctx.xcodeToolsActive), + // Key for XCODE_IDE_TOOL_CONFLICTS.md — hides tools when running inside Xcode's + // coding agent, where Xcode provides native equivalents. + hideWhenXcodeAgentMode: (ctx) => !ctx.runningUnderXcode, }; export function evalPredicates(names: string[] | undefined, ctx: PredicateContext): boolean { @@ -780,11 +777,9 @@ In MCP bootstrap, set `ctx.xcodeToolsActive` based on bridge status: - `workflowEnabled && bridgeAvailable && connected && proxiedToolCount > 0` ### 6.2 Re-apply static tool registration when bridge becomes active/inactive -When the bridge syncs tools / disconnects, `xcodeToolsActive` can flip. If it flips, you must re-run the static registration pass so `hideWhenXcodeAgentMode` takes effect immediately. +When the bridge syncs tools / disconnects, `xcodeToolsActive` can flip. If it flips, re-run the static registration pass so predicates that depend on `xcodeToolsActive` (e.g. `requiresXcodeTools`) take effect immediately. -This requires a small wiring change: -- add an event/callback in `XcodeToolsBridgeManager` (or expose status polling after sync) -- call `updateWorkflows(...)` (or a new `applyManifestSelection(...)`) when status changes +Note: `hideWhenXcodeAgentMode` depends only on `runningUnderXcode`, not bridge status — Xcode's coding agent always provides native equivalents regardless of bridge state. ---