Skip to content

Commit 7eb6999

Browse files
committed
feat(xcode-ide): Persist bridge response artifacts
Save raw Xcode IDE bridge responses as transient workspace artifacts and route call-tool output through the structured rendering pipeline. This keeps relayed payloads out of final text output while preserving machine-readable artifacts for callers that need the full remote response. Add schema coverage, snapshot fixtures, and regression tests for the updated Xcode IDE CLI, MCP, JSON, and JSONL output contracts.
1 parent 839c95f commit 7eb6999

72 files changed

Lines changed: 2670 additions & 959 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ ESM TypeScript project (`type: module`). Key layers:
5454
- No `.js` imports in `src/` (enforced by ESLint)
5555
- No barrel imports from `utils/index` - import from specific submodules (e.g., `src/utils/execution/index.ts`, `src/utils/logging/index.ts`)
5656

57+
58+
## Rendering and Streaming Contract
59+
- 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.
60+
- 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.
61+
- 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.
62+
5763
## Test Conventions
5864
- Vitest with colocated `__tests__/` directories using `*.test.ts`
5965
- Smoke tests in `src/smoke-tests/__tests__/` (separate Vitest config, serial execution)
@@ -88,6 +94,7 @@ When reading issues:
8894
- Use shared lock and atomic-write helpers for mutable shared files.
8995
- Prefer one-record-per-file registries over shared aggregate files.
9096
- Cleanup must verify ownership before deleting shared artifacts.
97+
- 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.
9198

9299
## Style
93100
- Keep answers short and concise
@@ -98,6 +105,7 @@ When reading issues:
98105
## Docs
99106
- Do not commit transient investigation notes, prompt exports, or scratch analysis docs after the work is complete.
100107
- If an investigation leaves unresolved follow-up work, move it to a GitHub issue instead of preserving the transient doc in the branch.
108+
- 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.
101109

102110
### Changelog
103111
Location: `CHANGELOG.md`

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
### Changed
1616

17+
- 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.
1718
- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.
19+
- 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)).
1820

1921
## [2.5.0-beta.1]
2022

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ When reading issues:
2929
- Use shared lock and atomic-write helpers for mutable shared files.
3030
- Prefer one-record-per-file registries over shared aggregate files.
3131
- Cleanup must verify ownership before deleting shared artifacts.
32+
- 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.
3233

3334
## Style
3435
- Keep answers short and concise
@@ -39,6 +40,7 @@ When reading issues:
3940
## Docs
4041
- Do not commit transient investigation notes, prompt exports, or scratch analysis docs after the work is complete.
4142
- If an investigation leaves unresolved follow-up work, move it to a GitHub issue instead of preserving the transient doc in the branch.
43+
- 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.
4244

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

67+
68+
## Rendering and Streaming Contract
69+
- 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.
70+
- 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.
71+
- 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.
72+
6573
## Test Execution Rules
6674
- 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.
6775
- 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.

manifests/tools/xcode_ide_call_tool.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ names:
66
description: Call a remote Xcode IDE MCP tool.
77
outputSchema:
88
schema: xcodebuildmcp.output.xcode-bridge-call-result
9-
version: "1"
9+
version: '2'
1010
routing:
1111
stateful: true
1212
annotations:

