diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46a01f14..9a0cbe8a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -201,49 +201,56 @@ Resources can reuse existing tool logic for consistency: ```typescript // src/mcp/resources/some_resource.ts +import { log, getDefaultCommandExecutor, CommandExecutor } from '../../utils/index.js'; +import { getSomeResourceLogic } from '../tools/some-workflow/get_some_resource.js'; + +// Testable resource logic separated from MCP handler +export async function someResourceResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing some resource request'); + + const result = await getSomeResourceLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error( + typeof errorText === 'string' ? errorText : 'Failed to retrieve some resource data', + ); + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No data for that resource is available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in some_resource resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving resource data: ${errorMessage}`, + }, + ], + }; + } +} + export default { uri: 'xcodebuildmcp://some_resource', name: 'some_resource', description: 'Returns some resource information', mimeType: 'text/plain', - async handler( - uri: URL, - executor: CommandExecutor = getDefaultCommandExecutor(), - ): Promise<{ contents: Array<{ text: string }> }> { - try { - log('info', 'Processing simulators resource request'); - - const result = await getSomeResource({}, executor); - - if (result.isError) { - const errorText = result.content[0]?.text; - throw new Error( - typeof errorText === 'string' ? errorText : 'Failed to retrieve some resource data', - ); - } - - return { - contents: [ - { - text: - typeof result.content[0]?.text === 'string' - ? result.content[0].text - : 'No data for that resource is available', - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in some_resource resource handler: ${errorMessage}`); - - return { - contents: [ - { - text: `Error retrieving resource data: ${errorMessage}`, - }, - ], - }; - } + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return someResourceResourceLogic(); }, }; ``` diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md index 2d7fb493..59025d0c 100644 --- a/docs/PLUGIN_DEVELOPMENT.md +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -304,19 +304,47 @@ Resources are located in `src/resources/` and follow this pattern: ```typescript // src/resources/example.ts +import { log, getDefaultCommandExecutor, CommandExecutor } from '../../utils/index.js'; + +// Testable resource logic separated from MCP handler +export async function exampleResourceLogic( + executor: CommandExecutor, +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing example resource request'); + + // Use the executor to get data + const result = await executor(['some', 'command'], 'Example Resource Operation'); + + if (!result.success) { + throw new Error(result.error || 'Failed to get resource data'); + } + + return { + contents: [{ text: result.output || 'resource data' }] + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in example resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving resource data: ${errorMessage}`, + }, + ], + }; + } +} + export default { uri: 'xcodebuildmcp://example', - name: 'example' + name: 'example', description: 'Description of the resource data', mimeType: 'text/plain', - async handler( - executor: CommandExecutor = getDefaultCommandExecutor() - ): Promise<{ contents: Array<{ text: string }> }> { - // Resource implementation - return { - contents: [{ text: 'resource data' }] - }; - } + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return exampleResourceLogic(getDefaultCommandExecutor()); + }, }; ``` @@ -325,17 +353,16 @@ export default { **Reuse Existing Logic**: Resources that mirror tools should reuse existing tool logic for consistency: ```typescript -// src/mcp/resources/simulators.ts (simplified exmaple) -import { list_simsLogic } from '../plugins/simulator-shared/list_sims.js'; +// src/mcp/resources/simulators.ts (simplified example) +import { list_simsLogic } from '../tools/simulator-shared/list_sims.js'; export default { uri: 'xcodebuildmcp://simulators', name: 'simulators' description: 'Available iOS simulators with UUIDs and states', mimeType: 'text/plain', - async handler( - executor: CommandExecutor = getDefaultCommandExecutor() - ): Promise<{ contents: Array<{ text: string }> }> { + async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }> { + const executor = getDefaultCommandExecutor(); const result = await list_simsLogic({}, executor); return { contents: [{ text: result.content[0].text }] @@ -352,19 +379,52 @@ Create tests in `src/mcp/resources/__tests__/`: ```typescript // src/mcp/resources/__tests__/example.test.ts -import exampleResource from '../example.js'; +import exampleResource, { exampleResourceLogic } from '../example.js'; import { createMockExecutor } from '../../utils/test-common.js'; describe('example resource', () => { - it('should return resource data', async () => { - const mockExecutor = createMockExecutor({ - success: true, - output: 'test data' + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(exampleResource.uri).toBe('xcodebuildmcp://example'); + }); + + it('should export correct description', () => { + expect(exampleResource.description).toBe('Description of the resource data'); + }); + + it('should export correct mimeType', () => { + expect(exampleResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof exampleResource.handler).toBe('function'); + }); + }); + + describe('Resource Logic Functionality', () => { + it('should return resource data successfully', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'test data' + }); + + // Test the logic function directly, not the handler + const result = await exampleResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('expected data'); + }); + + it('should handle command execution errors', async () => { + const mockExecutor = createMockExecutor({ + success: false, + error: 'Command failed' + }); + + const result = await exampleResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('Error retrieving'); }); - - const result = await exampleResource.handler(mockExecutor); - - expect(result.contents[0].text).toContain('expected data'); }); }); ``` diff --git a/docs/TESTING.md b/docs/TESTING.md index 6375806b..7c394589 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -94,18 +94,22 @@ Test → Plugin Handler → utilities → [DEPENDENCY INJECTION] createMockExecu All plugin handlers must support dependency injection: ```typescript +export function tool_nameLogic( + args: Record, + commandExecutor: CommandExecutor, + fileSystemExecutor?: FileSystemExecutor +): Promise { + // Use injected executors + const result = await executeCommand(['xcrun', 'simctl', 'list'], commandExecutor); + return createTextResponse(result.output); +} + export default { name: 'tool_name', description: 'Tool description', schema: { /* zod schema */ }, - async handler( - args: Record, - commandExecutor: CommandExecutor = getDefaultCommandExecutor(), - fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor() - ): Promise { - // Use injected executors - const result = await executeCommand(['xcrun', 'simctl', 'list'], commandExecutor); - return createTextResponse(result.output); + async handler(args: Record): Promise { + return tool_nameLogic(args, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()); }, }; ``` @@ -128,7 +132,7 @@ it('should handle successful command execution', async () => { output: 'BUILD SUCCEEDED' }); - const result = await tool.handler( + const result = await tool_nameLogic( { projectPath: '/test.xcodeproj', scheme: 'MyApp' }, mockExecutor ); @@ -509,12 +513,8 @@ const result = await tool.handler(params, mockCmd, mockFS); **Fix**: Update handler signature: ```typescript -async handler( - args: Record, - commandExecutor: CommandExecutor = getDefaultCommandExecutor(), - fileSystemExecutor: FileSystemExecutor = getDefaultFileSystemExecutor() -): Promise { - // Use injected executors +async handler(args: Record): Promise { + return tool_nameLogic(args, getDefaultCommandExecutor(), getDefaultFileSystemExecutor()); } ``` diff --git a/scripts/check-test-patterns.js b/scripts/check-test-patterns.js index 5805b482..bb74110f 100755 --- a/scripts/check-test-patterns.js +++ b/scripts/check-test-patterns.js @@ -15,6 +15,7 @@ * 2. NO setTimeout-based mocking patterns * 3. ONLY dependency injection with createMockExecutor() and createMockFileSystemExecutor() * 4. Proper test architecture compliance + * 5. NO handler signature violations (handlers must have exact MCP SDK signatures) */ import { readFileSync, readdirSync, statSync } from 'fs'; @@ -39,12 +40,13 @@ USAGE: node scripts/check-test-patterns.js [options] OPTIONS: - --pattern=TYPE Check specific pattern type (vitest|timeout|all) [default: all] + --pattern=TYPE Check specific pattern type (vitest|timeout|handler|all) [default: all] --help, -h Show this help message PATTERN TYPES: vitest Check only vitest mocking violations (vi.mock, vi.fn, etc.) - timeout Check only setTimeout-based mocking patterns + timeout Check only setTimeout-based mocking patterns + handler Check only handler signature violations all Check all pattern violations (default) EXAMPLES: @@ -97,6 +99,20 @@ const VITEST_MOCKING_PATTERNS = [ /\bexecSyncFn\b/, // execSyncFn usage - BANNED (use executeCommand instead) ]; +// CRITICAL: HANDLER SIGNATURE VIOLATIONS ARE FORBIDDEN +// MCP SDK requires handlers to have exact signatures: +// Tools: (args: Record) => Promise +// Resources: (uri: URL) => Promise<{ contents: Array<{ text: string }> }> +const HANDLER_SIGNATURE_VIOLATIONS = [ + /async\s+handler\s*\([^)]*:\s*[^,)]+,\s*[^)]+\s*:/ms, // Handler with multiple parameters separated by comma - BANNED + /async\s+handler\s*\(\s*args\?\s*:/ms, // Handler with optional args parameter - BANNED (should be required) + /async\s+handler\s*\([^)]*,\s*[^)]*CommandExecutor/ms, // Handler with CommandExecutor parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*FileSystemExecutor/ms, // Handler with FileSystemExecutor parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*Dependencies/ms, // Handler with Dependencies parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*executor\s*:/ms, // Handler with executor parameter - BANNED + /async\s+handler\s*\([^)]*,\s*[^)]*dependencies\s*:/ms, // Handler with dependencies parameter - BANNED +]; + // ALLOWED PATTERNS for cleanup (not mocking) const ALLOWED_CLEANUP_PATTERNS = [ // All cleanup patterns removed - no exceptions allowed @@ -134,6 +150,31 @@ function findTestFiles(dir) { return testFiles; } +function findToolAndResourceFiles(dir) { + const toolFiles = []; + + function traverse(currentDir) { + const items = readdirSync(currentDir); + + for (const item of items) { + const fullPath = join(currentDir, item); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + // Skip test directories and other non-relevant directories + if (!item.startsWith('.') && item !== '__tests__' && item !== 'node_modules' && item !== 'dist' && item !== 'build') { + traverse(fullPath); + } + } else if ((item.endsWith('.ts') || item.endsWith('.js')) && !item.includes('.test.') && item !== 'index.ts' && item !== 'index.js') { + toolFiles.push(fullPath); + } + } + } + + traverse(dir); + return toolFiles; +} + function analyzeTestFile(filePath) { try { const content = readFileSync(filePath, 'utf8'); @@ -199,19 +240,72 @@ function analyzeTestFile(filePath) { } } +function analyzeToolOrResourceFile(filePath) { + try { + const content = readFileSync(filePath, 'utf8'); + const relativePath = relative(projectRoot, filePath); + + // Check for handler signature violations (FORBIDDEN) + const hasHandlerSignatureViolations = HANDLER_SIGNATURE_VIOLATIONS.some(pattern => pattern.test(content)); + + // Extract handler signature violation details + const handlerSignatureDetails = []; + if (hasHandlerSignatureViolations) { + // Use regex to find the violation and its line number + const lines = content.split('\n'); + const fullContent = content; + + HANDLER_SIGNATURE_VIOLATIONS.forEach(pattern => { + let match; + const globalPattern = new RegExp(pattern.source, pattern.flags + 'g'); + while ((match = globalPattern.exec(fullContent)) !== null) { + // Find which line this match is on + const beforeMatch = fullContent.substring(0, match.index); + const lineNumber = beforeMatch.split('\n').length; + + handlerSignatureDetails.push({ + line: lineNumber, + content: match[0].replace(/\s+/g, ' ').trim(), + pattern: pattern.source + }); + } + }); + } + + return { + filePath: relativePath, + hasHandlerSignatureViolations, + handlerSignatureDetails, + needsConversion: hasHandlerSignatureViolations + }; + } catch (error) { + console.error(`Error reading file ${filePath}: ${error.message}`); + return null; + } +} + function main() { console.log('🔍 XcodeBuildMCP Test Pattern Violations Checker\n'); console.log(`🎯 Checking pattern type: ${patternFilter.toUpperCase()}\n`); console.log('TESTING GUIDELINES ENFORCED:'); console.log('✅ ONLY ALLOWED: createMockExecutor() and createMockFileSystemExecutor()'); console.log('❌ BANNED: vitest mocking patterns (vi.mock, vi.fn, .mockResolvedValue, etc.)'); - console.log('❌ BANNED: setTimeout-based mocking patterns\n'); + console.log('❌ BANNED: setTimeout-based mocking patterns'); + console.log('❌ BANNED: handler signature violations (handlers must have exact MCP SDK signatures)\n'); const testFiles = findTestFiles(join(projectRoot, 'src')); const results = testFiles.map(analyzeTestFile).filter(Boolean); + // Also check tool and resource files for handler signature violations + const toolFiles = findToolAndResourceFiles(join(projectRoot, 'src', 'mcp', 'tools')); + const resourceFiles = findToolAndResourceFiles(join(projectRoot, 'src', 'mcp', 'resources')); + const allToolAndResourceFiles = [...toolFiles, ...resourceFiles]; + const handlerResults = allToolAndResourceFiles.map(analyzeToolOrResourceFile).filter(Boolean); + // Filter results based on pattern type let filteredResults; + let filteredHandlerResults = []; + switch (patternFilter) { case 'vitest': filteredResults = results.filter(r => r.hasVitestMockingPatterns); @@ -221,10 +315,16 @@ function main() { filteredResults = results.filter(r => r.hasTimeoutPatterns); console.log(`Filtering to show only setTimeout violations (${filteredResults.length} files)`); break; + case 'handler': + filteredResults = []; + filteredHandlerResults = handlerResults.filter(r => r.hasHandlerSignatureViolations); + console.log(`Filtering to show only handler signature violations (${filteredHandlerResults.length} files)`); + break; case 'all': default: filteredResults = results.filter(r => r.needsConversion); - console.log(`Showing all pattern violations (${filteredResults.length} files)`); + filteredHandlerResults = handlerResults.filter(r => r.hasHandlerSignatureViolations); + console.log(`Showing all pattern violations (${filteredResults.length} test files + ${filteredHandlerResults.length} handler files)`); break; } @@ -276,6 +376,24 @@ function main() { }); } + // Handler signature violations reporting + if (filteredHandlerResults.length > 0) { + console.log(`🚨 HANDLER SIGNATURE VIOLATIONS (${filteredHandlerResults.length}):`); + console.log(`===========================================`); + filteredHandlerResults.forEach((result, index) => { + console.log(`${index + 1}. ${result.filePath}`); + + if (result.handlerSignatureDetails.length > 0) { + console.log(` 🛠️ HANDLER VIOLATIONS (${result.handlerSignatureDetails.length}):`); + result.handlerSignatureDetails.forEach(detail => { + console.log(` Line ${detail.line}: ${detail.content}`); + }); + } + + console.log(''); + }); + } + if (mixed.length > 0) { console.log(`⚠️ FILES WITH MIXED PATTERNS (${mixed.length}):`); console.log(`===================================`); @@ -296,9 +414,11 @@ function main() { } // Summary for next steps + const hasViolations = needsConversion.length > 0 || filteredHandlerResults.length > 0; + if (needsConversion.length > 0) { - console.log(`🚨 CRITICAL ACTION REQUIRED:`); - console.log(`===========================`); + console.log(`🚨 CRITICAL ACTION REQUIRED (TEST FILES):`); + console.log(`=======================================`); console.log(`1. IMMEDIATELY remove ALL vitest mocking from ${needsConversion.length} files`); console.log(`2. BANNED: vi.mock(), vi.fn(), .mockResolvedValue(), .toHaveBeenCalled(), etc.`); console.log(`3. ONLY ALLOWED: createMockExecutor() and createMockFileSystemExecutor()`); @@ -311,20 +431,35 @@ function main() { .sort((a, b) => (b.timeoutDetails.length + b.vitestMockingDetails.length) - (a.timeoutDetails.length + a.vitestMockingDetails.length)) .slice(0, 5); - console.log(`🚨 TOP 5 FILES WITH MOST VIOLATIONS:`); + console.log(`🚨 TOP 5 TEST FILES WITH MOST VIOLATIONS:`); sortedByPatterns.forEach((result, index) => { const totalPatterns = result.timeoutDetails.length + result.vitestMockingDetails.length; console.log(`${index + 1}. ${result.filePath} (${totalPatterns} violations: ${result.timeoutDetails.length} timeout + ${result.vitestMockingDetails.length} vitest)`); }); - } else if (mixed.length === 0) { - console.log(`🎉 ALL FILES COMPLY WITH VITEST MOCKING BAN!`); - console.log(`===========================================`); - console.log(`All test files use ONLY createMockExecutor() and createMockFileSystemExecutor().`); - console.log(`No vitest mocking patterns detected.`); + console.log(''); + } + + if (filteredHandlerResults.length > 0) { + console.log(`🚨 CRITICAL ACTION REQUIRED (HANDLER FILES):`); + console.log(`==========================================`); + console.log(`1. IMMEDIATELY fix ALL handler signature violations in ${filteredHandlerResults.length} files`); + console.log(`2. Tools: Handler must be: async handler(args: Record): Promise`); + console.log(`3. Resources: Handler must be: async handler(uri: URL): Promise<{ contents: Array<{ text: string }> }>`); + console.log(`4. Inject dependencies INSIDE handler body: const executor = getDefaultCommandExecutor()`); + console.log(`5. Run this script again after each fix to track progress`); + console.log(''); + } + + if (!hasViolations && mixed.length === 0) { + console.log(`🎉 ALL FILES COMPLY WITH PROJECT STANDARDS!`); + console.log(`==========================================`); + console.log(`✅ All test files use ONLY createMockExecutor() and createMockFileSystemExecutor()`); + console.log(`✅ All handler signatures comply with MCP SDK requirements`); + console.log(`✅ No violations detected!`); } // Exit with appropriate code - process.exit(needsConversion.length > 0 || mixed.length > 0 ? 1 : 0); + process.exit(hasViolations || mixed.length > 0 ? 1 : 0); } main(); \ No newline at end of file diff --git a/src/mcp/resources/__tests__/devices.test.ts b/src/mcp/resources/__tests__/devices.test.ts new file mode 100644 index 00000000..0e69a164 --- /dev/null +++ b/src/mcp/resources/__tests__/devices.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; + +import devicesResource, { devicesResourceLogic } from '../devices.js'; +import { createMockExecutor } from '../../../utils/command.js'; + +describe('devices resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(devicesResource.uri).toBe('xcodebuildmcp://devices'); + }); + + it('should export correct description', () => { + expect(devicesResource.description).toBe( + 'Connected physical Apple devices with their UUIDs, names, and connection status', + ); + }); + + it('should export correct mimeType', () => { + expect(devicesResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof devicesResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should handle successful device data retrieval with xctrace fallback', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `iPhone (12345-ABCDE-FGHIJ-67890) (13.0) +iPad (98765-KLMNO-PQRST-43210) (14.0) +My Device (11111-22222-33333-44444) (15.0)`, + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Device listing (xctrace output)'); + expect(result.contents[0].text).toContain('iPhone'); + expect(result.contents[0].text).toContain('iPad'); + }); + + it('should handle command execution failure', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Failed to list devices'); + expect(result.contents[0].text).toContain('Command failed'); + }); + + it('should handle spawn errors', async () => { + const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Error retrieving device data'); + expect(result.contents[0].text).toContain('spawn xcrun ENOENT'); + }); + + it('should handle empty device data with xctrace fallback', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: '', + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Device listing (xctrace output)'); + expect(result.contents[0].text).toContain('Xcode 15 or later'); + }); + + it('should handle device data with next steps guidance', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: `iPhone 15 Pro (12345-ABCDE-FGHIJ-67890) (17.0)`, + }); + + const result = await devicesResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Device listing (xctrace output)'); + expect(result.contents[0].text).toContain('iPhone 15 Pro'); + }); + }); +}); diff --git a/src/mcp/resources/__tests__/environment.test.ts b/src/mcp/resources/__tests__/environment.test.ts new file mode 100644 index 00000000..2ad8d0b0 --- /dev/null +++ b/src/mcp/resources/__tests__/environment.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; + +import environmentResource, { environmentResourceLogic } from '../environment.js'; +import { createMockExecutor } from '../../../utils/command.js'; + +describe('environment resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(environmentResource.uri).toBe('xcodebuildmcp://environment'); + }); + + it('should export correct description', () => { + expect(environmentResource.description).toBe( + 'Comprehensive development environment diagnostic information and configuration status', + ); + }); + + it('should export correct mimeType', () => { + expect(environmentResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(typeof environmentResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should handle successful environment data retrieval', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock command output', + }); + + const result = await environmentResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# XcodeBuildMCP Diagnostic Report'); + expect(result.contents[0].text).toContain('## System Information'); + expect(result.contents[0].text).toContain('## Node.js Information'); + expect(result.contents[0].text).toContain('## Dependencies'); + expect(result.contents[0].text).toContain('## Environment Variables'); + expect(result.contents[0].text).toContain('## Feature Status'); + }); + + it('should handle spawn errors by showing diagnostic info', async () => { + const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); + + const result = await environmentResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# XcodeBuildMCP Diagnostic Report'); + expect(result.contents[0].text).toContain('Error: spawn xcrun ENOENT'); + }); + + it('should include required diagnostic sections', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock output', + }); + + const result = await environmentResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('## Troubleshooting Tips'); + expect(result.contents[0].text).toContain('brew tap cameroncooke/axe'); + expect(result.contents[0].text).toContain('INCREMENTAL_BUILDS_ENABLED=1'); + expect(result.contents[0].text).toContain('discover_tools'); + }); + + it('should provide feature status information', async () => { + const mockExecutor = createMockExecutor({ + success: true, + output: 'Mock output', + }); + + const result = await environmentResourceLogic(mockExecutor); + + expect(result.contents[0].text).toContain('### UI Automation (axe)'); + expect(result.contents[0].text).toContain('### Incremental Builds'); + expect(result.contents[0].text).toContain('### Mise Integration'); + expect(result.contents[0].text).toContain('## Tool Availability Summary'); + }); + + it('should handle error conditions gracefully', async () => { + const mockExecutor = createMockExecutor({ + success: false, + output: '', + error: 'Command failed', + }); + + const result = await environmentResourceLogic(mockExecutor); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# XcodeBuildMCP Diagnostic Report'); + }); + }); +}); diff --git a/src/mcp/resources/__tests__/simulators.test.ts b/src/mcp/resources/__tests__/simulators.test.ts index 502c1d43..20b52723 100644 --- a/src/mcp/resources/__tests__/simulators.test.ts +++ b/src/mcp/resources/__tests__/simulators.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { z } from 'zod'; -import simulatorsResource from '../simulators.js'; +import simulatorsResource, { simulatorsResourceLogic } from '../simulators.js'; import { createMockExecutor } from '../../../utils/command.js'; describe('simulators resource', () => { @@ -43,10 +43,7 @@ describe('simulators resource', () => { }), }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Available iOS Simulators:'); @@ -61,10 +58,7 @@ describe('simulators resource', () => { error: 'Command failed', }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Failed to list simulators'); @@ -77,10 +71,7 @@ describe('simulators resource', () => { output: 'invalid json', }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toBe('invalid json'); @@ -89,10 +80,7 @@ describe('simulators resource', () => { it('should handle spawn errors', async () => { const mockExecutor = createMockExecutor(new Error('spawn xcrun ENOENT')); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Failed to list simulators'); @@ -105,10 +93,7 @@ describe('simulators resource', () => { output: JSON.stringify({ devices: {} }), }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents).toHaveLength(1); expect(result.contents[0].text).toContain('Available iOS Simulators:'); @@ -131,10 +116,7 @@ describe('simulators resource', () => { }), }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents[0].text).toContain('[Booted]'); }); @@ -162,10 +144,7 @@ describe('simulators resource', () => { }), }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents[0].text).toContain('iPhone 15 Pro'); expect(result.contents[0].text).not.toContain('iPhone 14'); @@ -188,10 +167,7 @@ describe('simulators resource', () => { }), }); - const result = await simulatorsResource.handler( - new URL('xcodebuildmcp://simulators'), - mockExecutor, - ); + const result = await simulatorsResourceLogic(mockExecutor); expect(result.contents[0].text).toContain('Next Steps:'); expect(result.contents[0].text).toContain('boot_sim'); diff --git a/src/mcp/resources/__tests__/swift-packages.test.ts b/src/mcp/resources/__tests__/swift-packages.test.ts new file mode 100644 index 00000000..f900bca4 --- /dev/null +++ b/src/mcp/resources/__tests__/swift-packages.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; + +import swiftPackagesResource, { swiftPackagesResourceLogic } from '../swift-packages.js'; + +describe('swift-packages resource', () => { + describe('Export Field Validation', () => { + it('should export correct uri', () => { + expect(swiftPackagesResource.uri).toBe('xcodebuildmcp://swift-packages'); + }); + + it('should export correct description', () => { + expect(swiftPackagesResource.description).toBe( + 'Currently running Swift Package processes with their PIDs and execution status', + ); + }); + + it('should export correct mimeType', () => { + expect(swiftPackagesResource.mimeType).toBe('text/plain'); + }); + + it('should export handler function', () => { + expect(swiftPackagesResource.handler).toBeDefined(); + expect(typeof swiftPackagesResource.handler).toBe('function'); + }); + }); + + describe('Handler Functionality', () => { + it('should return appropriate message when no processes are running', () => { + // Swift package list logic doesn't use CommandExecutor - it just manages process state + // No mock needed - it will return the "no processes" message + }); + + it('should handle resource logic function export', async () => { + const result = await swiftPackagesResourceLogic(); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('No Swift Package processes currently running'); + }); + }); +}); diff --git a/src/mcp/resources/devices.ts b/src/mcp/resources/devices.ts new file mode 100644 index 00000000..fcfb8a06 --- /dev/null +++ b/src/mcp/resources/devices.ts @@ -0,0 +1,56 @@ +/** + * Devices Resource Plugin + * + * Provides access to connected Apple devices through MCP resource system. + * This resource reuses the existing list_devices tool logic to maintain consistency. + */ + +import { log, getDefaultCommandExecutor, CommandExecutor } from '../../utils/index.js'; +import { list_devicesLogic } from '../tools/device-shared/list_devices.js'; + +// Testable resource logic separated from MCP handler +export async function devicesResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing devices resource request'); + const result = await list_devicesLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error(typeof errorText === 'string' ? errorText : 'Failed to retrieve device data'); + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No device data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in devices resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving device data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://devices', + name: 'devices', + description: 'Connected physical Apple devices with their UUIDs, names, and connection status', + mimeType: 'text/plain', + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return devicesResourceLogic(); + }, +}; diff --git a/src/mcp/resources/environment.ts b/src/mcp/resources/environment.ts new file mode 100644 index 00000000..ad6b5269 --- /dev/null +++ b/src/mcp/resources/environment.ts @@ -0,0 +1,66 @@ +/** + * Environment Resource Plugin + * + * Provides access to development environment diagnostic information through MCP resource system. + * This resource reuses the existing diagnostic tool logic to maintain consistency. + */ + +import { log, getDefaultCommandExecutor, CommandExecutor } from '../../utils/index.js'; +import { diagnosticLogic } from '../tools/diagnostics/diagnostic.js'; + +// Testable resource logic separated from MCP handler +export async function environmentResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing environment resource request'); + const result = await diagnosticLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + const errorMessage = + typeof errorText === 'string' ? errorText : 'Failed to retrieve environment data'; + log('error', `Error in environment resource handler: ${errorMessage}`); + return { + contents: [ + { + text: `Error retrieving environment data: ${errorMessage}`, + }, + ], + }; + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No environment data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in environment resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving environment data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://environment', + name: 'environment', + description: + 'Comprehensive development environment diagnostic information and configuration status', + mimeType: 'text/plain', + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return environmentResourceLogic(); + }, +}; diff --git a/src/mcp/resources/simulators.ts b/src/mcp/resources/simulators.ts index a34274f4..71facaa5 100644 --- a/src/mcp/resources/simulators.ts +++ b/src/mcp/resources/simulators.ts @@ -8,48 +8,51 @@ import { log, getDefaultCommandExecutor, CommandExecutor } from '../../utils/index.js'; import { list_simsLogic } from '../tools/simulator-shared/list_sims.js'; +// Testable resource logic separated from MCP handler +export async function simulatorsResourceLogic( + executor: CommandExecutor = getDefaultCommandExecutor(), +): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing simulators resource request'); + const result = await list_simsLogic({}, executor); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error( + typeof errorText === 'string' ? errorText : 'Failed to retrieve simulator data', + ); + } + + return { + contents: [ + { + text: + typeof result.content[0]?.text === 'string' + ? result.content[0].text + : 'No simulator data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in simulators resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving simulator data: ${errorMessage}`, + }, + ], + }; + } +} + export default { uri: 'xcodebuildmcp://simulators', name: 'simulators', description: 'Available iOS simulators with their UUIDs and states', mimeType: 'text/plain', - async handler( - uri: URL, - executor: CommandExecutor = getDefaultCommandExecutor(), - ): Promise<{ contents: Array<{ text: string }> }> { - try { - log('info', 'Processing simulators resource request'); - - const result = await list_simsLogic({}, executor); - - if (result.isError) { - const errorText = result.content[0]?.text; - throw new Error( - typeof errorText === 'string' ? errorText : 'Failed to retrieve simulator data', - ); - } - - return { - contents: [ - { - text: - typeof result.content[0]?.text === 'string' - ? result.content[0].text - : 'No simulator data available', - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log('error', `Error in simulators resource handler: ${errorMessage}`); - - return { - contents: [ - { - text: `Error retrieving simulator data: ${errorMessage}`, - }, - ], - }; - } + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return simulatorsResourceLogic(); }, }; diff --git a/src/mcp/resources/swift-packages.ts b/src/mcp/resources/swift-packages.ts new file mode 100644 index 00000000..08df79d3 --- /dev/null +++ b/src/mcp/resources/swift-packages.ts @@ -0,0 +1,58 @@ +/** + * Swift Packages Resource Plugin + * + * Provides access to running Swift Package processes through MCP resource system. + * This resource reuses the existing swift_package_list tool logic to maintain consistency. + */ + +import { log } from '../../utils/index.js'; +import { swift_package_listLogic } from '../tools/swift-package/swift_package_list.js'; + +// Testable resource logic separated from MCP handler +export async function swiftPackagesResourceLogic(): Promise<{ contents: Array<{ text: string }> }> { + try { + log('info', 'Processing swift-packages resource request'); + const result = await swift_package_listLogic({}); + + if (result.isError) { + const errorText = result.content[0]?.text; + throw new Error( + typeof errorText === 'string' ? errorText : 'Failed to retrieve Swift Package data', + ); + } + + // Combine all content text parts into a single response + const combinedText = result.content + .map((content) => (typeof content.text === 'string' ? content.text : '')) + .join('\n'); + + return { + contents: [ + { + text: combinedText || 'No Swift Package process data available', + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log('error', `Error in swift-packages resource handler: ${errorMessage}`); + + return { + contents: [ + { + text: `Error retrieving Swift Package data: ${errorMessage}`, + }, + ], + }; + } +} + +export default { + uri: 'xcodebuildmcp://swift-packages', + name: 'swift-packages', + description: 'Currently running Swift Package processes with their PIDs and execution status', + mimeType: 'text/plain', + async handler(_uri: URL): Promise<{ contents: Array<{ text: string }> }> { + return swiftPackagesResourceLogic(); + }, +}; diff --git a/src/mcp/tools/device-shared/list_devices.ts b/src/mcp/tools/device-shared/list_devices.ts index c9a56b07..4fb83354 100644 --- a/src/mcp/tools/device-shared/list_devices.ts +++ b/src/mcp/tools/device-shared/list_devices.ts @@ -276,7 +276,7 @@ export default { description: 'Lists connected physical Apple devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro) with their UUIDs, names, and connection status. Use this to discover physical devices for testing.', schema: {}, - async handler(args?: Record): Promise { + async handler(args: Record): Promise { return list_devicesLogic(args, getDefaultCommandExecutor()); }, }; diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts index 7a370446..4ac7ce06 100644 --- a/src/mcp/tools/swift-package/swift_package_list.ts +++ b/src/mcp/tools/swift-package/swift_package_list.ts @@ -66,7 +66,7 @@ export default { name: 'swift_package_list', description: 'Lists currently running Swift Package processes', schema: {}, - async handler(args?: unknown, dependencies?: ProcessListDependencies): Promise { - return swift_package_listLogic(args, dependencies); + async handler(args: Record): Promise { + return swift_package_listLogic(args); }, };