Skip to content

refactor(9/12): migrate debugging, swift-package, and remaining tools#327

Open
cameroncooke wants to merge 2 commits intorefactor/migrate-ui-automation-toolsfrom
refactor/migrate-remaining-tools
Open

refactor(9/12): migrate debugging, swift-package, and remaining tools#327
cameroncooke wants to merge 2 commits intorefactor/migrate-ui-automation-toolsfrom
refactor/migrate-remaining-tools

Conversation

@cameroncooke
Copy link
Copy Markdown
Collaborator

Summary

This is PR 9 of 12 in a stacked PR series that decouples the rendering pipeline from MCP transport. Depends on PR 8 (UI automation migrations). This completes the tool migration -- after this PR, every tool handler in the codebase uses the new event-based contract.

Tools migrated (68 files)

Debugging tools: debug_attach_sim, debug_breakpoint_add, debug_breakpoint_remove, debug_continue, debug_detach, debug_lldb_command, debug_stack, debug_variables

Swift package tools: swift_package_build, swift_package_clean, swift_package_list, swift_package_run, swift_package_stop, swift_package_test

Coverage tools: get_coverage_report, get_file_coverage

Project discovery tools: discover_projs, get_app_bundle_id, get_mac_bundle_id, list_schemes, show_build_settings

Project scaffolding tools: scaffold_ios_project, scaffold_macos_project

Session management tools: session_clear_defaults, session_set_defaults, session_show_defaults, session_use_defaults_profile

Other tools: doctor, clean, manage_workflows, sync_xcode_defaults, xcode_ide_call_tool, xcode_ide_list_tools, xcode_tools_bridge_disconnect, xcode_tools_bridge_status, xcode_tools_bridge_sync

Notable changes

  • Session format helpers (session-format-helpers.ts): Extracted formatting logic for session default display that was duplicated across the 4 session management tools.
  • Debugger modules (src/utils/debugger/): Updated DAP backend, transport, and UI automation guard to work with the event-based context. The debugger's state machine continues to work the same way -- events are emitted instead of constructing response content.
  • Swift package test: Delegates to the same test-preflight.ts and xcodebuild pipeline modules as the platform-specific test tools.

Why this is one PR despite the file count

All 68 files follow the identical mechanical transformation (return toolResponse([...]) to ctx.emit(...)). Splitting by tool category would create 5+ PRs with no architectural difference between them. Reviewers can verify the pattern in a few files and trust it applies uniformly. The file count is high but the conceptual complexity is low.

Stack navigation

  • PR 1-5/12: Foundation, utilities, runtime contract
  • PR 6-8/12: Simulator, device/macOS, UI automation migrations
  • PR 9/12 (this PR): All remaining tool migrations
  • PR 10/12: CLI, daemon, and MCP server boundaries
  • PR 11-12/12: Config, docs, tests

Test plan

  • npx vitest run passes -- all tool tests updated
  • Every tool handler in the codebase now uses ctx.emit (no remaining toolResponse calls)
  • Debugger tools maintain correct state machine behavior through the new contract
  • Session management tools display formatted output correctly via events

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Duplicated runLogic test helper across many files
    • Created shared test-helpers.ts module with runLogic helper, eliminating duplication across 46+ test files.
  • ✅ Fixed: Removed showAsciiLogo parameter breaks CLI doctor usage
    • Restored showAsciiLogo parameter to runDoctor and doctorLogic functions with ASCII logo rendering for CLI usage.

Create PR

Or push these changes by commenting:

@cursor push 984868d074
Preview (984868d074)
diff --git a/manifests/tools/launch_app_logs_sim.yaml b/manifests/tools/launch_app_logs_sim.yaml
deleted file mode 100644
--- a/manifests/tools/launch_app_logs_sim.yaml
+++ /dev/null
@@ -1,17 +1,0 @@
-id: launch_app_logs_sim
-module: mcp/tools/simulator/launch_app_logs_sim
-names:
-  mcp: launch_app_logs_sim
-  cli: launch-app-with-logs
-description: Launch sim app with logs.
-routing:
-  stateful: true
-annotations:
-  title: Launch App Logs Simulator
-  readOnlyHint: false
-  destructiveHint: false
-  openWorldHint: false
-nextSteps:
-  - label: Stop capture and retrieve logs
-    toolId: stop_sim_log_cap
-    priority: 1
\ No newline at end of file

diff --git a/manifests/tools/start_device_log_cap.yaml b/manifests/tools/start_device_log_cap.yaml
deleted file mode 100644
--- a/manifests/tools/start_device_log_cap.yaml
+++ /dev/null
@@ -1,17 +1,0 @@
-id: start_device_log_cap
-module: mcp/tools/logging/start_device_log_cap
-names:
-  mcp: start_device_log_cap
-  cli: start-device-log-capture
-description: Start device log capture.
-routing:
-  stateful: true
-annotations:
-  title: Start Device Log Capture
-  readOnlyHint: false
-  destructiveHint: false
-  openWorldHint: false
-nextSteps:
-  - label: Stop capture and retrieve logs
-    toolId: stop_device_log_cap
-    priority: 1
\ No newline at end of file

