AutoShelf has exactly one external dependency: ZIPFoundation, for creating zip archives. Everything else is Apple frameworks and my own code. I wanted to keep it that way for the MCP server.
There is no Swift package for the Model Context Protocol. Or at least there was not when I started building this. The protocol specification is simple enough that I could write a server from scratch in about 300 lines of Swift.
The JSON-RPC 2.0 Wire Protocol
MCP is built on top of JSON-RPC 2.0. Every message is a JSON object sent on its own line (newline-delimited JSON). The client sends requests, the server sends responses. There are also notifications (requests without an id) that do not get responses.
The lifecycle is:
- Client sends an
initializerequest with protocol version and capabilities - Server responds with its own version and capabilities (including the list of tools it supports)
- Client sends a
notifications/initializednotification to confirm - Client starts calling tools with
tools/callrequests - Client can request
tools/listat any time to refresh the tool list
Here is the initialization response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "autoshelf",
"version": "2.0.0"
}
}
}
I had to get every field right because MCP clients are strict about the protocol. A missing field in the capabilities object causes Claude Code to reject the server entirely.
Reading Stdin, Writing Stdout
The MCP server reads stdin one line at a time. Each line is a complete JSON-RPC message. I use FileHandle.standardInput with availableData in a loop.
Wait, no. Actually, I use the C-level read() on fileno(stdin) because FileHandle has some quirks when dealing with pipes. Here is the core read loop:
while true {
var buffer = Data()
var byte: UInt8 = 0
while read(fileno(stdin), &byte, 1) > 0, byte != 0x0a {
buffer.append(byte)
}
if buffer.isEmpty { break }
handleMessage(buffer)
}
Read one byte at a time until we hit a newline (0x0a). Then parse the JSON and dispatch. This is not the most efficient approach, but MCP traffic is low volume. A few messages per second at most. It works fine.
Parsing and Dispatching Without Foundation JSON
Actually, I do use JSONSerialization for parsing. Swift's Codable is nice, but when you are dealing with dynamic JSON where the method name and parameters change per request, JSONSerialization is more practical.
The handler function switches on the method field:
switch method {
case "initialize":
sendInitializeResponse(id)
case "notifications/initialized":
break // no response needed
case "tools/list":
sendToolsList(id)
case "tools/call":
handleToolCall(id, params)
default:
sendError(id, -32601, "Method not found")
}
Each handler constructs the JSON response manually using string interpolation. I know, string interpolation for JSON is fragile. But adding a dependency on a JSON encoder library for what amounts to 10 lines of response construction felt like overkill.
Building the Tool Schemas
The most substantial part of the MCP server is the tool schema definitions. MCP requires each tool to have a JSON Schema describing its parameters. These schemas are what the AI assistant uses to understand what arguments each tool expects.
Here is a simplified version of the create_rule schema:
{
"name": "create_rule",
"description": "Create a new AutoShelf rule",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Rule name" },
"conditionType": {
"type": "string",
"enum": ["anyItem", "fileType", "fileExtension", "filenameContains",
"whereFromContains", "downloadedBy", "dateAddedOlderThan",
"fileSizeLargerThan", "lastModifiedOlderThan", "lastOpenedOlderThan"]
},
"actionType": {
"type": "string",
"enum": ["moveTo", "copyTo", "trash", "rename", "addTag",
"archiveToZip", "optimizeAndOverwrite", "duplicateAndOptimize",
"copyToPhotos", "moveToPhotos", "convertImage", "convertVideo", "convertAudio"]
}
},
"required": ["name", "conditionType"]
}
}
The schema has to be exhaustive enough that the AI can figure out what to pass without guessing. I spent a lot of time on the descriptions. Good descriptions make the difference between the AI calling the right tool with the right arguments on the first try versus hallucinating parameters.
Pro-Aware Tool Schemas
One of the things I am most proud of in this design is that the tool schemas change based on the user's license status. If the user has not purchased Pro, the action type enum in the schema excludes Pro-only actions like optimizeAndOverwrite and copyToPhotos.
The AI never even sees options it cannot use. This prevents the frustrating experience of the AI trying to call a tool with an action that gets rejected.
The Pro check goes through the IPC layer back to the GUI app, with a 5-second cache TTL so it does not hammer the socket on every request.
Handling the Interactive Folder Picker
Most MCP tool calls are fast. You call list_rules and get a response in milliseconds. But pick_folder is different. It needs to show a native macOS folder picker dialog, which blocks until the user selects something or cancels.
The MCP server sends the request over the IPC socket and waits. And waits. And waits. The GUI app shows the dialog, the user navigates Finder, picks a folder, and the response comes back.
I set the timeout on these calls to 300 seconds. If the user walks away, the request eventually times out and the AI gets a clean error message it can handle.
What the AI Assistant Sees
When the MCP server starts, Claude Code or Cursor or opencode sees 13 available tools. Each tool has a clear name, description, and parameter schema. The AI can figure out the workflow on its own:
- Call
get_statusto check if monitoring is active - Call
list_rulesto see existing rules - Call
create_rulewith a name, condition, and action - Call
run_ruleto execute it immediately
The whole thing works without the user writing any configuration beyond the initial MCP server setup in their AI client's config file.
Zero Dependencies Was the Right Call
Writing the MCP server without any external dependencies means the code is entirely under my control. There is no Swift package that could break on the next Xcode update. No version compatibility issues. No license concerns for a Mac App Store submission.
The tradeoff is that I had to implement JSON serialization and protocol handling myself. But for a protocol as simple as MCP, that took maybe two evenings of work. It has been running in production for months without a single bug report related to the protocol handling.
Sometimes the simplest approach is the best one.