AutoShelf - Auto-organize files on your Mac | Product Hunt AutoShelf - Auto-organize files on your Mac | Product Hunt
← Back to journey

One Binary, Two Modes: How I Turn a Mac GUI App Into an MCP Server at Launch

AutoShelf ships as a single binary that runs in two modes: the GUI app and a CLI tool. Both access the same SwiftData store.

One Binary, Two Modes: How I Turn a Mac GUI App Into an MCP Server at Launch

I spent weeks building AutoShelf as a normal macOS app. Menu bar icon, SwiftUI windows, folder watchers, the whole thing. Then I decided I wanted AI assistants like Claude Code to be able to control it. That meant building an MCP server.

The obvious approach was to ship a separate command line binary. A little daemon that lives in your Applications folder and handles the MCP protocol. People do this all the time.

I really did not want to do that.

Shipping two binaries means two codebases to maintain, two signing pipelines, two distribution mechanisms. For a solo dev building an app in their spare time, that is a lot of overhead. So I asked myself: what if the same binary could be both the GUI app and the MCP server?

The stdio Trick

MCP works over stdio. An AI assistant like Claude Code spawns a child process, sends JSON-RPC requests on stdin, and reads responses on stdout. That is the entire protocol. It is dead simple.

The problem is that GUI apps do not have a stdin/stdout lifecycle. You double click them, the system launches them, and they open a window. There is no parent process piping data to them.

But what if someone launches the app from a terminal? Or what if an MCP client spawns it directly by path?

When a process is spawned by another process with pipes, its stdin is not a terminal. It is a pipe. And you can check that with a single line of C code.

// main.swift
import Foundation

signal(SIGPIPE, SIG_IGN)

if MCPServer.shouldRunAsBridge() {
    MCPServer.run()
} else {
    AutoShelfApp.main()
}

The shouldRunAsBridge() function does one thing:

static func shouldRunAsBridge() -> Bool {
    var statStruct = stat()
    fstat(fileno(stdin), &statStruct)
    return (stat_struct.st_mode & S_IFMT) != S_IFCHR
}

It calls fstat on stdin and checks whether the file mode is a character device. Terminals are character devices. Pipes and redirects are not. If stdin is not a character device, someone launched the app programmatically and expects to talk to it.

That is it. One function call. Two runtime modes.

What Happens in Each Mode

If the app detects it is being used as an MCP server, it calls MCPServer.run(). This function reads stdin line by line, parses JSON-RPC 2.0 messages, and writes responses to stdout. It never returns. The process stays alive until the MCP client kills it.

If the app detects it is running normally, it calls AutoShelfApp.main() and the normal SwiftUI app lifecycle takes over. Window opens, menu bar icon appears, folder watchers start.

The MCP client configuration points directly at the app binary:

{
  "mcpServers": {
    "autoshelf": {
      "command": "/Applications/AutoShelf.app/Contents/MacOS/AutoShelf"
    }
  }
}

No wrapper script. No symlink. Just the raw binary inside the app bundle.

The SIGPIPE Problem

There is one gotcha I hit early on. When an MCP client disconnects, it closes its stdin pipe. The app is still writing a response to stdout, and suddenly there is no reader on the other end. That triggers SIGPIPE, which kills your process by default.

The fix is the first line of main.swift:

signal(SIGPIPE, SIG_IGN)

This tells the kernel: if the other end of the pipe disappears, do not kill me. Just let the write() call fail with an error code. The MCP server handles that gracefully and exits.

Why This Matters

Shipping one binary instead of two removes an entire class of problems. I do not need to worry about version drift between the GUI app and the MCP server. I do not need a separate build target. I do not need to tell users to install a second thing.

If you update the app, the MCP server updates with it automatically. They are the same file.

There is also a nice developer experience benefit. During development, I run the app directly from Xcode's DerivedData. I can test MCP mode by piping stdin:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | /path/to/DerivedData/AutoShelf/Build/Products/Debug/AutoShelf.app/Contents/MacOS/AutoShelf

No special build step. No separate target. Just a pipe.

The Tradeoffs

This approach is not for everyone. If your app needs to run as a long-lived background daemon independent of the GUI, a separate binary makes more sense. The MCP server only lives as long as the AI assistant keeps the connection open.

Also, the binary is sandboxed because it is a Mac App Store app. That creates interesting constraints when the MCP server needs to do things like show a folder picker or access files. But those are solvable problems, and I will cover them in the next post.

For a menu bar app that runs in the background anyway, the single binary approach has been working great. It is one of those solutions that feels obvious in hindsight, but took a while to arrive at.