diff --git a/CHANGELOG.md b/CHANGELOG.md index 9040109da..5e5df346d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- Fixed `xcode_tools_bridge_disconnect` immediately re-syncing proxied tools after a manual disconnect ([#343](https://github.com/getsentry/XcodeBuildMCP/issues/343)). + ## [2.3.2] ### Fixed @@ -426,5 +432,3 @@ Please note that the UI automation features are an early preview and currently i ## [v1.0.1] - 2025-04-02 - Initial release of XcodeBuildMCP - Basic support for building iOS and macOS applications - - diff --git a/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts new file mode 100644 index 000000000..29691fcdb --- /dev/null +++ b/src/integrations/xcode-tools-bridge/__tests__/manager.test.ts @@ -0,0 +1,108 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { registryMocks, buildStatusMock, serviceMocks, onToolCatalogInvalidatedRef } = vi.hoisted( + () => ({ + registryMocks: { + clear: vi.fn(), + getRegisteredCount: vi.fn(() => 0), + sync: vi.fn(() => ({ added: 0, updated: 0, removed: 0, total: 0 })), + }, + buildStatusMock: vi.fn(), + serviceMocks: { + setWorkflowEnabled: vi.fn(), + disconnect: vi.fn(), + getClientStatus: vi.fn(), + getLastError: vi.fn(), + listTools: vi.fn(), + invokeTool: vi.fn(), + }, + onToolCatalogInvalidatedRef: { + current: undefined as (() => void) | undefined, + }, + }), +); + +vi.mock('../registry.ts', () => ({ + XcodeToolsProxyRegistry: vi.fn().mockImplementation(() => registryMocks), +})); + +vi.mock('../core.ts', () => ({ + buildXcodeToolsBridgeStatus: buildStatusMock, + classifyBridgeError: vi.fn(() => 'XCODE_MCP_UNAVAILABLE'), + getMcpBridgeAvailability: vi.fn(), + serializeBridgeTool: vi.fn((tool) => tool), +})); + +vi.mock('../tool-service.ts', () => ({ + XcodeIdeToolService: vi + .fn() + .mockImplementation((options: { onToolCatalogInvalidated?: () => void }) => { + onToolCatalogInvalidatedRef.current = options.onToolCatalogInvalidated; + return serviceMocks; + }), +})); + +import { XcodeToolsBridgeManager } from '../manager.ts'; + +describe('XcodeToolsBridgeManager', () => { + beforeEach(() => { + onToolCatalogInvalidatedRef.current = undefined; + + registryMocks.clear.mockReset(); + registryMocks.getRegisteredCount.mockReset(); + registryMocks.getRegisteredCount.mockReturnValue(0); + registryMocks.sync.mockReset(); + registryMocks.sync.mockReturnValue({ added: 0, updated: 0, removed: 0, total: 0 }); + + buildStatusMock.mockReset(); + buildStatusMock.mockResolvedValue({ + workflowEnabled: true, + bridgeAvailable: false, + bridgePath: null, + xcodeRunning: null, + connected: false, + bridgePid: null, + proxiedToolCount: 0, + lastError: null, + xcodePid: null, + xcodeSessionId: null, + }); + + serviceMocks.setWorkflowEnabled.mockReset(); + serviceMocks.disconnect.mockReset(); + serviceMocks.disconnect.mockImplementation(async () => { + onToolCatalogInvalidatedRef.current?.(); + }); + serviceMocks.getClientStatus.mockReset(); + serviceMocks.getClientStatus.mockReturnValue({ + connected: false, + bridgePid: null, + lastError: null, + }); + serviceMocks.getLastError.mockReset(); + serviceMocks.getLastError.mockReturnValue(null); + serviceMocks.listTools.mockReset(); + serviceMocks.invokeTool.mockReset(); + }); + + it('does not resync on listChanged while a manual disconnect is in progress', async () => { + const server = { + sendToolListChanged: vi.fn(), + } as unknown as McpServer; + + const manager = new XcodeToolsBridgeManager(server); + manager.setWorkflowEnabled(true); + + const syncSpy = vi.spyOn(manager, 'syncTools'); + + await manager.disconnectTool(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(serviceMocks.disconnect).toHaveBeenCalledOnce(); + expect(syncSpy).not.toHaveBeenCalled(); + expect(registryMocks.clear).toHaveBeenCalledOnce(); + expect(server.sendToolListChanged).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/integrations/xcode-tools-bridge/manager.ts b/src/integrations/xcode-tools-bridge/manager.ts index bf59a424a..9b0e60146 100644 --- a/src/integrations/xcode-tools-bridge/manager.ts +++ b/src/integrations/xcode-tools-bridge/manager.ts @@ -20,12 +20,16 @@ export class XcodeToolsBridgeManager { private workflowEnabled = false; private lastError: string | null = null; private syncInFlight: Promise | null = null; + private suppressListChangedSync = false; constructor(server: McpServer) { this.server = server; this.registry = new XcodeToolsProxyRegistry(server); this.service = new XcodeIdeToolService({ onToolCatalogInvalidated: (): void => { + if (this.suppressListChangedSync) { + return; + } void this.syncTools({ reason: 'listChanged' }); }, }); @@ -57,6 +61,10 @@ export class XcodeToolsBridgeManager { throw new Error('xcode-ide workflow is not enabled'); } + if (opts.reason !== 'listChanged') { + this.suppressListChangedSync = false; + } + if (this.syncInFlight) return this.syncInFlight; this.syncInFlight = (async (): Promise => { @@ -103,6 +111,7 @@ export class XcodeToolsBridgeManager { } async disconnect(): Promise { + this.suppressListChangedSync = true; this.registry.clear(); this.server.sendToolListChanged(); await this.service.disconnect();