Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ ESM TypeScript project (`type: module`). Key layers:
- No `.js` imports in `src/` (enforced by ESLint)
- No barrel imports from `utils/index` - import from specific submodules (e.g., `src/utils/execution/index.ts`, `src/utils/logging/index.ts`)


## Rendering and Streaming Contract
- Streaming fragments are transient output only. They MUST NOT be used as internal state, cached for final responses, or promoted into final MCP/JSON/CLI text output.
- Non-streaming runtimes/output modes, including MCP final responses, MUST render only from the final structured result and next-step metadata. If final output needs data, add it to the final result type instead of reading it from fragments.
- Only streaming-capable renderers may observe fragment callbacks, and only to print live progress. Their fragment handling must not affect final structured output or final rendered text.

## Test Conventions
- Vitest with colocated `__tests__/` directories using `*.test.ts`
- Smoke tests in `src/smoke-tests/__tests__/` (separate Vitest config, serial execution)
Expand Down Expand Up @@ -88,6 +94,7 @@ When reading issues:
- Use shared lock and atomic-write helpers for mutable shared files.
- Prefer one-record-per-file registries over shared aggregate files.
- Cleanup must verify ownership before deleting shared artifacts.
- User-facing artifact/log paths in final text or structured output must use `displayPath()` from `src/utils/build-preflight.ts`, so paths are cwd-relative when possible or `~/...` instead of absolute home paths. Keep stored files at their real absolute paths; only normalize response/display values.

## Style
- Keep answers short and concise
Expand All @@ -98,6 +105,7 @@ When reading issues:
## Docs
- Do not commit transient investigation notes, prompt exports, or scratch analysis docs after the work is complete.
- If an investigation leaves unresolved follow-up work, move it to a GitHub issue instead of preserving the transient doc in the branch.
- Structured output JSON schemas are auto-published to the website/public schema mirror when merged; do not manually update public schema copies unless explicitly asked.

### Changelog
Location: `CHANGELOG.md`
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

### Changed

