"""Build execution tools for xcsift MCP server.
These tools execute xcodebuild/swift commands and parse the output.
"""
import asyncio
import os
import shlex
from typing import Literal
from mcp.server.fastmcp import FastMCP, Context
from mcp.server.session import ServerSession
from xcsift_mcp.tools.parse import _run_xcsift
async def _run_command(
args: list[str],
cwd: str | None = None,
timeout: int = 600,
) -> tuple[str, int]:
"""Run a command and return combined stdout/stderr output.
Args:
args: Command arguments.
cwd: Working directory.
timeout: Timeout in seconds.
Returns:
Tuple of (combined output, return code).
"""
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return stdout.decode(), proc.returncode or 0
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise TimeoutError(f"Command timed out after {timeout} seconds")
def register_build_tools(mcp: FastMCP) -> None:
"""Register build execution tools with the MCP server."""
@mcp.tool()
async def xcodebuild(
action: Literal["build", "test", "clean", "analyze"] = "build",
scheme: str | None = None,
project: str | None = None,
workspace: str | None = None,
destination: str | None = None,
configuration: Literal["Debug", "Release"] | None = None,
sdk: str | None = None,
derived_data_path: str | None = None,
enable_code_coverage: bool = False,
extra_args: list[str] | None = None,
working_directory: str | None = None,
output_format: Literal["json", "toon"] = "json",
include_warnings: bool = True,
timeout: int = 600,
ctx: Context[ServerSession, None] | None = None,
) -> str:
"""Execute xcodebuild and parse the output.
This tool runs xcodebuild with the specified options and returns
the parsed output in a structured format.
Args:
action: Build action - build, test, clean, or analyze.
scheme: Scheme to build.
project: Path to .xcodeproj file.
workspace: Path to .xcworkspace file.
destination: Destination specifier (e.g., "platform=iOS Simulator,name=iPhone 15").
configuration: Build configuration - Debug or Release.
sdk: SDK to build with (e.g., "iphonesimulator", "macosx").
derived_data_path: Custom DerivedData path.
enable_code_coverage: Enable code coverage for test action.
extra_args: Additional arguments to pass to xcodebuild.
working_directory: Directory to run the command in.
output_format: Output format - json or toon.
include_warnings: Include detailed warnings in output.
timeout: Command timeout in seconds (default: 600).
Returns:
Parsed build output in the requested format.
"""
if ctx:
await ctx.info(f"Running xcodebuild {action}")
args = ["xcodebuild", action]
if scheme:
args.extend(["-scheme", scheme])
if project:
args.extend(["-project", project])
if workspace:
args.extend(["-workspace", workspace])
if destination:
args.extend(["-destination", destination])
if configuration:
args.extend(["-configuration", configuration])
if sdk:
args.extend(["-sdk", sdk])
if derived_data_path:
args.extend(["-derivedDataPath", derived_data_path])
if enable_code_coverage and action == "test":
args.append("-enableCodeCoverage")
args.append("YES")
if extra_args:
args.extend(extra_args)
if ctx:
await ctx.debug(f"Command: {shlex.join(args)}")
try:
output, return_code = await _run_command(
args,
cwd=working_directory,
timeout=timeout,
)
except TimeoutError as e:
return f'{{"status": "failed", "error": "{str(e)}"}}'
if ctx:
await ctx.debug(f"xcodebuild exited with code {return_code}")
# Get xcsift path from context
xcsift_path = None
if ctx and ctx.request_context.lifespan_context:
xcsift_path = ctx.request_context.lifespan_context.xcsift_path
# Parse with xcsift
result = await _run_xcsift(
output=output,
format=output_format,
warnings=include_warnings,
coverage=enable_code_coverage,
xcsift_path=xcsift_path,
)
return result
@mcp.tool()
async def swift_build(
configuration: Literal["debug", "release"] = "debug",
package_path: str | None = None,
target: str | None = None,
product: str | None = None,
extra_args: list[str] | None = None,
working_directory: str | None = None,
output_format: Literal["json", "toon"] = "json",
include_warnings: bool = True,
timeout: int = 300,
ctx: Context[ServerSession, None] | None = None,
) -> str:
"""Execute swift build and parse the output.
This tool runs swift build for Swift Package Manager projects
and returns the parsed output in a structured format.
Args:
configuration: Build configuration - debug or release.
package_path: Path to the Swift package.
target: Specific target to build.
product: Specific product to build.
extra_args: Additional arguments to pass to swift build.
working_directory: Directory to run the command in.
output_format: Output format - json or toon.
include_warnings: Include detailed warnings in output.
timeout: Command timeout in seconds (default: 300).
Returns:
Parsed build output in the requested format.
"""
if ctx:
await ctx.info(f"Running swift build ({configuration})")
args = ["swift", "build", "-c", configuration]
if package_path:
args.extend(["--package-path", package_path])
if target:
args.extend(["--target", target])
if product:
args.extend(["--product", product])
if extra_args:
args.extend(extra_args)
if ctx:
await ctx.debug(f"Command: {shlex.join(args)}")
try:
output, return_code = await _run_command(
args,
cwd=working_directory,
timeout=timeout,
)
except TimeoutError as e:
return f'{{"status": "failed", "error": "{str(e)}"}}'
if ctx:
await ctx.debug(f"swift build exited with code {return_code}")
# Get xcsift path from context
xcsift_path = None
if ctx and ctx.request_context.lifespan_context:
xcsift_path = ctx.request_context.lifespan_context.xcsift_path
# Parse with xcsift
result = await _run_xcsift(
output=output,
format=output_format,
warnings=include_warnings,
xcsift_path=xcsift_path,
)
return result
@mcp.tool()
async def swift_test(
package_path: str | None = None,
filter_test: str | None = None,
enable_code_coverage: bool = False,
parallel: bool = True,
extra_args: list[str] | None = None,
working_directory: str | None = None,
output_format: Literal["json", "toon"] = "json",
include_warnings: bool = True,
timeout: int = 600,
ctx: Context[ServerSession, None] | None = None,
) -> str:
"""Execute swift test and parse the output.
This tool runs swift test for Swift Package Manager projects
and returns the parsed output including test results and
optional coverage information.
Args:
package_path: Path to the Swift package.
filter_test: Filter to run specific tests (e.g., "MyTests.testFoo").
enable_code_coverage: Enable code coverage collection.
parallel: Run tests in parallel (default: True).
extra_args: Additional arguments to pass to swift test.
working_directory: Directory to run the command in.
output_format: Output format - json or toon.
include_warnings: Include detailed warnings in output.
timeout: Command timeout in seconds (default: 600).
Returns:
Parsed test output in the requested format, including:
- Test pass/fail status
- Failed test details
- Code coverage (if enabled)
"""
if ctx:
await ctx.info("Running swift test")
args = ["swift", "test"]
if package_path:
args.extend(["--package-path", package_path])
if filter_test:
args.extend(["--filter", filter_test])
if enable_code_coverage:
args.append("--enable-code-coverage")
if not parallel:
args.append("--no-parallel")
if extra_args:
args.extend(extra_args)
if ctx:
await ctx.debug(f"Command: {shlex.join(args)}")
try:
output, return_code = await _run_command(
args,
cwd=working_directory,
timeout=timeout,
)
except TimeoutError as e:
return f'{{"status": "failed", "error": "{str(e)}"}}'
if ctx:
await ctx.debug(f"swift test exited with code {return_code}")
# Get xcsift path from context
xcsift_path = None
if ctx and ctx.request_context.lifespan_context:
xcsift_path = ctx.request_context.lifespan_context.xcsift_path
# Parse with xcsift
result = await _run_xcsift(
output=output,
format=output_format,
warnings=include_warnings,
coverage=enable_code_coverage,
xcsift_path=xcsift_path,
)
return result
@mcp.tool()
async def run_shell_build_command(
command: str,
working_directory: str | None = None,
output_format: Literal["json", "toon"] = "json",
include_warnings: bool = True,
include_coverage: bool = False,
timeout: int = 600,
ctx: Context[ServerSession, None] | None = None,
) -> str:
"""Execute an arbitrary build command and parse the output with xcsift.
This tool allows running custom build commands and parsing the output.
Useful for complex build scenarios or custom scripts.
SECURITY NOTE: This executes shell commands. Only use with trusted input.
Args:
command: Shell command to execute (e.g., "xcodebuild -project Foo.xcodeproj build").
working_directory: Directory to run the command in.
output_format: Output format - json or toon.
include_warnings: Include detailed warnings in output.
include_coverage: Include coverage information if available.
timeout: Command timeout in seconds (default: 600).
Returns:
Parsed output in the requested format.
"""
if ctx:
await ctx.info(f"Running custom build command")
await ctx.debug(f"Command: {command}")
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=working_directory,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
output = stdout.decode()
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return f'{{"status": "failed", "error": "Command timed out after {timeout} seconds"}}'
if ctx:
await ctx.debug(f"Command exited with code {proc.returncode}")
# Get xcsift path from context
xcsift_path = None
if ctx and ctx.request_context.lifespan_context:
xcsift_path = ctx.request_context.lifespan_context.xcsift_path
# Parse with xcsift
result = await _run_xcsift(
output=output,
format=output_format,
warnings=include_warnings,
coverage=include_coverage,
xcsift_path=xcsift_path,
)
return result