manifests/tools/xcode_ide_list_tools.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ names:
66
description: "Lists Xcode-IDE-only MCP capabilities (Use for: SwiftUI previews image capture, code snippet execution, issue Navigator/build logs, and window/tab context)."
77
outputSchema:
88
schema: xcodebuildmcp.output.xcode-bridge-tool-list
9-
version: "1"
9+
version: "2"
1010
routing:
1111
stateful: true
1212
annotations:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.error/1.schema.json",
4+
"type": "object",
5+
"additionalProperties": false,
6+
"allOf": [
7+
{
8+
"$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency"
9+
}
10+
],
11+
"properties": {
12+
"schema": {
13+
"const": "xcodebuildmcp.output.error"
14+
},
15+
"schemaVersion": {
16+
"const": "1"
17+
},
18+
"didError": {
19+
"const": true
20+
},
21+
"error": {
22+
"type": "string",
23+
"minLength": 1
24+
},
25+
"data": {
26+
"type": "object",
27+
"additionalProperties": false,
28+
"properties": {
29+
"category": {
30+
"enum": ["runtime", "validation", "schema"]
31+
},
32+
"code": {
33+
"type": "string",
34+
"minLength": 1
35+
}
36+
},
37+
"required": ["category", "code"]
38+
}
39+
},
40+
"required": ["schema", "schemaVersion", "didError", "error", "data"]
41+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.xcode-bridge-call-result/2.schema.json",
4+
"type": "object",
5+
"additionalProperties": false,
6+
"allOf": [
7+
{
8+
"$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency"
9+
}
10+
],
11+
"$defs": {
12+
"relayedContentItem": {
13+
"type": "object",
14+
"additionalProperties": true,
15+
"properties": {
16+
"type": { "type": "string" }
17+
},
18+
"required": ["type"]
19+
},
20+
"artifacts": {
21+
"type": "object",
22+
"additionalProperties": false,
23+
"properties": {
24+
"rawResponseJsonPath": { "type": "string" }
25+
},
26+
"required": ["rawResponseJsonPath"]
27+
}
28+
},
29+
"properties": {
30+
"schema": { "const": "xcodebuildmcp.output.xcode-bridge-call-result" },
31+
"schemaVersion": { "const": "2" },
32+
"didError": { "type": "boolean" },
33+
"error": { "type": ["string", "null"] },
34+
"data": {
35+
"type": "object",
36+
"additionalProperties": false,
37+
"properties": {
38+
"remoteTool": { "type": "string" },
39+
"succeeded": { "type": "boolean" },
40+
"content": {
41+
"type": "array",
42+
"items": { "$ref": "#/$defs/relayedContentItem" }
43+
},
44+
"artifacts": { "$ref": "#/$defs/artifacts" }
45+
},
46+
"required": ["remoteTool", "succeeded", "content"]
47+
}
48+
},
49+
"required": ["schema", "schemaVersion", "didError", "error", "data"]
50+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://xcodebuildmcp.com/schemas/structured-output/xcodebuildmcp.output.xcode-bridge-tool-list/2.schema.json",
4+
"type": "object",
5+
"additionalProperties": false,
6+
"allOf": [
7+
{
8+
"$ref": "https://xcodebuildmcp.com/schemas/structured-output/_defs/common.schema.json#/$defs/errorConsistency"
9+
}
10+
],
11+
"$defs": {
12+
"artifacts": {
13+
"type": "object",
14+
"additionalProperties": false,
15+
"properties": {
16+
"rawResponseJsonPath": { "type": "string" }
17+
},
18+
"required": ["rawResponseJsonPath"]
19+
}
20+
},
21+
"properties": {
22+
"schema": { "const": "xcodebuildmcp.output.xcode-bridge-tool-list" },
23+
"schemaVersion": { "const": "2" },
24+
"didError": { "type": "boolean" },
25+
"error": { "type": ["string", "null"] },
26+
"data": {
27+
"type": "object",
28+
"additionalProperties": false,
29+
"properties": {
30+
"toolCount": { "type": "integer", "minimum": 0 },
31+
"artifacts": { "$ref": "#/$defs/artifacts" }
32+
},
33+
"required": ["toolCount"]
34+
}
35+
},
36+
"required": ["schema", "schemaVersion", "didError", "error", "data"]
37+
}

src/cli.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ function findTopLevelCommand(argv: string[]): string | undefined {
3232
return undefined;
3333
}
3434

