Skip to content

Commit 87d403e

Browse files
dpearson2699Copilot
andcommitted
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>
1 parent b6f49dd commit 87d403e

File tree

7 files changed

+143
-8
lines changed

7 files changed

+143
-8
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Added
6+
7+
- 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)).
8+
9+
### Fixed
10+
11+
- 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)).
12+
313
## [2.3.2]
414

515
### Fixed

src/integrations/xcode-tools-bridge/__tests__/fixtures/fake-xcode-tools-server.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@ function registerInitialTools() {
6060
return { content: [{ type: 'text', text: 'changed' }], isError: false };
6161
},
6262
);
63+
64+
server.registerTool(
65+
'Echo',
66+
{
67+
description: 'Echoes back received arguments as JSON',
68+
inputSchema: z
69+
.object({
70+
filePath: z.string(),
71+
tabIdentifier: z.string().optional(),
72+
})
73+
.passthrough(),
74+
},
75+
async (args) => ({
76+
content: [{ type: 'text', text: JSON.stringify(args) }],
77+
isError: false,
78+
}),
79+
);
6380
}
6481

6582
function applyCatalogChange() {

src/integrations/xcode-tools-bridge/__tests__/jsonschema-to-zod.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,40 @@ describe('jsonSchemaToZod', () => {
6969
const parsed = zod.parse({ a: 'x', extra: 1 }) as Record<string, unknown>;
7070
expect(parsed.extra).toBe(1);
7171
});
72+
73+
it('applies default values from JSON Schema', () => {
74+
const schema = {
75+
type: 'object',
76+
properties: {
77+
name: { type: 'string' },
78+
format: { type: 'string', default: 'project-relative' },
79+
},
80+
required: ['name'],
81+
};
82+
83+
const zod = jsonSchemaToZod(schema);
84+
85+
// Default is applied when property is absent
86+
const withDefault = zod.parse({ name: 'test' }) as Record<string, unknown>;
87+
expect(withDefault.format).toBe('project-relative');
88+
89+
// Explicit value overrides the default
90+
const withExplicit = zod.parse({ name: 'test', format: 'absolute' }) as Record<string, unknown>;
91+
expect(withExplicit.format).toBe('absolute');
92+
});
93+
94+
it('applies default values on primitive types', () => {
95+
const stringSchema = { type: 'string', default: 'hello' };
96+
expect(jsonSchemaToZod(stringSchema).parse(undefined)).toBe('hello');
97+
expect(jsonSchemaToZod(stringSchema).parse('world')).toBe('world');
98+
99+
const numberSchema = { type: 'number', default: 42 };
100+
expect(jsonSchemaToZod(numberSchema).parse(undefined)).toBe(42);
101+
102+
const boolSchema = { type: 'boolean', default: true };
103+
expect(jsonSchemaToZod(boolSchema).parse(undefined)).toBe(true);
104+
105+
const intSchema = { type: 'integer', default: 7 };
106+
expect(jsonSchemaToZod(intSchema).parse(undefined)).toBe(7);
107+
});
72108
});

src/integrations/xcode-tools-bridge/__tests__/registry.integration.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,50 @@ describe('XcodeToolsProxyRegistry (stdio integration)', () => {
132132
})) as CallToolResult;
133133
expect(res.content[0]).toMatchObject({ type: 'text', text: 'Alpha2:hi:e' });
134134
});
135+
136+
it('passes arguments through the proxy without modification', async () => {
137+
const tools = await localClient.listTools();
138+
expect(tools.tools.map((t) => t.name)).toContain('xcode_tools_Echo');
139+
140+
const testCases = [
141+
{
142+
name: 'file paths with slashes and spaces',
143+
args: {
144+
filePath: '/Users/test/My Project/Sources/Views/MainTabView.swift',
145+
tabIdentifier: 'windowtab1',
146+
},
147+
},
148+
{
149+
name: 'unicode characters in paths',
150+
args: {
151+
filePath: '/Users/test/Projekt/Ansichten/\u00dcbersicht.swift',
152+
tabIdentifier: 'tab-2',
153+
},
154+
},
155+
{
156+
name: 'extra properties not in schema (passthrough)',
157+
args: {
158+
filePath: 'Sources/App.swift',
159+
tabIdentifier: 'wt1',
160+
extraFlag: true,
161+
nested: { deep: 'value' },
162+
},
163+
},
164+
];
165+
166+
for (const tc of testCases) {
167+
const res = (await localClient.callTool({
168+
name: 'xcode_tools_Echo',
169+
arguments: tc.args,
170+
})) as CallToolResult;
171+
expect(res.isError).not.toBe(true);
172+
const echoed = JSON.parse((res.content[0] as { text: string }).text) as Record<
173+
string,
174+
unknown
175+
>;
176+
for (const [key, value] of Object.entries(tc.args)) {
177+
expect(echoed[key]).toEqual(value);
178+
}
179+
}
180+
});
135181
});

src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type JsonSchemaEnumValue = string | number | boolean | null;
55
type JsonSchema = {
66
type?: string | string[];
77
description?: string;
8+
default?: unknown;
89
enum?: unknown[];
910
items?: JsonSchema;
1011
properties?: Record<string, JsonSchema>;
@@ -16,6 +17,11 @@ function applyDescription<T extends z.ZodTypeAny>(schema: T, description?: strin
1617
return schema.describe(description) as T;
1718
}
1819

20+
function applyDefault(schema: z.ZodTypeAny, defaultValue: unknown): z.ZodTypeAny {
21+
if (defaultValue === undefined) return schema;
22+
return schema.default(defaultValue);
23+
}
24+
1925
function isObjectSchema(schema: JsonSchema): boolean {
2026
const types =
2127
schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
@@ -74,21 +80,21 @@ export function jsonSchemaToZod(schema: JsonSchema | unknown): z.ZodTypeAny {
7480

7581
switch (primaryType) {
7682
case 'string':
77-
return applyDescription(z.string(), s.description);
83+
return applyDefault(applyDescription(z.string(), s.description), s.default);
7884
case 'integer':
79-
return applyDescription(z.number().int(), s.description);
85+
return applyDefault(applyDescription(z.number().int(), s.description), s.default);
8086
case 'number':
81-
return applyDescription(z.number(), s.description);
87+
return applyDefault(applyDescription(z.number(), s.description), s.default);
8288
case 'boolean':
83-
return applyDescription(z.boolean(), s.description);
89+
return applyDefault(applyDescription(z.boolean(), s.description), s.default);
8490
case 'array': {
8591
const itemSchema = jsonSchemaToZod(s.items ?? {});
86-
return applyDescription(z.array(itemSchema), s.description);
92+
return applyDefault(applyDescription(z.array(itemSchema), s.description), s.default);
8793
}
8894
case 'object':
8995
default: {
9096
if (!isObjectSchema(s)) {
91-
return applyDescription(z.any(), s.description);
97+
return applyDefault(applyDescription(z.any(), s.description), s.default);
9298
}
9399
const required = new Set(s.required ?? []);
94100
const props = s.properties ?? {};
@@ -98,7 +104,10 @@ export function jsonSchemaToZod(schema: JsonSchema | unknown): z.ZodTypeAny {
98104
shape[key] = required.has(key) ? propSchema : propSchema.optional();
99105
}
100106
// Use passthrough to avoid breaking when Apple adds new fields.
101-
return applyDescription(z.object(shape).passthrough(), s.description);
107+
return applyDefault(
108+
applyDescription(z.object(shape).passthrough(), s.description),
109+
s.default,
110+
);
102111
}
103112
}
104113
}

src/integrations/xcode-tools-bridge/registry.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import type { CallToolResult, Tool, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
33
import * as z from 'zod';
4+
import { log } from '../../utils/logger.ts';
45
import { jsonSchemaToZod } from './jsonschema-to-zod.ts';
56

67
export type CallRemoteTool = (
@@ -114,7 +115,16 @@ export class XcodeToolsProxyRegistry {
114115
},
115116
async (args: unknown) => {
116117
const params = (args ?? {}) as Record<string, unknown>;
117-
return callRemoteTool(tool.name, params);
118+
log(
119+
'debug',
120+
`[xcode-tools-bridge] Proxy call: ${tool.name} args=${JSON.stringify(params)}`,
121+
);
122+
const result = await callRemoteTool(tool.name, params);
123+
log(
124+
'debug',
125+
`[xcode-tools-bridge] Proxy result: ${tool.name} contentItems=${result.content.length} isError=${result.isError ?? false}`,
126+
);
127+
return result;
118128
},
119129
);
120130
}

src/integrations/xcode-tools-bridge/tool-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
2+
import { log } from '../../utils/logger.ts';
23
import {
34
XcodeToolsBridgeClient,
45
type XcodeToolsBridgeClientOptions,
@@ -95,12 +96,18 @@ export class XcodeIdeToolService {
9596
opts: { timeoutMs?: number } = {},
9697
): Promise<CallToolResult> {
9798
await this.ensureConnected();
99+
log('debug', `[xcode-tools-bridge] invokeTool: ${name} args=${JSON.stringify(args)}`);
98100
try {
99101
const response = await this.client.callTool(name, args, opts);
100102
this.lastError = null;
103+
log(
104+
'debug',
105+
`[xcode-tools-bridge] invokeTool result: ${name} contentItems=${response.content.length} isError=${response.isError ?? false}`,
106+
);
101107
return response;
102108
} catch (error) {
103109
this.lastError = toErrorMessage(error);
110+
log('debug', `[xcode-tools-bridge] invokeTool error: ${name} error=${this.lastError}`);
104111
throw error;
105112
}
106113
}

0 commit comments

Comments
 (0)