I have a little hub, claude-net, that lets my Claude Code sessions talk to each other. Sessions register a name, send each other messages, join teams. It’s genuinely useful, the agent working on the MicroPython unix port can ask the agent working on the picolet runtime what branch it’s actually building, and get an answer, without me being the messenger.
Every session runs a plugin to talk to that hub. The plugin is an MCP server: a child process Claude Code spawns, speaking JSON-RPC over stdio on one side and holding a wss:// websocket to the hub on the other. One per session. Written in TypeScript, run under Bun.
Here’s what that costs on this machine right now:
bun: n=21 rss_total=932MB median_rss=39.2MB threads_total=239
Nearly a gigabyte of RAM and 239 threads, to hold 21 idle websockets open. Each process is fine on its own, 39 MB is unremarkable for a JS runtime. Twenty-one of them is a different conversation, and this laptop isn’t even the machine with 40 sessions on it.
Why picolet looked like the right hammer
I’ve been building picolet for a while now. It compiles a Python program into a single native executable by freezing your bytecode into a trimmed MicroPython interpreter and appending your assets as a romfs image. Hello world comes out around 650 KB on Linux and starts in milliseconds. It’s aimed at desktop tools, small GUI apps, that kind of thing.
An MCP server is a really good fit for it, though, and it took me embarrassingly long to notice. Think about the shape of the workload:
- Read newline-delimited JSON off stdin
- Hold one socket open
- Do essentially nothing, for hours
- Occasionally serialise a small dict and write it back out
There’s no CPU work. There’s no concurrency to speak of. What you’re actually paying for, 21 times over, is a general-purpose JavaScript runtime with a JIT and a GC thread pool, sitting idle. Neither --smol nor UV_THREADPOOL_SIZE=1 moves the floor much, that’s just what Bun costs to have running.
MicroPython’s whole design point is being the runtime that isn’t that.
Four libraries, because none of them existed
The catch is that MicroPython doesn’t have an MCP stack. Or a JSON-RPC stack. Or a websocket client, at least not one that does TLS and client-side masking properly. So before I could write the plugin I had to write the layers under it:
mpyws, an RFC6455 client with TLSmpyjsonrpc, newline-delimited JSON-RPC 2.0 over stdio, task-per-request dispatch, one serialised writermpyschema, parameter specs toinputSchema, plus validation and coercionmpyfastmcp, a FastMCP-shaped server on top of those two
About 7000 lines all up, though more than half of that is tests. The libraries themselves come to roughly 2400 lines, of which mpyfastmcp is 623.
I wrote them as libraries rather than as bits of one plugin quite deliberately. If picolet plus these four layers is a decent way to ship an MCP server, then the interesting artefact isn’t my chat plugin, it’s the stack: pick a template, write your tools, get a megabyte-ish binary out the other end that anyone can drop on a machine with no Python on it. So none of them bake in a single claude-net-specific name, tool, or notification method. The template and the micropython-lib packaging are still on the list rather than done, but the layering is already honest about it.
The one that forced a real design decision was mpyschema. CPython’s FastMCP is lovely because it reads your type hints: you write def add(a: int, b: int) -> int with a docstring, and the framework derives the JSON schema, the parameter names, the descriptions, all of it, via inspect and __annotations__.
MicroPython keeps none of that. No inspect, no retained annotations, not even parameter names at runtime. I verified it on the binary rather than trusting my memory, and it’s just gone; the compiler throws annotations away because storing them would cost flash on a microcontroller for something no embedded program reads.
So the schema has to be explicit:
|
|
Slightly more typing than the decorator-reads-your-mind version. It’s also the only version that works unconditionally, and honestly after using it for a while I don’t mind it, the schema is right there where you can see it rather than being inferred three layers away. There’s a path to generating those spec objects from real type hints at build time on the CPython side, since picolet already runs a build step, but that’s additive sugar and it hasn’t been the thing blocking anything.
Everything else was less dramatic than I expected. asyncio.open_connection(host, port, ssl=ctx, server_hostname=...) works on the unix port, the mbedtls handshake surfaces WANT_READ/WANT_WRITE as EAGAIN and the poll loop drives it to completion. TLS 1.2, DER-encoded CA (PEM parsing is off), SNI mandatory. The whole thing, TLS handshake plus websocket upgrade plus framed IO plus concurrent stdin, runs under one select.poll loop in one thread.
The runtime variant that carries all this is 899 KB of interpreter plus statically linked mbedtls. With the plugin, its four libraries and the bundled CA cert appended as romfs, the binary Claude Code actually launches is 962 KB. One file, no sidecars, nothing to install.
MicroPython bites back, a bit
Two things went wrong that are worth recording because they’re both “you’re not on a desktop anymore” problems.
The plugin reads the Claude Code session transcript to work out what the session is called. The original code did raw = f.read(). On a microcontroller-sized heap that’s fine, transcripts are small. Except they aren’t; the transcript for the session I’m writing this post in is 23 MB. It blew up with fatal: memory allocation failed, allocating 1820672 bytes, but only on reconnect, and only sometimes, which made it look like a network bug for a while. Streaming the file line by line fixed it, peak memory is now one line instead of the whole file.
The unix port’s default GC heap is about 2 MB and Claude Code runs the binary with no arguments, so -X heapsize= isn’t available to me. gc.add_heap() at startup gets it to 8 MB of headroom instead, lazily, so unused pages never get committed.
The good news
mpy: n=7 rss_total=74MB median_rss=10.6MB threads_total=7
One thread instead of eleven. About a quarter of the RSS. Zero measurable CPU.
That being said, let me be honest about the memory number, because the headline ratio is better than the real one. The Bun processes share a lot of pages, they’re all mapping the same runtime. Proportional set size, which is the number that actually tells you what one more session costs you, is about 16.2 MB for Bun and 8.5 MB for the MicroPython build. So the marginal cost is a bit under half, not a quarter. Still a win, just not the near-4x the RSS column implies. The thread count is real though, and so is the total: the seven migrated sessions come to 74 MB between them, the twenty-one still on Bun come to 932 MB.
I also quoted “about 3 MB” for weeks, which was true of a fresh idle process and stopped being true once the 8 MB heap headroom got used. Numbers measured during a spike have a way of outliving their conditions.
The bad news
An idle process was burning 2.4% of a CPU core.
Doing nothing. One websocket, no traffic, no timers worth mentioning. Multiply by a couple of dozen processes and you’ve got half a core permanently on fire, which is a worse look than the memory problem I set out to fix.
strace said the event loop was waking about a thousand times a second, issuing a two-call poll cycle roughly every 1.2 ms.
The root cause is nicer than I expected, in the sense that it’s a real design tension rather than someone’s typo.
MicroPython’s select has an optimisation: if every object in the poll set is backed by a real file descriptor, it hands the whole set to the kernel’s poll() and blocks properly. If any object isn’t fd-backed, it can’t do that, so it falls back to walking the set calling each object’s MP_STREAM_POLL ioctl on a timer. That timer’s period was a hard-coded 1 ms.
An mbedtls TLS socket doesn’t answer MP_STREAM_GET_FILENO. And for a good reason: its readiness isn’t the socket’s readiness. Bytes arriving on the fd might not produce any application data (a partial record), and application data can be available with the fd completely quiet (a decrypted record already sitting in mbedtls’ buffer). Answering GET_FILENO honestly and letting the kernel decide would strand that buffered data.
So the TLS socket declines to be an fd, one non-fd object drops the entire poll set into the 1 ms fallback, and an idle asyncio program spins at 1 kHz. That’s not a claude-net bug, that’s every MicroPython unix program that puts asyncio and TLS in the same process.
The quick fix was to make that hard-coded period a config knob and set it to 50 ms in my variant. 2.4% down to 0.07%, a 25x cut, about twenty minutes of work.
It also bothered me, because it’s still a spin. It’s a slower spin. The loop is still waking up to ask “anything?” fifty times a second forever, and the only reason 50 ms is acceptable is that nothing I do cares about 50 ms of latency. It’s a number chosen by shrugging.
The bit I didn’t see coming
Here’s where it gets good.
Back in February I’d opened micropython#18810, which teaches the unix port to process pending callbacks during time.sleep(). To do that it needs something waitable that can be signalled from anywhere, so it adds a wake event: an eventfd, or a self-pipe where eventfd isn’t available, or an auto-reset event object on Windows. Raising it is a single non-blocking write, safe from any thread and from inside the scheduler’s atomic section. It latches, so there’s no check-then-sleep gap.
That PR had nothing to do with MCP servers or TLS. It came out of embedded work, the general problem of a MicroPython program blocking in a wait that can’t be interrupted when a hardware interrupt or a scheduled callback wants attention.
And separately, in a different repo with a different agent working on it, I’d been sketching a broader version of the same idea: a central event registration mechanism so a driver can declare how it wakes you, instead of every subsystem inventing its own workaround. Hardware interrupts, ThreadSafeFlag, that sort of thing.
Then my desktop MCP server, over TLS, on x86, turned out to be the exact same problem.
I’d been circling a point fix for the SSL case and the agent doing the event-notify design pushed back on it, which I’d asked for. (“aio” below is an older session on MicroPython’s asyncio internals, whose write-up of past issues and PRs the others kept citing back at each other.) My own note back to it at the time was:
I thought the point of the new event system that had been planned by aio based on past issues/prs in micropython was to have a new centralised event registration mechanism to avoid these domain specific workarounds.
and then:
I really just want one consolidated mechanism that addresses all three use cases originally discussed with aio - having all three in one consolidated system makes a much stronger argument for it than a stack up of related systems.
The three, for the record: TLS readiness from mbedtls, hardware events (BLE, UART, machine.Pin interrupts, ThreadSafeFlag), and asyncio idle CPU. Two of those are embedded problems I’d been chewing on for a while, one of them is a desktop MCP server I built last month, and they want the identical thing from the event loop.
Which is the whole thing, really. A TLS socket and an interrupt-driven sensor look nothing alike until you write down what they need from the event loop, at which point they’re identical: I have something you can sleep on, and separately, I have an opinion about whether I’m ready. Don’t assume those are the same fact.
extmod/modselect.c conflated them. If you had a pollfd, your revents were your readiness. That works for a plain socket and breaks for anything with a layer on top.
Worth saying that this isn’t a novel insight, it’s just one MicroPython hadn’t had to make yet. A chunk of the design work was reading how nginx, libevent and Netty handle the same thing, and they all defend it at the loop level rather than trusting the descriptor. Nice to find out you’re re-deriving something well understood; it means the shape is probably right, and it gives you references when you go to argue for it upstream.
The actual fix
The mechanism is a new stream ioctl, MP_STREAM_SET_EVENT_SOURCE, where a stream declares its wake source rather than having one inferred:
|
|
The commit message for it puts it better than I can:
This separates “what can I sleep on” from “what is my readiness”, which extmod/modselect.c currently conflates by assuming a non-NULL pollfd is both.
The TLS socket now registers as a composed entry: fd-backed, so fresh socket data wakes poll() directly with no polling at all, and ioctl-consulted, so its real readiness (decrypted bytes, or a record already buffered inside mbedtls) gets resolved before the loop blocks. Both halves are needed. Drop the fd and you’re back to spinning; drop the ioctl and you strand buffered data whenever no new packets arrive.
With that in place the loop stops period-capping and sleeps to the actual asyncio deadline. It’s not something to declare a win on vibes, so, measured on the built binary against the live hub with a pending SSL read:
- idle CPU 0.07% → 0.000%
- poll rate ~600 per 30 seconds → ~57 per 30 seconds
Keepalive holds, inbound messages on an idle socket still arrive, a 200000-byte buffered remainder comes through with nothing stranded.
The SOURCE_SIGNAL flag is the interesting one for later. It’s for a source with no fd at all, that instead calls a callback when its readiness may have changed. That’s the hardware ISR and ThreadSafeFlag case, the thing this was originally for. It isn’t wired up yet; TLS on a desktop just happened to be the easiest first consumer to build and measure, which is a funny way round to develop an embedded feature.
The 50 ms period cap stays in the build as a fallback, incidentally, because the “is everything fd-backed” test is a whole-poll-set AND. One genuinely fd-less pollable in the same loop and everything, TLS included, drops back to the timer.
The agents talking to each other
The thing I keep coming back to is how this got joined up.
There wasn’t one session doing all of this. There was a session on the picolet runtime, a session on the claude-net plugin rewrite, a session on the time.sleep() PR, a session on the event-notify design, and an earlier asyncio session whose analysis the others kept citing. Different repos, different worktrees, some on different machines. They were registered on the hub as event-notify, unix-sleep-process-pending, and so on, sitting in a team called notify-dev, and they messaged each other directly.
Which is how the overlap actually got found. From the event-notify session’s log, my own instruction was:
Actually the unix-sleep-process-pending claude-net agent has been researching event mechanisms, maybe describe the issues we’re brainstorming with them for their suggested architecture
and a couple of days later:
#18810 Did bring in some kind of notify system, didn’t it? Does that directly overlap slash reinforce what we’re going to be working on here?
I could have chased that down myself by reading four repos. In practice I asked one agent to go ask another, and it came back with the answer plus the argument for consolidating rather than stacking point fixes. The unglamorous value here isn’t that the agents are clever, it’s that they hold four separate contexts simultaneously and I don’t. I get to be the person who says “those two things sound like the same problem” and then have someone else go check.
There’s a pleasing loop in it too. The MCP server that lets the agents talk to each other is the thing they were collaboratively building and profiling. The plugin binaries running on this laptop right now, holding the sockets those messages went over, are the artefact of the conversation they carried.
Where it’s at
Running, on this machine, in production for me. Seven processes up, several of them for days, 0.0% CPU, one thread each.
Honest list of what’s still open:
select-event-sourcelives on my fork, not upstream yet. #18810 underneath it is open and has been since February.- Only linux-x64 is built. Windows needs its own async-stdin work and is deferred.
- The default install path is still the Bun one. Flipping it is gated on a real soak period, not on me being pleased with the numbers.
- The four libraries live in the plugin’s own tree. They should be micropython-lib packages, which needs an API review first, while there’s still exactly one consumer to break.
The part I’d actually recommend to anyone else, though, is the profiling detour. I set out to fix a memory problem, found a CPU problem I’d created, chased it down to a long-standing assumption in modselect.c, and came out the other side with a general mechanism that a bunch of embedded use cases have wanted for years. The MCP server was almost incidental. It was just the first workload I’d built that sat idle long enough for the waste to be embarrassing.