diff --git a/CHANGELOG.md b/CHANGELOG.md index 9040109d..caffeee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Added + +- Added debug logging for xcode-tools-bridge proxy tool calls to aid diagnosis of bridge-related issues ([#310](https://github.com/getsentry/XcodeBuildMCP/pull/310) by [@dpearson2699](https://github.com/dpearson2699)). + +### Fixed + +- Fixed `jsonSchemaToZod` converter to apply `default` values from remote tool JSON schemas, preventing potential argument loss when proxying bridge tools ([#310](https://github.com/getsentry/XcodeBuildMCP/pull/310) by [@dpearson2699](https://github.com/dpearson2699)). + ## [2.3.2] ### Fixed diff --git a/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs b/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs index 6718f409..d6ae168e 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs +++ b/src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs @@ -60,6 +60,23 @@ function registerInitialTools() { return { content: [{ type: 'text', text: 'changed' }], isError: false }; }, ); + + server.registerTool( + 'Echo', + { + description: 'Echoes back received arguments as JSON', + inputSchema: z + .object({ + filePath: z.string(), + tabIdentifier: z.string().optional(), + }) + .passthrough(), + }, + async (args) => ({ + content: [{ type: 'text', text: JSON.stringify(args) }], + isError: false, + }), + ); } function applyCatalogChange() { diff --git a/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts b/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts index 5e1c5cf9..760e2f39 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts +++ b/src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts @@ -69,4 +69,63 @@ describe('jsonSchemaToZod', () => { const parsed = zod.parse({ a: 'x', extra: 1 }) as Record; expect(parsed.extra).toBe(1); }); + + it('applies default values from JSON Schema', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + format: { type: 'string', default: 'project-relative' }, + }, + required: ['name'], + }; + + const zod = jsonSchemaToZod(schema); + + // Default is applied when property is absent + const withDefault = zod.parse({ name: 'test' }) as Record; + expect(withDefault.format).toBe('project-relative'); + + // Explicit value overrides the default + const withExplicit = zod.parse({ name: 'test', format: 'absolute' }) as Record; + expect(withExplicit.format).toBe('absolute'); + }); + + it('applies default values on primitive types', () => { + const stringSchema = { type: 'string', default: 'hello' }; + expect(jsonSchemaToZod(stringSchema).parse(undefined)).toBe('hello'); + expect(jsonSchemaToZod(stringSchema).parse('world')).toBe('world'); + + const numberSchema = { type: 'number', default: 42 }; + expect(jsonSchemaToZod(numberSchema).parse(undefined)).toBe(42); + + const boolSchema = { type: 'boolean', default: true }; + expect(jsonSchemaToZod(boolSchema).parse(undefined)).toBe(true); + + const intSchema = { type: 'integer', default: 7 }; + expect(jsonSchemaToZod(intSchema).parse(undefined)).toBe(7); + }); + + it('applies default values on enum types', () => { + // String enum with default + const stringEnumSchema = { + enum: ['project-relative', 'absolute'], + default: 'project-relative', + }; + expect(jsonSchemaToZod(stringEnumSchema).parse(undefined)).toBe('project-relative'); + expect(jsonSchemaToZod(stringEnumSchema).parse('absolute')).toBe('absolute'); + + // Single-value string enum (literal) with default + const singleEnumSchema = { enum: ['only'], default: 'only' }; + expect(jsonSchemaToZod(singleEnumSchema).parse(undefined)).toBe('only'); + + // Mixed-type enum with default + const mixedEnumSchema = { enum: ['a', 1, true], default: 1 }; + expect(jsonSchemaToZod(mixedEnumSchema).parse(undefined)).toBe(1); + expect(jsonSchemaToZod(mixedEnumSchema).parse('a')).toBe('a'); + + // Single mixed literal with default + const singleMixedSchema = { enum: [42], default: 42 }; + expect(jsonSchemaToZod(singleMixedSchema).parse(undefined)).toBe(42); + }); }); diff --git a/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts b/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts index c02ec611..2f584b06 100644 --- a/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts +++ b/src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts @@ -132,4 +132,50 @@ describe('XcodeToolsProxyRegistry (stdio integration)', () => { })) as CallToolResult; expect(res.content[0]).toMatchObject({ type: 'text', text: 'Alpha2:hi:e' }); }); + + it('passes arguments through the proxy without modification', async () => { + const tools = await localClient.listTools(); + expect(tools.tools.map((t) => t.name)).toContain('xcode_tools_Echo'); + + const testCases = [ + { + name: 'file paths with slashes and spaces', + args: { + filePath: '/Users/test/My Project/Sources/Views/MainTabView.swift', + tabIdentifier: 'windowtab1', + }, + }, + { + name: 'unicode characters in paths', + args: { + filePath: '/Users/test/Projekt/Ansichten/\u00dcbersicht.swift', + tabIdentifier: 'tab-2', + }, + }, + { + name: 'extra properties not in schema (passthrough)', + args: { + filePath: 'Sources/App.swift', + tabIdentifier: 'wt1', + extraFlag: true, + nested: { deep: 'value' }, + }, + }, + ]; + + for (const tc of testCases) { + const res = (await localClient.callTool({ + name: 'xcode_tools_Echo', + arguments: tc.args, + })) as CallToolResult; + expect(res.isError).not.toBe(true); + const echoed = JSON.parse((res.content[0] as { text: string }).text) as Record< + string, + unknown + >; + for (const [key, value] of Object.entries(tc.args)) { + expect(echoed[key]).toEqual(value); + } + } + }); }); diff --git a/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts b/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts index b1bb9744..7f87f03e 100644 --- a/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts +++ b/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts @@ -5,6 +5,7 @@ type JsonSchemaEnumValue = string | number | boolean | null; type JsonSchema = { type?: string | string[]; description?: string; + default?: unknown; enum?: unknown[]; items?: JsonSchema; properties?: Record; @@ -16,6 +17,11 @@ function applyDescription(schema: T, description?: strin return schema.describe(description) as T; } +function applyDefault(schema: z.ZodTypeAny, defaultValue: unknown): z.ZodTypeAny { + if (defaultValue === undefined) return schema; + return schema.default(defaultValue); +} + function isObjectSchema(schema: JsonSchema): boolean { const types = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type]; @@ -41,31 +47,37 @@ export function jsonSchemaToZod(schema: JsonSchema | unknown): z.ZodTypeAny { if (Array.isArray(s.enum)) { const enumValues = s.enum.filter(isEnumValue); if (enumValues.length === 0) { - return applyDescription(z.any(), s.description); + return applyDefault(applyDescription(z.any(), s.description), s.default); } const allStrings = enumValues.every((v) => typeof v === 'string'); if (allStrings) { const stringValues = enumValues as string[]; if (stringValues.length === 1) { - return applyDescription(z.literal(stringValues[0]), s.description); + return applyDefault(applyDescription(z.literal(stringValues[0]), s.description), s.default); } - return applyDescription(z.enum(stringValues as [string, ...string[]]), s.description); + return applyDefault( + applyDescription(z.enum(stringValues as [string, ...string[]]), s.description), + s.default, + ); } // z.enum only supports string unions; use z.literal union for mixed enums. const literals = enumValues.map((v) => z.literal(v)) as z.ZodLiteral[]; if (literals.length === 1) { - return applyDescription(literals[0], s.description); + return applyDefault(applyDescription(literals[0], s.description), s.default); } - return applyDescription( - z.union( - literals as [ - z.ZodLiteral, - z.ZodLiteral, - ...z.ZodLiteral[], - ], + return applyDefault( + applyDescription( + z.union( + literals as [ + z.ZodLiteral, + z.ZodLiteral, + ...z.ZodLiteral[], + ], + ), + s.description, ), - s.description, + s.default, ); } @@ -74,21 +86,21 @@ export function jsonSchemaToZod(schema: JsonSchema | unknown): z.ZodTypeAny { switch (primaryType) { case 'string': - return applyDescription(z.string(), s.description); + return applyDefault(applyDescription(z.string(), s.description), s.default); case 'integer': - return applyDescription(z.number().int(), s.description); + return applyDefault(applyDescription(z.number().int(), s.description), s.default); case 'number': - return applyDescription(z.number(), s.description); + return applyDefault(applyDescription(z.number(), s.description), s.default); case 'boolean': - return applyDescription(z.boolean(), s.description); + return applyDefault(applyDescription(z.boolean(), s.description), s.default); case 'array': { const itemSchema = jsonSchemaToZod(s.items ?? {}); - return applyDescription(z.array(itemSchema), s.description); + return applyDefault(applyDescription(z.array(itemSchema), s.description), s.default); } case 'object': default: { if (!isObjectSchema(s)) { - return applyDescription(z.any(), s.description); + return applyDefault(applyDescription(z.any(), s.description), s.default); } const required = new Set(s.required ?? []); const props = s.properties ?? {}; @@ -98,7 +110,10 @@ export function jsonSchemaToZod(schema: JsonSchema | unknown): z.ZodTypeAny { shape[key] = required.has(key) ? propSchema : propSchema.optional(); } // Use passthrough to avoid breaking when Apple adds new fields. - return applyDescription(z.object(shape).passthrough(), s.description); + return applyDefault( + applyDescription(z.object(shape).passthrough(), s.description), + s.default, + ); } } } diff --git a/src/integrations/xcode-tools-bridge/registry.ts b/src/integrations/xcode-tools-bridge/registry.ts index 4dd98ac4..24cd0d09 100644 --- a/src/integrations/xcode-tools-bridge/registry.ts +++ b/src/integrations/xcode-tools-bridge/registry.ts @@ -1,6 +1,7 @@ import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult, Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import * as z from 'zod'; +import { log } from '../../utils/logger.ts'; import { jsonSchemaToZod } from './jsonschema-to-zod.ts'; export type CallRemoteTool = ( @@ -114,7 +115,16 @@ export class XcodeToolsProxyRegistry { }, async (args: unknown) => { const params = (args ?? {}) as Record; - return callRemoteTool(tool.name, params); + log( + 'debug', + `[xcode-tools-bridge] Proxy call: ${tool.name} args=${JSON.stringify(params)}`, + ); + const result = await callRemoteTool(tool.name, params); + log( + 'debug', + `[xcode-tools-bridge] Proxy result: ${tool.name} contentItems=${result.content.length} isError=${result.isError ?? false}`, + ); + return result; }, ); } diff --git a/src/integrations/xcode-tools-bridge/tool-service.ts b/src/integrations/xcode-tools-bridge/tool-service.ts index 9656a44d..c22dd7ba 100644 --- a/src/integrations/xcode-tools-bridge/tool-service.ts +++ b/src/integrations/xcode-tools-bridge/tool-service.ts @@ -1,4 +1,5 @@ import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js'; +import { log } from '../../utils/logger.ts'; import { XcodeToolsBridgeClient, type XcodeToolsBridgeClientOptions, @@ -95,12 +96,18 @@ export class XcodeIdeToolService { opts: { timeoutMs?: number } = {}, ): Promise { await this.ensureConnected(); + log('debug', `[xcode-tools-bridge] invokeTool: ${name} args=${JSON.stringify(args)}`); try { const response = await this.client.callTool(name, args, opts); this.lastError = null; + log( + 'debug', + `[xcode-tools-bridge] invokeTool result: ${name} contentItems=${response.content.length} isError=${response.isError ?? false}`, + ); return response; } catch (error) { this.lastError = toErrorMessage(error); + log('debug', `[xcode-tools-bridge] invokeTool error: ${name} error=${this.lastError}`); throw error; } }