I wrote an MCP server in Swift in an afternoon. It was 170 lines, it connected to Claude Code on the first try, and I used it for a week without noticing anything wrong.
Then I read the official Swift MCP SDK — 13,749 lines — expecting to find that it mostly buys convenience. Instead I found five bugs in my 170 lines. Four of them reproduce in a single shell pipe. The worst one makes a client hang forever, and I could have used my server for a year without hitting it.
This post is that comparison, and the fixes. If you have written a small protocol implementation and shipped it because it worked, this is an argument for reading the reference implementation anyway.
Why MCP, when I already had tool use working
In the previous post I built an agent loop by hand: five tools over the App Store Connect API, wired directly into the Anthropic Messages API. That works, but the tools only exist inside my loop. Nothing else can call them.
MCP inverts that. The tools become a server; any MCP client can use them. Concretely: after registering, I can ask Claude Code "what did people say in the Fed? reviews this week" in the middle of a coding session, and it queries my real App Store data — no context switch, no dashboard.
The part that surprised me was how little code the transition took. My tools already conformed to one protocol:
public protocol AgentTool {
var spec: AnthropicToolSpec { get }
func execute(input: [String: Any]) async throws -> String
}
The MCP server reuses those implementations unchanged. Adding a sixth tool means appending one line to an array; the protocol layer never changes. That's the payoff of having defined a tool abstraction in post 2 instead of calling the API inline — and it's the strongest practical argument I know for the abstraction.
The entire wire format
MCP's stdio transport is simpler than its documentation makes it sound. It is newline-delimited JSON-RPC 2.0: the client writes one JSON object per line to your stdin, you write one JSON object per line to stdout. That's it. No framing headers, no handshake bytes, no length prefixes.
A useful server needs four methods: initialize, ping, tools/list, tools/call. You can drive one by hand:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| ./.build/release/ASCMCPServer
Being able to test the whole server with printf is worth pointing out: there is no mystery layer. Everything below is something you can reproduce in a terminal in ten seconds.
One field name is worth memorising, because it cost me a confused half hour: MCP spells it inputSchema, the Anthropic Messages API spells the identical JSON Schema input_schema. Same object, different key, no error message — the tool simply doesn't appear.
The two that broke it immediately
stdout is the protocol channel. My first version logged startup information with print(). The client dropped the connection instantly, because a human-readable log line is not valid JSON-RPC and the transport has no way to skip it. Every log statement has to go to stderr:
func log(_ message: String) {
FileHandle.standardError.write(("[asc-mcp] " + message + "\n").data(using: .utf8)!)
}
This is obvious in retrospect and catches most people once. What makes it nastier than a normal bug is that print() is the first thing you reach for when a server won't start — so the debugging tool is the bug.
The Keychain deadlock. My credentials live in the macOS Keychain, and reading them can raise a GUI authorization prompt. An MCP server is launched headlessly by its client, so reading the Keychain during startup deadlocks the handshake: the client blocks waiting for initialize, and the server blocks waiting for a click nobody can see. It doesn't crash. It just hangs.
The fix has two halves. Environment variables win when present, so a wrapper script can bypass the Keychain entirely; otherwise the read is deferred until a tool that needs credentials is actually called, so initialize and tools/list always answer. The result is a server that starts instantly, advertises everything, and only asks for permission when you make it do real work.
A general lesson about agent plumbing: anything that can block on human interaction — Keychain, biometric prompts, OAuth browser flows, first-run dialogs — must be pushed out of the startup path. A headless client has no way to satisfy it and no way to report why it gave up.
It worked. Then I read the SDK.
At this point the server was in daily use. My own note from that week predicted what the official SDK would add: "multi-transport support, resources/prompts, type safety, protocol version negotiation." I was partly right, and I had missed every actual bug.
Here is what reading modelcontextprotocol/swift-sdk surfaced, with the command that reproduces each one against my 170-line version:
1. It claimed to speak protocol versions that don't exist
My initialize handler echoed back whatever version the client asked for. Ask it for a version from 1999 and it agrees:
$ echo '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"1999-01-01"}}' | ASCMCPServer
{"result":{"protocolVersion":"1999-01-01", ...}}
The SDK keeps a set of versions it actually implements and negotiates: honour the request if it's supported, otherwise answer with your newest and let the client decide. Thirteen lines. Echoing is not negotiation — it's a server lying about its capabilities, and the failure lands later, somewhere unrelated.
2. Batched requests were silently dropped — the client hangs forever
JSON-RPC 2.0 lets a client send an array of requests in one message. My parser did jsonObject(with:) as? [String: Any]. An array fails that cast, hits my continue, and vanishes:
$ echo '[{"jsonrpc":"2.0","id":2,"method":"ping"},
{"jsonrpc":"2.0","id":3,"method":"ping"}]' | ASCMCPServer
(no output at all)
This is the one that changed my mind about reading reference implementations. It is a silent failure — no error, no log, no crash — and the client waits forever for replies that will never come. And I would never have found it myself, because Claude Code doesn't batch. A different client would have appeared to hang for no reason, and I'd have blamed the client.
3. Head-of-line blocking: one slow tool froze everything
My read loop awaited tool.execute() inline, so the server handled exactly one request at a time. Send a ping while a multi-storefront rating lookup is running and the ping waits for it. Measured, with timestamps on each response line:
17:30:43.018 id 1 initialize → answered
17:30:45.233 id 2 tools/call → answered (2.2s later)
17:30:45.239 id 3 ping → answered (waited the full 2.2s)
The SDK spawns a task per request. A liveness probe that can be blocked by unrelated work isn't a liveness probe.
4. It served requests before the handshake
Sending tools/list with no initialize first got a complete tool list. The SDK gates every method behind an isInitialized check. This one is mild in practice, but it means my server would happily talk to a client whose capabilities it never learned.
5. It ignored every notification, including cancellation
My loop had this line, and I was proud of it:
// Notifications (no id) must not be answered.
guard let id = message["id"] else { continue }
Half right. Notifications must not be answered — but they still have to be handled. The one that matters is notifications/cancelled: when you hit escape in Claude Code, that's what it sends. My server ignored it, kept running a 30-second sales-report download, and then wrote a response for a request nobody was waiting for.
Fixing them, and the sixth bug I introduced
The fixes came to about 90 lines. Four were mechanical. The concurrency one was not, and it taught me the most.
Handling requests in parallel means stdout becomes shared mutable state. Two responses written concurrently interleave into one corrupt line, and the client drops the connection. So the writer became an actor:
actor Transport {
func send(_ object: Any) {
guard let data = try? JSONSerialization.data(withJSONObject: object) else { return }
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write(Data([0x0A]))
}
}
Then the tests came back worse. tools/list before the handshake produced no output — and only sometimes. The cause: when stdin closes, top-level code falls off the end and the process exits while responses are still in flight. Whichever task lost the race lost its response.
I had traded a correctness bug for an intermittent one, which is a worse trade than it sounds. The serial version couldn't have this failure at all. The fix is to wait for in-flight work before exiting:
/// Waits for every dispatched request to finish writing.
func drain() async {
while let (key, task) = inFlight.first {
await task.value
inFlight[key] = nil
}
}
Twenty consecutive runs, stable. And a rule I'd write on the wall: concurrency is not free. The five lines that made requests parallel required fifteen more to stay correct — an actor for the shared output, a registry of in-flight tasks, and an explicit drain at shutdown. If a hand-rolled server only ever talks to one client doing one thing at a time, serial is a legitimate choice. Just know you're choosing it.
After the fixes, the same commands:
protocolVersion "1999-01-01" → answers "2025-11-25"
batch of two pings → one JSON array, both ids
ping during an 8s tool call → answered in 3ms
tools/list before initialize → error -32600
notifications/cancelled → request cancelled, no response sent
So what are the other 13,000 lines for?
Having found real bugs, it would be easy to conclude "always use the SDK." That's not what the code says. Here is what the SDK contains that my server doesn't, and my honest assessment of each:
- HTTP/SSE and Network transports, plus a full OAuth implementation (PKCE, dynamic client registration, discovery, token storage). This is the single largest chunk. It matters if your server is remote and multi-tenant. Mine is a local binary on my own laptop — I use none of it.
- Resources, prompts, completion, logging, progress notifications. Genuine protocol surface I skipped.
progressis the one I'll regret: a 30-second report gives the client no feedback at all today. - A client implementation — irrelevant to me, since Claude Code is my client.
- Typed
Codablemessages instead of[String: Any]. Real value, and the thing I'd port first: several of my bugs were casts that silently failed rather than errors that surfaced. - Conformance tests. The part I most obviously lack. My "tests" are shell pipes I ran by hand.
Of 13,749 lines, the portion I actually needed was maybe 300 — and the SDK's real value to me wasn't code I could import. It was a checklist of protocol details I didn't know existed.
The question isn't "SDK or hand-rolled." It's "which protocol details am I missing?" — and you cannot answer that by guessing, only by reading a correct implementation. I wrote down my prediction before reading, which is how I know guessing doesn't work: I predicted the feature list and missed all five bugs. Write your prediction down first. The gap is the lesson.
Would I still hand-roll it?
Yes, for this. 170 lines that I fully understand, with no dependency, for a local single-client server, was the right call — and I'd have learned none of the protocol by importing a package. The mistake wasn't hand-rolling. The mistake was shipping it without reading the reference implementation, and then trusting "it works with my client" as evidence of correctness.
That generalises past MCP. "It works against the one client I tested" is the weakest form of protocol confidence there is, and it's the form most hand-written implementations run on.
Next
The next post compares this hand-rolled agent loop against the Claude Agent SDK — what a harness actually buys you, measured on the same tasks rather than argued in the abstract. After the exercise above, I have a specific hypothesis: the value won't be the loop, it'll be the details I don't know I'm missing.