diff --git a/manifests/tools/start_sim_log_cap.yaml b/manifests/tools/start_sim_log_cap.yaml
deleted file mode 100644
--- a/manifests/tools/start_sim_log_cap.yaml
+++ /dev/null
@@ -1,17 +1,0 @@
-id: start_sim_log_cap
-module: mcp/tools/logging/start_sim_log_cap
-names:
-  mcp: start_sim_log_cap
-  cli: start-simulator-log-capture
-description: Start sim log capture.
-routing:
-  stateful: true
-annotations:
-  title: Start Simulator Log Capture
-  readOnlyHint: false
-  destructiveHint: false
-  openWorldHint: false
-nextSteps:
-  - label: Stop capture and retrieve logs
-    toolId: stop_sim_log_cap
-    priority: 1
\ No newline at end of file

diff --git a/manifests/tools/stop_device_log_cap.yaml b/manifests/tools/stop_device_log_cap.yaml
deleted file mode 100644
--- a/manifests/tools/stop_device_log_cap.yaml
+++ /dev/null
@@ -1,13 +1,0 @@
-id: stop_device_log_cap
-module: mcp/tools/logging/stop_device_log_cap
-names:
-  mcp: stop_device_log_cap
-  cli: stop-device-log-capture
-description: Stop device app and return logs.
-routing:
-  stateful: true
-annotations:
-  title: Stop Device and Return Logs
-  readOnlyHint: false
-  destructiveHint: false
-  openWorldHint: false
\ No newline at end of file

diff --git a/manifests/tools/stop_sim_log_cap.yaml b/manifests/tools/stop_sim_log_cap.yaml
deleted file mode 100644
--- a/manifests/tools/stop_sim_log_cap.yaml
+++ /dev/null
@@ -1,13 +1,0 @@
-id: stop_sim_log_cap
-module: mcp/tools/logging/stop_sim_log_cap
-names:
-  mcp: stop_sim_log_cap
-  cli: stop-simulator-log-capture
-description: Stop sim app and return logs.
-routing:
-  stateful: true
-annotations:
-  title: Stop Simulator and Return Logs
-  readOnlyHint: false
-  destructiveHint: false
-  openWorldHint: false
\ No newline at end of file

diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml
--- a/manifests/workflows/device.yaml
+++ b/manifests/workflows/device.yaml
@@ -15,7 +15,5 @@
   - list_schemes
   - show_build_settings
   - get_app_bundle_id
-  - start_device_log_cap
-  - stop_device_log_cap
   - get_coverage_report
   - get_file_coverage

diff --git a/manifests/workflows/logging.yaml b/manifests/workflows/logging.yaml
deleted file mode 100644
--- a/manifests/workflows/logging.yaml
+++ /dev/null
@@ -1,8 +1,0 @@
-id: logging
-title: Log Capture
-description: Capture and retrieve logs from simulator and device apps.
-tools:
-  - start_sim_log_cap
-  - stop_sim_log_cap
-  - start_device_log_cap
-  - stop_device_log_cap
\ No newline at end of file

diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml
--- a/manifests/workflows/simulator.yaml
+++ b/manifests/workflows/simulator.yaml
@@ -14,7 +14,6 @@
   - get_sim_app_path
   - install_app_sim
   - launch_app_sim
-  - launch_app_logs_sim
   - stop_app_sim
   - record_sim_video
   - clean
@@ -24,7 +23,5 @@
   - get_app_bundle_id
   - screenshot
   - snapshot_ui
-  - stop_sim_log_cap
-  - start_sim_log_cap
   - get_coverage_report
   - get_file_coverage