35+
function findGlobalSocketOverride(argv: string[]): string | undefined {
36+
for (let index = 0; index < argv.length; index += 1) {
37+
const token = argv[index];
38+
if (token === '--socket') {
39+
const value = argv[index + 1];
40+
return value && !value.startsWith('-') ? value : undefined;
41+
}
42+
43+
if (token.startsWith('--socket=')) {
44+
const value = token.slice('--socket='.length);
45+
return value || undefined;
46+
}
47+
}
48+
49+
return undefined;
50+
}
51+
3552
async function buildLightweightYargsApp(): Promise<ReturnType<typeof import('yargs').default>> {
3653
const yargs = (await import('yargs')).default;
3754
const { hideBin } = await import('yargs/helpers');
@@ -120,10 +137,12 @@ async function main(): Promise<void> {
120137

121138
const { workspaceRoot, workspaceKey } = result;
122139

123-
const defaultSocketPath = getSocketPath({
124-
cwd: result.runtime.cwd,
125-
projectConfigPath: result.configPath,
126-
});
140+
const socketPath =
141+
findGlobalSocketOverride(process.argv.slice(2)) ??
142+
getSocketPath({
143+
cwd: result.runtime.cwd,
144+
projectConfigPath: result.configPath,
145+
});
127146

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

134153
// CLI uses a manifest-resolved catalog plus daemon-backed xcode-ide dynamic tools.
135154
const catalog = await buildCliToolCatalog({
136-
socketPath: defaultSocketPath,
155+
socketPath,
137156
workspaceRoot,
138157
cliExposedWorkflowIds,
139158
discoveryMode,
@@ -142,7 +161,7 @@ async function main(): Promise<void> {
142161
const yargsApp = buildYargsApp({
143162
catalog,
144163
runtimeConfig: result.runtime.config,
145-
defaultSocketPath,
164+
defaultSocketPath: socketPath,
146165
workspaceRoot,
147166
workspaceKey,
148167
workflowNames: cliExposedWorkflowIds,

src/cli/__tests__/register-tool-commands.test.ts

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,6 @@ function mockInvokeDirectThroughHandler() {
9797
attach: (image) => {
9898
opts.renderSession?.attach(image);
9999
},
100-
liveProgressEnabled: Boolean(opts.onProgress),
101-
streamingFragmentsEnabled: Boolean(opts.onProgress),
102100
};
103101

104102
await tool.handler(args, handlerContext);
@@ -576,7 +574,10 @@ describe('registerToolCommands', () => {
576574
schemaVersion: '1',
577575
didError: true,
578576
error: 'Tool did not produce structured output for --output json',
579-
data: null,
577+
data: {
578+
category: 'runtime',
579+
code: 'STRUCTURED_OUTPUT_MISSING',
580+
},
580581
},
581582
null,
582583
2,
@@ -585,9 +586,38 @@ describe('registerToolCommands', () => {
585586
expect(process.exitCode).toBe(1);
586587
});
587588

588-
it('rejects json and jsonl output for xcode-ide tools', async () => {
589-
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
590-
const tool = createTool({ workflow: 'xcode-ide' });
589+
it('supports json and jsonl output for xcode-ide tools through the generic output path', async () => {
590+
mockInvokeDirectThroughHandler();
591+
const stdoutChunks: string[] = [];
592+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
593+
stdoutChunks.push(String(chunk));
594+
return true;
595+
});
596+
597+
const tool = createTool({
598+
workflow: 'xcode-ide',
599+
cliSchema: {},
600+
mcpSchema: {},
601+
handler: vi.fn(async (_args, ctx) => {
602+
if (ctx) {
603+
ctx.structuredOutput = {
604+
schema: 'xcodebuildmcp.output.xcode-bridge-call-result',
605+
schemaVersion: '2',
606+
result: {
607+
kind: 'xcode-bridge-call-result',
608+
didError: false,
609+
error: null,
610+
remoteTool: 'DocumentationSearch',
611+
succeeded: true,
612+
content: [],
613+
artifacts: {
614+
rawResponseJsonPath: '/tmp/xcode-ide-response.json',
615+
},
616+
},
617+
};
618+
}
619+
}) as ToolDefinition['handler'],
620+
});
591621
const app = yargs()
592622
.scriptName('xcodebuildmcp')
593623
.exitProcess(false)
@@ -605,19 +635,34 @@ describe('registerToolCommands', () => {
605635
await expect(
606636
app.parseAsync(['xcode-ide', 'run-tool', '--output', 'json']),
607637
).resolves.toBeDefined();
608-
expect(consoleError).toHaveBeenLastCalledWith(
609-
'Error: --output json is not supported for xcode-ide tools yet',
638+
expect(stdoutChunks.join('')).toBe(
639+
`${JSON.stringify(
640+
{
641+
schema: 'xcodebuildmcp.output.xcode-bridge-call-result',
642+
schemaVersion: '2',
643+
didError: false,
644+
error: null,
645+
data: {
646+
remoteTool: 'DocumentationSearch',
647+
succeeded: true,
648+
content: [],
649+
artifacts: {
650+
rawResponseJsonPath: '/tmp/xcode-ide-response.json',
651+
},
652+
},
653+
},
654+
null,
655+
2,
656+
)}\n`,
610657
);
611-
expect(process.exitCode).toBe(1);
658+
expect(process.exitCode).toBeUndefined();
612659

613-
process.exitCode = undefined;
660+
stdoutChunks.length = 0;
614661

615662
await expect(
616663
app.parseAsync(['xcode-ide', 'run-tool', '--output', 'jsonl']),
617664
).resolves.toBeDefined();
618-
expect(consoleError).toHaveBeenLastCalledWith(
619-
'Error: --output jsonl is not supported for xcode-ide tools yet',
620-
);
621-
expect(process.exitCode).toBe(1);
665+
expect(stdoutChunks.join('')).toBe('');
666+
expect(process.exitCode).toBeUndefined();
622667
});
623668
});

0 commit comments

Comments
 (0)