- Updated Xcode IDE `call-tool` output to save raw remote responses as transient workspace-state JSON artifacts, summarize text output without embedding large relayed payloads, and support `--output json` / `--output jsonl` through the generic CLI output path.
- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.
- Removed internal streaming-fragment context flags so final tool state now comes from explicit structured outputs instead of transient progress fragments ([#360](https://github.com/getsentry/XcodeBuildMCP/issues/360)).

## [2.5.0-beta.1]

Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ When reading issues:
- Use shared lock and atomic-write helpers for mutable shared files.
- Prefer one-record-per-file registries over shared aggregate files.
- Cleanup must verify ownership before deleting shared artifacts.
- User-facing artifact/log paths in final text or structured output must use `displayPath()` from `src/utils/build-preflight.ts`, so paths are cwd-relative when possible or `~/...` instead of absolute home paths. Keep stored files at their real absolute paths; only normalize response/display values.

## Style
- Keep answers short and concise
Expand All @@ -39,6 +40,7 @@ When reading issues:
## Docs
- Do not commit transient investigation notes, prompt exports, or scratch analysis docs after the work is complete.
- If an investigation leaves unresolved follow-up work, move it to a GitHub issue instead of preserving the transient doc in the branch.
- Structured output JSON schemas are auto-published to the website/public schema mirror when merged; do not manually update public schema copies unless explicitly asked.

### Changelog
Location: `CHANGELOG.md`
Expand All @@ -62,6 +64,12 @@ Use these sections under `## [Unreleased]`:
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/cameroncook/XcodeBuildMCP/issues/123))`
- **External contributions**: `Added feature X ([#456](https://github.com/cameroncook/XcodeBuildMCP/pull/456) by [@username](https://github.com/username))`


## Rendering and Streaming Contract
- Streaming fragments are transient output only. They MUST NOT be used as internal state, cached for final responses, or promoted into final MCP/JSON/CLI text output.
- Non-streaming runtimes/output modes, including MCP final responses, MUST render only from the final structured result and next-step metadata. If final output needs data, add it to the final result type instead of reading it from fragments.
- Only streaming-capable renderers may observe fragment callbacks, and only to print live progress. Their fragment handling must not affect final structured output or final rendered text.

## Test Execution Rules
- When running long test suites (snapshot tests, smoke tests), ALWAYS write full output to a log file and read it afterwards. NEVER pipe through `tail` or `grep` directly — that loses output you may need to debug failures.
- Pattern: `DEVICE_ID=... npm run test:snapshot 2>&1 | tee /tmp/snapshot-results.txt` then read `/tmp/snapshot-results.txt` with the native read tool.
Expand Down
2 changes: 1 addition & 1 deletion manifests/tools/xcode_ide_call_tool.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ names:
description: Call a remote Xcode IDE MCP tool.
outputSchema:
schema: xcodebuildmcp.output.xcode-bridge-call-result
version: "1"
version: '2'
routing:
stateful: true
annotations:
Expand Down
2 changes: 1 addition & 1 deletion manifests/tools/xcode_ide_list_tools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ names:
description: "Lists Xcode-IDE-only MCP capabilities (Use for: SwiftUI previews image capture, code snippet execution, issue Navigator/build logs, and window/tab context)."
outputSchema:
schema: xcodebuildmcp.output.xcode-bridge-tool-list
version: "1"
version: "2"
routing:
stateful: true
annotations:
Expand Down
41 changes: 41 additions & 0 deletions schemas/structured-output/xcodebuildmcp.output.error/1.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.error/1.schema.json",
"type": "object",
"additionalProperties": false,
"allOf": [
{
"$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency"
}
],
"properties": {
"schema": {
"const": "xcodebuildmcp.output.error"
},
"schemaVersion": {
"const": "1"
},
"didError": {
"const": true
},
"error": {
"type": "string",
"minLength": 1
},
"data": {
"type": "object",
"additionalProperties": false,
"properties": {
"category": {
"enum": ["runtime", "validation", "schema"]
},
"code": {
"type": "string",
"minLength": 1
}
},
"required": ["category", "code"]
}
},
"required": ["schema", "schemaVersion", "didError", "error", "data"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.xcode-bridge-call-result/2.schema.json",
"type": "object",
"additionalProperties": false,
"allOf": [
{
"$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency"
}
],
"$defs": {
"relayedContentItem": {
"type": "object",
"additionalProperties": true,
"properties": {
"type": { "type": "string" }
},
"required": ["type"]
},
"artifacts": {
"type": "object",
"additionalProperties": false,
"properties": {
"rawResponseJsonPath": { "type": "string" }
},
"required": ["rawResponseJsonPath"]
}
},
"properties": {
"schema": { "const": "xcodebuildmcp.output.xcode-bridge-call-result" },
"schemaVersion": { "const": "2" },
"didError": { "type": "boolean" },
"error": { "type": ["string", "null"] },
"data": {
"type": "object",
"additionalProperties": false,
"properties": {
"remoteTool": { "type": "string" },
"succeeded": { "type": "boolean" },
"content": {
"type": "array",
"items": { "$ref": "#/$defs/relayedContentItem" }
},
"artifacts": { "$ref": "#/$defs/artifacts" }
},
"required": ["remoteTool", "succeeded", "content"]
}
},
"required": ["schema", "schemaVersion", "didError", "error", "data"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.xcode-bridge-tool-list/2.schema.json",
"type": "object",
"additionalProperties": false,
"allOf": [
{
"$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency"
}
],
"$defs": {
"artifacts": {
"type": "object",
"additionalProperties": false,
"properties": {
"rawResponseJsonPath": { "type": "string" }
},
"required": ["rawResponseJsonPath"]
}
},
"properties": {
"schema": { "const": "xcodebuildmcp.output.xcode-bridge-tool-list" },
"schemaVersion": { "const": "2" },
"didError": { "type": "boolean" },
"error": { "type": ["string", "null"] },
"data": {
"type": "object",
"additionalProperties": false,
"properties": {
"toolCount": { "type": "integer", "minimum": 0 },
"artifacts": { "$ref": "#/$defs/artifacts" }
},
"required": ["toolCount"]
}
},
"required": ["schema", "schemaVersion", "didError", "error", "data"]
}
31 changes: 25 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ function findTopLevelCommand(argv: string[]): string | undefined {
return undefined;
}

function findGlobalSocketOverride(argv: string[]): string | undefined {
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--socket') {
const value = argv[index + 1];
return value && !value.startsWith('-') ? value : undefined;
}

if (token.startsWith('--socket=')) {
const value = token.slice('--socket='.length);
return value || undefined;
}
}

return undefined;
}

async function buildLightweightYargsApp(): Promise<ReturnType<typeof import('yargs').default>> {
const yargs = (await import('yargs')).default;
const { hideBin } = await import('yargs/helpers');
Expand Down Expand Up @@ -120,10 +137,12 @@ async function main(): Promise<void> {

const { workspaceRoot, workspaceKey } = result;

const defaultSocketPath = getSocketPath({
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});
const socketPath =
findGlobalSocketOverride(process.argv.slice(2)) ??
getSocketPath({
cwd: result.runtime.cwd,
projectConfigPath: result.configPath,
});

const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
excludeWorkflows: ['session-management', 'workflow-discovery'],
Expand All @@ -133,7 +152,7 @@ async function main(): Promise<void> {

// CLI uses a manifest-resolved catalog plus daemon-backed xcode-ide dynamic tools.
const catalog = await buildCliToolCatalog({
socketPath: defaultSocketPath,
socketPath,
workspaceRoot,
cliExposedWorkflowIds,
discoveryMode,
Expand All @@ -142,7 +161,7 @@ async function main(): Promise<void> {
const yargsApp = buildYargsApp({
catalog,
runtimeConfig: result.runtime.config,
defaultSocketPath,
defaultSocketPath: socketPath,
workspaceRoot,
workspaceKey,
workflowNames: cliExposedWorkflowIds,
Expand Down
73 changes: 59 additions & 14 deletions src/cli/__tests__/register-tool-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ function mockInvokeDirectThroughHandler() {
attach: (image) => {
opts.renderSession?.attach(image);
},
liveProgressEnabled: Boolean(opts.onProgress),
streamingFragmentsEnabled: Boolean(opts.onProgress),
};

await tool.handler(args, handlerContext);
Expand Down Expand Up @@ -576,7 +574,10 @@ describe('registerToolCommands', () => {
schemaVersion: '1',
didError: true,
error: 'Tool did not produce structured output for --output json',
data: null,
data: {
category: 'runtime',
code: 'STRUCTURED_OUTPUT_MISSING',
},
},
null,
2,
Expand All @@ -585,9 +586,38 @@ describe('registerToolCommands', () => {
expect(process.exitCode).toBe(1);
});

it('rejects json and jsonl output for xcode-ide tools', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const tool = createTool({ workflow: 'xcode-ide' });
it('supports json and jsonl output for xcode-ide tools through the generic output path', async () => {
mockInvokeDirectThroughHandler();
const stdoutChunks: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdoutChunks.push(String(chunk));
return true;
});

const tool = createTool({
workflow: 'xcode-ide',
cliSchema: {},
mcpSchema: {},
handler: vi.fn(async (_args, ctx) => {
if (ctx) {
ctx.structuredOutput = {
schema: 'xcodebuildmcp.output.xcode-bridge-call-result',
schemaVersion: '2',
result: {
kind: 'xcode-bridge-call-result',
didError: false,
error: null,
remoteTool: 'DocumentationSearch',
succeeded: true,
content: [],
artifacts: {
rawResponseJsonPath: '/tmp/xcode-ide-response.json',
},
},
};
}
}) as ToolDefinition['handler'],
});
const app = yargs()
.scriptName('xcodebuildmcp')
.exitProcess(false)
Expand All @@ -605,19 +635,34 @@ describe('registerToolCommands', () => {
await expect(
app.parseAsync(['xcode-ide', 'run-tool', '--output', 'json']),
).resolves.toBeDefined();
expect(consoleError).toHaveBeenLastCalledWith(
'Error: --output json is not supported for xcode-ide tools yet',
expect(stdoutChunks.join('')).toBe(
`${JSON.stringify(
{
schema: 'xcodebuildmcp.output.xcode-bridge-call-result',
schemaVersion: '2',
didError: false,
error: null,
data: {
remoteTool: 'DocumentationSearch',
succeeded: true,
content: [],
artifacts: {
rawResponseJsonPath: '/tmp/xcode-ide-response.json',
},
},
},
null,
2,
)}\n`,
);
expect(process.exitCode).toBe(1);
expect(process.exitCode).toBeUndefined();

process.exitCode = undefined;
stdoutChunks.length = 0;

await expect(
app.parseAsync(['xcode-ide', 'run-tool', '--output', 'jsonl']),
).resolves.toBeDefined();
expect(consoleError).toHaveBeenLastCalledWith(
'Error: --output jsonl is not supported for xcode-ide tools yet',
);
expect(process.exitCode).toBe(1);
expect(stdoutChunks.join('')).toBe('');
expect(process.exitCode).toBeUndefined();
});
});
Loading
Loading