I'm sure we've all been there. Just idly chatting with friends. Throwing around ideas and suddenly you find yourself expressing a work frustration and then going "I'm going to end up building that aren't I". Sooo, yeah, that happened to me recently as I've been dealing with wanting to have a way to look at all the previews in an App but no way to present them as a gallery.
As much as I would love to say, this is an easy to solve problem, it is not. There's a lot of context required and many moving parts. This post is aimed to cut through that and help me, and others, understand what is there.
note: This post assumes Xcode 27 beta 3 and things may change with later Xcode versions. Please check everything if you're using it as a guide to build an app.
What is MCP
The first moving piece is MCP, model context protocol which is defined by the MCP specification. It is built using JSON-RPC as the communication method.
The idea behind MCP is for there to be a standardized way to communicate with a server and have it perform tasks and return results.
What does Xcode provide
As of Xcode 27 beta 3 (because a girl has to live life on the bleading edge) there are 47 tools exposed as part of the mcpbridge. Because of the nature of MCP these are easy to query for and identify. The list from asking Claude to do just that is:
Files & project structure (operate on Xcode's project organization, not the raw filesystem)
XcodeRead,XcodeWrite,XcodeUpdate— read / create / edit filesXcodeLS,XcodeGlob,XcodeGrep— list, find, searchXcodeMakeDir,XcodeMV,XcodeRM— mkdir, move/rename, removeXcodeGetCurrentFile— active editor file + selection
Build & run
BuildProject,RunProject,StopProjectGetBuildLog,GetConsoleOutputRunCodeSnippet— build/run an ad-hoc snippet in a file's contextRenderPreview— render a SwiftUI Preview and snapshot the UI
Testing
GetTestList,RunAllTests,RunSomeTests
Diagnostics & debugging
XcodeListNavigatorIssues,XcodeRefreshCodeIssuesInFileInvokeDebuggerCommand— send lldb commands to the active debug session
Schemes & run destinations
XcodeListSchemes,XcodeSwitchSchemeXcodeListRunDestinations,XcodeSwitchRunDestinationXcodeListWindows
Build config / target settings
GetTargetBuildSettings,UpdateTargetBuildSettingGetFileCompilerFlags,UpdateFileCompilerFlagsAddInfoPlist,AddEntitlement
On-device / simulator interaction
DeviceInteractionStartSession,DeviceInteractionEndSessionDeviceInteractionInstallAndRun,DeviceInteractionSynthesize(tap/swipe/type)
Localization (String Catalogs — each requires activating an xcode-integration:translation* skill first)
LocalizationPlanner,StringCatalogRead,StringCatalogContext,StringCatalogEdit
Apple Developer services & field data
DocumentationSearch— semantic search of Apple docsGetTopCrashIssues,GetCrashIssueLogsGetTopFieldPerformanceIssues,GetFieldPerformanceIssueLogs
For the app I'm building, I'm concerned with the RenderPreview tool. That will give me the image relating to the SwiftUI preview.
Creating a connection
The first step in calling the RenderPreview tool is to establish a connection to Xcode via STDIO. It is here that we have the first and most important challenge. We need to execute the command xcrun mcpbridge. In order to create that connection the app needs to not be sandboxed.
And well, that's a bad idea as it means you are prevented from releasing on the mac App Store if the app is unsandboxed.
There's a solution to that, and it's to have a helper app which is unsandboxed and that is a world of pain to manage and build. I'll do a full write up about that at some point, but for now assume that there's some magic involved to make it all work.
The first part is to establish some configuration options for the call. This will be a arguments and environment. Everything needed to construct a command line call.
public static func xcodeBridge(
arguments: [String] = ["mcpbridge"],
environment: [String: String] = [:]
) -> MCPServerConfiguration {
MCPServerConfiguration(
executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"),
arguments: arguments,
environment: environment
)
}
This custom value type can then be used to start a process and use it to send / receive via the stdio connection.
You'll want to create some variables for handling the process and input, output such as the following:
private let process = Process()
private let inboundPipe = Pipe() // child stdout -> us
private let outboundPipe = Pipe() // us -> child stdin
private nonisolated let messages: AsyncThrowingStream<Data, any Swift.Error>
private nonisolated let continuation: AsyncThrowingStream<Data, any Swift.Error>.Continuation
The connection process is then established as such:
public func connect() async throws {
process.executableURL = configuration.executableURL
process.arguments = configuration.arguments
if configuration.environment.isEmpty == false {
process.environment = configuration.environment
}
if let workingDirectory = configuration.workingDirectory {
process.currentDirectoryURL = workingDirectory
}
process.standardInput = outboundPipe
process.standardOutput = inboundPipe
process.terminationHandler = { [continuation, onProcessTermination] _ in
continuation.finish()
onProcessTermination?()
}
try process.run()
// Read on a detached task using the raw descriptor with non-blocking I/O, so the read never
// holds this actor. A blocking read here would starve `send()` and deadlock the handshake.
let fileDescriptor = inboundPipe.fileHandleForReading.fileDescriptor
readerTask = Task.detached { [continuation, idMap] in
await Self.readInboundMessages(
fileDescriptor: fileDescriptor,
continuation: continuation,
idMap: idMap
)
}
}
There's extra magic involved here, but that's app architecture specific. The connection process is what's important here.
Sending data
As MCP is JSON-RPC based, it works by sending requests and then reading responses. As MCP provides a schema for tool calls, it is easy to know what needs to be sent and what can be received. For the sending, this covers the following:
Request (inputSchema)
| Param | Type | Required | Meaning |
|---|---|---|---|
tabIdentifier | string | ✅ | The workspace tab to act on |
sourceFilePath | string | ✅ | Path in Xcode project organization (e.g. ProjectName/Sources/MyFile.swift), not a raw filesystem path |
previewDefinitionIndexInFile | integer | — | Zero-based index of the #Preview macro / PreviewProvider in the file, counting from top. Default 0 |
timeout | integer | — | Seconds to wait for render. Default 120 |
previewLocalizationOverride | string | — | Locale to render in (e.g. "fr", "ja"). Must be a value from a prior call's supportedLocalizations |
previewVariantOverrides | object | — | Map of variant-group name → variant name. Keys/values must come from a prior call's supportedPreviewVariantOverrides |
previewCanvasControlOverrides | object | — | { timelineIndex?: int, toggleState?: bool } — for Widgets/Live Activities. Valid values come from a prior call's supportedCanvasControlOverrides |
Example
Request
{
"tabIdentifier": "<tab>",
"sourceFilePath": "PreviewSmith/Sources/Views/HelperStatusView.swift",
"previewDefinitionIndexInFile": 0,
"timeout": 120
}
Making a request
The data being sent is a JSON body, so needs to be a type that conforms to Codeable. This can be something like the following.
public enum MCPServiceRequest: Sendable, Codable {
case connect
case disconnect
case listTools
case callTool(name: String, arguments: [String: MCPArgument])
case renderPreview(arguments: [String: MCPArgument])
}
Sending this over stdio to Xcode's MCP is accomplished by the following:
private func send(
_ request: MCPServiceRequest,
overMach connection: xpc_connection_t
) async throws -> MCPServiceResponse {
let message = try makeXPCMessage(request)
return try await withCheckedThrowingContinuation { continuation in
xpc_connection_send_message_with_reply(connection, message, nil) { reply in
guard xpc_get_type(reply) != XPC_TYPE_ERROR else {
continuation.resume(
throwing: MCPServiceRemoteError(message: "MCP Mach connection error: \(reply)")
)
return
}
do {
continuation.resume(returning: try decodeXPCPayload(MCPServiceResponse.self, from: reply))
} catch {
continuation.resume(throwing: error)
}
}
}
}
Receiving data
Following the JSON-RPC format of MCP, a response will come back in the following structure.
Response (outputSchema)
| Field | Type | Meaning |
|---|---|---|
previewSnapshotPath | string | Path to the rendered PNG snapshot of the preview |
errors | array of { message } | Errors during the attempt (e.g. input validation, render failures) |
renderedDestination | object | The destination actually used — { deviceModelName, platformName, systemVersion }. May differ from the workspace's selected destination; omitted if unknown |
supportedLocalizations | string[] | Locales you can pass back via previewLocalizationOverride |
supportedPreviewVariantOverrides | object | Variant groups → allowed variants, for previewVariantOverrides |
supportedCanvasControlOverrides | object | { timelineIndexes: int[], toggleStates: bool[] } for timeline/toggle previews |
Nothing is required in the output — a failed render comes back with errors populated and no previewSnapshotPath.
Response (success)
{
"previewSnapshotPath": "/var/folders/.../preview-XXXX.png",
"renderedDestination": {
"deviceModelName": "My Mac",
"platformName": "macOS",
"systemVersion": "27.0"
},
"supportedLocalizations": ["en", "fr", "ja"],
"errors": []
}
The previewSnapshotPath is again a value that means the process can't be sandboxed. It needs to read a file located in a folder outside the sandbox.
Receiving a response
Receiving the response is again a JSON-RPC format. It can be decoded into a format that allows for easy use in the app such as:
public enum MCPServiceResponse: Sendable, Codable {
case serverInfo(MCPServerInfo)
case tools([MCPToolDescriptor])
case toolResult(MCPToolResult)
/// A rendered preview's PNG bytes, read from Xcode's temp namespace by the (unsandboxed) service
/// and shipped inline so the sandboxed app needn't reach a filesystem location it can't access.
case snapshot(MCPImageContent)
/// Acknowledges a request that has no payload (`disconnect`).
case ok
case failure(MCPServiceError)
}
The response handling is part of the xpc_connection_send_message_with_reply in the send function. Decoding it is handled as such:
func decodeXPCPayload<Value: Decodable>(_ type: Value.Type, from message: xpc_object_t) throws -> Value {
var length = 0
guard let bytes = xpc_dictionary_get_data(message, mcpPayloadKey, &length) else {
throw MCPServiceRemoteError(message: "MCP XPC message is missing its payload")
}
return try JSONDecoder().decode(Value.self, from: Data(bytes: bytes, count: length))
}
Queen of the Sandbox
Fair warning, here be dragons and I promise that I'll do a full write up at some point in the future. Though there are a couple of key points that need highlighting which at a high level make it all work.
- Use an XPC service so that the main app target (sandboxed) can be separated from the unsandboxed service that talks to Xcode MCP.
- The XPC service can't be embedded in the app target as that violates sandbox requirements.
- Your app will need to have some way to download the helper app from a URL. This means hosting the helper on the internet somewhere and making sure the helper is properly notarized.
- You can apply for an app sandbox tempory exception entitlement (not guaranteed). Details are here
Yes, I'm releasing an app
Because I love making things that not only solve my requirements but are good for others to use, I will be releasing PreviewSmith later this year which includes the functionality of providing a gallery view of the previews in an app.
Listening to
While writing this I had San Francisco on repeat. Because a girl needs to dream about a city dear to her heart while she shares what she's working on.