No description
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-22 21:18:41 +02:00
src init 2026-07-22 21:18:41 +02:00
.gitignore init 2026-07-22 21:18:41 +02:00
bun.lock init 2026-07-22 21:18:41 +02:00
mise.toml init 2026-07-22 21:18:41 +02:00
package.json init 2026-07-22 21:18:41 +02:00
README.md init 2026-07-22 21:18:41 +02:00
tsconfig.json init 2026-07-22 21:18:41 +02:00

mcp-over-socket

A small, illustrative example of letting a coding agent talk to a running desktop application through an MCP server.

The idea: your desktop app already holds live state (open documents, windows, selections). Rather than have the MCP server reimplement any of that, the app exposes a tiny local API over a Unix domain socket, and a separate, stateless MCP server acts as a bridge that translates the agent's tool calls into calls on that API.

This repo is deliberately simple — it favours being easy to read over handling every edge case. Read the source top to bottom; it's heavily commented.


The shape

┌──────────────┐        stdio (MCP)        ┌───────────────────┐     Unix socket      ┌──────────────┐
│ Coding agent │  <───────────────────>    │  demo-app-mcp     │  <──────────────>    │  demo-app    │
│ (Claude Code,│    JSON-RPC over stdin/   │  (the bridge)     │   JSON lines,        │ (your app)   │
│  Cursor, …)  │    stdout                  │  stateless        │   1 req/conn         │ owns state,  │
└──────────────┘                            └───────────────────┘                      │ logs calls   │
                                                                                        └──────────────┘

Two commands:

Command Role
demo-app Stands in for the desktop app. Opens the socket, answers tool calls, logs each one to the screen, returns plausible fake data.
demo-app-mcp The stdio MCP server an agent launches. Owns no logic; forwards tool calls to demo-app and relays replies.

The agent only ever knows about demo-app-mcp. The socket is a private detail between the two commands.


Try it

mise install        # gets the pinned Bun (see mise.toml)
bun install         # one dependency: the MCP SDK

# Terminal 1 — start the "app". Leave it running and watch it log calls.
bun run src/app/main.ts        # or: mise run app

# Terminal 2 — run the end-to-end check that role-plays an agent.
bun run src/verify.ts          # or: mise run verify

verify connects to the bridge over real MCP stdio (the same handshake an agent does), lists the tools, and calls a couple. With the app running you'll see a live round-trip; with it stopped you'll see the graceful "app isn't running" path. Watch Terminal 1 to see each call logged and the peer check fire.


Wiring it into an agent

Build the standalone binaries and put them somewhere on your PATH:

bun run build       # writes dist/demo-app and dist/demo-app-mcp

# Pick a directory already on your PATH (or add one). For a single user:
mkdir -p ~/.local/bin
cp dist/demo-app dist/demo-app-mcp ~/.local/bin/
# Ensure ~/.local/bin is on PATH (add to ~/.zshrc if needed):
#   export PATH="$HOME/.local/bin:$PATH"

Then register the MCP server with your agent. For Claude Code:

claude mcp add demo-app -- demo-app-mcp

…or add it to your agent's MCP config by hand:

{
  "mcpServers": {
    "demo-app": { "command": "demo-app-mcp" }
  }
}

Now start your agent and — separately — launch demo-app. The order doesn't matter: the bridge starts fine on its own, and if the agent calls a tool before the app is open it simply gets told the app isn't running (see below).


Design decisions worth understanding

The bridge never connects to the app at startup

demo-app-mcp connects only its stdio transport when it launches — it does not touch the app socket. The socket is dialled lazily, per tool call. This matches the realistic flow where a user opens their agent first and the app later. If the socket isn't there when a tool is called, the agent receives a clear message ("The desktop app doesn't appear to be running… ask the user to start it, then try again.") instead of an error at launch. See src/mcp/client.ts and the catch in src/mcp/main.ts.

One connection per request, newline-delimited JSON — not HTTP

The original sketch was "an HTTP server over a Unix socket." We use a minimal newline-delimited JSON protocol instead. Why: the peer-credential check below needs the raw connection file descriptor, and Bun only exposes that on its low-level socket API (Bun.listen/Bun.connect), not on its high-level HTTP stack (Bun.serve/fetch). A one-request-per-connection JSON-lines protocol is a handful of lines (src/shared/protocol.ts), needs no request-ID correlation, and makes the peer check fire on every call. The trade for losing literal HTTP framing on a local loopback socket is worth it. (If you must keep real HTTP framing and peer creds, Node is the better runtime — see below.)

Same-user enforcement — two layers

  1. Filesystem permissions (primary). The socket lives in ~/.demo-app/, created mode 0700 (owner-only), and the socket file itself is chmod 0600. Because a Unix socket is a filesystem object, the kernel refuses connect() to any other user. This alone is a real same-user guarantee.

  2. getpeereid() peer check (belt-and-suspenders). On every connection the app calls the libc function getpeereid(fd, &uid, &gid) via bun:ffi and rejects the connection if the peer's uid isn't ours — logging it. This is the "for good measure" check. getpeereid is portable across macOS, the BSDs and Linux (unlike Linux-only SO_PEERCRED or macOS-only LOCAL_PEERCRED), and bun:ffi means no native addon and no build step. See src/shared/peer-cred.ts.

    Redundant with (1) for the same-user case, but it's the right tool when a socket must be reachable by multiple users and you want to filter which.

Why Bun (and when Node would be better)

Bun gives us built-in FFI (so getpeereid needs zero dependencies) and bun build --compile (single self-contained binaries). The cost is that its high-level HTTP stack hides the connection fd, so the peer check forces the raw socket + JSON-lines protocol. If literal HTTP framing over the socket is a hard requirement alongside peer-credential checks, use Node instead — Node exposes the fd even on HTTP sockets (req.socket._handle.fd), at the price of a third-party FFI library (koffi) or a small native addon. It's a contained swap of one file (src/app/main.ts).


File tour

src/
  shared/
    config.ts      Where the socket lives + why (path length, permissions).
    protocol.ts    The wire types + newline framing (LineBuffer).
    tools.ts       The tool catalog: name/description/JSON-Schema, shared by both.
    peer-cred.ts   getpeereid() via bun:ffi + same-user check.
  app/
    handlers.ts    Fake behaviour behind each tool (in-memory state).
    main.ts        demo-app: opens socket, peer-checks, logs calls, replies.
  mcp/
    client.ts      Tiny socket client (connect → send → read one line → close).
    main.ts        demo-app-mcp: stdio MCP server that forwards to the app.
  verify.ts        End-to-end harness that role-plays an agent over real MCP.
mise.toml          Pins Bun; convenience tasks.

This is a demo

It skips things a production build would want: no reconnection/retry, no concurrent-request multiplexing (one request per connection by design), no schema validation of tool arguments beyond what the agent enforces, no versioning of the socket protocol, and the fake handlers hold state in memory. All of that is intentional — the goal is to make the architecture legible.