From 87d403e65241d2a766f8bd902edeedb2bd405c3e Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 4 Apr 2026 00:41:21 -0400 Subject: [PATCH 1/3] fix(xcode-ide): add bridge proxy diagnostics and default schema handling Add debug logging to xcode-tools-bridge proxy tool calls so the exact arguments and responses can be inspected when diagnosing bridge-related issues like XcodeGlob returning paths rejected by XcodeRead/RenderPreview. Handle the JSON Schema `default` keyword in jsonSchemaToZod so remote tool schema defaults are preserved through the proxy, preventing potential argument loss. Add integration test verifying argument passthrough integrity through the full proxy pipeline (paths with spaces, unicode, extra properties). Closes #252 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 ++++ .../fixtures/fake-xcode-tools-server.mjs | 17 +++++++ .../__tests__/jsonschema-to-zod.test.ts | 36 +++++++++++++++ .../__tests__/registry.integration.test.ts | 46 +++++++++++++++++++ .../xcode-tools-bridge/jsonschema-to-zod.ts | 23 +++++++--- .../xcode-tools-bridge/registry.ts | 12 ++++- .../xcode-tools-bridge/tool-service.ts | 7 +++ 7 files changed, 143 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9040109d..9aa29c04 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 ([#252](https://github.com/getsentry/XcodeBuildMCP/issues/252)). + +### Fixed + +- Fixed `jsonSchemaToZod` converter to apply `default` values from remote tool JSON schemas, preventing potential argument loss when proxying bridge tools ([#252](https://github.com/getsentry/XcodeBuildMCP/issues/252)). + ## [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..a5ff3174 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,40 @@ 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); + }); }); 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..106bf3ea 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]; @@ -74,21 +80,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 +104,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; } } From 219945511be554d04676976428d584b3693faa79 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 4 Apr 2026 14:56:19 -0500 Subject: [PATCH 2/3] fix: update CHANGELOG attribution for external contributor --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa29c04..caffeee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ ### Added -- Added debug logging for xcode-tools-bridge proxy tool calls to aid diagnosis of bridge-related issues ([#252](https://github.com/getsentry/XcodeBuildMCP/issues/252)). +- 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 ([#252](https://github.com/getsentry/XcodeBuildMCP/issues/252)). +- 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] From ddd6a2f70b598300bf3812607bfe24186d1a8fe0 Mon Sep 17 00:00:00 2001 From: Derek Pearson Date: Sat, 4 Apr 2026 15:05:14 -0500 Subject: [PATCH 3/3] fix: add applyDefault to enum branches in jsonSchemaToZod --- .../__tests__/jsonschema-to-zod.test.ts | 23 ++++++++++++++ .../xcode-tools-bridge/jsonschema-to-zod.ts | 30 +++++++++++-------- 2 files changed, 41 insertions(+), 12 deletions(-) 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 a5ff3174..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 @@ -105,4 +105,27 @@ describe('jsonSchemaToZod', () => { 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/jsonschema-to-zod.ts b/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts index 106bf3ea..7f87f03e 100644 --- a/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts +++ b/src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts @@ -47,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, ); }