From a7c906d299d9d9bb1b06858e81d5817e2995fad2 Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sat, 25 Apr 2026 21:39:41 +0300 Subject: [PATCH 1/8] feat(build-tools): Add build pipeline workflow with 4 new tools Add build-tools workflow group for macOS build pipeline automation: - xcodegen_generate: run xcodegen generate in project directory - create_dmg: create DMG from built app (with path traversal/symlink escape protection) - codesign_app: code sign + optional notarization pipeline (sign, verify, notarize, staple) - pfctl_anchor: read-only PF firewall anchor inspection (cannot modify firewall state) All tools use shared command-result structured output schema and createTypedTool pattern. 1 upstream file modified (domain-results.ts, +8 lines), 15 new files, 68 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 + docs/TOOLS-CLI.md | 20 +- docs/TOOLS.md | 20 +- manifests/tools/codesign_app.yaml | 16 + manifests/tools/create_dmg.yaml | 21 ++ manifests/tools/pfctl_anchor.yaml | 14 + manifests/tools/xcodegen_generate.yaml | 21 ++ manifests/workflows/build-tools.yaml | 8 + .../1.schema.json | 56 ++++ .../__tests__/codesign_app.test.ts | 303 ++++++++++++++++++ .../build-tools/__tests__/create_dmg.test.ts | 237 ++++++++++++++ .../__tests__/pfctl_anchor.test.ts | 251 +++++++++++++++ .../__tests__/xcodegen_generate.test.ts | 98 ++++++ src/mcp/tools/build-tools/codesign_app.ts | 189 +++++++++++ .../build-tools/command-result-helpers.ts | 36 +++ src/mcp/tools/build-tools/create_dmg.ts | 130 ++++++++ src/mcp/tools/build-tools/pfctl_anchor.ts | 105 ++++++ .../tools/build-tools/xcodegen_generate.ts | 66 ++++ src/types/domain-results.ts | 9 + 19 files changed, 1597 insertions(+), 10 deletions(-) create mode 100644 manifests/tools/codesign_app.yaml create mode 100644 manifests/tools/create_dmg.yaml create mode 100644 manifests/tools/pfctl_anchor.yaml create mode 100644 manifests/tools/xcodegen_generate.yaml create mode 100644 manifests/workflows/build-tools.yaml create mode 100644 schemas/structured-output/xcodebuildmcp.output.command-result/1.schema.json create mode 100644 src/mcp/tools/build-tools/__tests__/codesign_app.test.ts create mode 100644 src/mcp/tools/build-tools/__tests__/create_dmg.test.ts create mode 100644 src/mcp/tools/build-tools/__tests__/pfctl_anchor.test.ts create mode 100644 src/mcp/tools/build-tools/__tests__/xcodegen_generate.test.ts create mode 100644 src/mcp/tools/build-tools/codesign_app.ts create mode 100644 src/mcp/tools/build-tools/command-result-helpers.ts create mode 100644 src/mcp/tools/build-tools/create_dmg.ts create mode 100644 src/mcp/tools/build-tools/pfctl_anchor.ts create mode 100644 src/mcp/tools/build-tools/xcodegen_generate.ts 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/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..eddddbbaf --- /dev/null +++ b/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts @@ -0,0 +1,237 @@ +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 error = _validateScriptPath('/usr/bin/evil', '/project'); + expect(error).toContain('must be relative'); + }); + + it('should reject path traversal', () => { + const error = _validateScriptPath('../evil.sh', '/project'); + expect(error).toContain('path traversal'); + }); + + it('should reject nested path traversal', () => { + const error = _validateScriptPath('Scripts/../../evil.sh', '/project'); + expect(error).toContain('path traversal'); + }); + + it('should return error when script not found', () => { + mockedRealpathSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + const error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect(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 error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect(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 error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect(error).toContain('symlink escape'); + }); + + it('should accept valid script within project', () => { + mockedRealpathSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + if (pathStr.includes('create-dmg.sh')) return '/project/Scripts/create-dmg.sh'; + return '/project'; + }); + const error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); + expect(error).toBeNull(); + }); + }); + + 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 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..a215c6aa9 --- /dev/null +++ b/src/mcp/tools/build-tools/codesign_app.ts @@ -0,0 +1,189 @@ +import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +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 { + STRUCTURED_OUTPUT_SCHEMA, + 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 && val.bundleId != null), + { message: 'teamId and bundleId are required when notarize is true', path: ['teamId'] }, +); + +type CodesignParams = z.infer; + +function setStructuredOutput( + ctx: ToolHandlerContext, + result: ReturnType, +): void { + ctx.structuredOutput = { + result, + schema: STRUCTURED_OUTPUT_SCHEMA, + schemaVersion: '1', + }; +} + +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..fe4369616 --- /dev/null +++ b/src/mcp/tools/build-tools/command-result-helpers.ts @@ -0,0 +1,36 @@ +import type { CommandResultDomainResult, BasicDiagnostics } from '../../../types/domain-results.ts'; +import { createBasicDiagnostics } from '../../../utils/diagnostics.ts'; + +export const STRUCTURED_OUTPUT_SCHEMA = 'xcodebuildmcp.output.command-result'; + +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..5a407b7c1 --- /dev/null +++ b/src/mcp/tools/build-tools/create_dmg.ts @@ -0,0 +1,130 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +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 { + STRUCTURED_OUTPUT_SCHEMA, + 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 setStructuredOutput( + ctx: ToolHandlerContext, + result: ReturnType, +): void { + ctx.structuredOutput = { + result, + schema: STRUCTURED_OUTPUT_SCHEMA, + schemaVersion: '1', + }; +} + +function validateScriptPath(resolvedScript: string, projectPath: string): string | null { + if (resolvedScript.startsWith('/')) { + return `scriptPath must be relative, not absolute: ${resolvedScript}`; + } + if (resolvedScript.includes('..')) { + return `scriptPath must not contain path traversal (..): ${resolvedScript}`; + } + + const absoluteScriptPath = path.join(projectPath, resolvedScript); + + let realScriptPath: string; + try { + realScriptPath = fs.realpathSync(absoluteScriptPath); + } catch { + return `Script not found: ${absoluteScriptPath}`; + } + + let realProjectPath: string; + try { + realProjectPath = fs.realpathSync(projectPath); + } catch { + return `Project path not found: ${projectPath}`; + } + + if ( + realScriptPath !== realProjectPath && + !realScriptPath.startsWith(realProjectPath + path.sep) + ) { + return `Script resolves outside project directory (possible symlink escape): ${resolvedScript}`; + } + + return null; +} + +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 validationError = validateScriptPath(resolvedScript, params.projectPath); + if (validationError != null) { + const result = createCommandFailure(commandLabel, validationError); + setStructuredOutput(ctx, result); + return; + } + + const realScriptPath = fs.realpathSync(path.join(params.projectPath, resolvedScript)); + + const args: string[] = ['/bin/sh', realScriptPath]; + if (params.appPath != null) { + args.push(params.appPath); + } + if (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 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..d0b64c144 --- /dev/null +++ b/src/mcp/tools/build-tools/pfctl_anchor.ts @@ -0,0 +1,105 @@ +import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +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 { + STRUCTURED_OUTPUT_SCHEMA, + 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 setStructuredOutput( + ctx: ToolHandlerContext, + result: ReturnType, +): void { + ctx.structuredOutput = { + result, + schema: STRUCTURED_OUTPUT_SCHEMA, + schemaVersion: '1', + }; +} + +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 command = buildCommand(params); + const commandLabel = `pfctl -a ${params.anchorName} (${params.action})`; + + try { + 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 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..a9aab53a0 --- /dev/null +++ b/src/mcp/tools/build-tools/xcodegen_generate.ts @@ -0,0 +1,66 @@ +import * as z from 'zod'; +import type { ToolHandlerContext } from '../../../rendering/types.ts'; +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 { + STRUCTURED_OUTPUT_SCHEMA, + 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; + +function setStructuredOutput( + ctx: ToolHandlerContext, + result: ReturnType, +): void { + ctx.structuredOutput = { + result, + schema: STRUCTURED_OUTPUT_SCHEMA, + schemaVersion: '1', + }; +} + +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; From 048d4a3ba382ba72b6aae13c70c563939312eaa0 Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sat, 25 Apr 2026 23:22:50 +0300 Subject: [PATCH 2/8] feat(scripts): Add serve-mcp.sh for Docker/remote MCP access Wrapper script that launches supergateway with correct PATH (homebrew, MacPorts, user-local) and workflow config so build-tools and other homebrew-installed binaries are reachable from Docker containers via Streamable HTTP. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/serve-mcp.sh | 63 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100755 scripts/serve-mcp.sh diff --git a/scripts/serve-mcp.sh b/scripts/serve-mcp.sh new file mode 100755 index 000000000..3441cc0fd --- /dev/null +++ b/scripts/serve-mcp.sh @@ -0,0 +1,63 @@ +#!/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 "" + +exec npx supergateway \ + --port "$PORT" \ + --stdio "node ${PROJECT_ROOT}/build/cli.js mcp" \ + --outputTransport streamableHttp \ + --cors \ + "$@" From 45922434f39781b5b77ab7574a2c9268982a1d08 Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sat, 25 Apr 2026 23:47:41 +0300 Subject: [PATCH 3/8] fix(build-tools): Address PR review findings from Cursor Bugbot and Sentry - Fix TOCTOU race in create_dmg: validateScriptPath now returns the resolved realScriptPath instead of discarding it, eliminating the second unguarded realpathSync call - Fix positional arg misordering in create_dmg: outputPath is only passed when appPath is also present, preventing wrong-slot injection - Fix unused bundleId in codesign_app: removed from .refine() check since xcrun notarytool submit does not accept --bundle-id - Fix validation ordering in pfctl_anchor: rulesFile extension check now runs before buildCommand, keeping unvalidated values out of the command array - Fix duplicate --port in serve-mcp.sh: filter --port from passthrough args before forwarding to supergateway - Deduplicate setStructuredOutput into command-result-helpers.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/serve-mcp.sh | 17 +++++- .../build-tools/__tests__/create_dmg.test.ts | 61 ++++++++++++++----- src/mcp/tools/build-tools/codesign_app.ts | 22 ++----- .../build-tools/command-result-helpers.ts | 12 ++++ src/mcp/tools/build-tools/create_dmg.ts | 46 ++++++-------- src/mcp/tools/build-tools/pfctl_anchor.ts | 37 ++++------- .../tools/build-tools/xcodegen_generate.ts | 14 +---- 7 files changed, 112 insertions(+), 97 deletions(-) diff --git a/scripts/serve-mcp.sh b/scripts/serve-mcp.sh index 3441cc0fd..4101d8ced 100755 --- a/scripts/serve-mcp.sh +++ b/scripts/serve-mcp.sh @@ -55,9 +55,24 @@ 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 + exec npx supergateway \ --port "$PORT" \ --stdio "node ${PROJECT_ROOT}/build/cli.js mcp" \ --outputTransport streamableHttp \ --cors \ - "$@" + "${FILTERED_ARGS[@]}" diff --git a/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts b/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts index eddddbbaf..9dd3e1724 100644 --- a/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts +++ b/src/mcp/tools/build-tools/__tests__/create_dmg.test.ts @@ -52,26 +52,30 @@ describe('create_dmg tool', () => { describe('validateScriptPath', () => { it('should reject absolute scriptPath', () => { - const error = _validateScriptPath('/usr/bin/evil', '/project'); - expect(error).toContain('must be relative'); + 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 error = _validateScriptPath('../evil.sh', '/project'); - expect(error).toContain('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 error = _validateScriptPath('Scripts/../../evil.sh', '/project'); - expect(error).toContain('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 error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); - expect(error).toContain('Script not found'); + 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', () => { @@ -80,8 +84,9 @@ describe('create_dmg tool', () => { if (pathStr === '/project/Scripts/create-dmg.sh') return pathStr; throw new Error('ENOENT'); }); - const error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); - expect(error).toContain('Project path not found'); + 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', () => { @@ -90,18 +95,21 @@ describe('create_dmg tool', () => { if (pathStr.includes('create-dmg.sh')) return '/outside/evil.sh'; return '/project'; }); - const error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); - expect(error).toContain('symlink escape'); + 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', () => { + 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 error = _validateScriptPath('Scripts/create-dmg.sh', '/project'); - expect(error).toBeNull(); + 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'); }); }); @@ -213,6 +221,29 @@ describe('create_dmg tool', () => { 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); diff --git a/src/mcp/tools/build-tools/codesign_app.ts b/src/mcp/tools/build-tools/codesign_app.ts index a215c6aa9..82a117069 100644 --- a/src/mcp/tools/build-tools/codesign_app.ts +++ b/src/mcp/tools/build-tools/codesign_app.ts @@ -1,10 +1,9 @@ import * as z from 'zod'; -import type { ToolHandlerContext } from '../../../rendering/types.ts'; 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 { - STRUCTURED_OUTPUT_SCHEMA, + setStructuredOutput, createCommandSuccess, createCommandFailure, } from './command-result-helpers.ts'; @@ -32,24 +31,13 @@ const codesignBaseSchema = z.object({ bundleId: z.string().min(1).optional().describe('Bundle ID (required for notarization)'), }); -const codesignSchema = codesignBaseSchema.refine( - (val) => !val.notarize || (val.teamId != null && val.bundleId != null), - { message: 'teamId and bundleId are required when notarize is true', path: ['teamId'] }, -); +const codesignSchema = codesignBaseSchema.refine((val) => !val.notarize || val.teamId != null, { + message: 'teamId is required when notarize is true', + path: ['teamId'], +}); type CodesignParams = z.infer; -function setStructuredOutput( - ctx: ToolHandlerContext, - result: ReturnType, -): void { - ctx.structuredOutput = { - result, - schema: STRUCTURED_OUTPUT_SCHEMA, - schemaVersion: '1', - }; -} - export async function codesignLogic( params: CodesignParams, executor: CommandExecutor, diff --git a/src/mcp/tools/build-tools/command-result-helpers.ts b/src/mcp/tools/build-tools/command-result-helpers.ts index fe4369616..fbe1c0f28 100644 --- a/src/mcp/tools/build-tools/command-result-helpers.ts +++ b/src/mcp/tools/build-tools/command-result-helpers.ts @@ -1,8 +1,20 @@ 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, diff --git a/src/mcp/tools/build-tools/create_dmg.ts b/src/mcp/tools/build-tools/create_dmg.ts index 5a407b7c1..adb5cb3fe 100644 --- a/src/mcp/tools/build-tools/create_dmg.ts +++ b/src/mcp/tools/build-tools/create_dmg.ts @@ -1,12 +1,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as z from 'zod'; -import type { ToolHandlerContext } from '../../../rendering/types.ts'; 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 { - STRUCTURED_OUTPUT_SCHEMA, + setStructuredOutput, createCommandSuccess, createCommandFailure, } from './command-result-helpers.ts'; @@ -27,23 +26,15 @@ const createDmgSchema = z.object({ type CreateDmgParams = z.infer; -function setStructuredOutput( - ctx: ToolHandlerContext, - result: ReturnType, -): void { - ctx.structuredOutput = { - result, - schema: STRUCTURED_OUTPUT_SCHEMA, - schemaVersion: '1', - }; -} - -function validateScriptPath(resolvedScript: string, projectPath: string): string | null { +function validateScriptPath( + resolvedScript: string, + projectPath: string, +): { error: string } | { realScriptPath: string } { if (resolvedScript.startsWith('/')) { - return `scriptPath must be relative, not absolute: ${resolvedScript}`; + return { error: `scriptPath must be relative, not absolute: ${resolvedScript}` }; } if (resolvedScript.includes('..')) { - return `scriptPath must not contain path traversal (..): ${resolvedScript}`; + return { error: `scriptPath must not contain path traversal (..): ${resolvedScript}` }; } const absoluteScriptPath = path.join(projectPath, resolvedScript); @@ -52,24 +43,26 @@ function validateScriptPath(resolvedScript: string, projectPath: string): string try { realScriptPath = fs.realpathSync(absoluteScriptPath); } catch { - return `Script not found: ${absoluteScriptPath}`; + return { error: `Script not found: ${absoluteScriptPath}` }; } let realProjectPath: string; try { realProjectPath = fs.realpathSync(projectPath); } catch { - return `Project path not found: ${projectPath}`; + return { error: `Project path not found: ${projectPath}` }; } if ( realScriptPath !== realProjectPath && !realScriptPath.startsWith(realProjectPath + path.sep) ) { - return `Script resolves outside project directory (possible symlink escape): ${resolvedScript}`; + return { + error: `Script resolves outside project directory (possible symlink escape): ${resolvedScript}`, + }; } - return null; + return { realScriptPath }; } export async function createDmgLogic( @@ -80,20 +73,18 @@ export async function createDmgLogic( const resolvedScript = params.scriptPath ?? 'Scripts/create-dmg.sh'; const commandLabel = `create-dmg (${resolvedScript})`; - const validationError = validateScriptPath(resolvedScript, params.projectPath); - if (validationError != null) { - const result = createCommandFailure(commandLabel, validationError); + const validation = validateScriptPath(resolvedScript, params.projectPath); + if ('error' in validation) { + const result = createCommandFailure(commandLabel, validation.error); setStructuredOutput(ctx, result); return; } - const realScriptPath = fs.realpathSync(path.join(params.projectPath, resolvedScript)); - - const args: string[] = ['/bin/sh', realScriptPath]; + const args: string[] = ['/bin/sh', validation.realScriptPath]; if (params.appPath != null) { args.push(params.appPath); } - if (params.outputPath != null) { + if (params.appPath != null && params.outputPath != null) { args.push(params.outputPath); } @@ -124,6 +115,7 @@ export async function createDmgLogic( } export { validateScriptPath as _validateScriptPath }; +export type ValidateResult = ReturnType; export const schema = createDmgSchema.shape; diff --git a/src/mcp/tools/build-tools/pfctl_anchor.ts b/src/mcp/tools/build-tools/pfctl_anchor.ts index d0b64c144..e2f3e9e4d 100644 --- a/src/mcp/tools/build-tools/pfctl_anchor.ts +++ b/src/mcp/tools/build-tools/pfctl_anchor.ts @@ -1,10 +1,9 @@ import * as z from 'zod'; -import type { ToolHandlerContext } from '../../../rendering/types.ts'; 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 { - STRUCTURED_OUTPUT_SCHEMA, + setStructuredOutput, createCommandSuccess, createCommandFailure, } from './command-result-helpers.ts'; @@ -32,17 +31,6 @@ const pfctlSchema = pfctlBaseSchema.refine( type PfctlParams = z.infer; -function setStructuredOutput( - ctx: ToolHandlerContext, - result: ReturnType, -): void { - ctx.structuredOutput = { - result, - schema: STRUCTURED_OUTPUT_SCHEMA, - schemaVersion: '1', - }; -} - function buildCommand(params: PfctlParams): string[] { const base = ['sudo', '-n', 'pfctl', '-a', params.anchorName]; @@ -58,21 +46,22 @@ function buildCommand(params: PfctlParams): string[] { export async function pfctlLogic(params: PfctlParams, executor: CommandExecutor): Promise { const ctx = getHandlerContext(); - const command = buildCommand(params); const commandLabel = `pfctl -a ${params.anchorName} (${params.action})`; - try { - 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; - } + 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) { diff --git a/src/mcp/tools/build-tools/xcodegen_generate.ts b/src/mcp/tools/build-tools/xcodegen_generate.ts index a9aab53a0..ba701bc7e 100644 --- a/src/mcp/tools/build-tools/xcodegen_generate.ts +++ b/src/mcp/tools/build-tools/xcodegen_generate.ts @@ -1,10 +1,9 @@ import * as z from 'zod'; -import type { ToolHandlerContext } from '../../../rendering/types.ts'; 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 { - STRUCTURED_OUTPUT_SCHEMA, + setStructuredOutput, createCommandSuccess, createCommandFailure, } from './command-result-helpers.ts'; @@ -16,17 +15,6 @@ const xcodegenSchema = z.object({ type XcodegenParams = z.infer; -function setStructuredOutput( - ctx: ToolHandlerContext, - result: ReturnType, -): void { - ctx.structuredOutput = { - result, - schema: STRUCTURED_OUTPUT_SCHEMA, - schemaVersion: '1', - }; -} - export async function xcodegenLogic( params: XcodegenParams, executor: CommandExecutor, From 68a069801adc62ec75c82f629eb2bd209134ae6b Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sun, 26 Apr 2026 00:09:50 +0300 Subject: [PATCH 4/8] fix(scripts): Handle empty args array in serve-mcp.sh Bash set -u treats empty array expansion as unbound variable. Use ${arr[@]+...} pattern for safe empty-array expansion. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/serve-mcp.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/serve-mcp.sh b/scripts/serve-mcp.sh index 4101d8ced..8c8fc50fc 100755 --- a/scripts/serve-mcp.sh +++ b/scripts/serve-mcp.sh @@ -75,4 +75,4 @@ exec npx supergateway \ --stdio "node ${PROJECT_ROOT}/build/cli.js mcp" \ --outputTransport streamableHttp \ --cors \ - "${FILTERED_ARGS[@]}" + ${FILTERED_ARGS[@]+"${FILTERED_ARGS[@]}"} From fdb060b3610017d21474aa65b7c6ab7287cadd1c Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sun, 26 Apr 2026 17:30:46 +0300 Subject: [PATCH 5/8] fix(config): Remove Sentry MCP server and fix supergateway child spawning - Remove Sentry MCP server from .mcp.json (not needed for fork) - Add --stateful flag to supergateway in serve-mcp.sh to prevent spawning a new child process per HTTP request - Disable Sentry telemetry via .xcodebuildmcp/config.yaml (not committed, local-only) Co-Authored-By: Claude Opus 4.6 (1M context) --- .mcp.json | 7 +------ scripts/serve-mcp.sh | 1 + 2 files changed, 2 insertions(+), 6 deletions(-) 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/scripts/serve-mcp.sh b/scripts/serve-mcp.sh index 8c8fc50fc..54b586039 100755 --- a/scripts/serve-mcp.sh +++ b/scripts/serve-mcp.sh @@ -74,5 +74,6 @@ exec npx supergateway \ --port "$PORT" \ --stdio "node ${PROJECT_ROOT}/build/cli.js mcp" \ --outputTransport streamableHttp \ + --stateful \ --cors \ ${FILTERED_ARGS[@]+"${FILTERED_ARGS[@]}"} From 3cd7bba006d3a8805262a5276aa374d92e2923c0 Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sun, 26 Apr 2026 20:47:56 +0300 Subject: [PATCH 6/8] fix(command): Enrich PATH at module level for homebrew/MacPorts tools Spawned commands like xcodegen failed with ENOENT when the MCP server was launched outside a login shell (supergateway, Docker). Instead of relying on wrapper scripts to set PATH, the executor now prepends common tool directories (/opt/homebrew/bin, /usr/local/bin, /opt/local/bin) at module load time. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/command.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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, }; From 1342b10406c66c92902fd45ccbafca2688a86513 Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sun, 26 Apr 2026 20:48:20 +0300 Subject: [PATCH 7/8] fix(scripts): Add PID tracking and signal propagation to prevent zombie MCP processes ensure-mcp-server.sh now uses a per-port PID file to track the running server. On re-invocation it gracefully stops the old process before starting a new one, and refuses to start if the port is owned by an unknown process. serve-mcp.sh replaces exec with trap/wait so SIGTERM/SIGINT propagate to the entire process group (supergateway + node mcp), preventing orphaned child processes after the wrapper is killed. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/ensure-mcp-server.sh | 90 ++++++++++++++++++++++++++++++++++++ scripts/serve-mcp.sh | 16 ++++++- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100755 scripts/ensure-mcp-server.sh 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/serve-mcp.sh b/scripts/serve-mcp.sh index 54b586039..b3fa0e738 100755 --- a/scripts/serve-mcp.sh +++ b/scripts/serve-mcp.sh @@ -70,10 +70,22 @@ for arg in "$@"; do FILTERED_ARGS+=("$arg") done -exec npx supergateway \ +# 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[@]}"} + ${FILTERED_ARGS[@]+"${FILTERED_ARGS[@]}"} & + +wait $! From 8d80ec71cabcca50e62e7a8298a55550f50be5aa Mon Sep 17 00:00:00 2001 From: Paul Maas Date: Sun, 26 Apr 2026 21:17:21 +0300 Subject: [PATCH 8/8] fix(scripts): Add supergateway patch for unhandled async send rejection supergateway v3.4.3 calls transport.send() without await inside a sync forEach/try-catch. When the HTTP connection closes before the child responds, the rejected promise crashes the process. This patch appends .catch() to all three affected gateway files. Upstream: https://github.com/supercorp-ai/supergateway/issues/116 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/patch-supergateway.sh | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100755 scripts/patch-supergateway.sh 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