diff --git a/src/core/manifest/__tests__/load-manifest.test.ts b/src/core/manifest/__tests__/load-manifest.test.ts
deleted file mode 100644
--- a/src/core/manifest/__tests__/load-manifest.test.ts
+++ /dev/null
@@ -1,187 +1,0 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import * as fs from 'node:fs';
-import * as path from 'node:path';
-import * as os from 'node:os';
-import {
-  loadManifest,
-  getWorkflowTools,
-  getToolsForWorkflows,
-  ManifestValidationError,
-} from '../load-manifest.ts';
-
-describe('load-manifest', () => {
-  describe('loadManifest (integration with real manifests)', () => {
-    it('should load all manifests from the manifests directory', () => {
-      const manifest = loadManifest();
-
-      // Check that we have tools and workflows
-      expect(manifest.tools.size).toBeGreaterThan(0);
-      expect(manifest.workflows.size).toBeGreaterThan(0);
-    });
-
-    it('should have required workflows', () => {
-      const manifest = loadManifest();
-
-      expect(manifest.workflows.has('simulator')).toBe(true);
-      expect(manifest.workflows.has('device')).toBe(true);
-      expect(manifest.workflows.has('session-management')).toBe(true);
-    });
-
-    it('should have required tools', () => {
-      const manifest = loadManifest();
-
-      expect(manifest.tools.has('build_sim')).toBe(true);
-      expect(manifest.tools.has('discover_projs')).toBe(true);
-      expect(manifest.tools.has('session_show_defaults')).toBe(true);
-      expect(manifest.tools.has('session_use_defaults_profile')).toBe(true);
-    });
-
-    it('should validate tool references in workflows', () => {
-      const manifest = loadManifest();
-
-      // Every tool referenced in a workflow should exist
-      for (const [workflowId, workflow] of manifest.workflows) {
-        for (const toolId of workflow.tools) {
-          expect(
-            manifest.tools.has(toolId),
-            `Workflow '${workflowId}' references unknown tool '${toolId}'`,
-          ).toBe(true);
-        }
-      }
-    });
-
-    it('should have unique MCP names across all tools', () => {
-      const manifest = loadManifest();
-      const mcpNames = new Set<string>();
-
-      for (const [, tool] of manifest.tools) {
-        expect(mcpNames.has(tool.names.mcp), `Duplicate MCP name '${tool.names.mcp}'`).toBe(false);
-        mcpNames.add(tool.names.mcp);
-      }
-    });
-
-    it('should have session-management as auto-include workflow', () => {
-      const manifest = loadManifest();
-      const sessionMgmt = manifest.workflows.get('session-management');
-
-      expect(sessionMgmt).toBeDefined();
-      expect(sessionMgmt?.selection?.mcp?.autoInclude).toBe(true);
-    });
-
-    it('should have simulator as default-enabled workflow', () => {
-      const manifest = loadManifest();
-      const simulator = manifest.workflows.get('simulator');
-
-      expect(simulator).toBeDefined();
-      expect(simulator?.selection?.mcp?.defaultEnabled).toBe(true);
-    });
-
-    it('should have doctor workflow with debugEnabled predicate', () => {
-      const manifest = loadManifest();
-      const doctor = manifest.workflows.get('doctor');
-
-      expect(doctor).toBeDefined();
-      expect(doctor?.predicates).toContain('debugEnabled');
-      expect(doctor?.selection?.mcp?.autoInclude).toBe(true);
-    });
-
-    it('should have xcode-ide workflow hidden in Xcode agent mode only', () => {
-      const manifest = loadManifest();
-      const xcodeIde = manifest.workflows.get('xcode-ide');
-
-      expect(xcodeIde).toBeDefined();
-      expect(xcodeIde?.predicates).toContain('hideWhenXcodeAgentMode');
-      expect(xcodeIde?.predicates).not.toContain('debugEnabled');
-    });
-
-    it('should keep xcode bridge gateway tools daemon-routed and debug tools gated', () => {
-      const manifest = loadManifest();
-
-      expect(manifest.tools.get('xcode_ide_list_tools')?.routing?.stateful).toBe(true);
-      expect(manifest.tools.get('xcode_ide_call_tool')?.routing?.stateful).toBe(true);
-      expect(manifest.tools.get('xcode_tools_bridge_status')?.predicates).toContain('debugEnabled');
-      expect(manifest.tools.get('xcode_tools_bridge_sync')?.predicates).toContain('debugEnabled');
-      expect(manifest.tools.get('xcode_tools_bridge_disconnect')?.predicates).toContain(
-        'debugEnabled',
-      );
-    });
-
-    it('should provide explicit approval annotations for every tool', () => {
-      const manifest = loadManifest();
-
-      for (const [toolId, tool] of manifest.tools) {
-        expect(tool.annotations, `Tool '${toolId}' is missing annotations`).toBeDefined();
-        expect(
-          tool.annotations?.title,
-          `Tool '${toolId}' is missing annotations.title`,
-        ).toBeTruthy();
-        expect(
-          tool.annotations?.readOnlyHint,
-          `Tool '${toolId}' is missing annotations.readOnlyHint`,
-        ).not.toBeUndefined();
-        expect(
-          tool.annotations?.destructiveHint,
-          `Tool '${toolId}' is missing annotations.destructiveHint`,
-        ).not.toBeUndefined();
-        expect(
-          tool.annotations?.openWorldHint,
-          `Tool '${toolId}' is missing annotations.openWorldHint`,
-        ).not.toBeUndefined();
-      }
-    });
-  });
-
-  describe('getWorkflowTools', () => {
-    it('should return tools for a workflow', () => {
-      const manifest = loadManifest();
-      const tools = getWorkflowTools(manifest, 'simulator');
-
-      expect(tools.length).toBeGreaterThan(0);
-      expect(tools.some((t) => t.id === 'build_sim')).toBe(true);
-    });
-
-    it('should return empty array for unknown workflow', () => {
-      const manifest = loadManifest();
-      const tools = getWorkflowTools(manifest, 'nonexistent-workflow');
-
-      expect(tools).toEqual([]);
-    });
-  });
-
-  describe('getToolsForWorkflows', () => {
-    it('should return unique tools across multiple workflows', () => {
-      const manifest = loadManifest();
-      const tools = getToolsForWorkflows(manifest, ['simulator', 'device']);
-
-      // Should have tools from both workflows
-      expect(tools.some((t) => t.id === 'build_sim')).toBe(true);
-      expect(tools.some((t) => t.id === 'build_device')).toBe(true);
-
-      // Tools should be unique (discover_projs is in both)
-      const toolIds = tools.map((t) => t.id);
-      const uniqueIds = new Set(toolIds);
-      expect(toolIds.length).toBe(uniqueIds.size);
-    });
-
-    it('should return empty array for empty workflow list', () => {
-      const manifest = loadManifest();
-      const tools = getToolsForWorkflows(manifest, []);
-
-      expect(tools).toEqual([]);
-    });
-  });
-});
-
-describe('ManifestValidationError', () => {
-  it('should include source file in message', () => {
-    const error = new ManifestValidationError('Test error', 'test.yaml');
-    expect(error.message).toBe('Test error (in test.yaml)');
-    expect(error.sourceFile).toBe('test.yaml');
-  });
-
-  it('should work without source file', () => {
-    const error = new ManifestValidationError('Test error');
-    expect(error.message).toBe('Test error');
-    expect(error.sourceFile).toBeUndefined();
-  });
-});
\ No newline at end of file

diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts
--- a/src/core/manifest/__tests__/schema.test.ts
+++ b/src/core/manifest/__tests__/schema.test.ts
@@ -2,223 +2,78 @@
 import {
   toolManifestEntrySchema,
   workflowManifestEntrySchema,
-  deriveCliName,
+  resourceManifestEntrySchema,
   getEffectiveCliName,
-  type ToolManifestEntry,
 } from '../schema.ts';
 
 describe('schema', () => {
-  describe('toolManifestEntrySchema', () => {
-    it('should parse valid tool manifest', () => {
-      const input = {
-        id: 'build_sim',
-        module: 'mcp/tools/simulator/build_sim',
-        names: { mcp: 'build_sim' },
-        description: 'Build iOS app for simulator',
-        availability: { mcp: true, cli: true },
-        predicates: [],
-        routing: { stateful: false },
-      };
+  it('parses a representative manifest/tool naming pipeline', () => {
+    const toolInput = {
+      id: 'build_sim',
+      module: 'mcp/tools/simulator/build_sim',
+      names: { mcp: 'build_sim' },
+    };
+    const workflowInput = {
+      id: 'simulator',
+      title: 'iOS Simulator Development',
+      description: 'Build and test iOS apps on simulators',
+      tools: ['build_sim'],
+    };
 
-      const result = toolManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.id).toBe('build_sim');
-        expect(result.data.names.mcp).toBe('build_sim');
-      }
-    });
+    const toolResult = toolManifestEntrySchema.safeParse(toolInput);
+    const workflowResult = workflowManifestEntrySchema.safeParse(workflowInput);
 
-    it('should apply default availability', () => {
-      const input = {
-        id: 'test_tool',
-        module: 'mcp/tools/test/test_tool',
-        names: { mcp: 'test_tool' },
-      };
+    expect(toolResult.success).toBe(true);
+    expect(workflowResult.success).toBe(true);
 
-      const result = toolManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.availability).toEqual({ mcp: true, cli: true });
-        expect(result.data.predicates).toEqual([]);
-        expect(result.data.nextSteps).toEqual([]);
-      }
-    });
+    if (!toolResult.success || !workflowResult.success) {
+      throw new Error('Expected representative manifest inputs to parse');
+    }
 
-    it('should reject missing required fields', () => {
-      const input = {
-        id: 'test_tool',
-        // missing module and names
-      };
-
-      const result = toolManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(false);
-    });
-
-    it('should accept optional CLI name', () => {
-      const input = {
-        id: 'build_sim',
-        module: 'mcp/tools/simulator/build_sim',
-        names: { mcp: 'build_sim', cli: 'build-simulator' },
-      };
-
-      const result = toolManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.names.cli).toBe('build-simulator');
-      }
-    });
-
-    it('should reject availability.daemon', () => {
-      const input = {
-        id: 'tool1',
-        module: 'mcp/tools/test/tool1',
-        names: { mcp: 'tool1' },
-        availability: { mcp: true, cli: true, daemon: true },
-      };
-
-      expect(toolManifestEntrySchema.safeParse(input).success).toBe(false);
-    });
-
-    it('should reject routing.daemonAffinity', () => {
-      const input = {
-        id: 'tool2',
-        module: 'mcp/tools/test/tool2',
-        names: { mcp: 'tool2' },
-        routing: { stateful: true, daemonAffinity: 'required' },
-      };
-
-      expect(toolManifestEntrySchema.safeParse(input).success).toBe(false);
-    });
+    expect(toolResult.data.availability).toEqual({ mcp: true, cli: true });
+    expect(toolResult.data.nextSteps).toEqual([]);
+    expect(toolResult.data.predicates).toEqual([]);
+    expect(workflowResult.data.availability).toEqual({ mcp: true, cli: true });
+    expect(workflowResult.data.predicates).toEqual([]);
+    expect(workflowResult.data.tools).toEqual(['build_sim']);
+    expect(getEffectiveCliName(toolResult.data)).toBe('build-sim');
   });
 
