Writing

Rewriting My Own Infrastructure as a Product

Rewriting My Own Infrastructure as a Product - abstract illustration

I've had a fleet of autonomous AI agents running in production since early 2025, all of them talking to the same Java service: a 59-tool MCP server spanning 12 functional domains, everything from filesystem access to email sync to scheduled agent jobs. It has run, mostly unattended, for over a year. I'm rewriting it from scratch as a native Mac app, and I intend to sell it. This is the first post in a short series on that build, and I want to start with the decisions that shaped everything after them, not the code.

The service was never the product

spring-agent-mcp does its job well, which is exactly why replacing it is a real decision and not an obvious one. It runs as a launchd service on a home server, and every tool call from every agent in my fleet goes through it. The case for leaving it alone is strong: it works, it's tested by a year of real usage, and touching production infrastructure for its own sake is how you introduce an outage nobody asked for.

The case for rewriting it is that a Java service on my home server was never something I could hand to another person. It assumes my filesystem layout, my launchd setup, my mental model of what's running where. A product has to assume none of that. It needs its own installer, its own update mechanism, its own onboarding for someone who has never seen an MCP server before. Once I decided this was worth selling, not just worth running, the Java service stopped being the destination and started being the thing the new app has to prove itself against.

One process, not a service plus a helper

The first architecture question was whether the Mac app should be a background daemon with a separate UI, the pattern a lot of menu-bar utilities use: a LaunchAgent-style helper doing the real work, a thin app on top for status and settings. I decided against it. The app is a single process: a normal app with a Dock icon and a main window, plus an additive MenuBarExtra status item for the common at-a-glance case. No helper, no XPC boundary, no second process to keep in sync with the first.

The reason is concrete, not aesthetic. This app spawns child processes, the Claude CLI chief among them, and a LaunchAgent-style helper running under App Sandbox has real trouble with that. Sandboxed processes can spawn children, but the children inherit the sandbox, and a lot of what this app needs to do, reading arbitrary paths a user configures, running shell commands a scheduled job specifies, doesn't fit inside that boundary no matter how many entitlements you request. Full Disk Access doesn't help either; it grants file access, not an escape from sandboxing for spawned processes. So the app ships Developer ID signed and notarized, with App Sandbox off. One process, one identity, one thing to reason about when something goes wrong.

The engine doesn't care if the window is open

The MenuBarExtra is additive, not the only way in, which sounds like a small detail but drove a real piece of the architecture. The engine, the part that actually holds the scheduler, the sync loops, the MCP server itself, gets started once from applicationDidFinishLaunching and lives independent of whether any window is visible. Closing the main window doesn't pause anything; quitting from the menu bar is the only thing that stops the engine. That split, a window-independent engine coordinator versus a window that's just one way to look at it, is what makes an app with a Dock icon behave correctly as a background service the rest of the time. Get that backwards, tie the engine's lifecycle to the window's, and you've built something that silently stops doing its job the moment someone closes it out of habit.

Two platforms, two engines, not one shared core

Windows was always part of the plan; my fiancé and plenty of prospective users run Windows, not Mac. The obvious efficiency move is a shared core, one engine written in something portable, with thin native shells on top for menu bar versus system tray. I rejected that too, and this is the decision I went back and forth on the longest.

A shared core sounds like less work. In practice it means picking a language and runtime that's second-class on at least one platform, and it means every platform-specific integration, Keychain versus Windows Credential Manager, EventKit versus Microsoft Graph, notarization versus code signing, has to be bridged through whatever abstraction the shared core imposes. I decided the abstraction would cost more than it saved. So there are two separate engines: Swift on Mac, C# on Windows, each idiomatic to its platform, each free to use the native calendar and contacts APIs and native process-spawning without an FFI layer in the way.

What ties them together instead of a shared codebase is a small set of shared contracts: one JSON Schema that both apps validate their configuration document against, a set of golden fixtures, cron expressions and daylight-saving transitions and API-key parity cases, that both test suites run against, and one shared database schema for the local stores. Neither app reads the other's code. Both apps have to agree with the same fixture file. That's a genuinely different failure mode than a shared core: instead of one bug propagating to both platforms, a fixture mismatch shows up as a loud, specific, cross-platform test failure the day it happens, not a subtle divergence discovered months later. I'll come back to exactly how that plays out once I've hit a few real parity bugs, because it already has.

What's actually true right now

As of this post, the Mac side has its MCP spine running end to end: a real client can connect with an issued key, call a tool, and get a correctly scoped tools/list back. The Windows side has its own process shell up and a scheduler skeleton underneath it. Neither has cut over a single agent from the Java service yet. spring-agent-mcp is still what runs my fleet today, and it will keep running until each domain, filesystem first, then email, then the scheduler itself, proves itself against the new app one phase at a time.

That's the honest state of it: a set of foundational decisions made, load-bearing ones, and a long build still ahead. The next post in this series is about a bug those foundations didn't prevent, a one-line difference in how two languages handle a line ending that cost me an afternoon in two different parsers before I learned the actual lesson once.

Building something like this?

This is the kind of work I do for clients. Tell me what you're building and I'll give you a straight read on the approach.

Book a 30-minute call