Previews and MCP

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 files
  • XcodeLS, XcodeGlob, XcodeGrep — list, find, search
  • XcodeMakeDir, XcodeMV, XcodeRM — mkdir, move/rename, remove
  • XcodeGetCurrentFile — active editor file + selection

Build & run

  • BuildProject, RunProject, StopProject
  • GetBuildLog, GetConsoleOutput
  • RunCodeSnippet — build/run an ad-hoc snippet in a file's context
  • RenderPreview — render a SwiftUI Preview and snapshot the UI

Testing

  • GetTestList, RunAllTests, RunSomeTests

Diagnostics & debugging

  • XcodeListNavigatorIssues, XcodeRefreshCodeIssuesInFile
  • InvokeDebuggerCommand — send lldb commands to the active debug session

Schemes & run destinations

  • XcodeListSchemes, XcodeSwitchScheme
  • XcodeListRunDestinations, XcodeSwitchRunDestination
  • XcodeListWindows

Build config / target settings

  • GetTargetBuildSettings, UpdateTargetBuildSetting
  • GetFileCompilerFlags, UpdateFileCompilerFlags
  • AddInfoPlist, AddEntitlement

On-device / simulator interaction

  • DeviceInteractionStartSession, DeviceInteractionEndSession
  • DeviceInteractionInstallAndRun, 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 docs
  • GetTopCrashIssues, GetCrashIssueLogs
  • GetTopFieldPerformanceIssues, 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)

ParamTypeRequiredMeaning
tabIdentifierstringThe workspace tab to act on
sourceFilePathstringPath in Xcode project organization (e.g. ProjectName/Sources/MyFile.swift), not a raw filesystem path
previewDefinitionIndexInFileintegerZero-based index of the #Preview macro / PreviewProvider in the file, counting from top. Default 0
timeoutintegerSeconds to wait for render. Default 120
previewLocalizationOverridestringLocale to render in (e.g. "fr", "ja"). Must be a value from a prior call's supportedLocalizations
previewVariantOverridesobjectMap of variant-group name → variant name. Keys/values must come from a prior call's supportedPreviewVariantOverrides
previewCanvasControlOverridesobject{ 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)

FieldTypeMeaning
previewSnapshotPathstringPath to the rendered PNG snapshot of the preview
errorsarray of { message }Errors during the attempt (e.g. input validation, render failures)
renderedDestinationobjectThe destination actually used — { deviceModelName, platformName, systemVersion }. May differ from the workspace's selected destination; omitted if unknown
supportedLocalizationsstring[]Locales you can pass back via previewLocalizationOverride
supportedPreviewVariantOverridesobjectVariant groups → allowed variants, for previewVariantOverrides
supportedCanvasControlOverridesobject{ 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.