-  describe('workflowManifestEntrySchema', () => {
-    it('should parse valid workflow manifest', () => {
-      const input = {
-        id: 'simulator',
-        title: 'iOS Simulator Development',
-        description: 'Build and test iOS apps on simulators',
-        availability: { mcp: true, cli: true },
-        selection: {
-          mcp: {
-            defaultEnabled: true,
-            autoInclude: false,
-          },
-        },
-        predicates: [],
-        tools: ['build_sim', 'test_sim', 'boot_sim'],
-      };
+  it('parses a resource manifest entry with defaults', () => {
+    const input = {
+      id: 'simulators',
+      module: 'mcp/resources/simulators',
+      name: 'simulators',
+      uri: 'xcodebuildmcp://simulators',
+      description: 'Available iOS simulators',
+      mimeType: 'text/plain',
+    };
 
-      const result = workflowManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.id).toBe('simulator');
-        expect(result.data.tools).toHaveLength(3);
-        expect(result.data.selection?.mcp?.defaultEnabled).toBe(true);
-      }
-    });
+    const result = resourceManifestEntrySchema.safeParse(input);
 
-    it('should apply default values', () => {
-      const input = {
-        id: 'test-workflow',
-        title: 'Test Workflow',
-        description: 'A test workflow',
-        tools: ['tool1'],
-      };
+    expect(result.success).toBe(true);
+    if (!result.success) throw new Error('Expected resource manifest input to parse');
 
-      const result = workflowManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.availability).toEqual({ mcp: true, cli: true });
-        expect(result.data.predicates).toEqual([]);
-      }
-    });
-
-    it('should reject empty tools array', () => {
-      const input = {
-        id: 'empty-workflow',
-        title: 'Empty Workflow',
-        description: 'A workflow with no tools',
-        tools: [],
-      };
-
-      // Empty tools array is technically valid per schema
-      const result = workflowManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-    });
-
-    it('should parse autoInclude workflow', () => {
-      const input = {
-        id: 'session-management',
-        title: 'Session Management',
-        description: 'Manage session defaults',
-        availability: { mcp: true, cli: false },
-        selection: {
-          mcp: {
-            defaultEnabled: true,
-            autoInclude: true,
-          },
-        },
-        tools: ['session_show_defaults'],
-      };
-
-      const result = workflowManifestEntrySchema.safeParse(input);
-      expect(result.success).toBe(true);
-      if (result.success) {
-        expect(result.data.selection?.mcp?.autoInclude).toBe(true);
-        expect(result.data.availability.cli).toBe(false);
-      }
-    });
+    expect(result.data.availability).toEqual({ mcp: true });
+    expect(result.data.predicates).toEqual([]);
   });
 
