8 · MCP servers

LeanTEA ships six MCP (Model Context Protocol) servers, each driving a different capability surface that an LLM client (Claude Code, Claude Desktop, Cursor, Zed, …) can call as tools:

ServerDrivesHighlights
chrome_cdp_mcp_serveA real Chrome via DevTools Protocol (WebSocket)Attaches to the user's signed-in profile — no Playwright detection
browser_mcp_serveChromium via PlaywrightCross-browser, full Playwright API
comfyui_mcp_serveComfyUI's HTTP + WebSocket APItxt2img, img2img, workflow submission
desktop_mcp_serveOS-level mouse + screenshot (macOS Quartz today)Click any app, including native ones
image_mcp_serveHTML/CSS → PNG compositorSpeech bubbles, captioned image overlays
Sheet /mcpAdds/moves/edits shapes on the SheetShares the SPA's port

Every one of them sits on LeanTea.Mcp.Handler, a 3-field record. The JSON-RPC envelope, the stdio loop, the HTTP transport, the content shapes (text / error / image), the schema builders — all shared.

The Handler contract

structure LeanTea.Mcp.Handler where
  initializeResult : Json
  toolsList        : Json
  callTool         : String → Json → IO Json

Supply those three and you get stdio and HTTP transports for free.

import LeanTea
open LeanTea.Mcp (textContent errContent argSchema toolDef
                  defaultInitializeResult)

def initializeResult : Json := defaultInitializeResult "my-mcp"

def toolsList : Json :=
  Json.mkObj [("tools", Json.arr #[
    toolDef "greet" "Greet someone by name."
      #[ argSchema "name" "string" "person to greet" ]
      #["name"]
  ])]

def callTool (name : String) (args : Json) : IO Json := do
  match name with
  | "greet" =>
    let who := args.getStrD "name" "stranger"
    return textContent s!"Hello, {who}!"
  | _ => return errContent s!"unknown tool: {name}"

def main : IO Unit := do
  let h : LeanTea.Mcp.Handler := {
    initializeResult, toolsList, callTool
  }
  h.serveStdio   -- or h.serveHttp 8000 "0.0.0.0"

That's a complete MCP server. Drop it into .mcp.json and the LLM sees one tool.

What the shared module gives you

LeanTea.Mcp exposes:

handleMcp, httpHandler

tools/call, and notifications/*

The five top-level servers + Sheet all share the same dispatch — before extraction they each duplicated ~80 lines of boilerplate.

Worked example — Chrome CDP MCP

examples/ChromeCdpMcp/Serve.lean drives a real Chrome instance via the DevTools Protocol. The key insight: Google detects Playwright but not a vanilla CDP WebSocket attached to the user's own session. So:

  1. The user launches Chrome with `--remote-debugging-port=9222

--user-data-dir=/tmp/chrome-cdp`.

  1. chrome_cdp_mcp_serve opens a fresh WebSocket per tool call,

sends one CDP command, reads the matching response, closes.

  1. Tools exposed: chrome_targets, chrome_navigate, chrome_evaluate,

chrome_screenshot, chrome_click, chrome_fill, chrome_wait_for_selector, chrome_find_tab, chrome_get_html, chrome_scroll_collect.

A few design choices worth highlighting:

handshake via SHA-1, masking, ping/pong handling). No node bridge.

only state is the IO.Ref holding the workspace path + CDP URL.

instead of returning it inline — large screenshots / DOM dumps never enter the LLM client's context budget.

appends them under the prompt as fenced code blocks. The MCP client doesn't waste context reading files first.

Gemini sidebar, infinite scroll) to the bottom and dumps every visible item — the one specialised tool that's much faster than a generic chrome_evaluate round trip per scroll.

validated against --workspace <root> so a misbehaving LLM can't read ~/.ssh/id_rsa or write /etc/passwd.

Hooking into a client

Drop the binary path into the client's MCP config. For Claude Code:

{
  "mcpServers": {
    "chrome_cdp": {
      "command": "/path/to/chrome_cdp_mcp_serve",
      "args": ["--stdio", "--workspace", "/Users/you/projects"]
    }
  }
}

The same shape works for any other server. Set args to whatever flags the binary takes; common ones are --stdio (default), --http --port N, and server-specific URLs (--cdp http://..., --comfy http://...).

HTTP mode for debugging

Every server also accepts --http --port N and serves the same JSON-RPC at POST /mcp. Useful for curl-driven smoke tests:

curl -s -X POST http://127.0.0.1:8014/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  | python3 -m json.tool

When to write your own

The bundled servers cover the common AI-driving surfaces. Write your own when:

game) the LLM should call directly.

— same port, SPA at /, MCP at /mcp).

in which case copy the dispatch and write your own pump.

For the common case it's three Json values + a callTool. The rest is free.