From e4c123e98ae60671f46083aa80ed28dfd501ed6b Mon Sep 17 00:00:00 2001 From: Sam Zhang Date: Thu, 5 Mar 2026 10:39:57 +0800 Subject: [PATCH 1/2] feat: add launchd service management and agent skill install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `xcode-mcp service` commands (install/uninstall/status/logs) to run the bridge as a persistent macOS launchd daemon with auto-start on login and auto-restart on crash — replacing the need for pm2. Add `xcode-mcp skill install --skill-root-dir ` to copy the bundled SKILL.md into Claude Code or Codex skill directories, so agents learn how to use the CLI without loading 20 MCP tool definitions into context. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 3 + README.md | 30 ++++++ package-lock.json | 44 ++------ package.json | 5 + skills/xcode-mcp/SKILL.md | 107 ++++++++++++++++++ src/xcode-service.ts | 220 ++++++++++++++++++++++++++++++++++++++ src/xcode-skill.ts | 52 +++++++++ src/xcode.ts | 59 ++++++++++ 8 files changed, 482 insertions(+), 38 deletions(-) create mode 100644 skills/xcode-mcp/SKILL.md create mode 100644 src/xcode-service.ts create mode 100644 src/xcode-skill.ts diff --git a/AGENTS.md b/AGENTS.md index 362c070..fc26817 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,10 @@ This repository provides a user-friendly CLI (`xcode-mcp`) for interacting with - `src/xcode.ts`: main CLI entrypoint and command definitions. - `src/xcode-output.ts`: text/json result formatting. - `src/xcode-mcp.ts`: HTTP bridge server that proxies to `xcrun mcpbridge`. +- `src/xcode-service.ts`: launchd service management (install/uninstall/status/logs). +- `src/xcode-skill.ts`: skill install/uninstall for agent skill directories. - `src/mcpbridge.ts`: generated MCP client/bindings (generated, not hand-maintained). +- `skills/xcode-mcp/SKILL.md`: agent skill definition shipped with the package. - `tests/*.test.ts`: Node test suite. - `bin/xcode-mcp`: runtime launcher. diff --git a/README.md b/README.md index 75fbcc2..bc9c62b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,36 @@ xcode-mcp --tab build If exactly one Xcode tab is open, `--tab` is auto-detected. +## Background Service (launchd) + +Run the bridge as a persistent background service that auto-starts on login: + +```bash +# install and start the launchd service +xcode-mcp service install + +# check if the bridge is running +xcode-mcp service status + +# follow bridge logs +xcode-mcp service logs -f + +# stop and remove the service +xcode-mcp service uninstall +``` + +## Install Skill for Claude Code / Codex + +Install the `xcode-mcp` skill so agents know how to use the CLI: + +```bash +# install skill for Claude Code +xcode-mcp skill install --skill-root-dir ~/.claude/skills + +# install skill for Codex +xcode-mcp skill install --skill-root-dir ~/.codex/skills +``` + ## Use With Codex / Claude This repo includes a helper command to register the **HTTP bridge** as an MCP server with your agent. diff --git a/package-lock.json b/package-lock.json index bb4d763..fe6f228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,23 @@ { - "name": "xcode-mcp", + "name": "xcode-mcp-bridge", "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "xcode-mcp", + "name": "xcode-mcp-bridge", "version": "1.0.5", - "license": "ISC", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.0", "commander": "^14.0.3", - "mcporter": "^0.7.3" + "mcporter": "^0.7.3", + "tsx": "^4.21.0" }, "bin": { "xcode-mcp": "bin/xcode-mcp" }, - "devDependencies": { - "tsx": "^4.21.0" - } + "devDependencies": {} }, "node_modules/@emnapi/core": { "version": "1.8.1", @@ -58,7 +57,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -75,7 +73,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -92,7 +89,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -109,7 +105,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -126,7 +121,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -143,7 +137,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -160,7 +153,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -177,7 +169,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -194,7 +185,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -211,7 +201,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -228,7 +217,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -245,7 +233,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -262,7 +249,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -279,7 +265,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -296,7 +281,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -313,7 +297,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -330,7 +313,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -347,7 +329,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -364,7 +345,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -381,7 +361,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -398,7 +377,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -415,7 +393,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -432,7 +409,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -449,7 +425,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -466,7 +441,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -483,7 +457,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1150,7 +1123,6 @@ "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1350,7 +1322,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1423,7 +1394,6 @@ "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -1902,7 +1872,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -2205,7 +2174,6 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", diff --git a/package.json b/package.json index 40ea656..93ead36 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ }, "devDependencies": {}, "type": "module", + "files": [ + "bin/", + "src/", + "skills/" + ], "bin": { "xcode-mcp": "./bin/xcode-mcp" } diff --git a/skills/xcode-mcp/SKILL.md b/skills/xcode-mcp/SKILL.md new file mode 100644 index 0000000..9e2ed9e --- /dev/null +++ b/skills/xcode-mcp/SKILL.md @@ -0,0 +1,107 @@ +--- +name: xcode-mcp +version: 1.0.0 +description: >- + Xcode IDE interaction skill. Uses the xcode-mcp CLI to build, diagnose, + test, preview, and edit Xcode projects via MCP. Use when the user asks to + build an Xcode project, view build errors, run diagnostics, run tests, + render SwiftUI previews, search Apple documentation, or manage project files. +--- + +# xcode-mcp Skill + +Interact with Xcode through the `xcode-mcp` CLI backed by the Xcode MCP bridge. + +## Prerequisites + +- Xcode 26.3 or later is installed and open with the target project +- `xcode-mcp-bridge` is installed: `npm install -g xcode-mcp-bridge` +- The bridge is running via one of: + - Background service: `xcode-mcp service install` + - Foreground (in a separate terminal): `xcode-mcp bridge` +- The bridge listens on `http://127.0.0.1:49321/mcp` + +## Workflow + +### 1. Discover tab identifier +```bash +xcode-mcp windows +``` +Returns the `tabIdentifier` (e.g. `windowtab1`) and corresponding workspace path. +If exactly one Xcode tab is open, `--tab` is auto-detected for all commands. + +### 2. Build +```bash +xcode-mcp build +``` + +### 3. View build errors +```bash +xcode-mcp build-log --severity error +``` + +### 4. Single-file diagnostics (no full build required) +```bash +xcode-mcp file-issues "MyApp/Sources/Controllers/MyFile.swift" +``` + +### 5. View all Navigator issues +```bash +xcode-mcp issues --severity error +``` + +### 6. Quick project status (windows + issues) +```bash +xcode-mcp status +``` + +### 7. SwiftUI preview (requires #Preview macro) +```bash +xcode-mcp preview "MyApp/Sources/Views/MyView.swift" --out ./preview-out +``` + +### 8. Execute code snippet +```bash +xcode-mcp snippet "MyApp/Sources/SomeFile.swift" "print(someExpression)" +``` + +### 9. Testing +```bash +xcode-mcp test all +xcode-mcp test some "TargetName/testMethod()" +xcode-mcp test list +``` + +### 10. Search Apple documentation +```bash +xcode-mcp doc "SwiftUI NavigationStack" --frameworks SwiftUI +``` + +### 11. File operations (within Xcode project structure) +```bash +xcode-mcp read "path/to/file" +xcode-mcp ls / +xcode-mcp ls -r / +xcode-mcp grep "TODO|FIXME" +xcode-mcp glob "**/*.swift" +xcode-mcp write "path/to/file" "content" +xcode-mcp update "path/to/file" "oldText" "newText" --replace-all +xcode-mcp mv "Old.swift" "New.swift" +xcode-mcp mkdir "MyApp/Sources/Feature" +xcode-mcp rm "MyApp/Sources/Unused.swift" +``` + +### 12. Service management +```bash +xcode-mcp service install # Install and start as background service (launchd) +xcode-mcp service status # Check if bridge is running +xcode-mcp service logs -f # Follow bridge logs +xcode-mcp service uninstall # Stop and remove service +``` + +## Notes +- File paths are relative to the Xcode project structure, not absolute filesystem paths. +- Use `--tab ` if multiple Xcode tabs are open. +- If the bridge is not responding: `xcode-mcp service status` then `xcode-mcp service uninstall && xcode-mcp service install`. +- For JSON output, add `--json` to any command. +- Use `xcode-mcp run --args '{"key":"value"}'` to invoke any MCP tool directly. diff --git a/src/xcode-service.ts b/src/xcode-service.ts new file mode 100644 index 0000000..afae23b --- /dev/null +++ b/src/xcode-service.ts @@ -0,0 +1,220 @@ +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +const LAUNCHD_LABEL = 'com.xcode-mcp.bridge'; +const PLIST_FILENAME = `${LAUNCHD_LABEL}.plist`; +const LAUNCH_AGENTS_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents'); +const PLIST_PATH = path.join(LAUNCH_AGENTS_DIR, PLIST_FILENAME); +const LOG_DIR = path.join(os.homedir(), 'Library', 'Logs'); +const STDOUT_LOG = path.join(LOG_DIR, 'xcode-mcp-bridge.stdout.log'); +const STDERR_LOG = path.join(LOG_DIR, 'xcode-mcp-bridge.stderr.log'); + +export type ServiceStatus = { + installed: boolean; + running: boolean; + pid?: number; + healthy?: boolean; + endpoint?: string; +}; + +function escapeXml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +export function generatePlist(binaryPath: string, port: number): string { + const envPath = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin'; + return [ + '', + '', + '', + '', + ` Label`, + ` ${escapeXml(LAUNCHD_LABEL)}`, + ` ProgramArguments`, + ` `, + ` ${escapeXml(binaryPath)}`, + ` bridge`, + ` --port`, + ` ${port}`, + ` `, + ` RunAtLoad`, + ` `, + ` KeepAlive`, + ` `, + ` StandardOutPath`, + ` ${escapeXml(STDOUT_LOG)}`, + ` StandardErrorPath`, + ` ${escapeXml(STDERR_LOG)}`, + ` EnvironmentVariables`, + ` `, + ` PATH`, + ` ${escapeXml(envPath)}`, + ` `, + '', + '', + '', + ].join('\n'); +} + +function exec(command: string, args: string[], ignoreErrors = false): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, (error, stdout, stderr) => { + if (error && !ignoreErrors) { + reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`)); + } else { + resolve(stdout); + } + }); + }); +} + +function uid(): string { + return String(process.getuid?.() ?? 501); +} + +async function resolveBinaryPath(): Promise { + // Use the currently running binary's resolved path + const argv1 = process.argv[1]; + if (argv1) { + // Walk up from the source file to find bin/xcode-mcp + const root = path.resolve(path.dirname(argv1), '..'); + const candidate = path.join(root, 'bin', 'xcode-mcp'); + try { + await fs.access(candidate, fs.constants.X_OK); + return candidate; + } catch { + // fall through + } + } + + // Fallback: which xcode-mcp + try { + const result = (await exec('which', ['xcode-mcp'])).trim(); + if (result) return result; + } catch { + // fall through + } + + throw new Error( + 'Cannot resolve xcode-mcp binary path. Ensure xcode-mcp-bridge is installed globally.', + ); +} + +export async function installService(options: { port?: number } = {}): Promise { + const port = options.port ?? 49321; + const binaryPath = await resolveBinaryPath(); + + // Ensure LaunchAgents dir exists + await fs.mkdir(LAUNCH_AGENTS_DIR, { recursive: true }); + + // Unload existing if present + try { + await fs.access(PLIST_PATH); + await exec('launchctl', ['bootout', `gui/${uid()}`, PLIST_PATH], true); + } catch { + // not installed yet + } + + // Write plist + const plist = generatePlist(binaryPath, port); + await fs.writeFile(PLIST_PATH, plist, 'utf8'); + + // Load + await exec('launchctl', ['bootstrap', `gui/${uid()}`, PLIST_PATH]); + + console.log(`Installed launchd service: ${LAUNCHD_LABEL}`); + console.log(`Plist: ${PLIST_PATH}`); + console.log(`Bridge: http://127.0.0.1:${port}/mcp`); + console.log(`Logs: ${STDERR_LOG}`); +} + +export async function uninstallService(): Promise { + try { + await fs.access(PLIST_PATH); + } catch { + console.log('Service is not installed.'); + return; + } + + await exec('launchctl', ['bootout', `gui/${uid()}`, PLIST_PATH], true); + await fs.unlink(PLIST_PATH); + console.log(`Removed launchd service: ${LAUNCHD_LABEL}`); +} + +export async function getServiceStatus(port = 49321): Promise { + // Check plist exists + let installed = false; + try { + await fs.access(PLIST_PATH); + installed = true; + } catch { + // not installed + } + + // Check launchctl + let running = false; + let pid: number | undefined; + if (installed) { + try { + const output = await exec( + 'launchctl', + ['print', `gui/${uid()}/${LAUNCHD_LABEL}`], + true, + ); + if (output.includes('state = running')) { + running = true; + } + const pidMatch = output.match(/pid\s*=\s*(\d+)/); + if (pidMatch) { + pid = Number(pidMatch[1]); + running = true; + } + } catch { + // not loaded + } + } + + // Health check + let healthy: boolean | undefined; + const endpoint = `http://127.0.0.1:${port}/health`; + try { + const res = await fetch(endpoint); + healthy = res.ok; + } catch { + healthy = false; + } + + return { installed, running, pid, healthy, endpoint: `http://127.0.0.1:${port}/mcp` }; +} + +export async function printServiceStatus(port = 49321): Promise { + const status = await getServiceStatus(port); + + if (!status.installed) { + console.log('Service: not installed'); + console.log(`Run: xcode-mcp service install`); + return; + } + + console.log(`Service: ${status.running ? 'running' : 'stopped'}${status.pid ? ` (pid ${status.pid})` : ''}`); + console.log(`Healthy: ${status.healthy ? 'yes' : 'no'}`); + console.log(`Endpoint: ${status.endpoint}`); + console.log(`Plist: ${PLIST_PATH}`); + console.log(`Logs: ${STDERR_LOG}`); +} + +export function tailLogs(options: { lines?: number; follow?: boolean } = {}): void { + const lines = String(options.lines ?? 50); + const args = ['-n', lines]; + if (options.follow) args.push('-f'); + args.push(STDOUT_LOG, STDERR_LOG); + + const child = spawn('tail', args, { stdio: 'inherit' }); + child.on('error', (err) => { + console.error(`Failed to tail logs: ${err.message}`); + }); +} diff --git a/src/xcode-skill.ts b/src/xcode-skill.ts new file mode 100644 index 0000000..82cfb8d --- /dev/null +++ b/src/xcode-skill.ts @@ -0,0 +1,52 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SKILL_DIR_NAME = 'xcode-mcp'; +const SKILL_FILENAME = 'SKILL.md'; + +function getSkillSourcePath(): string { + const thisFile = fileURLToPath(import.meta.url); + const packageRoot = path.resolve(path.dirname(thisFile), '..'); + return path.join(packageRoot, 'skills', SKILL_DIR_NAME, SKILL_FILENAME); +} + +export async function installSkill(rootDir: string): Promise { + const source = getSkillSourcePath(); + try { + await fs.access(source); + } catch { + throw new Error(`Skill source not found at ${source}`); + } + + const targetDir = path.join(rootDir, SKILL_DIR_NAME); + const targetFile = path.join(targetDir, SKILL_FILENAME); + + await fs.mkdir(targetDir, { recursive: true }); + await fs.copyFile(source, targetFile); + console.log(`Installed skill: ${targetFile}`); +} + +export async function uninstallSkill(rootDir: string): Promise { + const targetDir = path.join(rootDir, SKILL_DIR_NAME); + const targetFile = path.join(targetDir, SKILL_FILENAME); + + try { + await fs.access(targetFile); + } catch { + console.log(`Skill not found at ${targetFile}`); + return; + } + + await fs.unlink(targetFile); + // Remove directory if empty + try { + const entries = await fs.readdir(targetDir); + if (entries.length === 0) { + await fs.rmdir(targetDir); + } + } catch { + // ignore + } + console.log(`Removed skill: ${targetFile}`); +} diff --git a/src/xcode.ts b/src/xcode.ts index 38d8275..2f63f5a 100644 --- a/src/xcode.ts +++ b/src/xcode.ts @@ -8,6 +8,8 @@ import { copyPreviewToOutput, findPreviewPath } from './xcode-preview.ts'; import { parseTestSpecifier } from './xcode-test.ts'; import { renderLsTree } from './xcode-tree.ts'; import { startMcpBridge } from './xcode-mcp.ts'; +import { installService, uninstallService, printServiceStatus, tailLogs } from './xcode-service.ts'; +import { installSkill, uninstallSkill } from './xcode-skill.ts'; import type { CommonOpts, ClientContext } from './xcode-types.ts'; const SERVER_NAME = 'xcode-tools'; @@ -520,6 +522,61 @@ program }); }); +// ── service (launchd daemon management) ────────────────────────────── + +const service = program.command('service').description('Manage bridge as a background launchd service'); + +service + .command('install') + .description('Install and start bridge as a macOS launchd service') + .option('--port ', 'Bridge port', DEFAULT_PORT) + .action(async (options: { port: string }) => { + await installService({ port: Number(options.port) }); + }); + +service + .command('uninstall') + .description('Stop and remove bridge launchd service') + .action(async () => { + await uninstallService(); + }); + +service + .command('status') + .description('Show bridge service status') + .action(async () => { + await printServiceStatus(); + }); + +service + .command('logs') + .description('Show bridge service logs') + .option('-n, --lines ', 'Number of lines', '50') + .option('-f, --follow', 'Follow log output') + .action((options: { lines: string; follow?: boolean }) => { + tailLogs({ lines: Number(options.lines), follow: options.follow }); + }); + +// ── skill (agent skill management) ────────────────────────────────── + +const skill = program.command('skill').description('Manage xcode-mcp skill for agents'); + +skill + .command('install') + .description('Install xcode-mcp skill to a skills directory') + .requiredOption('--skill-root-dir ', 'Target skills root directory (e.g. ~/.claude/skills)') + .action(async (options: { skillRootDir: string }) => { + await installSkill(options.skillRootDir); + }); + +skill + .command('uninstall') + .description('Remove xcode-mcp skill from a skills directory') + .requiredOption('--skill-root-dir ', 'Target skills root directory (e.g. ~/.claude/skills)') + .action(async (options: { skillRootDir: string }) => { + await uninstallSkill(options.skillRootDir); + }); + applyCommandOrder(program, [ 'status', 'build', @@ -541,6 +598,8 @@ applyCommandOrder(program, [ 'snippet', 'doc', 'agent-setup', + 'skill', + 'service', 'bridge', 'tools', 'run', From b93fb85c49a6de9d7a5d6c33e2d3d9f8b26b73d0 Mon Sep 17 00:00:00 2001 From: Sam Zhang Date: Thu, 5 Mar 2026 15:10:22 +0800 Subject: [PATCH 2/2] fix: align test invocation across CLI bridge and skill --- skills/xcode-mcp/SKILL.md | 33 +++++- src/xcode-mcp.ts | 227 +++++++++++++++++++++++++++++++++++++- src/xcode-test.ts | 116 +++++++++++++++++-- src/xcode.ts | 160 ++++++++++++++++++++++++++- tests/xcode-test.test.ts | 34 ++++-- 5 files changed, 547 insertions(+), 23 deletions(-) diff --git a/skills/xcode-mcp/SKILL.md b/skills/xcode-mcp/SKILL.md index 9e2ed9e..9c0b856 100644 --- a/skills/xcode-mcp/SKILL.md +++ b/skills/xcode-mcp/SKILL.md @@ -68,9 +68,17 @@ xcode-mcp snippet "MyApp/Sources/SomeFile.swift" "print(someExpression)" ### 9. Testing ```bash xcode-mcp test all -xcode-mcp test some "TargetName/testMethod()" +xcode-mcp test list --json +xcode-mcp test some "TargetName::ClassName/testMethod()" +xcode-mcp test some --target TargetName "ClassName#testMethod" xcode-mcp test list ``` +For exact MCP parity, use `targetName` + `identifier` from `test list --json`: +- `targetName` maps to `RunSomeTests.tests[].targetName` +- `identifier` maps to `RunSomeTests.tests[].testIdentifier` + +`RunSomeTests` only runs tests from the active scheme's active test plan in Xcode. +If a target is missing (for example you need `DashProxyMac` while `DashProxy` is active), switch scheme in Xcode first, then run `xcode-mcp test list --json` again. ### 10. Search Apple documentation ```bash @@ -105,3 +113,26 @@ xcode-mcp service uninstall # Stop and remove service - If the bridge is not responding: `xcode-mcp service status` then `xcode-mcp service uninstall && xcode-mcp service install`. - For JSON output, add `--json` to any command. - Use `xcode-mcp run --args '{"key":"value"}'` to invoke any MCP tool directly. + +## CLI to MCP Mapping +- `status` → `XcodeListWindows` + `XcodeListNavigatorIssues` +- `build` → `BuildProject` +- `build-log` → `GetBuildLog` +- `test all` → `RunAllTests` +- `test list` → `GetTestList` +- `test some` → `RunSomeTests` +- `issues` → `XcodeListNavigatorIssues` +- `file-issues` → `XcodeRefreshCodeIssuesInFile` +- `windows` → `XcodeListWindows` +- `read` → `XcodeRead` +- `grep` → `XcodeGrep` +- `ls` → `XcodeLS` +- `glob` → `XcodeGlob` +- `write` → `XcodeWrite` +- `update` → `XcodeUpdate` +- `mv` → `XcodeMV` +- `mkdir` → `XcodeMakeDir` +- `rm` → `XcodeRM` +- `preview` → `RenderPreview` +- `snippet` → `ExecuteSnippet` +- `doc` → `DocumentationSearch` diff --git a/src/xcode-mcp.ts b/src/xcode-mcp.ts index 59db003..9d2c9cd 100644 --- a/src/xcode-mcp.ts +++ b/src/xcode-mcp.ts @@ -10,6 +10,7 @@ import { isInitializeRequest, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; +import { parseTestSpecifier, type ParsedTestSpecifier } from './xcode-test.ts'; export type McpBridgeStartOptions = { host: string; @@ -219,7 +220,8 @@ function createSessionServer(upstream: Client): Server { }); server.setRequestHandler(CallToolRequestSchema, async (request) => { - return await upstream.callTool(request.params); + const params = await normalizeRunSomeTestsCall(request.params, upstream); + return await upstream.callTool(params); }); return server; @@ -256,3 +258,226 @@ async function parseJsonBody(req: http.IncomingMessage): Promise { } return JSON.parse(raw); } + +type TestCatalogEntry = { + targetName: string; + identifier: string; +}; + +async function normalizeRunSomeTestsCall( + params: Record, + upstream: Client, +): Promise> { + if (params.name !== 'RunSomeTests') { + return params; + } + if (!params.arguments || typeof params.arguments !== 'object' || Array.isArray(params.arguments)) { + return params; + } + + const argumentsRecord = params.arguments as Record; + const testsValue = argumentsRecord.tests; + if (!Array.isArray(testsValue)) { + return params; + } + + const defaultTargetName = normalizeString(argumentsRecord.targetName); + const parsed = testsValue.map((value) => parseBridgeTestSpecifier(value, defaultTargetName)); + const normalizedTests = await resolveBridgeTestSpecifiers(parsed, argumentsRecord, upstream); + + return { + ...params, + arguments: { + ...argumentsRecord, + tests: normalizedTests, + }, + }; +} + +function parseBridgeTestSpecifier( + value: unknown, + defaultTargetName?: string, +): ParsedTestSpecifier { + if (typeof value === 'string') { + return parseTestSpecifier(value, defaultTargetName); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid RunSomeTests entry '${String(value)}'. Expected a string or object.`); + } + + const record = value as Record; + const targetName = normalizeString(record.targetName) ?? defaultTargetName; + const testIdentifier = normalizeString(record.testIdentifier); + + if (testIdentifier) { + return { + source: testIdentifier, + targetName, + testIdentifier, + }; + } + + const shorthand = normalizeString(record.identifier) ?? normalizeString(record.test); + if (shorthand) { + return parseTestSpecifier(shorthand, targetName); + } + + throw new Error('Invalid RunSomeTests entry. Missing testIdentifier/identifier/test field.'); +} + +async function resolveBridgeTestSpecifiers( + parsed: ParsedTestSpecifier[], + args: Record, + upstream: Client, +): Promise> { + if (parsed.every((entry) => Boolean(entry.targetName))) { + return parsed.map((entry) => ({ + targetName: entry.targetName!.trim(), + testIdentifier: entry.testIdentifier, + })); + } + + const tabIdentifier = normalizeString(args.tabIdentifier); + if (!tabIdentifier) { + throw new Error( + "RunSomeTests shorthand requires 'tabIdentifier' to resolve test target. Provide 'targetName' explicitly or use Target::Identifier.", + ); + } + + const catalog = await fetchTestCatalog(upstream, tabIdentifier); + const availableTargets = [...new Set(catalog.map((entry) => entry.targetName))].sort(); + const lookup = buildCatalogLookup(catalog); + + return parsed.map((entry) => { + if (entry.targetName) { + return { + targetName: entry.targetName.trim(), + testIdentifier: entry.testIdentifier, + }; + } + + const matches = resolveCatalogEntries(lookup, entry.testIdentifier); + if (matches.length === 0) { + const targetHint = + availableTargets.length > 0 + ? ` Active scheme targets: ${availableTargets.join(', ')}.` + : ' Active scheme has no discoverable test targets.'; + throw new Error( + `Unable to resolve target for '${entry.source}'. Use Target::Identifier or provide targetName.${targetHint} If this test belongs to another scheme, switch the active scheme in Xcode first.`, + ); + } + + const targets = [...new Set(matches.map((match) => match.targetName))].sort(); + if (targets.length > 1) { + throw new Error( + `Ambiguous RunSomeTests shorthand '${entry.source}'. Matching targets: ${targets.join(', ')}. Use Target::Identifier.`, + ); + } + + return { + targetName: targets[0], + testIdentifier: matches[0].identifier, + }; + }); +} + +async function fetchTestCatalog(upstream: Client, tabIdentifier: string): Promise { + const response = await upstream.callTool({ + name: 'GetTestList', + arguments: { tabIdentifier }, + }); + const value = + (response as { structuredContent?: unknown }).structuredContent ?? response; + return extractTestCatalog(value); +} + +function extractTestCatalog(value: unknown): TestCatalogEntry[] { + const entries: TestCatalogEntry[] = []; + const queue: unknown[] = [value]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + if (typeof current !== 'object') { + continue; + } + + const record = current as Record; + const targetName = normalizeString(record.targetName); + const identifier = normalizeString(record.identifier); + if (targetName && identifier) { + entries.push({ targetName, identifier }); + } + + for (const nested of Object.values(record)) { + if (!nested) { + continue; + } + if (Array.isArray(nested)) { + queue.push(...nested); + } else if (typeof nested === 'object') { + queue.push(nested); + } + } + } + return entries; +} + +function buildCatalogLookup(catalog: TestCatalogEntry[]): Map { + const lookup = new Map(); + for (const entry of catalog) { + for (const key of identifierLookupKeys(entry.identifier)) { + const existing = lookup.get(key); + if (existing) { + existing.push(entry); + } else { + lookup.set(key, [entry]); + } + } + } + return lookup; +} + +function resolveCatalogEntries( + lookup: Map, + testIdentifier: string, +): TestCatalogEntry[] { + const matches = new Map(); + for (const key of identifierLookupKeys(testIdentifier)) { + const entries = lookup.get(key); + if (!entries) { + continue; + } + for (const entry of entries) { + matches.set(`${entry.targetName}::${entry.identifier}`, entry); + } + } + return [...matches.values()]; +} + +function identifierLookupKeys(identifier: string): string[] { + const trimmed = identifier.trim(); + if (!trimmed) { + return []; + } + const keys = new Set([trimmed]); + if (trimmed.endsWith('()')) { + keys.add(trimmed.slice(0, -2)); + } else if (!trimmed.endsWith(')')) { + keys.add(`${trimmed}()`); + } + return [...keys]; +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/xcode-test.ts b/src/xcode-test.ts index f6a22b4..4069834 100644 --- a/src/xcode-test.ts +++ b/src/xcode-test.ts @@ -1,19 +1,115 @@ -export function parseTestSpecifier(input: string): { targetName: string; testIdentifier: string } { +export type ParsedTestSpecifier = { + source: string; + targetName?: string; + testIdentifier: string; +}; + +export function parseTestSpecifier(input: string, defaultTargetName?: string): ParsedTestSpecifier { const value = input.trim(); - const slash = value.indexOf('/'); - if (slash <= 0) { + if (!value) { throw new Error( - `Invalid test specifier '${input}'. Expected format: TargetName/testName()`, + `Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test(), Class#test`, ); } - const targetName = value.slice(0, slash).trim(); - if (!targetName) { - throw new Error( - `Invalid test specifier '${input}'. Expected format: TargetName/testName()`, - ); + + const explicit = parseExplicitTargetAndIdentifier(value); + if (explicit) { + return { + source: input, + targetName: explicit.targetName, + testIdentifier: explicit.testIdentifier, + }; + } + + const hashIdentifier = parseHashIdentifier(value); + if (hashIdentifier) { + return { + source: input, + targetName: normalizeTarget(defaultTargetName), + testIdentifier: hashIdentifier, + }; + } + + if (value.includes('/')) { + const slashCount = countCharacter(value, '/'); + if (slashCount >= 2) { + const firstSlash = value.indexOf('/'); + const targetName = value.slice(0, firstSlash).trim(); + const testIdentifier = value.slice(firstSlash + 1).trim(); + if (!targetName || !testIdentifier) { + throw new Error( + `Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test()`, + ); + } + return { + source: input, + targetName, + testIdentifier, + }; + } + + const firstSlash = value.indexOf('/'); + if (firstSlash <= 0 || firstSlash >= value.length - 1) { + throw new Error( + `Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test(), Class#test`, + ); + } + + return { + source: input, + targetName: normalizeTarget(defaultTargetName), + testIdentifier: value, + }; + } + + throw new Error( + `Invalid test specifier '${input}'. Expected formats: Target::Class/test(), Target/Class/test(), Class#test`, + ); +} + +function parseExplicitTargetAndIdentifier( + value: string, +): { targetName: string; testIdentifier: string } | undefined { + const separator = value.indexOf('::'); + if (separator <= 0) { + return undefined; + } + const targetName = value.slice(0, separator).trim(); + const identifierRaw = value.slice(separator + 2).trim(); + if (!targetName || !identifierRaw) { + return undefined; } return { targetName, - testIdentifier: value, + testIdentifier: parseHashIdentifier(identifierRaw) ?? identifierRaw, }; } + +function parseHashIdentifier(value: string): string | undefined { + const separator = value.indexOf('#'); + if (separator <= 0 || separator >= value.length - 1) { + return undefined; + } + const suiteName = value.slice(0, separator).trim(); + const methodName = value.slice(separator + 1).trim(); + if (!suiteName || !methodName) { + return undefined; + } + const normalizedMethod = methodName.endsWith('()') ? methodName : `${methodName}()`; + return `${suiteName}/${normalizedMethod}`; +} + +function normalizeTarget(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function countCharacter(value: string, character: string): number { + let count = 0; + for (const entry of value) { + if (entry === character) { + count += 1; + } + } + return count; +} diff --git a/src/xcode.ts b/src/xcode.ts index 2f63f5a..808e5e5 100644 --- a/src/xcode.ts +++ b/src/xcode.ts @@ -5,7 +5,7 @@ import type { CallResult } from 'mcporter'; import { spawn } from 'node:child_process'; import { printResult, unwrapResult } from './xcode-output.ts'; import { copyPreviewToOutput, findPreviewPath } from './xcode-preview.ts'; -import { parseTestSpecifier } from './xcode-test.ts'; +import { parseTestSpecifier, type ParsedTestSpecifier } from './xcode-test.ts'; import { renderLsTree } from './xcode-tree.ts'; import { startMcpBridge } from './xcode-mcp.ts'; import { installService, uninstallService, printServiceStatus, tailLogs } from './xcode-service.ts'; @@ -256,11 +256,22 @@ tests tests .command('some ') - .description('Run selected tests by identifiers (e.g. TargetName/testName())') - .action(async (testsArg: string[]) => { + .description('Run selected tests using target+identifier specifiers') + .option('--target ', 'Default test target for identifier-only specs') + .addHelpText( + 'after', + ` +Examples: + xcode-mcp test some "DashProxyTests::AccessKeyTests/testParseEndpointSimple()" + xcode-mcp test some "DashProxyTests/AccessKeyTests/testParseEndpointSimple()" + xcode-mcp test some --target DashProxyTests "AccessKeyTests#testParseEndpointSimple" +`, + ) + .action(async (testsArg: string[], options: { target?: string }) => { await withClient(async (ctx) => { const tabIdentifier = await resolveTabIdentifier(ctx, true); - const tests = testsArg.map(parseTestSpecifier); + const parsed = testsArg.map((value) => parseTestSpecifier(value, options.target)); + const tests = await resolveTestSpecifiers(parsed, ctx, tabIdentifier); const result = await ctx.call('RunSomeTests', { tabIdentifier, tests }); printResult(result, ctx.output); }); @@ -729,6 +740,147 @@ function collectTabIdentifiersFromText(text: string, sink: Set) { } } +type NormalizedTestSpecifier = { + targetName: string; + testIdentifier: string; +}; + +type TestCatalogEntry = { + targetName: string; + identifier: string; +}; + +async function resolveTestSpecifiers( + parsed: ParsedTestSpecifier[], + ctx: Pick, + tabIdentifier: string, +): Promise { + if (parsed.every((entry) => Boolean(entry.targetName))) { + return parsed.map((entry) => ({ + targetName: entry.targetName!.trim(), + testIdentifier: entry.testIdentifier, + })); + } + + const listResult = await ctx.call('GetTestList', { tabIdentifier }); + const catalog = extractTestCatalog(unwrapResult(listResult)); + const availableTargets = [...new Set(catalog.map((entry) => entry.targetName))].sort(); + const byIdentifier = buildCatalogLookup(catalog); + + return parsed.map((entry) => { + if (entry.targetName) { + return { + targetName: entry.targetName.trim(), + testIdentifier: entry.testIdentifier, + }; + } + + const candidates = resolveCatalogEntries(byIdentifier, entry.testIdentifier); + if (candidates.length === 0) { + const targetHint = + availableTargets.length > 0 + ? ` Active scheme targets: ${availableTargets.join(', ')}.` + : ' Active scheme has no discoverable test targets.'; + throw new Error( + `Unable to resolve target for '${entry.source}'. Run 'xcode-mcp --tab ${tabIdentifier} test list --json' and use 'Target::Identifier'.${targetHint} If this test belongs to another scheme, switch active scheme in Xcode first.`, + ); + } + + const targetNames = [...new Set(candidates.map((candidate) => candidate.targetName))].sort(); + if (targetNames.length > 1) { + throw new Error( + `Ambiguous test specifier '${entry.source}'. Matching targets: ${targetNames.join(', ')}. Use 'Target::${entry.testIdentifier}'.`, + ); + } + + return { + targetName: targetNames[0], + testIdentifier: candidates[0].identifier, + }; + }); +} + +function extractTestCatalog(value: unknown): TestCatalogEntry[] { + const entries: TestCatalogEntry[] = []; + const queue: unknown[] = [value]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + if (typeof current !== 'object') { + continue; + } + const record = current as Record; + const targetName = typeof record.targetName === 'string' ? record.targetName.trim() : ''; + const identifier = typeof record.identifier === 'string' ? record.identifier.trim() : ''; + if (targetName && identifier) { + entries.push({ targetName, identifier }); + } + for (const nested of Object.values(record)) { + if (!nested) { + continue; + } + if (Array.isArray(nested)) { + queue.push(...nested); + } else if (typeof nested === 'object') { + queue.push(nested); + } + } + } + return entries; +} + +function buildCatalogLookup(catalog: TestCatalogEntry[]): Map { + const lookup = new Map(); + for (const entry of catalog) { + for (const key of identifierLookupKeys(entry.identifier)) { + const existing = lookup.get(key); + if (existing) { + existing.push(entry); + } else { + lookup.set(key, [entry]); + } + } + } + return lookup; +} + +function resolveCatalogEntries( + lookup: Map, + testIdentifier: string, +): TestCatalogEntry[] { + const matches = new Map(); + for (const key of identifierLookupKeys(testIdentifier)) { + const entries = lookup.get(key); + if (!entries) { + continue; + } + for (const entry of entries) { + matches.set(`${entry.targetName}::${entry.identifier}`, entry); + } + } + return [...matches.values()]; +} + +function identifierLookupKeys(identifier: string): string[] { + const trimmed = identifier.trim(); + if (!trimmed) { + return []; + } + const keys = new Set([trimmed]); + if (trimmed.endsWith('()')) { + keys.add(trimmed.slice(0, -2)); + } else if (!trimmed.endsWith(')')) { + keys.add(`${trimmed}()`); + } + return [...keys]; +} + function parseOutputFormat(value: string): CommonOpts['output'] { const normalized = value.trim().toLowerCase(); if (normalized === 'text' || normalized === 'json') { diff --git a/tests/xcode-test.test.ts b/tests/xcode-test.test.ts index 4627bf5..46c3617 100644 --- a/tests/xcode-test.test.ts +++ b/tests/xcode-test.test.ts @@ -2,21 +2,41 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { parseTestSpecifier } from '../src/xcode-test.ts'; -test('parseTestSpecifier parses valid target/test value', () => { - const parsed = parseTestSpecifier('AppTests/testExample()'); +test('parseTestSpecifier parses explicit target::identifier value', () => { + const parsed = parseTestSpecifier('AppTests::FeatureTests/testExample()'); assert.deepEqual(parsed, { + source: 'AppTests::FeatureTests/testExample()', targetName: 'AppTests', - testIdentifier: 'AppTests/testExample()', + testIdentifier: 'FeatureTests/testExample()', }); }); -test('parseTestSpecifier trims input and target name', () => { - const parsed = parseTestSpecifier(' AppTests /testExample() '); +test('parseTestSpecifier parses Target/Class/test() format', () => { + const parsed = parseTestSpecifier('AppTests/FeatureTests/testExample()'); assert.equal(parsed.targetName, 'AppTests'); - assert.equal(parsed.testIdentifier, 'AppTests /testExample()'); + assert.equal(parsed.testIdentifier, 'FeatureTests/testExample()'); }); -test('parseTestSpecifier throws for invalid format', () => { +test('parseTestSpecifier parses Class#test shorthand with default target', () => { + const parsed = parseTestSpecifier('AccessKeyTests#testParseEndpointSimple', 'DashProxyTests'); + assert.deepEqual(parsed, { + source: 'AccessKeyTests#testParseEndpointSimple', + targetName: 'DashProxyTests', + testIdentifier: 'AccessKeyTests/testParseEndpointSimple()', + }); +}); + +test('parseTestSpecifier parses Class/test identifier without explicit target', () => { + const parsed = parseTestSpecifier('AccessKeyTests/testParseEndpointSimple()'); + assert.deepEqual(parsed, { + source: 'AccessKeyTests/testParseEndpointSimple()', + targetName: undefined, + testIdentifier: 'AccessKeyTests/testParseEndpointSimple()', + }); +}); + +test('parseTestSpecifier throws for invalid formats', () => { assert.throws(() => parseTestSpecifier('JustATarget'), /Invalid test specifier/); assert.throws(() => parseTestSpecifier('/testOnly()'), /Invalid test specifier/); + assert.throws(() => parseTestSpecifier('Target::'), /Invalid test specifier/); });