Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Added configurable file artifact text rendering with CLI output defaulting to labeled `Files:` lists, MCP text preserving compact trees, and `filePathRenderStyle` / `XCODEBUILDMCP_FILE_PATH_RENDER_STYLE` / `--file-path-render-style` overrides.
- Added workspace-scoped default xcresult bundles for simulator, device, and macOS test tools so test artifacts are available in structured and text output even when callers do not pass `-resultBundlePath`.
- Added opt-in MCP server idle shutdown via `XCODEBUILDMCP_MCP_IDLE_TIMEOUT_MS`, allowing unused MCP server processes to gracefully exit after a configured idle period ([#394](https://github.com/getsentry/XcodeBuildMCP/issues/394)).

Expand Down
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ disableSessionDefaults: false
incrementalBuildsEnabled: false
debug: false
sentryDisabled: false
filePathRenderStyle: 'list' # text output file artifacts: list or tree
sessionDefaults:
projectPath: './MyApp.xcodeproj' # xor workspacePath
workspacePath: './MyApp.xcworkspace' # xor projectPath
Expand Down
12 changes: 11 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts';
import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';

function findTopLevelCommand(argv: string[]): string | undefined {
const flagsWithValue = new Set(['--socket', '--log-level', '--style']);
const flagsWithValue = new Set([
'--socket',
'--log-level',
'--style',
'--file-path-render-style',
]);
let skipNext = false;

for (const token of argv) {
Expand Down Expand Up @@ -75,6 +80,11 @@ async function buildLightweightYargsApp(): Promise<ReturnType<typeof import('yar
choices: ['normal', 'minimal'] as const,
default: 'normal',
})
.option('file-path-render-style', {
type: 'string',
describe: 'Render file artifacts as a compact tree or labeled list in text output',
choices: ['tree', 'list'] as const,
})
.middleware((argv) => {
const level = argv['log-level'] as LogLevel | undefined;
if (level) {
Expand Down
56 changes: 56 additions & 0 deletions src/cli/__tests__/register-tool-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,62 @@ describe('registerToolCommands', () => {
stdoutWrite.mockRestore();
});

it('applies --file-path-render-style to text output without forwarding it to tool args', async () => {
vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockImplementation(
async (tool, args, opts) => {
const handlerContext: ToolHandlerContext = opts.handlerContext ?? {
emit: (fragment) => {
opts.renderSession?.emit(fragment);
},
attach: (image) => {
opts.renderSession?.attach(image);
},
};

await tool.handler(args, handlerContext);

if (handlerContext.structuredOutput) {
opts.renderSession?.setStructuredOutput?.(handlerContext.structuredOutput);
opts.onStructuredOutput?.(handlerContext.structuredOutput);
}
},
);
const stdoutChunks: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdoutChunks.push(String(chunk));
return true;
});

const tool = createTool({
handler: vi.fn(async (_args, ctx) => {
if (ctx) {
ctx.structuredOutput = {
schema: 'xcodebuildmcp.output.app-path',
schemaVersion: '1',
result: {
kind: 'app-path',
didError: false,
error: null,
artifacts: { appPath: '/tmp/MyApp.app' },
},
};
}
}) as ToolDefinition['handler'],
});
const app = createApp(createCatalog([tool]));

await expect(
app.parseAsync(['simulator', 'run-tool', '--file-path-render-style', 'tree']),
).resolves.toBeDefined();

expect(tool.handler).toHaveBeenCalledWith(
{ workspacePath: 'Profile.xcworkspace' },
expect.any(Object),
);
expect(stdoutChunks.join('')).toContain('└── /tmp/MyApp.app — App Path');
expect(stdoutChunks.join('')).not.toContain('└ App Path: /tmp/MyApp.app');
});

it('writes a structured envelope for tools that provide structured output', async () => {
mockInvokeDirectThroughHandler();
const stdoutChunks: string[] = [];
Expand Down
35 changes: 25 additions & 10 deletions src/cli/register-tool-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { groupToolsByWorkflow } from '../runtime/tool-catalog.ts';
import { getWorkflowMetadataFromManifest } from '../core/manifest/load-manifest.ts';
import type { ResolvedRuntimeConfig } from '../utils/config-store.ts';
import type { ToolHandlerContext } from '../rendering/types.ts';
import type { FilePathRenderStyle } from '../utils/runtime-config-types.ts';
import type { AnyFragment } from '../types/domain-fragments.ts';
import { transcriptEmitterStorage } from '../utils/transcript-context.ts';
import {
Expand Down Expand Up @@ -138,7 +139,10 @@ export function registerToolCommands(
workflowDescription,
(yargs) => {
// Hide root-level options from workflow help
yargs.option('log-level', { hidden: true }).option('style', { hidden: true });
yargs
.option('log-level', { hidden: true })
.option('style', { hidden: true })
.option('file-path-render-style', { hidden: true });

// Register each tool as a subcommand under this workflow
for (const tool of tools) {
Expand Down Expand Up @@ -201,7 +205,10 @@ function registerToolSubcommand(
tool.description ?? `Run the ${tool.mcpName} tool`,
(subYargs) => {
// Hide root-level options from tool help
subYargs.option('log-level', { hidden: true }).option('style', { hidden: true });
subYargs
.option('log-level', { hidden: true })
.option('style', { hidden: true })
.option('file-path-render-style', { hidden: true });

// Parse option-like values as arguments (e.g. --extra-args "-only-testing:...")
subYargs.parserConfiguration({
Expand Down Expand Up @@ -270,6 +277,7 @@ function registerToolSubcommand(
const outputFormat = (argv.output as OutputFormat) ?? 'text';
const socketPath = argv.socket as string;
const logLevel = argv['log-level'] as string | undefined;
const filePathRenderStyle = argv.filePathRenderStyle as FilePathRenderStyle | undefined;

if (
profileOverride &&
Expand Down Expand Up @@ -302,6 +310,8 @@ function registerToolSubcommand(
'socket',
'log-level',
'logLevel',
'file-path-render-style',
'filePathRenderStyle',
'_',
'$0',
]);
Expand Down Expand Up @@ -340,14 +350,19 @@ function registerToolSubcommand(
const restoreCliOutputFormat = setEnvScoped('XCODEBUILDMCP_CLI_OUTPUT_FORMAT', outputFormat);

try {
const session =
outputFormat === 'text'
? createRenderSession('cli-text', {
interactive: process.stdout.isTTY === true,
})
: outputFormat === 'raw'
? createRenderSession('raw')
: createRenderSession('text');
let renderStrategy: 'cli-text' | 'raw' | 'text';
if (outputFormat === 'text') {
renderStrategy = 'cli-text';
} else if (outputFormat === 'raw') {
renderStrategy = 'raw';
} else {
renderStrategy = 'text';
}
const session = createRenderSession(renderStrategy, {
interactive: outputFormat === 'text' && process.stdout.isTTY === true,
runtime: 'cli',
filePathRenderStyle,
});
const writeJsonlFragment =
outputFormat === 'jsonl'
? (fragment: AnyFragment) => {
Expand Down
5 changes: 5 additions & 0 deletions src/cli/yargs-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType<typeof yargs> {
choices: ['normal', 'minimal'] as const,
default: 'normal',
})
.option('file-path-render-style', {
type: 'string',
describe: 'Render file artifacts as a compact tree or labeled list in text output',
choices: ['tree', 'list'] as const,
})
.middleware((argv) => {
const level = argv['log-level'] as LogLevel | undefined;
if (level) {
Expand Down
16 changes: 10 additions & 6 deletions src/mcp/resources/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import { getDefaultCommandExecutor } from '../../utils/execution/index.ts';
import { list_devicesLogic } from '../tools/device/list_devices.ts';
import { handlerContextStorage } from '../../utils/typed-tool-factory.ts';
import type { ToolHandlerContext } from '../../rendering/types.ts';

import { renderCliTextTranscript } from '../../utils/renderers/cli-text-renderer.ts';
import { renderTranscript } from '../../rendering/render.ts';

export async function devicesResourceLogic(
executor: CommandExecutor = getDefaultCommandExecutor(),
Expand All @@ -25,10 +24,15 @@ export async function devicesResourceLogic(
try {
log('info', 'Processing devices resource request');
await handlerContextStorage.run(ctx, () => list_devicesLogic({}, executor));
const text = renderCliTextTranscript({
structuredOutput: ctx.structuredOutput,
nextSteps: ctx.nextSteps,
});
const text = renderTranscript(
{
structuredOutput: ctx.structuredOutput,
nextSteps: ctx.nextSteps,
nextStepsRuntime: 'mcp',
},
'text',
{ runtime: 'mcp' },
);
const isError = ctx.structuredOutput?.result.didError === true;
if (isError) {
throw new Error(text || 'Failed to retrieve device data');
Expand Down
16 changes: 10 additions & 6 deletions src/mcp/resources/simulators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import type { CommandExecutor } from '../../utils/execution/index.ts';
import { list_simsLogic } from '../tools/simulator/list_sims.ts';
import { handlerContextStorage } from '../../utils/typed-tool-factory.ts';
import type { ToolHandlerContext } from '../../rendering/types.ts';

import { renderCliTextTranscript } from '../../utils/renderers/cli-text-renderer.ts';
import { renderTranscript } from '../../rendering/render.ts';

export async function simulatorsResourceLogic(
executor: CommandExecutor = getDefaultCommandExecutor(),
Expand All @@ -25,10 +24,15 @@ export async function simulatorsResourceLogic(
try {
log('info', 'Processing simulators resource request');
await handlerContextStorage.run(ctx, () => list_simsLogic({ enabled: true }, executor));
const text = renderCliTextTranscript({
structuredOutput: ctx.structuredOutput,
nextSteps: ctx.nextSteps,
});
const text = renderTranscript(
{
structuredOutput: ctx.structuredOutput,
nextSteps: ctx.nextSteps,
nextStepsRuntime: 'mcp',
},
'text',
{ runtime: 'mcp' },
);
const structuredError = ctx.structuredOutput?.result.didError
? (ctx.structuredOutput.result.error ?? null)
: null;
Expand Down
36 changes: 35 additions & 1 deletion src/rendering/__tests__/text-render-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ describe('text render parity', () => {

expect(output).toBe(captureCliText(fixture));
expect(output.match(/Discovered 2 test\(s\):/g)).toHaveLength(1);
expect(output.match(/MCPTestTests\n ✗ testTwo\(\):/g)).toHaveLength(1);
expect(output.match(/MCPTestTests\n {2}✗ testTwo\(\):/g)).toHaveLength(1);
expect(output.match(/1 test failed, 1 passed, 0 skipped/g)).toHaveLength(1);
expect(output).toContain('Result Bundle: /tmp/App Tests.xcresult');
expect(output).toContain('Build Logs: /tmp/Test.log');
Expand Down Expand Up @@ -340,6 +340,40 @@ describe('text render parity', () => {
expect(rendered).not.toContain('❌ Build failed. (⏱️ 9.9s)');
});

it('omits header frontmatter for MCP runtime text transcripts', () => {
const output = renderTranscript(
{
items: [],
structuredOutput: {
schema: 'xcodebuildmcp.output.build-run-result',
schemaVersion: '1.0.0',
result: {
kind: 'build-run-result',
request: {
scheme: 'MyApp',
projectPath: '/tmp/MyApp.xcodeproj',
configuration: 'Debug',
platform: 'iOS Simulator',
},
didError: false,
error: null,
summary: { status: 'SUCCEEDED', durationMs: 5000 },
artifacts: { appPath: '/tmp/build/MyApp.app', buildLogPath: '/tmp/build.log' },
diagnostics: { warnings: [], errors: [] },
},
},
},
'text',
{ runtime: 'mcp' },
);

expect(output).toContain('🚀 Build & Run');
expect(output).not.toContain('Scheme: MyApp');
expect(output).not.toContain('Project: /tmp/MyApp.xcodeproj');
expect(output).not.toContain('Configuration: Debug');
expect(output).toContain('✅ Build succeeded. (⏱️ 5.0s)');
});

it('renders next steps in MCP tool-call syntax for MCP runtime text transcripts', () => {
const fixture: TranscriptFixture = {
progressEvents: [],
Expand Down
Loading
Loading