diff --git a/.mcp.json b/.mcp.json index c25dcb38c..700113020 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,3 @@ { - "mcpServers": { - "sentry": { - "type": "http", - "url": "https://mcp.sentry.dev/mcp" - } - } + "mcpServers": {} } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f657851aa..3a280b649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Added - Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied. +- Added `build-tools` workflow group with 4 new tools for macOS build pipeline automation +- Added `xcodegen_generate` tool for generating Xcode projects from xcodegen specs +- Added `create_dmg` tool for creating DMG disk images with path traversal and symlink escape protection +- Added `codesign_app` tool for code signing and optional notarization (sign, verify, notarize, staple) +- Added `pfctl_anchor` tool for read-only PF firewall anchor inspection +- Added `xcodebuildmcp.output.command-result` structured output schema shared by all build-tools +- Added `CommandResultDomainResult` type to domain results union ## [2.3.2] diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index dcfdb27ce..d1795b5ef 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -2,10 +2,20 @@ This document lists CLI tool names as exposed by `xcodebuildmcp `. -XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. +XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups. ## Workflow Groups +### Build Pipeline Tools (`build-tools`) +**Purpose**: Extended build pipeline — project generation (xcodegen), DMG creation, code signing/notarization, and PF firewall anchor inspection. (4 tools) + +- `codesign` - Code-sign and optionally notarize a macOS application or DMG. +- `dmg` - Create DMG disk image from built macOS application. +- `pfctl` - Inspect PF firewall anchor rules (read-only). Cannot modify firewall state. Requires passwordless sudo for pfctl on host. +- `xcodegen` - Generate Xcode project from xcodegen spec (project.yml). + + + ### Build Utilities (`utilities`) **Purpose**: Utility tools for cleaning build products and managing build artifacts. (1 tools) @@ -185,10 +195,10 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. ## Summary Statistics -- **Canonical Tools**: 71 -- **Total Tools**: 99 -- **Workflow Groups**: 13 +- **Canonical Tools**: 75 +- **Total Tools**: 103 +- **Workflow Groups**: 14 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-24T09:29:18.061Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-25T17:46:30.532Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 533133e52..9f37fa581 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,9 +1,19 @@ # XcodeBuildMCP MCP Tools Reference -This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 77 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 81 canonical tools organized into 16 workflow groups for comprehensive Apple development workflows. ## Workflow Groups +### Build Pipeline Tools (`build-tools`) +**Purpose**: Extended build pipeline — project generation (xcodegen), DMG creation, code signing/notarization, and PF firewall anchor inspection. (4 tools) + +- `codesign_app` - Code-sign and optionally notarize a macOS application or DMG. +- `create_dmg` - Create DMG disk image from built macOS application. +- `pfctl_anchor` - Inspect PF firewall anchor rules (read-only). Cannot modify firewall state. Requires passwordless sudo for pfctl on host. +- `xcodegen_generate` - Generate Xcode project from xcodegen spec (project.yml). + + + ### Build Utilities (`utilities`) **Purpose**: Utility tools for cleaning build products and managing build artifacts. (1 tools) @@ -201,10 +211,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ## Summary Statistics -- **Canonical Tools**: 77 -- **Total Tools**: 105 -- **Workflow Groups**: 15 +- **Canonical Tools**: 81 +- **Total Tools**: 109 +- **Workflow Groups**: 16 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-24T09:29:18.061Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-04-25T17:46:30.532Z UTC* diff --git a/manifests/tools/codesign_app.yaml b/manifests/tools/codesign_app.yaml new file mode 100644 index 000000000..32276886a --- /dev/null +++ b/manifests/tools/codesign_app.yaml @@ -0,0 +1,16 @@ +id: codesign_app +module: mcp/tools/build-tools/codesign_app +names: + mcp: codesign_app + cli: codesign +description: Code-sign and optionally notarize a macOS application or DMG. +outputSchema: + schema: xcodebuildmcp.output.command-result + version: "1" +predicates: + - hideWhenXcodeAgentMode +annotations: + title: Code Sign App + readOnlyHint: false + destructiveHint: false + openWorldHint: false diff --git a/manifests/tools/create_dmg.yaml b/manifests/tools/create_dmg.yaml new file mode 100644 index 000000000..889d7e03a --- /dev/null +++ b/manifests/tools/create_dmg.yaml @@ -0,0 +1,21 @@ +id: create_dmg +module: mcp/tools/build-tools/create_dmg +names: + mcp: create_dmg + cli: dmg +description: Create DMG disk image from built macOS application. +outputSchema: + schema: xcodebuildmcp.output.command-result + version: "1" +predicates: + - hideWhenXcodeAgentMode +annotations: + title: Create DMG + readOnlyHint: false + destructiveHint: false + openWorldHint: false +nextSteps: + - label: Sign the app + toolId: codesign_app + priority: 1 + when: success diff --git a/manifests/tools/pfctl_anchor.yaml b/manifests/tools/pfctl_anchor.yaml new file mode 100644 index 000000000..f1904001d --- /dev/null +++ b/manifests/tools/pfctl_anchor.yaml @@ -0,0 +1,14 @@ +id: pfctl_anchor +module: mcp/tools/build-tools/pfctl_anchor +names: + mcp: pfctl_anchor + cli: pfctl +description: Inspect PF firewall anchor rules (read-only). Cannot modify firewall state. Requires passwordless sudo for pfctl on host. +outputSchema: + schema: xcodebuildmcp.output.command-result + version: "1" +annotations: + title: PF Anchor Inspector + readOnlyHint: true + destructiveHint: false + openWorldHint: false diff --git a/manifests/tools/xcodegen_generate.yaml b/manifests/tools/xcodegen_generate.yaml new file mode 100644 index 000000000..0920a02af --- /dev/null +++ b/manifests/tools/xcodegen_generate.yaml @@ -0,0 +1,21 @@ +id: xcodegen_generate +module: mcp/tools/build-tools/xcodegen_generate +names: + mcp: xcodegen_generate + cli: xcodegen +description: Generate Xcode project from xcodegen spec (project.yml). +outputSchema: + schema: xcodebuildmcp.output.command-result + version: "1" +predicates: + - hideWhenXcodeAgentMode +annotations: + title: Xcodegen Generate + readOnlyHint: false + destructiveHint: false + openWorldHint: false +nextSteps: + - label: Build macOS app + toolId: build_macos + priority: 1 + when: success diff --git a/manifests/workflows/build-tools.yaml b/manifests/workflows/build-tools.yaml new file mode 100644 index 000000000..6681d76f0 --- /dev/null +++ b/manifests/workflows/build-tools.yaml @@ -0,0 +1,8 @@ +id: build-tools +title: Build Pipeline Tools +description: Extended build pipeline — project generation (xcodegen), DMG creation, code signing/notarization, and PF firewall anchor inspection. +tools: + - xcodegen_generate + - create_dmg + - codesign_app + - pfctl_anchor diff --git a/schemas/structured-output/xcodebuildmcp.output.command-result/1.schema.json b/schemas/structured-output/xcodebuildmcp.output.command-result/1.schema.json new file mode 100644 index 000000000..8551af460 --- /dev/null +++ b/schemas/structured-output/xcodebuildmcp.output.command-result/1.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.command-result/1.schema.json", + "title": "Command Result", + "description": "Structured output envelope for shell command execution results.", + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency" + } + ], + "properties": { + "schema": { + "const": "xcodebuildmcp.output.command-result" + }, + "schemaVersion": { + "const": "1" + }, + "didError": { + "type": "boolean" + }, + "error": { + "type": ["string", "null"] + }, + "data": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "command": { + "type": "string", + "description": "The tool/command that was executed" + }, + "summary": { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/statusSummary" + }, + "output": { + "type": "string", + "description": "Stdout from the command" + }, + "diagnostics": { + "$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/basicDiagnostics" + } + }, + "required": ["command", "summary", "diagnostics"] + }, + { + "type": "null" + } + ] + } + }, + "required": ["schema", "schemaVersion", "didError", "error", "data"] +} diff --git a/scripts/ensure-mcp-server.sh b/scripts/ensure-mcp-server.sh new file mode 100755 index 000000000..e86fda9e5 --- /dev/null +++ b/scripts/ensure-mcp-server.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# ensure-mcp-server.sh — Start the MCP bridge if not already running. +# +# Idempotent: safe to call on every container start, cron, or manually. +# Checks if port is already in use before spawning. +# +# Usage: +# ./scripts/ensure-mcp-server.sh # default port 9090 +# ./scripts/ensure-mcp-server.sh --port 8080 # custom port +# WORKFLOWS="build-tools" ./scripts/ensure-mcp-server.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PORT=9090 + +# Parse --port from args +prev_arg="" +for arg in "$@"; do + if [[ "$prev_arg" == "--port" ]]; then + PORT="$arg" + fi + prev_arg="$arg" +done + +# --- PID file management --- +LOG_DIR="${SCRIPT_DIR}/../logs" +mkdir -p "$LOG_DIR" +LOG_FILE="${LOG_DIR}/mcp-server.log" +PID_FILE="${LOG_DIR}/mcp-server-${PORT}.pid" + +stop_old_process() { + if [[ ! -f "$PID_FILE" ]]; then + return + fi + local old_pid + old_pid=$(cat "$PID_FILE") + if kill -0 "$old_pid" 2>/dev/null; then + echo "[ensure-mcp] Stopping previous server (PID $old_pid)..." + kill -TERM "$old_pid" 2>/dev/null || true + # Wait up to 5s for graceful shutdown (serve-mcp.sh trap propagates to children) + local i=0 + while kill -0 "$old_pid" 2>/dev/null && (( i < 10 )); do + sleep 0.5 + (( i++ )) + done + if kill -0 "$old_pid" 2>/dev/null; then + echo "[ensure-mcp] Force-killing old server (PID $old_pid)..." + kill -9 "$old_pid" 2>/dev/null || true + fi + fi + rm -f "$PID_FILE" +} + +# Check if already running and healthy on this port +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + if kill -0 "$old_pid" 2>/dev/null && lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "[ensure-mcp] Server already running (PID $old_pid, port $PORT)" + exit 0 + fi + # Stale PID file or port not listening — clean up + stop_old_process +fi + +# Port in use but no PID file — something else owns it +if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "[ensure-mcp] ERROR: Port $PORT in use by another process" + lsof -ti :"$PORT" -sTCP:LISTEN + exit 1 +fi + +# --- Launch --- +echo "[ensure-mcp] Starting MCP server on port $PORT..." +nohup "$SCRIPT_DIR/serve-mcp.sh" --port "$PORT" "$@" \ + >> "$LOG_FILE" 2>&1 & + +SERVER_PID=$! +echo "$SERVER_PID" > "$PID_FILE" + +# Wait briefly to confirm it started +sleep 2 +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "[ensure-mcp] MCP server running (PID $SERVER_PID, port $PORT)" + echo "[ensure-mcp] Log: $LOG_FILE" +else + echo "[ensure-mcp] ERROR: MCP server failed to start. Check $LOG_FILE" + rm -f "$PID_FILE" + exit 1 +fi diff --git a/scripts/patch-supergateway.sh b/scripts/patch-supergateway.sh new file mode 100755 index 000000000..db8b44562 --- /dev/null +++ b/scripts/patch-supergateway.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# patch-supergateway.sh — Fix missing `await` on async transport.send() in supergateway. +# +# supergateway calls `transport.send(jsonMsg)` without `await` inside a +# synchronous try/catch in a forEach callback. Since send() is async, +# rejected promises escape the catch and crash the process as unhandled +# rejections when an HTTP connection closes before the response is sent. +# +# This patch wraps the send() calls in an async IIFE with proper error +# handling, avoiding the need to restructure the forEach -> for..of. +# +# Upstream issue: https://github.com/supercorp-ai/supergateway/issues/116 +# +# Usage: +# ./scripts/patch-supergateway.sh # auto-detect npx cache +# ./scripts/patch-supergateway.sh /path/to/supergateway + +set -euo pipefail + +if [[ -n "${1:-}" ]]; then + SG_DIR="$1" +else + SG_DIR=$(find "$HOME/.npm/_npx" -path "*/node_modules/supergateway" -type d -maxdepth 4 2>/dev/null | head -1) + if [[ -z "$SG_DIR" ]]; then + echo "ERROR: Could not find supergateway in npx cache. Pass the path explicitly." + exit 1 + fi +fi + +GATEWAYS="$SG_DIR/dist/gateways" + +if [[ ! -d "$GATEWAYS" ]]; then + echo "ERROR: $GATEWAYS does not exist" + exit 1 +fi + +VERSION=$(node -p "require('$SG_DIR/package.json').version" 2>/dev/null || echo "unknown") +echo "Patching supergateway v${VERSION} at ${SG_DIR}" + +patch_count=0 + +patch_file() { + local file="$1" + local name + name=$(basename "$file") + + if [[ ! -f "$file" ]]; then + echo " SKIP: $name (not found)" + return + fi + + if grep -q 'await transport\.send\|\.send(jsonMsg)\.catch' "$file" 2>/dev/null; then + echo " OK: $name (already patched)" + return + fi + + if ! grep -q 'transport\.send(jsonMsg)' "$file" 2>/dev/null; then + echo " SKIP: $name (pattern not found)" + return + fi + + # Wrap bare `transport.send(jsonMsg)` in `.catch()` to handle rejected promises. + # This is minimally invasive — no structural changes to forEach/callback. + sed -i.bak \ + 's/transport\.send(jsonMsg);/transport.send(jsonMsg).catch((e) => logger.error("Async send failed:", e));/g' \ + "$file" + + # Same for session.transport.send (stdioToSse.js) + sed -i.bak \ + 's/session\.transport\.send(jsonMsg);/session.transport.send(jsonMsg).catch((e) => logger.error("Async send failed:", e));/g' \ + "$file" + + rm -f "${file}.bak" + echo " PATCH: $name" + patch_count=$((patch_count + 1)) +} + +patch_file "$GATEWAYS/stdioToStatefulStreamableHttp.js" +patch_file "$GATEWAYS/stdioToStatelessStreamableHttp.js" +patch_file "$GATEWAYS/stdioToSse.js" + +echo "" +if [[ $patch_count -gt 0 ]]; then + echo "Patched $patch_count file(s). Restart supergateway to apply." +else + echo "Nothing to patch." +fi diff --git a/scripts/serve-mcp.sh b/scripts/serve-mcp.sh new file mode 100755 index 000000000..b3fa0e738 --- /dev/null +++ b/scripts/serve-mcp.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# serve-mcp.sh — Expose the MCP server over HTTP for Docker/remote clients. +# +# Wraps supergateway to bridge stdio MCP to Streamable HTTP, setting the +# correct PATH and workflow config so homebrew-installed tools (xcodegen, +# create-dmg, etc.) are reachable from the child process. +# +# Usage: +# ./scripts/serve-mcp.sh # defaults: port 9090, all workflows +# ./scripts/serve-mcp.sh --port 8080 # custom port +# WORKFLOWS="build-tools,simulator" ./scripts/serve-mcp.sh # specific workflows +# +# Docker client config (.mcp.json inside container): +# { +# "mcpServers": { +# "xcode": { +# "type": "http", +# "url": "http://host.docker.internal:/mcp" +# } +# } +# } + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# --- PATH: include common tool locations --- +# homebrew (Apple Silicon + Intel), MacPorts, user-local +for dir in /opt/homebrew/bin /usr/local/bin /opt/local/bin "$HOME/.local/bin"; do + [[ -d "$dir" ]] && [[ ":$PATH:" != *":$dir:"* ]] && PATH="$dir:$PATH" +done +export PATH + +# --- Workflows --- +# Default: all workflows. Override with WORKFLOWS env var. +if [[ -z "${XCODEBUILDMCP_ENABLED_WORKFLOWS:-}" ]]; then + export XCODEBUILDMCP_ENABLED_WORKFLOWS="${WORKFLOWS:-build-tools,simulator,macos,device,doctor,workflow-discovery,project-discovery,utilities}" +fi + +# --- Port --- +PORT=9090 +prev_arg="" +for arg in "$@"; do + if [[ "$prev_arg" == "--port" ]]; then + PORT="$arg" + fi + prev_arg="$arg" +done + +# --- Launch --- +echo "MCP server: ${PROJECT_ROOT}/build/cli.js" +echo "Endpoint: http://localhost:${PORT}/mcp" +echo "Workflows: ${XCODEBUILDMCP_ENABLED_WORKFLOWS}" +echo "PATH includes: $(which xcodegen 2>/dev/null || echo '(xcodegen not found)'), $(which codesign 2>/dev/null || echo '(codesign not found)')" +echo "" + +# Filter out --port from passthrough args to avoid duplication +FILTERED_ARGS=() +skip_next=false +for arg in "$@"; do + if $skip_next; then + skip_next=false + continue + fi + if [[ "$arg" == "--port" ]]; then + skip_next=true + continue + fi + FILTERED_ARGS+=("$arg") +done + +# Propagate signals to the entire process group so child processes +# (supergateway -> node mcp) are cleaned up when this script is killed. +cleanup() { + # Send TERM to our process group (negative PID), excluding ourselves + trap - TERM INT # prevent re-entry + kill -TERM 0 2>/dev/null + wait +} +trap cleanup TERM INT + +npx supergateway \ + --port "$PORT" \ + --stdio "node ${PROJECT_ROOT}/build/cli.js mcp" \ + --outputTransport streamableHttp \ + --stateful \ + --cors \ + ${FILTERED_ARGS[@]+"${FILTERED_ARGS[@]}"} & + +wait $! diff --git a/src/mcp/tools/build-tools/__tests__/codesign_app.test.ts b/src/mcp/tools/build-tools/__tests__/codesign_app.test.ts new file mode 100644 index 000000000..109e5a59b --- /dev/null +++ b/src/mcp/tools/build-tools/__tests__/codesign_app.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { + createMockExecutor, + createCommandMatchingMockExecutor, +} from '../../../../test-utils/mock-executors.ts'; +import { schema, handler, codesignLogic } from '../codesign_app.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; + +describe('codesign_app tool', () => { + describe('schema', () => { + it('should have handler function', () => { + expect(typeof handler).toBe('function'); + }); + + it('should require targetPath and identity', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ targetPath: '/a.app' }).success).toBe(false); + expect(schemaObj.safeParse({ identity: 'Dev ID' }).success).toBe(false); + }); + + it('should reject empty targetPath', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ targetPath: '', identity: 'Dev ID' }).success).toBe(false); + }); + + it('should reject empty identity', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ targetPath: '/a.app', identity: '' }).success).toBe(false); + }); + + it('should accept valid sign-only params', () => { + const schemaObj = z.object(schema); + expect( + schemaObj.safeParse({ + targetPath: '/build/App.app', + identity: 'Developer ID Application: Test (TEAM)', + }).success, + ).toBe(true); + }); + }); + + describe('logic', () => { + it('should sign and verify successfully', async () => { + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid on disk' }, + }); + + const { result } = await runToolLogic(() => + codesignLogic({ targetPath: '/build/App.app', identity: 'Dev ID' }, mockExecutor), + ); + + expect(result.isError()).toBe(false); + }); + + it('should fail when signing fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'no identity found', + }); + + const { result } = await runToolLogic(() => + codesignLogic({ targetPath: '/build/App.app', identity: 'Bad ID' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should fail when verification fails', async () => { + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { success: false, error: 'invalid signature' }, + }); + + const { result } = await runToolLogic(() => + codesignLogic({ targetPath: '/build/App.app', identity: 'Dev ID' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should sign + notarize + staple when notarize is true', async () => { + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid' }, + notarytool: { output: 'Accepted' }, + stapler: { output: 'Stapled' }, + }); + + const { result } = await runToolLogic(() => + codesignLogic( + { + targetPath: '/build/App.app', + identity: 'Dev ID', + notarize: true, + teamId: 'TEAM123', + bundleId: 'com.test.app', + keychainProfile: 'my-profile', + }, + mockExecutor, + ), + ); + + expect(result.isError()).toBe(false); + }); + + it('should fail when notarization fails', async () => { + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid' }, + notarytool: { success: false, error: 'Invalid credentials' }, + }); + + const { result } = await runToolLogic(() => + codesignLogic( + { + targetPath: '/build/App.app', + identity: 'Dev ID', + notarize: true, + teamId: 'TEAM123', + bundleId: 'com.test.app', + }, + mockExecutor, + ), + ); + + expect(result.isError()).toBe(true); + }); + + it('should reject invalid targetPath extension', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const { result } = await runToolLogic(() => + codesignLogic({ targetPath: '/build/App.pkg', identity: 'Dev ID' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should accept .dmg targetPath', async () => { + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid' }, + }); + + const { result } = await runToolLogic(() => + codesignLogic({ targetPath: '/dist/App.dmg', identity: 'Dev ID' }, mockExecutor), + ); + + expect(result.isError()).toBe(false); + }); + + it('should reject invalid entitlements extension', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const { result } = await runToolLogic(() => + codesignLogic( + { + targetPath: '/build/App.app', + identity: 'Dev ID', + entitlements: '/build/wrong.plist', + }, + mockExecutor, + ), + ); + + expect(result.isError()).toBe(true); + }); + + it('should pass entitlements to codesign command', async () => { + let capturedCommand: string[] | undefined; + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { + output: 'signed', + }, + 'codesign --verify': { output: 'valid' }, + }); + + // Override to capture command + const wrappedExecutor: typeof mockExecutor = async (cmd, ...rest) => { + if (cmd.includes('--force')) { + capturedCommand = cmd; + } + return mockExecutor(cmd, ...rest); + }; + + await runToolLogic(() => + codesignLogic( + { + targetPath: '/build/App.app', + identity: 'Dev ID', + entitlements: '/build/App.entitlements', + }, + wrappedExecutor, + ), + ); + + expect(capturedCommand).toContain('--entitlements'); + expect(capturedCommand).toContain('/build/App.entitlements'); + }); + + it('should include --keychain-profile in notarytool command when provided', async () => { + let notarizeCommand: string[] | undefined; + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid' }, + notarytool: { output: 'Accepted' }, + stapler: { output: 'Stapled' }, + }); + + const wrappedExecutor: typeof mockExecutor = async (cmd, ...rest) => { + if (cmd.includes('notarytool')) { + notarizeCommand = cmd; + } + return mockExecutor(cmd, ...rest); + }; + + await runToolLogic(() => + codesignLogic( + { + targetPath: '/build/App.app', + identity: 'Dev ID', + notarize: true, + teamId: 'TEAM123', + bundleId: 'com.test.app', + keychainProfile: 'my-profile', + }, + wrappedExecutor, + ), + ); + + expect(notarizeCommand).toContain('--keychain-profile'); + expect(notarizeCommand).toContain('my-profile'); + }); + + it('should not include --keychain-profile when not provided', async () => { + let notarizeCommand: string[] | undefined; + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid' }, + notarytool: { output: 'Accepted' }, + stapler: { output: 'Stapled' }, + }); + + const wrappedExecutor: typeof mockExecutor = async (cmd, ...rest) => { + if (cmd.includes('notarytool')) { + notarizeCommand = cmd; + } + return mockExecutor(cmd, ...rest); + }; + + await runToolLogic(() => + codesignLogic( + { + targetPath: '/build/App.app', + identity: 'Dev ID', + notarize: true, + teamId: 'TEAM123', + bundleId: 'com.test.app', + }, + wrappedExecutor, + ), + ); + + expect(notarizeCommand).not.toContain('--keychain-profile'); + }); + + it('should handle executor throwing error', async () => { + const mockExecutor = createMockExecutor({ + shouldThrow: new Error('codesign binary not found'), + }); + + const { result } = await runToolLogic(() => + codesignLogic({ targetPath: '/build/App.app', identity: 'Dev ID' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should not use --deep flag in sign command', async () => { + let signCommand: string[] | undefined; + const mockExecutor = createCommandMatchingMockExecutor({ + 'codesign --force': { output: 'signed' }, + 'codesign --verify': { output: 'valid' }, + }); + + const wrappedExecutor: typeof mockExecutor = async (cmd, ...rest) => { + if (cmd.includes('--force')) { + signCommand = cmd; + } + return mockExecutor(cmd, ...rest); + }; + + await runToolLogic(() => + codesignLogic({ targetPath: '/build/App.app', identity: 'Dev ID' }, wrappedExecutor), + ); + + expect(signCommand).not.toContain('--deep'); + }); + }); +}); diff --git a/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts b/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts new file mode 100644 index 000000000..9dd3e1724 --- /dev/null +++ b/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as z from 'zod'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { schema, handler, createDmgLogic, _validateScriptPath } from '../create_dmg.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + realpathSync: vi.fn(), + }; +}); + +const mockedRealpathSync = vi.mocked(fs.realpathSync); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('create_dmg tool', () => { + describe('schema', () => { + it('should have handler function', () => { + expect(typeof handler).toBe('function'); + }); + + it('should require projectPath', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + }); + + it('should reject empty projectPath', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ projectPath: '' }).success).toBe(false); + }); + + it('should accept valid projectPath with optional fields', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ projectPath: '/project' }).success).toBe(true); + expect( + schemaObj.safeParse({ + projectPath: '/project', + scriptPath: 'build/dmg.sh', + appPath: '/build/App.app', + outputPath: '/dist/App.dmg', + }).success, + ).toBe(true); + }); + }); + + describe('validateScriptPath', () => { + it('should reject absolute scriptPath', () => { + const result = _validateScriptPath('/usr/bin/evil', '/project'); + expect('error' in result).toBe(true); + if ('error' in result) expect(result.error).toContain('must be relative'); + }); + + it('should reject path traversal', () => { + const result = _validateScriptPath('../evil.sh', '/project'); + expect('error' in result).toBe(true); + if ('error' in result) expect(result.error).toContain('path traversal'); + }); + + it('should reject nested path traversal', () => { + const result = _validateScriptPath('Scripts/../../evil.sh', '/project'); + expect('error' in result).toBe(true); + if ('error' in result) expect(result.error).toContain('path traversal'); + }); + + it('should return error when script not found', () => { + mockedRealpathSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const result = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect('error' in result).toBe(true); + if ('error' in result) expect(result.error).toContain('Script not found'); + }); + + it('should return error when project path not found', () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr === '/project/Scripts/create-dmg.sh') return pathStr; + throw new Error('ENOENT'); + }); + const result = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect('error' in result).toBe(true); + if ('error' in result) expect(result.error).toContain('Project path not found'); + }); + + it('should reject symlink escape', () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/outside/evil.sh'; + return '/project'; + }); + const result = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect('error' in result).toBe(true); + if ('error' in result) expect(result.error).toContain('symlink escape'); + }); + + it('should accept valid script within project and return realScriptPath', () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + const result = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect('realScriptPath' in result).toBe(true); + if ('realScriptPath' in result) + expect(result.realScriptPath).toBe('/project/Scripts/create-dmg.sh'); + }); + }); + + describe('logic', () => { + it('should return success on successful DMG creation', async () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + + const mockExecutor = createMockExecutor({ + success: true, + output: 'DMG created at /dist/App.dmg', + }); + + const { result } = await runToolLogic(() => + createDmgLogic({ projectPath: '/project' }, mockExecutor), + ); + + expect(result.isError()).toBe(false); + }); + + it('should pass appPath and outputPath as script arguments', async () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: 'DMG created', + onExecute: (cmd) => { + capturedCommand = cmd; + }, + }); + + await runToolLogic(() => + createDmgLogic( + { + projectPath: '/project', + appPath: '/build/App.app', + outputPath: '/dist/App.dmg', + }, + mockExecutor, + ), + ); + + expect(capturedCommand).toEqual([ + '/bin/sh', + '/project/Scripts/create-dmg.sh', + '/build/App.app', + '/dist/App.dmg', + ]); + }); + + it('should return failure for absolute scriptPath', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const { result } = await runToolLogic(() => + createDmgLogic({ projectPath: '/project', scriptPath: '/usr/bin/evil' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should return failure for path traversal scriptPath', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const { result } = await runToolLogic(() => + createDmgLogic({ projectPath: '/project', scriptPath: '../evil.sh' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should return failure when script not found', async () => { + mockedRealpathSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const { result } = await runToolLogic(() => + createDmgLogic({ projectPath: '/project' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should return failure when executor fails', async () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + + const mockExecutor = createMockExecutor({ + success: false, + error: 'Script failed with exit code 1', + }); + + const { result } = await runToolLogic(() => + createDmgLogic({ projectPath: '/project' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should not pass outputPath when appPath is omitted', async () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: '', + onExecute: (cmd) => { + capturedCommand = cmd; + }, + }); + + await runToolLogic(() => + createDmgLogic({ projectPath: '/project', outputPath: '/dist/App.dmg' }, mockExecutor), + ); + + expect(capturedCommand).toEqual(['/bin/sh', '/project/Scripts/create-dmg.sh']); + }); + + it('should use default script path when scriptPath not provided', async () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('Scripts/create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: '', + onExecute: (cmd) => { + capturedCommand = cmd; + }, + }); + + await runToolLogic(() => createDmgLogic({ projectPath: '/project' }, mockExecutor)); + + expect(capturedCommand![1]).toBe('/project/Scripts/create-dmg.sh'); + }); + }); +}); diff --git a/src/mcp/tools/build-tools/__tests__/pfctl_anchor.test.ts b/src/mcp/tools/build-tools/__tests__/pfctl_anchor.test.ts new file mode 100644 index 000000000..ed677055a --- /dev/null +++ b/src/mcp/tools/build-tools/__tests__/pfctl_anchor.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { schema, handler, pfctlLogic, _buildCommand } from '../pfctl_anchor.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; + +describe('pfctl_anchor tool', () => { + describe('schema', () => { + it('should have handler function', () => { + expect(typeof handler).toBe('function'); + }); + + it('should require anchorName and action', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + expect(schemaObj.safeParse({ anchorName: 'com.test' }).success).toBe(false); + expect(schemaObj.safeParse({ action: 'show-rules' }).success).toBe(false); + }); + + it('should reject empty anchorName', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ anchorName: '', action: 'show-rules' }).success).toBe(false); + }); + + it('should accept valid anchorName', () => { + const schemaObj = z.object(schema); + expect( + schemaObj.safeParse({ anchorName: 'com.splitTunnel', action: 'show-rules' }).success, + ).toBe(true); + }); + + it('should accept nested anchor name with slash', () => { + const schemaObj = z.object(schema); + expect( + schemaObj.safeParse({ anchorName: 'com.splitTunnel/bypass', action: 'show-rules' }).success, + ).toBe(true); + }); + + it('should reject shell injection in anchorName', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ anchorName: '; rm -rf /', action: 'show-rules' }).success).toBe( + false, + ); + }); + + it('should reject leading slash in anchorName', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ anchorName: '/com.evil', action: 'show-rules' }).success).toBe( + false, + ); + }); + + it('should reject double slash in anchorName', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ anchorName: 'com//evil', action: 'show-rules' }).success).toBe( + false, + ); + }); + + it('should reject spaces in anchorName', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ anchorName: 'com evil', action: 'show-rules' }).success).toBe( + false, + ); + }); + }); + + describe('buildCommand', () => { + it('should build show-rules command', () => { + const cmd = _buildCommand({ anchorName: 'com.test', action: 'show-rules' }); + expect(cmd).toEqual(['sudo', '-n', 'pfctl', '-a', 'com.test', '-sr']); + }); + + it('should build show-all command', () => { + const cmd = _buildCommand({ anchorName: 'com.test', action: 'show-all' }); + expect(cmd).toEqual(['sudo', '-n', 'pfctl', '-a', 'com.test', '-sa']); + }); + + it('should build test-syntax command', () => { + const cmd = _buildCommand({ + anchorName: 'com.test', + action: 'test-syntax', + rulesFile: '/etc/pf.rules', + }); + expect(cmd).toEqual(['sudo', '-n', 'pfctl', '-a', 'com.test', '-n', '-f', '/etc/pf.rules']); + }); + + it('should always start with sudo -n pfctl', () => { + const actions: Array<{ + anchorName: string; + action: 'show-rules' | 'show-all' | 'test-syntax'; + rulesFile?: string; + }> = [ + { anchorName: 'a', action: 'show-rules' }, + { anchorName: 'b', action: 'show-all' }, + { anchorName: 'c', action: 'test-syntax', rulesFile: '/f.conf' }, + ]; + for (const params of actions) { + const cmd = _buildCommand(params); + expect(cmd[0]).toBe('sudo'); + expect(cmd[1]).toBe('-n'); + expect(cmd[2]).toBe('pfctl'); + } + }); + + it('should never include -F flag (flush) in any command', () => { + const actions: Array<{ + anchorName: string; + action: 'show-rules' | 'show-all' | 'test-syntax'; + rulesFile?: string; + }> = [ + { anchorName: 'a', action: 'show-rules' }, + { anchorName: 'b', action: 'show-all' }, + { anchorName: 'c', action: 'test-syntax', rulesFile: '/f.conf' }, + ]; + for (const params of actions) { + const cmd = _buildCommand(params); + expect(cmd).not.toContain('-F'); + } + }); + + it('should never include -f without -n (load without dry-run)', () => { + const cmd = _buildCommand({ + anchorName: 'test', + action: 'test-syntax', + rulesFile: '/rules.conf', + }); + const fIndex = cmd.indexOf('-f'); + if (fIndex !== -1) { + const nIndex = cmd.indexOf('-n'); + expect(nIndex).not.toBe(-1); + expect(nIndex).toBeLessThan(fIndex); + } + }); + }); + + describe('logic', () => { + it('should return success with output for show-rules', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'pass in proto tcp from any to any port 80', + }); + + const { result } = await runToolLogic(() => + pfctlLogic({ anchorName: 'com.test', action: 'show-rules' }, mockExecutor), + ); + + expect(result.isError()).toBe(false); + }); + + it('should return success with empty output for non-existent anchor', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const { result } = await runToolLogic(() => + pfctlLogic({ anchorName: 'com.nonexistent', action: 'show-rules' }, mockExecutor), + ); + + expect(result.isError()).toBe(false); + }); + + it('should return failure when pfctl fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'pfctl: /dev/pf: Permission denied', + }); + + const { result } = await runToolLogic(() => + pfctlLogic({ anchorName: 'com.test', action: 'show-rules' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should reject rulesFile without .conf or .rules extension', async () => { + const mockExecutor = createMockExecutor({ success: true, output: '' }); + + const { result } = await runToolLogic(() => + pfctlLogic( + { anchorName: 'com.test', action: 'test-syntax', rulesFile: '/etc/evil.sh' }, + mockExecutor, + ), + ); + + expect(result.isError()).toBe(true); + }); + + it('should accept rulesFile with .conf extension', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Rules OK', + }); + + const { result } = await runToolLogic(() => + pfctlLogic( + { anchorName: 'com.test', action: 'test-syntax', rulesFile: '/etc/pf.conf' }, + mockExecutor, + ), + ); + + expect(result.isError()).toBe(false); + }); + + it('should accept rulesFile with .rules extension', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Rules OK', + }); + + const { result } = await runToolLogic(() => + pfctlLogic( + { anchorName: 'com.test', action: 'test-syntax', rulesFile: '/etc/anchor.rules' }, + mockExecutor, + ), + ); + + expect(result.isError()).toBe(false); + }); + + it('should handle executor throwing error', async () => { + const mockExecutor = createMockExecutor({ + shouldThrow: new Error('sudo: pfctl: command not found'), + }); + + const { result } = await runToolLogic(() => + pfctlLogic({ anchorName: 'com.test', action: 'show-rules' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should pass correct command to executor for show-rules', async () => { + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: '', + onExecute: (cmd) => { + capturedCommand = cmd; + }, + }); + + await runToolLogic(() => + pfctlLogic({ anchorName: 'com.splitTunnel', action: 'show-rules' }, mockExecutor), + ); + + expect(capturedCommand).toEqual(['sudo', '-n', 'pfctl', '-a', 'com.splitTunnel', '-sr']); + }); + }); +}); diff --git a/src/mcp/tools/build-tools/__tests__/xcodegen_generate.test.ts b/src/mcp/tools/build-tools/__tests__/xcodegen_generate.test.ts new file mode 100644 index 000000000..f77d5cbdc --- /dev/null +++ b/src/mcp/tools/build-tools/__tests__/xcodegen_generate.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; +import { schema, handler, xcodegenLogic } from '../xcodegen_generate.ts'; +import { runToolLogic } from '../../../../test-utils/test-helpers.ts'; + +describe('xcodegen_generate tool', () => { + describe('schema', () => { + it('should have handler function', () => { + expect(typeof handler).toBe('function'); + }); + + it('should require projectPath', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({}).success).toBe(false); + }); + + it('should reject empty projectPath', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ projectPath: '' }).success).toBe(false); + }); + + it('should accept valid projectPath', () => { + const schemaObj = z.object(schema); + expect(schemaObj.safeParse({ projectPath: '/path/to/project' }).success).toBe(true); + }); + }); + + describe('logic', () => { + it('should return success on successful generation', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Generated project.xcodeproj', + }); + + const { result } = await runToolLogic(() => + xcodegenLogic({ projectPath: '/path/to/project' }, mockExecutor), + ); + + expect(result.isError()).toBe(false); + }); + + it('should pass cwd to executor', async () => { + let capturedOpts: Record | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: 'Generated', + onExecute: (_cmd, _prefix, _shell, opts) => { + capturedOpts = opts as Record; + }, + }); + + await runToolLogic(() => xcodegenLogic({ projectPath: '/my/project' }, mockExecutor)); + + expect(capturedOpts?.cwd).toBe('/my/project'); + }); + + it('should execute xcodegen generate command', async () => { + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: 'Generated', + onExecute: (cmd) => { + capturedCommand = cmd; + }, + }); + + await runToolLogic(() => xcodegenLogic({ projectPath: '/path/to/project' }, mockExecutor)); + + expect(capturedCommand).toEqual(['xcodegen', 'generate']); + }); + + it('should return failure when xcodegen fails', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'project.yml not found', + }); + + const { result } = await runToolLogic(() => + xcodegenLogic({ projectPath: '/bad/path' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + + it('should handle executor throwing error', async () => { + const mockExecutor = createMockExecutor({ + shouldThrow: new Error('xcodegen not found'), + }); + + const { result } = await runToolLogic(() => + xcodegenLogic({ projectPath: '/path/to/project' }, mockExecutor), + ); + + expect(result.isError()).toBe(true); + }); + }); +}); diff --git a/src/mcp/tools/build-tools/codesign_app.ts b/src/mcp/tools/build-tools/codesign_app.ts new file mode 100644 index 000000000..82a117069 --- /dev/null +++ b/src/mcp/tools/build-tools/codesign_app.ts @@ -0,0 +1,177 @@ +import * as z from 'zod'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { + setStructuredOutput, + createCommandSuccess, + createCommandFailure, +} from './command-result-helpers.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +const codesignBaseSchema = z.object({ + targetPath: z.string().min(1).describe('Path to .app or .dmg to sign'), + identity: z + .string() + .min(1) + .describe('Code signing identity (e.g. "Developer ID Application: Name (TEAM)")'), + entitlements: z.string().min(1).optional().describe('Path to .entitlements file'), + notarize: z + .boolean() + .optional() + .describe('Submit for notarization after signing (default: false)'), + keychainProfile: z + .string() + .min(1) + .optional() + .describe( + 'Keychain profile name for notarization credentials (created via `xcrun notarytool store-credentials`). Required for notarization unless NOTARYTOOL env vars are set.', + ), + teamId: z.string().min(1).optional().describe('Apple Team ID (required for notarization)'), + bundleId: z.string().min(1).optional().describe('Bundle ID (required for notarization)'), +}); + +const codesignSchema = codesignBaseSchema.refine((val) => !val.notarize || val.teamId != null, { + message: 'teamId is required when notarize is true', + path: ['teamId'], +}); + +type CodesignParams = z.infer; + +export async function codesignLogic( + params: CodesignParams, + executor: CommandExecutor, +): Promise { + const ctx = getHandlerContext(); + const commandLabel = 'codesign'; + const outputParts: string[] = []; + + if (!/\.(app|dmg)$/.test(params.targetPath)) { + const result = createCommandFailure( + commandLabel, + `targetPath must end with .app or .dmg: ${params.targetPath}`, + ); + setStructuredOutput(ctx, result); + return; + } + + if (params.entitlements != null && !params.entitlements.endsWith('.entitlements')) { + const result = createCommandFailure( + commandLabel, + `entitlements path must end with .entitlements: ${params.entitlements}`, + ); + setStructuredOutput(ctx, result); + return; + } + + // Step 1: Sign + const signCommand: string[] = [ + 'codesign', + '--force', + '--options', + 'runtime', + '--sign', + params.identity, + ]; + if (params.entitlements != null) { + signCommand.push('--entitlements', params.entitlements); + } + signCommand.push(params.targetPath); + + try { + const signResponse = await executor(signCommand, 'Code Signing', false); + if (!signResponse.success) { + const errorMessage = signResponse.error ?? signResponse.output ?? 'Code signing failed'; + const result = createCommandFailure( + commandLabel, + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: signResponse.output }), + ); + setStructuredOutput(ctx, result); + return; + } + outputParts.push(`[sign] ${signResponse.output}`); + + // Step 2: Verify + const verifyResponse = await executor( + ['codesign', '--verify', '--deep', '--strict', params.targetPath], + 'Code Sign Verification', + false, + ); + if (!verifyResponse.success) { + const errorMessage = + verifyResponse.error ?? verifyResponse.output ?? 'Code sign verification failed'; + const result = createCommandFailure( + commandLabel, + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: verifyResponse.output }), + ); + setStructuredOutput(ctx, result); + return; + } + outputParts.push(`[verify] ${verifyResponse.output}`); + + // Step 3: Notarize (optional) + if (params.notarize) { + const notarizeCommand: string[] = [ + 'xcrun', + 'notarytool', + 'submit', + params.targetPath, + '--team-id', + params.teamId!, + ]; + if (params.keychainProfile != null) { + notarizeCommand.push('--keychain-profile', params.keychainProfile); + } + notarizeCommand.push('--wait'); + + const notarizeResponse = await executor(notarizeCommand, 'Notarization', false); + if (!notarizeResponse.success) { + const errorMessage = + notarizeResponse.error ?? notarizeResponse.output ?? 'Notarization failed'; + const result = createCommandFailure( + commandLabel, + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: notarizeResponse.output }), + ); + setStructuredOutput(ctx, result); + return; + } + outputParts.push(`[notarize] ${notarizeResponse.output}`); + + // Step 4: Staple + const stapleResponse = await executor( + ['xcrun', 'stapler', 'staple', params.targetPath], + 'Stapling', + false, + ); + if (!stapleResponse.success) { + const errorMessage = stapleResponse.error ?? stapleResponse.output ?? 'Stapling failed'; + const result = createCommandFailure( + commandLabel, + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: stapleResponse.output }), + ); + setStructuredOutput(ctx, result); + return; + } + outputParts.push(`[staple] ${stapleResponse.output}`); + } + + const result = createCommandSuccess( + commandLabel, + outputParts.join('\n'), + createBasicDiagnostics({ rawOutput: outputParts }), + ); + setStructuredOutput(ctx, result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const result = createCommandFailure(commandLabel, message); + setStructuredOutput(ctx, result); + } +} + +export const schema = codesignBaseSchema.shape; + +export const handler = createTypedTool(codesignSchema, codesignLogic, getDefaultCommandExecutor); diff --git a/src/mcp/tools/build-tools/command-result-helpers.ts b/src/mcp/tools/build-tools/command-result-helpers.ts new file mode 100644 index 000000000..fbe1c0f28 --- /dev/null +++ b/src/mcp/tools/build-tools/command-result-helpers.ts @@ -0,0 +1,48 @@ +import type { CommandResultDomainResult, BasicDiagnostics } from '../../../types/domain-results.ts'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +export const STRUCTURED_OUTPUT_SCHEMA = 'xcodebuildmcp.output.command-result'; + +export function setStructuredOutput( + ctx: ToolHandlerContext, + result: CommandResultDomainResult, +): void { + ctx.structuredOutput = { + result, + schema: STRUCTURED_OUTPUT_SCHEMA, + schemaVersion: '1', + }; +} + +export function createCommandSuccess( + command: string, + output: string, + diagnostics?: BasicDiagnostics, +): CommandResultDomainResult { + return { + kind: 'command-result', + didError: false, + error: null, + command, + summary: { status: 'SUCCEEDED' }, + output, + diagnostics: diagnostics ?? createBasicDiagnostics({}), + }; +} + +export function createCommandFailure( + command: string, + errorMessage: string, + diagnostics?: BasicDiagnostics, +): CommandResultDomainResult { + return { + kind: 'command-result', + didError: true, + error: errorMessage, + command, + summary: { status: 'FAILED' }, + output: '', + diagnostics: diagnostics ?? createBasicDiagnostics({ errors: [errorMessage] }), + }; +} diff --git a/src/mcp/tools/build-tools/create_dmg.ts b/src/mcp/tools/build-tools/create_dmg.ts new file mode 100644 index 000000000..adb5cb3fe --- /dev/null +++ b/src/mcp/tools/build-tools/create_dmg.ts @@ -0,0 +1,122 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as z from 'zod'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { + setStructuredOutput, + createCommandSuccess, + createCommandFailure, +} from './command-result-helpers.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +const createDmgSchema = z.object({ + projectPath: z.string().min(1).describe('Path to project root (containing Scripts/)'), + scriptPath: z + .string() + .min(1) + .optional() + .describe( + 'Relative path to DMG creation script within project (default: Scripts/create-dmg.sh)', + ), + appPath: z.string().min(1).optional().describe('Path to .app bundle (passed as arg to script)'), + outputPath: z.string().min(1).optional().describe('Output DMG path (passed as arg to script)'), +}); + +type CreateDmgParams = z.infer; + +function validateScriptPath( + resolvedScript: string, + projectPath: string, +): { error: string } | { realScriptPath: string } { + if (resolvedScript.startsWith('/')) { + return { error: `scriptPath must be relative, not absolute: ${resolvedScript}` }; + } + if (resolvedScript.includes('..')) { + return { error: `scriptPath must not contain path traversal (..): ${resolvedScript}` }; + } + + const absoluteScriptPath = path.join(projectPath, resolvedScript); + + let realScriptPath: string; + try { + realScriptPath = fs.realpathSync(absoluteScriptPath); + } catch { + return { error: `Script not found: ${absoluteScriptPath}` }; + } + + let realProjectPath: string; + try { + realProjectPath = fs.realpathSync(projectPath); + } catch { + return { error: `Project path not found: ${projectPath}` }; + } + + if ( + realScriptPath !== realProjectPath && + !realScriptPath.startsWith(realProjectPath + path.sep) + ) { + return { + error: `Script resolves outside project directory (possible symlink escape): ${resolvedScript}`, + }; + } + + return { realScriptPath }; +} + +export async function createDmgLogic( + params: CreateDmgParams, + executor: CommandExecutor, +): Promise { + const ctx = getHandlerContext(); + const resolvedScript = params.scriptPath ?? 'Scripts/create-dmg.sh'; + const commandLabel = `create-dmg (${resolvedScript})`; + + const validation = validateScriptPath(resolvedScript, params.projectPath); + if ('error' in validation) { + const result = createCommandFailure(commandLabel, validation.error); + setStructuredOutput(ctx, result); + return; + } + + const args: string[] = ['/bin/sh', validation.realScriptPath]; + if (params.appPath != null) { + args.push(params.appPath); + } + if (params.appPath != null && params.outputPath != null) { + args.push(params.outputPath); + } + + try { + const response = await executor(args, 'DMG Creation', false, { cwd: params.projectPath }); + + if (response.success) { + const result = createCommandSuccess( + commandLabel, + response.output, + createBasicDiagnostics({ rawOutput: response.output }), + ); + setStructuredOutput(ctx, result); + } else { + const errorMessage = response.error ?? response.output ?? 'DMG creation failed'; + const result = createCommandFailure( + commandLabel, + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: response.output }), + ); + setStructuredOutput(ctx, result); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const result = createCommandFailure(commandLabel, message); + setStructuredOutput(ctx, result); + } +} + +export { validateScriptPath as _validateScriptPath }; +export type ValidateResult = ReturnType; + +export const schema = createDmgSchema.shape; + +export const handler = createTypedTool(createDmgSchema, createDmgLogic, getDefaultCommandExecutor); diff --git a/src/mcp/tools/build-tools/pfctl_anchor.ts b/src/mcp/tools/build-tools/pfctl_anchor.ts new file mode 100644 index 000000000..e2f3e9e4d --- /dev/null +++ b/src/mcp/tools/build-tools/pfctl_anchor.ts @@ -0,0 +1,94 @@ +import * as z from 'zod'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { + setStructuredOutput, + createCommandSuccess, + createCommandFailure, +} from './command-result-helpers.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +const pfctlBaseSchema = z.object({ + anchorName: z + .string() + .min(1) + .regex( + /^[a-zA-Z0-9._-]+(?:\/[a-zA-Z0-9._-]+)*$/, + 'anchorName must match [a-zA-Z0-9._-] segments separated by / (e.g. "com.splitTunnel" or "com.splitTunnel/bypass")', + ) + .describe('PF anchor name (e.g. "com.splitTunnel" or "com.splitTunnel/bypass")'), + action: z + .enum(['show-rules', 'show-all', 'test-syntax']) + .describe('Action: show-rules (-sr), show-all (-sa), or test-syntax (-n -f)'), + rulesFile: z.string().min(1).optional().describe('Rules file path (only for test-syntax action)'), +}); + +const pfctlSchema = pfctlBaseSchema.refine( + (val) => val.action !== 'test-syntax' || val.rulesFile != null, + { message: 'rulesFile is required for test-syntax action', path: ['rulesFile'] }, +); + +type PfctlParams = z.infer; + +function buildCommand(params: PfctlParams): string[] { + const base = ['sudo', '-n', 'pfctl', '-a', params.anchorName]; + + switch (params.action) { + case 'show-rules': + return [...base, '-sr']; + case 'show-all': + return [...base, '-sa']; + case 'test-syntax': + return [...base, '-n', '-f', params.rulesFile!]; + } +} + +export async function pfctlLogic(params: PfctlParams, executor: CommandExecutor): Promise { + const ctx = getHandlerContext(); + const commandLabel = `pfctl -a ${params.anchorName} (${params.action})`; + + if (params.action === 'test-syntax' && params.rulesFile != null) { + if (!/\.(conf|rules)$/.test(params.rulesFile)) { + const result = createCommandFailure( + commandLabel, + `rulesFile must end with .conf or .rules: ${params.rulesFile}`, + ); + setStructuredOutput(ctx, result); + return; + } + } + + const command = buildCommand(params); + + try { + const response = await executor(command, 'PF Anchor Inspection', false); + + if (response.success) { + const result = createCommandSuccess( + commandLabel, + response.output, + createBasicDiagnostics({ rawOutput: response.output }), + ); + setStructuredOutput(ctx, result); + } else { + const errorMessage = response.error ?? response.output ?? 'pfctl command failed'; + const result = createCommandFailure( + commandLabel, + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: response.output }), + ); + setStructuredOutput(ctx, result); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const result = createCommandFailure(commandLabel, message); + setStructuredOutput(ctx, result); + } +} + +export { buildCommand as _buildCommand }; + +export const schema = pfctlBaseSchema.shape; + +export const handler = createTypedTool(pfctlSchema, pfctlLogic, getDefaultCommandExecutor); diff --git a/src/mcp/tools/build-tools/xcodegen_generate.ts b/src/mcp/tools/build-tools/xcodegen_generate.ts new file mode 100644 index 000000000..ba701bc7e --- /dev/null +++ b/src/mcp/tools/build-tools/xcodegen_generate.ts @@ -0,0 +1,54 @@ +import * as z from 'zod'; +import type { CommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { createTypedTool, getHandlerContext } from '../../../utils/typed-tool-factory.ts'; +import { + setStructuredOutput, + createCommandSuccess, + createCommandFailure, +} from './command-result-helpers.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +const xcodegenSchema = z.object({ + projectPath: z.string().min(1).describe('Path to directory containing project.yml'), +}); + +type XcodegenParams = z.infer; + +export async function xcodegenLogic( + params: XcodegenParams, + executor: CommandExecutor, +): Promise { + const ctx = getHandlerContext(); + + try { + const response = await executor(['xcodegen', 'generate'], 'Xcode Project Generation', false, { + cwd: params.projectPath, + }); + + if (response.success) { + const result = createCommandSuccess( + 'xcodegen generate', + response.output, + createBasicDiagnostics({ rawOutput: response.output }), + ); + setStructuredOutput(ctx, result); + } else { + const errorMessage = response.error ?? response.output ?? 'xcodegen generate failed'; + const result = createCommandFailure( + 'xcodegen generate', + errorMessage, + createBasicDiagnostics({ errors: [errorMessage], rawOutput: response.output }), + ); + setStructuredOutput(ctx, result); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const result = createCommandFailure('xcodegen generate', message); + setStructuredOutput(ctx, result); + } +} + +export const schema = xcodegenSchema.shape; + +export const handler = createTypedTool(xcodegenSchema, xcodegenLogic, getDefaultCommandExecutor); diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index 8f0f14c48..e6b570eef 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -30,6 +30,7 @@ export type ToolDomainResultKind = | 'xcode-bridge-call-result' | 'xcode-bridge-status' | 'xcode-bridge-sync' + | 'command-result' | 'xcode-bridge-tool-list'; export interface ToolDomainResultBase { kind: string; @@ -621,6 +622,13 @@ export type XcodeBridgeToolListDomainResult = ToolDomainResultBase & { toolCount: number; tools: XcodeBridgeToolDescriptor[]; }; +export type CommandResultDomainResult = ToolDomainResultBase & { + kind: 'command-result'; + command: string; + summary: StatusSummary; + output: string; + diagnostics: BasicDiagnostics; +}; export type WorkflowSelectionDomainResult = ToolDomainResultBase & { kind: 'workflow-selection'; enabledWorkflows: string[]; @@ -658,4 +666,5 @@ export type ToolDomainResult = | XcodeBridgeCallResultDomainResult | XcodeBridgeStatusDomainResult | XcodeBridgeSyncDomainResult + | CommandResultDomainResult | XcodeBridgeToolListDomainResult; diff --git a/src/utils/command.ts b/src/utils/command.ts index b9e5589be..ee0b28953 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -10,6 +10,19 @@ import type { CommandExecutor, CommandResponse, CommandExecOptions } from './Com export type { CommandExecutor, CommandResponse, CommandExecOptions } from './CommandExecutor.ts'; export type { FileSystemExecutor } from './FileSystemExecutor.ts'; +/** + * Ensure common tool directories (homebrew, MacPorts, user-local) are on PATH + * so spawned commands like xcodegen, create-dmg, etc. are found regardless of + * how the MCP server process was launched (direct node, supergateway, Docker). + */ +const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']; +const enrichedPath: string = (() => { + const current = process.env.PATH ?? ''; + const currentDirs = current.split(':'); + const missing = EXTRA_PATH_DIRS.filter((d) => existsSync(d) && !currentDirs.includes(d)); + return missing.length ? `${missing.join(':')}:${current}` : current; +})(); + async function defaultExecutor( command: string[], logPrefix?: string, @@ -54,7 +67,7 @@ async function defaultExecutor( const spawnOpts: Parameters[2] = { stdio: ['ignore', 'pipe', 'pipe'], - env: opts?.env ? { ...process.env, ...opts.env } : process.env, + env: { ...process.env, PATH: enrichedPath, ...opts?.env }, cwd: opts?.cwd, };