-  describe('deriveCliName', () => {
-    it('should convert underscores to hyphens', () => {
-      expect(deriveCliName('build_sim')).toBe('build-sim');
-      expect(deriveCliName('get_app_bundle_id')).toBe('get-app-bundle-id');
-    });
+  it('parses a resource manifest entry with predicates', () => {
+    const input = {
+      id: 'xcode-ide-state',
+      module: 'mcp/resources/xcode-ide-state',
+      name: 'xcode-ide-state',
+      uri: 'xcodebuildmcp://xcode-ide-state',
+      description: 'Xcode IDE state',
+      mimeType: 'application/json',
+      predicates: ['runningUnderXcodeAgent'],
+    };
 
-    it('should convert camelCase to kebab-case', () => {
-      expect(deriveCliName('buildSim')).toBe('build-sim');
-      expect(deriveCliName('getAppBundleId')).toBe('get-app-bundle-id');
-    });
+    const result = resourceManifestEntrySchema.safeParse(input);
 
-    it('should handle mixed underscores and camelCase', () => {
-      expect(deriveCliName('build_simApp')).toBe('build-sim-app');
-    });
+    expect(result.success).toBe(true);
+    if (!result.success) throw new Error('Expected resource manifest input to parse');
 
-    it('should handle already kebab-case', () => {
-      expect(deriveCliName('build-sim')).toBe('build-sim');
-    });
-
-    it('should lowercase the result', () => {
-      expect(deriveCliName('BUILD_SIM')).toBe('build-sim');
-    });
+    expect(result.data.predicates).toEqual(['runningUnderXcodeAgent']);
   });
-
-  describe('getEffectiveCliName', () => {
-    it('should use explicit CLI name when provided', () => {
-      const tool: ToolManifestEntry = {
-        id: 'build_sim',
-        module: 'mcp/tools/simulator/build_sim',
-        names: { mcp: 'build_sim', cli: 'build-simulator' },
-        availability: { mcp: true, cli: true },
-        predicates: [],
-        nextSteps: [],
-      };
-
-      expect(getEffectiveCliName(tool)).toBe('build-simulator');
-    });
-
-    it('should derive CLI name when not provided', () => {
-      const tool: ToolManifestEntry = {
-        id: 'build_sim',
-        module: 'mcp/tools/simulator/build_sim',
-        names: { mcp: 'build_sim' },
-        availability: { mcp: true, cli: true },
-        predicates: [],
-        nextSteps: [],
-      };
-
-      expect(getEffectiveCliName(tool)).toBe('build-sim');
-    });
-  });
 });

diff --git a/src/core/manifest/import-resource-module.ts b/src/core/manifest/import-resource-module.ts
new file mode 100644
--- /dev/null
+++ b/src/core/manifest/import-resource-module.ts
@@ -1,0 +1,61 @@
+/**
+ * Resource module importer.
+ * Dynamically imports resource modules using named exports only.
+ */
+
+import * as path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { getPackageRoot } from './load-manifest.ts';
+
+export interface ImportedResourceModule {
+  handler: (uri: URL) => Promise<{ contents: Array<{ text: string }> }>;
+}
+
+const moduleCache = new Map<string, ImportedResourceModule>();
+
+/**
+ * Import a resource module by its manifest module path.
+ *
+ * Accepts named export only: `export const handler = ...`
+ *
+ * @param moduleId - Extensionless module path (e.g., 'mcp/resources/simulators')
+ * @returns Imported resource module with handler
+ */
+export async function importResourceModule(moduleId: string): Promise<ImportedResourceModule> {
+  const cached = moduleCache.get(moduleId);
+  if (cached) {
+    return cached;
+  }
+
+  const packageRoot = getPackageRoot();
+  const modulePath = path.join(packageRoot, 'build', `${moduleId}.js`);
+  const moduleUrl = pathToFileURL(modulePath).href;
+
+  let mod: Record<string, unknown>;
+  try {
+    mod = (await import(moduleUrl)) as Record<string, unknown>;
+  } catch (err) {
+    throw new Error(`Failed to import resource module '${moduleId}': ${err}`);
+  }
+
+  if (typeof mod.handler !== 'function') {
+    throw new Error(
+      `Resource module '${moduleId}' does not export the required shape. ` +
+        `Expected a named export: export const handler = ...`,
+    );
+  }
+
+  const result: ImportedResourceModule = {
+    handler: mod.handler as ImportedResourceModule['handler'],
+  };
+
+  moduleCache.set(moduleId, result);
+  return result;
+}
+
+/**
+ * Reset module cache (for tests).
+ */
+export function __resetResourceModuleCacheForTests(): void {
+  moduleCache.clear();
+}

diff --git a/src/core/manifest/import-tool-module.ts b/src/core/manifest/import-tool-module.ts
--- a/src/core/manifest/import-tool-module.ts
+++ b/src/core/manifest/import-tool-module.ts
@@ -1,40 +1,30 @@
 /**
- * Tool module importer with backward-compatible adapter.
- * Dynamically imports tool modules and adapts both old (PluginMeta default export)
- * and new (named exports) formats.
+ * Tool module importer.
+ * Dynamically imports tool modules using named exports only.
  */
 
 import * as path from 'node:path';
 import { pathToFileURL } from 'node:url';
 import type { ToolSchemaShape } from '../plugin-types.ts';
+import type { ToolHandlerContext } from '../../rendering/types.ts';
 import { getPackageRoot } from './load-manifest.ts';
 
