Skip to main content
Glama
README.md
# pi-modal-mcp

Drive **Modal** serverless compute (GPU workers + headless Chromium) from any
MCP-aware agent. Zero local dependencies for the remote path; a tiny stdio shim
for stdio-only clients like [pi](https://pi.dev).

Three pieces, one system:

```
agent (pi / Claude Desktop / Cursor / your Linux box)
  │
  │  MCP (stdio shim OR streamable HTTP)
  ▼
Modal frontend  ── FastAPI + MCP-over-HTTP
  │  (.remote.aio fan-out)
  ├─▶ run_model containers   (CPU workers, parallel)
  └─▶ browse_page container  (Playwright + Chromium → PNG)
```

## What's inside

| Path | What |
|---|---|
| `modal-frontend/app.py` | Modal app: FastAPI REST endpoints **and** a Modal-hosted MCP-over-HTTP server (stateless streamable HTTP). Deploys Playwright for screenshots. |
| `modal-mcp/modal_mcp.py` | Stdio MCP shim. Bridges stdio-only MCP clients (e.g. pi) to the Modal REST endpoints. Returns screenshots as MCP image content. |
| `mcp-runtime/index.ts` | Pi extension: a native MCP runtime for pi. Reads `~/.pi/agent/mcp.json`, spawns stdio MCP servers, registers their tools as pi tools. |
| `mcp-runtime/mcp.json.example` | Example pi MCP config. |

## Tools exposed

| Tool | Does |
|---|---|
| `modal_ping` | Health-check the Modal frontend. |
| `modal_models` | Fan out N parallel CPU workers on Modal. Returns JSON: concurrent count, elapsed seconds, per-worker results. |
| `modal_browse` | Open a URL in headless Chromium inside a Modal container. Returns a PNG screenshot (MCP image content). |
| `modal_swarm` | Run a prompt across N parallel **OSS model workers on Modal GPUs** (a model swarm). Returns JSON: `{concurrent, elapsed, results:[{model, completion, elapsed}]}`. Models are HuggingFace IDs (default `Salesforce/codegen-350M-mono`), cached in a shared Modal Volume. |

## Deploy the Modal app

Requires the [Modal CLI](https://modal.com/docs/guide) authenticated.

```bash
cd modal-frontend
modal deploy app.py
```

You'll get two URLs:

```
web:      https://YOUR-WORKSPACE--pi-frontend-web.modal.run       (REST: /api/*)
mcp_web:  https://YOUR-WORKSPACE--pi-frontend-mcp-web.modal.run/mcp  (MCP streamable HTTP)
```

### Why stateless?

Modal load-balances each HTTP request across containers, so stateful MCP
sessions (which require session affinity) break. The app sets
`stateless_http=True` and disables DNS-rebinding host checks so any host can
reach it. For a private deployment, tighten `transport_security.allowed_hosts`.

## Use it

### Remote MCP client (Claude Desktop / Cursor / your Linux box)

```json
{
  "mcpServers": {
    "modal": {
      "url": "https://YOUR-WORKSPACE--pi-frontend-mcp-web.modal.run/mcp"
    }
  }
}
```

Or the `mcp` Python client:

```bash
pip install mcp
python3 - <<'PY'
import asyncio
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.session import ClientSession
async def main():
    async with streamablehttp_client("https://YOUR-WORKSPACE--pi-frontend-mcp-web.modal.run/mcp") as (r,w,_):
        async with ClientSession(r,w) as s:
            await s.initialize()
            print([t.name for t in (await s.list_tools()).tools])
            res = await s.call_tool("modal_browse", {"url": "example.com"})
            print([getattr(c,'type',None) for c in res.content])
asyncio.run(main())
PY
```

### pi (native remote transport, no shim)

The `mcp-runtime` extension now speaks the remote (streamable-HTTP) MCP
transport directly, so pi can hit the Modal-hosted MCP server with **zero
local component**.

1. Install the extension:

```bash
mkdir -p ~/.pi/agent/extensions/mcp-runtime
cp mcp-runtime/index.ts mcp-runtime/package.json ~/.pi/agent/extensions/mcp-runtime/
cd ~/.pi/agent/extensions/mcp-runtime && npm install
```

2. Add `~/.pi/agent/mcp.json` (replace workspace + URL):

```json
{
  "mcpServers": {
    "modal": {
      "url": "https://YOUR-WORKSPACE--pi-frontend-mcp-web.modal.run/mcp",
      "transport": "streamable-http"
    }
  }
}
```

3. Restart pi (or `/reload`), then `/mcp list` to see `mcp_modal_modal_ping`,
   `mcp_modal_modal_models`, `mcp_modal_modal_browse` tagged `[remote]`.

### pi (stdio fallback) via the shim

For MCP servers that only expose a stdio interface (or for air-gapped use),
the `modal-mcp` shim still works:

1. Install the shim on PATH:

```bash
cp modal-mcp/run.sh /usr/local/bin/modal-mcp   # or anywhere on PATH
chmod +x /usr/local/bin/modal-mcp
```

2. Add `~/.pi/agent/mcp.json` using the `command` form instead of `url`:

```json
{
  "mcpServers": {
    "modal": {
      "command": "modal-mcp",
      "args": [],
      "env": {
        "MODAL_FRONTEND": "https://YOUR-WORKSPACE--pi-frontend-web.modal.run"
      }
    }
  }
}
```

3. `/mcp list` shows the tools tagged `[stdio]`.

### Why stateless + 405 GET?

Modal load-balances each HTTP request across containers, so stateful MCP
sessions (which require session affinity) break. The app sets
`stateless_http=True` and disables DNS-rebinding host checks so any host can
reach it.

A second subtlety: the MCP **Node** SDK's streamable-HTTP client, on `start()`,
first tries `GET /mcp` to open a listening SSE stream; if the server returns
`200` and holds it open, the client blocks waiting for events that a stateless
server never delivers. A stateless server has no stream to offer, so the app
wraps the MCP ASGI app in a middleware that returns **`405` for `GET /mcp`**,
telling strict clients to skip the GET stream and use POST-inline responses.
This makes the endpoint compatible with the Node SDK, the Python SDK, Claude
Desktop, and Cursor alike.

For a private deployment, tighten `transport_security.allowed_hosts`.

## Transports

The pi `mcp-runtime` extension supports both backends from one config:

- **stdio** — `{ "command": "...", "args": [...] }` spawns a local process.
- **remote** — `{ "url": "https://...", "transport": "streamable-http" }`
  talks to a remote MCP-over-HTTP server directly. `"transport": "sse"`
  selects the legacy SSE client. Optional `"headers": { ... }` for auth.

Remote transport means pi needs **zero local component** for cloud-hosted MCP
servers — no shim, no `npx`/Python process to spawn.

## AI-native IDE (model swarm)

The app serves a web IDE at `/ide` on the web URL: a Monaco editor whose
"Run swarm" button fans the current buffer across N parallel OSS model workers
on Modal GPUs and shows each completion in a side panel. This is the
self-hosted, OSS-brain equivalent of a cloud coding agent &mdash; local UI,
remote GPUs, no API keys.

```
https://YOUR-WORKSPACE--pi-frontend-web.modal.run/ide
```

Models default to `Salesforce/codegen-350M-mono` (small, fast, public) and are
cached in a shared Modal Volume so cold starts after the first only pay the
model-load cost. Pass any HuggingFace ID in the `models` field; gated models
need a `secrets=[modal.Secret.from_name("huggingface-secret")]` on `llm_worker`.

## How many models can I run simultaneously?

Modal scales each function horizontally — there's no fixed concurrency cap.
You can fan out hundreds of concurrent `.remote()` calls; Modal spins up
containers on demand. What actually bounds you:

- **Concurrent containers quota** per workspace (free tiers are low; extra calls queue, they don't fail).
- **GPU availability** for `gpu=...` functions.
- **Cold starts** — first container of a function takes a few seconds; warm containers are ~instant.
- **Your fan-out shape** — `asyncio.gather(*[fn.remote.aio(x) for x in items])` issues all calls concurrently.

## License

MIT