Add stdio transport for desktop/IDE MCP clients #15

Closed
opened 2026-05-17 15:56:09 +00:00 by claude-noether · 1 comment
Collaborator

Add stdio transport — the standard MCP transport for desktop and IDE clients.

Goal

lmcp is HTTP-only today. Most desktop MCP clients (Claude Desktop's claude_desktop_config.json, JetBrains/VS Code MCP integrations, the mcp CLI) expect stdio: launch the server as a subprocess, write JSON-RPC frames to its stdin, read responses from stdout. Without stdio, lmcp can't be a first-class citizen of those clients.

Adds reach without adding much code — the dispatch logic in lmcp:handle_request already does all the work; stdio is just a different read/write loop around it.

Spec

  • Framing: line-delimited JSON. One JSON-RPC message per line.
  • stdin: requests from client.
  • stdout: responses to client.
  • stderr: server diagnostics (mirrors current behaviour).
  • No HTTP, no SSE, no Bearer auth (parent process is the trust boundary).

API for lmcp

local server = lmcp.new("example-tools", { transport = "stdio" })
server:tool()
server:run()  -- dispatches by transport

Or, equivalently, a separate runner:

server:run_stdio()

Implementation sketch

function lmcp:run_stdio()
    for line in io.stdin:lines() do
        local ok, req = pcall(json.decode, line)
        if ok then
            local resp = self:handle_request(req)
            if resp then io.stdout:write(resp .. "\n"); io.stdout:flush() end
        end
    end
end

Single dispatch path; no changes to handle_request. Around 20 lines.

Concerns / spec details

  • No partial-line bufferingio.stdin:lines() blocks until a complete line; matches the spec.
  • No batching — spec 2025-06-18 removed JSON-RPC batching, so one-message-per-line is correct.
  • Notifications produce no response — already handled by handle_request returning nil; the runner just doesn't write a line.
  • Server-initiated messages (sampling/logging/progress) — write them to stdout as separate lines, interleaved with responses. The client correlates by id.

Capabilities advertisement

Unchanged. Transport is a deployment detail, not a capability.

Scope (v1)

  • lmcp:run_stdio() method.
  • New server.lua runner option (e.g. LMCP_TRANSPORT=stdio env, or a CLI arg).
  • Document in README.

Out of scope

  • WebSocket transport (not in spec).
  • Auto-detect transport from how the server was launched.

Priority

High. Unlocks Claude Desktop + many IDE clients in a single afternoon. Currently no MCP client speaks lmcp without curl-shaped HTTP. Half a day.

Add **stdio transport** — the standard MCP transport for desktop and IDE clients. ## Goal lmcp is HTTP-only today. Most desktop MCP clients (Claude Desktop's `claude_desktop_config.json`, JetBrains/VS Code MCP integrations, the `mcp` CLI) expect stdio: launch the server as a subprocess, write JSON-RPC frames to its stdin, read responses from stdout. Without stdio, lmcp can't be a first-class citizen of those clients. Adds reach without adding much code — the dispatch logic in `lmcp:handle_request` already does all the work; stdio is just a different read/write loop around it. ## Spec - Framing: line-delimited JSON. One JSON-RPC message per line. - stdin: requests from client. - stdout: responses to client. - stderr: server diagnostics (mirrors current behaviour). - No HTTP, no SSE, no Bearer auth (parent process is the trust boundary). ## API for lmcp ```lua local server = lmcp.new("example-tools", { transport = "stdio" }) server:tool(…) server:run() -- dispatches by transport ``` Or, equivalently, a separate runner: ```lua server:run_stdio() ``` ## Implementation sketch ```lua function lmcp:run_stdio() for line in io.stdin:lines() do local ok, req = pcall(json.decode, line) if ok then local resp = self:handle_request(req) if resp then io.stdout:write(resp .. "\n"); io.stdout:flush() end end end end ``` Single dispatch path; no changes to `handle_request`. Around 20 lines. ## Concerns / spec details - **No partial-line buffering** — `io.stdin:lines()` blocks until a complete line; matches the spec. - **No batching** — spec `2025-06-18` removed JSON-RPC batching, so one-message-per-line is correct. - **Notifications produce no response** — already handled by `handle_request` returning nil; the runner just doesn't write a line. - **Server-initiated messages** (sampling/logging/progress) — write them to stdout as separate lines, interleaved with responses. The client correlates by `id`. ## Capabilities advertisement Unchanged. Transport is a deployment detail, not a capability. ## Scope (v1) - `lmcp:run_stdio()` method. - New `server.lua` runner option (e.g. `LMCP_TRANSPORT=stdio` env, or a CLI arg). - Document in README. ## Out of scope - WebSocket transport (not in spec). - Auto-detect transport from how the server was launched. ## Priority **High**. Unlocks Claude Desktop + many IDE clients in a single afternoon. Currently no MCP client speaks lmcp without curl-shaped HTTP. Half a day.
Author
Collaborator

Implemented. lmcp:run_stdio() in lmcp.lua + transport switch in server.lua and example_server.lua. LMCP_TRANSPORT=stdio enters line-delimited JSON-RPC mode (stdin requests, stdout responses, stderr diagnostics). Does NOT require luasocket — handle_request is transport-agnostic. Auth bypassed (parent process is trust boundary). Buffering: setvbuf("no") + per-write flush() belt-and-braces. EOF closes cleanly. Parse errors recoverable. LMCP_PORT warning printed if set under stdio mode.

Phase 5 reviewer caught a cross-cutting bug: the catch-all dispatch was emitting -32601 with id:null for any unknown notifications/* (cancelled, roots/list_changed, etc.). Fix: top-level guard if id == nil then return nil end in handle_request — subsumes the per-method notifications/initialized branch and improves the HTTP path too. JSON-RPC 2.0 spec-correct.

Verified 7/7 cases live: init+notif+list+call, parse-error recovery, blank-line tolerance, EOF→exit 0, unknown-notification suppression, LMCP_PORT warning, HTTP backwards compat.

Memory: project_handle_request_is_shared.md captures the lesson — shared dispatch is transport-blind; HTTP masks bugs that stdio surfaces; vet notifications per transport.

Implemented. `lmcp:run_stdio()` in lmcp.lua + transport switch in server.lua and example_server.lua. `LMCP_TRANSPORT=stdio` enters line-delimited JSON-RPC mode (stdin requests, stdout responses, stderr diagnostics). Does NOT require luasocket — handle_request is transport-agnostic. Auth bypassed (parent process is trust boundary). Buffering: `setvbuf("no")` + per-write `flush()` belt-and-braces. EOF closes cleanly. Parse errors recoverable. `LMCP_PORT` warning printed if set under stdio mode. Phase 5 reviewer caught a cross-cutting bug: the catch-all dispatch was emitting `-32601` with `id:null` for any unknown `notifications/*` (cancelled, roots/list_changed, etc.). Fix: top-level guard `if id == nil then return nil end` in handle_request — subsumes the per-method `notifications/initialized` branch and improves the HTTP path too. JSON-RPC 2.0 spec-correct. Verified 7/7 cases live: init+notif+list+call, parse-error recovery, blank-line tolerance, EOF→exit 0, unknown-notification suppression, LMCP_PORT warning, HTTP backwards compat. Memory: project_handle_request_is_shared.md captures the lesson — shared dispatch is transport-blind; HTTP masks bugs that stdio surfaces; vet notifications per transport.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marfrit/lmcp#15