-/**
- * Imported tool module interface.
- * This is what we extract from each tool module for runtime use.
- */
 export interface ImportedToolModule {
   schema: ToolSchemaShape;
-  handler: (params: Record<string, unknown>) => Promise<unknown>;
+  handler: (params: Record<string, unknown>, ctx?: ToolHandlerContext) => Promise<unknown>;
 }
 
-/**
- * Cache for imported modules.
- */
 const moduleCache = new Map<string, ImportedToolModule>();
 
 /**
  * Import a tool module by its manifest module path.
  *
- * Supports two module formats:
- * 1. Legacy: `export default { name, schema, handler, ... }`
- * 2. New: Named exports `{ schema, handler }`
+ * Accepts named exports only: `export const schema = ...` and `export const handler = ...`
  *
  * @param moduleId - Extensionless module path (e.g., 'mcp/tools/simulator/build_sim')
  * @returns Imported tool module with schema and handler
  */
 export async function importToolModule(moduleId: string): Promise<ImportedToolModule> {
-  // Check cache first
   const cached = moduleCache.get(moduleId);
   if (cached) {
     return cached;
@@ -51,56 +41,28 @@
     throw new Error(`Failed to import tool module '${moduleId}': ${err}`);
   }
 
-  const result = extractToolExports(mod, moduleId);
+  if (!mod.schema || typeof mod.handler !== 'function') {
+    throw new Error(
+      `Tool module '${moduleId}' does not export the required shape. ` +
+        `Expected named exports: export const schema = ... and export const handler = ...`,
+    );
+  }
 
-  // Cache the result
+  const result: ImportedToolModule = {
+    schema: mod.schema as ToolSchemaShape,
+    handler: mod.handler as (
+      params: Record<string, unknown>,
+      ctx?: ToolHandlerContext,
+    ) => Promise<unknown>,
+  };
+
   moduleCache.set(moduleId, result);
-
   return result;
 }
 
 /**
- * Extract tool exports from a module, supporting both legacy and new formats.
+ * Reset module cache (for tests).
  */
-function extractToolExports(mod: Record<string, unknown>, moduleId: string): ImportedToolModule {
-  // Try legacy format first: default export with PluginMeta shape
-  if (mod.default && typeof mod.default === 'object') {
-    const defaultExport = mod.default as Record<string, unknown>;
-
-    // Check if it looks like a PluginMeta (has schema and handler)
-    if (defaultExport.schema && typeof defaultExport.handler === 'function') {
-      return {
-        schema: defaultExport.schema as ToolSchemaShape,
-        handler: defaultExport.handler as (params: Record<string, unknown>) => Promise<unknown>,
-      };
-    }
-  }
-
-  // Try new format: named exports
-  if (mod.schema && typeof mod.handler === 'function') {
-    return {
-      schema: mod.schema as ToolSchemaShape,
... diff truncated: showing 800 of 55013 lines

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from 46fdf65 to d2e93fa Compare April 8, 2026 21:29
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from f8a5827 to 23575fb Compare April 8, 2026 21:29
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from d2e93fa to 56e6ae9 Compare April 9, 2026 07:49
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from 23575fb to 0561a67 Compare April 9, 2026 07:49
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from 56e6ae9 to d79dae4 Compare April 9, 2026 07:59
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from 0561a67 to 01e6478 Compare April 9, 2026 07:59
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from d79dae4 to 9af3129 Compare April 9, 2026 08:45
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from 01e6478 to 8390e80 Compare April 9, 2026 08:45
const text = result.content[0].type === 'text' ? result.content[0].text : '';
expect(text).toContain('Code Coverage Report');
expect(text).toContain('Overall: 24.7%');
expect(text).toContain('180/730 lines');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report tests drop core calculation assertions

Medium Severity

The "Happy Path" test removed assertions that validated the overall coverage calculation ('Overall: 24.7%', '180/730 lines'), and the "nested targets format" test replaced specific coverage percentage checks ('Core: 10.0%', 'MyApp.app: 50.0%') with only expect(result.content.length).toBeGreaterThan(0). The new implementation still emits these values via statusLine, so the old assertions would still pass — they were unnecessarily dropped. Without them, arithmetic regressions in the coverage aggregation logic or parsing bugs in the nested-target format would go undetected.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8390e80. Configure here.

statusLine('error', `Failed to pause debugger after attach: ${message}`),
);
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New process interrupt behavior beyond mechanical migration

Medium Severity

When continueOnAttach is false, the old code simply skipped resuming and returned success. The new code actively sends a process interrupt LLDB command and then queries getExecutionState to determine the output message. This is a functional behavior change — not the "identical mechanical transformation" described in the PR. Sending process interrupt to a freshly-attached process that may already be stopped could produce unexpected side effects depending on the backend and debugger state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8390e80. Configure here.

@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from fd818c3 to dad2143 Compare April 9, 2026 10:39
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from 08d16bd to af53a77 Compare April 9, 2026 10:39
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dad2143. Configure here.

@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from af53a77 to 7fecbf7 Compare April 9, 2026 10:56
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from dad2143 to 87835ee Compare April 9, 2026 10:56
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from 7fecbf7 to 4e35374 Compare April 9, 2026 11:22
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from 87835ee to 0cf0796 Compare April 9, 2026 11:22
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from 0cf0796 to 701bb70 Compare April 9, 2026 11:31
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from 4e35374 to 1678b2e Compare April 9, 2026 11:31
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from 701bb70 to fb11021 Compare April 9, 2026 11:48
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch 2 times, most recently from c541317 to f62e76a Compare April 9, 2026 12:03
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from fb11021 to e31bf00 Compare April 9, 2026 12:03
Comment on lines 136 to 146
this.ensureAttached();

const normalizedCommand = command.trim().toLowerCase();
if (normalizedCommand === 'process interrupt') {
await this.pauseExecution(opts?.timeoutMs);
return 'Process interrupted.';
}

try {
const body = await this.request<
{ expression: string; context: string },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The runCommand handler for 'process interrupt' modifies shared state without using the enqueue method, creating a potential race condition with other debugger operations.
Severity: MEDIUM

Suggested Fix

Wrap the logic inside the runCommand method's 'process interrupt' case with this.enqueue(). This will ensure the operation is properly serialized along with all other state-modifying debugger actions, preventing race conditions.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/utils/debugger/backends/dap-backend.ts#L135-L146

Potential issue: The `runCommand` method, when handling a 'process interrupt', directly
calls `pauseExecution` without using `this.enqueue()` to serialize the operation. This
violates the class's established synchronization pattern. `pauseExecution` modifies the
shared `this.executionState`. Because this operation bypasses the queue, it can race
with other concurrent debugger operations. For example, if a 'stopped' event from a
breakpoint occurs while `pauseExecution` is running, `pauseExecution` may consume that
event and then overwrite `this.executionState`, losing the original reason for the stop
(e.g., 'breakpoint'). This leads to an ambiguous or corrupted debugger state.

…-discovery, scaffolding, session-management, utilities, workflow-discovery, and xcode-ide tools to event-based handler contract
Replace 18 identical local runLogic definitions with the shared import
from test-helpers.ts.
@cameroncooke cameroncooke force-pushed the refactor/migrate-ui-automation-tools branch from f62e76a to aaa305d Compare April 9, 2026 14:43
@cameroncooke cameroncooke force-pushed the refactor/migrate-remaining-tools branch from e31bf00 to 57874da Compare April 9, 2026 14:43
Comment on lines 200 to +208
}, timeout);
});

