Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,63 @@ describe('jsonSchemaToZod', () => {
const parsed = zod.parse({ a: 'x', extra: 1 }) as Record<string, unknown>;
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<string, unknown>;
expect(withDefault.format).toBe('project-relative');

// Explicit value overrides the default
const withExplicit = zod.parse({ name: 'test', format: 'absolute' }) as Record<string, unknown>;
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
});
53 changes: 34 additions & 19 deletions src/integrations/xcode-tools-bridge/jsonschema-to-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonSchema>;
Expand All @@ -16,6 +17,11 @@ function applyDescription<T extends z.ZodTypeAny>(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];
Expand All @@ -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<JsonSchemaEnumValue>[];
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<JsonSchemaEnumValue>,
z.ZodLiteral<JsonSchemaEnumValue>,
...z.ZodLiteral<JsonSchemaEnumValue>[],
],
return applyDefault(
applyDescription(
z.union(
literals as [
z.ZodLiteral<JsonSchemaEnumValue>,
z.ZodLiteral<JsonSchemaEnumValue>,
...z.ZodLiteral<JsonSchemaEnumValue>[],
],
),
s.description,
),
s.description,
s.default,
);
}

Expand All @@ -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 ?? {};
Expand All @@ -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,
);
}
}
}
12 changes: 11 additions & 1 deletion src/integrations/xcode-tools-bridge/registry.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -114,7 +115,16 @@ export class XcodeToolsProxyRegistry {
},
async (args: unknown) => {
const params = (args ?? {}) as Record<string, unknown>;
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;
},
);
}
Expand Down
7 changes: 7 additions & 0 deletions src/integrations/xcode-tools-bridge/tool-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
import { log } from '../../utils/logger.ts';
import {
XcodeToolsBridgeClient,
type XcodeToolsBridgeClientOptions,
Expand Down Expand Up @@ -95,12 +96,18 @@ export class XcodeIdeToolService {
opts: { timeoutMs?: number } = {},
): Promise<CallToolResult> {
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;
}
}
Expand Down