// Race between command completion and timeout
const result = await Promise.race([commandPromise, timeoutPromise]);

if ('timedOut' in result && result.timedOut) {
// For timeout case, the process may still be running - provide timeout response
if (isTestEnvironment) {
// In test environment, return mock response
const mockPid = 12345;
return {
content: [
createTextContent(
`⏱️ Process timed out after ${timeout / 1000} seconds but may continue running.`,
),
createTextContent(`PID: ${mockPid} (mock)`),
createTextContent(
`💡 Process may still be running. Use swift_package_stop with PID ${mockPid} to terminate when needed.`,
),
createTextContent(result.output || '(no output so far)'),
],
};
} else {
// Production: timeout occurred, but we don't start a new process
return {
content: [
createTextContent(`⏱️ Process timed out after ${timeout / 1000} seconds.`),
createTextContent(
`💡 Process execution exceeded the timeout limit. Consider using background mode for long-running executables.`,
),
createTextContent(result.output || '(no output so far)'),
],
};
}
if (isTimedOutResult(result)) {
const timeoutSeconds = timeout / 1000;
ctx.emit(headerEvent);
ctx.emit(statusLine('warning', `Process timed out after ${timeoutSeconds} seconds.`));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When a non-background Swift package operation times out, the headerEvent is emitted twice, once through the pipeline and again explicitly in the timeout handler, creating a duplicate event.
Severity: MEDIUM

Suggested Fix

Remove the explicit ctx.emit(headerEvent) call from the timeout handling logic in swift_package_run.ts. The event is already correctly emitted through the pipeline.emitEvent() call, making the second call redundant.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/mcp/tools/swift-package/swift_package_run.ts#L200-L208

Potential issue: In `src/mcp/tools/swift-package/swift_package_run.ts`, when a timeout
occurs during a non-background execution, the `headerEvent` is emitted twice. The first
emission happens via `pipeline.emitEvent(headerEvent)` which internally calls
`ctx.emit`. The second emission occurs from an explicit call to `ctx.emit(headerEvent)`
within the timeout handling logic. This duplication results in a malformed event stream,
which can cause redundant client output or break rendering pipelines that expect a
single header event.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant