Paseo MCP
README.md
<div align="center">
# Paseo MCP
**Let an orchestrator agent see and steer your Paseo agents.**
An MCP server that gives Hermes, Claude Code or any other MCP client access to the coding agents running in your local Paseo: list them, read what they did, start new ones, send follow-ups, wait for results, answer permission requests.
[](https://paseo.sh)
[](https://modelcontextprotocol.io)
[](https://nodejs.org)
[](LICENSE)
</div>
---
## Why
Paseo is a great place to run many Claude Code chats side by side, but it is built for a human in front of the app. When you want one agent to hand work to others (an assistant that reads your mail and starts a coding agent for a bug report, or a lead agent that splits a task), that agent needs a way into Paseo.
This server is that way in. Everything it starts is a normal Paseo chat: you see it in the app, on your phone, and can take over at any time.
It runs in one of two ways, with the same tools and the same [agent events](#events):
| | stdio | Paseo plugin |
|---|---|---|
| Runs as | a child process of your MCP client | part of the Paseo daemon |
| Transport | stdin/stdout | Streamable HTTP at `http://127.0.0.1:6790/mcp` |
| Configured in | `~/.config/paseo-mcp/config.json` | Settings → Plugins → paseo-mcp |
| Access | whoever can start the process | a bearer token per client, scoped `read` or `full` |
| Set up | [Install](#install), or the plugin's launcher `~/.local/bin/paseo-mcp` | [Plugin mode](#plugin-mode) |
## Features
| | |
|---|---|
| **Overview** | List agents with status, account, and whether they need you (finished, failed, waiting for a permission). |
| **Readable transcripts** | The tail of a chat as a compact transcript: user, agent, tool calls, todos, errors. |
| **Start agents** | New Claude Code chats with a first prompt, model, thinking level, permission mode, account and optional git worktree branch. |
| **Steer running agents** | Send follow-ups; for a running turn choose *steer* (inject) or *interrupt* (restart with the message). |
| **Wait for results** | Block until a turn ends, fails, or needs a permission, and get the final message back. |
| **Events instead of polling** | Agent created, turn started/ended (finished, error, limit), permission requested/resolved, archived, closed. As a long-poll tool with a cursor, as MCP resource notifications, or (plugin) as signed webhooks. |
| **Guard rails** | Agents only start inside `allowedRoots`, everything gets an `origin` label, a read-only mode, and agent output is marked as data, not instructions. |
| **Paseo plugin** | Optional: serve the tools over HTTP from inside Paseo, with per-client tokens and a settings screen. It also keeps a stable stdio launcher, tests the connection and hands out ready client configs. |
## Tools
| Tool | What it does |
|---|---|
| `paseo_list_agents` | Agents with status, account, attention flag. Filters: status, labels, archived |
| `paseo_get_agent` | One agent's state including pending permission requests |
| `paseo_read_timeline` | Last N timeline entries as a compact transcript |
| `paseo_list_accounts` | The Claude accounts from your config |
| `paseo_list_models` | Models of a provider as `claude/<model-id>` |
| `paseo_wait_for_events` | Agent events since a cursor; waits up to `timeout_sec` until one happens. See [Events](#events) |
| `paseo_create_agent` | Start a new agent (cwd must be inside `allowedRoots`) |
| `paseo_send_message` | Send a follow-up (`when_running`: `steer` or `interrupt`) |
| `paseo_wait_for_agent` | Wait until the turn is done, failed, needs a permission, or times out |
| `paseo_respond_permission` | Allow or deny a permission request |
| `paseo_archive_agent` | Archive a finished agent |
Agent ids can be passed in full or as a unique prefix, like the short ids `paseo ls` shows. In read-only mode, and for plugin tokens with the `read` scope, only the first six tools exist (the list/read tools and `paseo_wait_for_events`). `paseo_list_agents` also returns `events_cursor`, the starting point for events.
Resources: `paseo://agents` (all active agents) and `paseo://agents/{id}` (one agent), both JSON and subscribable, see [Events](#events).
## Requirements
- [Paseo](https://paseo.sh) **0.15 or newer**, running (desktop app or `paseo daemon start`)
- Node **22 or newer**
- For plugin mode: plugins enabled in Paseo, and `npm` on the daemon's `PATH` (Paseo runs `npm ci` once on install)
- An MCP client: [Hermes](https://hermes-agent.nousresearch.com), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), …
## Install
```sh
git clone https://github.com/MaxGierlachowski/paseo-mcp.git
cd paseo-mcp
npm install # also builds dist/
mkdir -p ~/.config/paseo-mcp
cp config.example.json ~/.config/paseo-mcp/config.json
```
Then edit `~/.config/paseo-mcp/config.json` (see below) and connect your client.
Installed the [plugin](#plugin-mode)? Then you don't need a clone: the plugin keeps a stable launcher at `~/.local/bin/paseo-mcp` and shows ready-made stdio configs, see [stdio clients through the plugin](#stdio-clients-through-the-plugin).
### Hermes
`~/.hermes/config.yaml`:
```yaml
mcp_servers:
paseo:
command: "node"
args: ["/path/to/paseo-mcp/dist/index.js"]
env:
PASEO_MCP_CONFIG: "/Users/<you>/.config/paseo-mcp/config.json"
timeout: 300 # paseo_wait_for_agent blocks up to timeout_sec
connect_timeout: 30
```
Hermes only passes `PATH` and `HOME` to MCP servers; anything else belongs under `env`.
### Claude Code
```sh
claude mcp add paseo-agents -- node /path/to/paseo-mcp/dist/index.js
```
Named `paseo-agents` because agents running inside Paseo already have an MCP server called `paseo`.
## Configuration
`~/.config/paseo-mcp/config.json` (another path via `PASEO_MCP_CONFIG`):
| Key | Default | Meaning |
|---|---|---|
| `daemonUrl` | `ws://127.0.0.1:6767/ws` | WebSocket of the Paseo daemon |
| `password` | – | Daemon password, if set. `PASEO_PASSWORD` wins. Without one, the server signs in with `$PASEO_HOME/local-credential` (loopback daemons only), like the `paseo` CLI |
| `allowedRoots` | **required** | Agents may only start inside these folders (symlinks resolved) |
| `defaultProvider` | – | `provider/model`, e.g. `claude/claude-sonnet-5-5`. Check with `paseo_list_models` |
| `defaultThinking` | – | e.g. `low`, `medium`, `high` |
| `defaultMode` | – | Permission mode: `auto`, `default`, `acceptEdits`, `plan` |
| `accounts` | `{}` | Name → `{ configDir, note? }`, one entry per Claude login |
| `defaultAccount` | – | Account used when the caller names none |
| `accountEnvVar` | `CLAUDE_CONFIG_DIR` | Env var set on new agents to the account's `configDir` |
| `originLabel` | `hermes` | Value of the `origin` label on every agent this server starts |
| `readOnly` | `false` | Only list/read tools. Also `PASEO_MCP_READONLY=1` |
| `events` | on | `{ enabled, previewChars, bufferSize, ttlMinutes }`, defaults `true`, `0`, `1000`, `60`. See [Events](#events) |
### Accounts and paseo-multi-account
When an agent is started with an account, the server sets `accountEnvVar` to that account's `configDir` for the new agent and adds the label `account=<name>`. With the default `CLAUDE_CONFIG_DIR`, Claude Code runs on exactly that login.
If you use [paseo-multi-account](https://github.com/MaxGierlachowski/paseo-multi-account), the plugin already decides which login Claude runs on and moves chats when you switch. Let it stay in charge: set `accountEnvVar` to a name Claude Code ignores (e.g. `PASEO_CLAUDE_ACCOUNT`). The account then only becomes a label you can filter by, and the plugin's switch applies to agents started over MCP like to any other chat.
## Plugin mode
Installed as a Paseo plugin, paseo-mcp serves the same tools over [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) from inside the Paseo daemon. There is no process to start and no config file: everything is set on the plugin's settings screen. The stdio mode above keeps working unchanged next to it.
<p align="center">
<img src="docs/plugin-settings.png" alt="The MCP server settings screen in Paseo: HTTP server, tokens, HTTP client configs" width="420">
<img src="docs/plugin-settings-phone.png" alt="The same screen in the Paseo phone app width" width="200">
</p>
### Install
```sh
paseo plugin install github:MaxGierlachowski/paseo-mcp
```
On macOS the CLI ships inside the app: `/Applications/Paseo.app/Contents/Resources/bin/paseo`. Paseo asks you to trust the plugin (plugins run unsandboxed), clones it, runs `npm ci --omit=dev` and loads it. Plugins must be enabled (`"pluginsEnabled": true` in `~/.paseo/config.json`). The HTTP server stays **off** until you turn it on.
If another plugin already uses the id `paseo-mcp` (for example the earlier local "Paseo MCP for Hermes" build), Paseo refuses the install; remove that one first or pass `--id`. This plugin covers everything the local build did and takes over its launcher.
### Set up
1. Open the settings: ⌘K → **MCP server** (also finds "hermes", "token", "config"), or Settings → Plugins → paseo-mcp.
2. Under **Agents**, add the folders agents may start in (`allowedRoots`). Without one, `paseo_create_agent` refuses every start.
3. Under **New token**, create one token per client: a label (e.g. `Hermes`), a scope, optionally an origin label. Copy the token; it is shown **once**.
4. Turn on **Enabled**. The status row shows the URL, or why the server could not start (e.g. the port is taken).
5. Copy the snippet for your client from **Connect a client**.
| Setting | Default | Meaning |
|---|---|---|
| Enabled | off | Run the HTTP server inside the daemon |
| Port | `6790` | Any free port except the daemon's own `6767` |
| Bind address | `127.0.0.1` | Anything else makes the server reachable from your network; the screen warns |
| Tokens | – | Label, scope (`read` or `full`), optional origin label. Stored as SHA-256 hashes |
| Read-only | off | Only the list/read tools, for every token |
| Allowed roots | – | Folders agents may start in (symlinks resolved) |
| Provider, Thinking, Permission mode | – | Defaults for new agents, like `defaultProvider`, `defaultThinking`, `defaultMode` |
| Origin label | token label | `origin=…` on agents started by tokens without their own origin label |
| Account env var | `CLAUDE_CONFIG_DIR` | Like `accountEnvVar` above |
| Claude accounts, default account | – | Like `accounts` and `defaultAccount` above |
Changes apply at once: port, bind address and the switch restart the listener; everything else is read on the next tool call. The daemon connection needs no setup: the plugin reaches its own daemon over loopback with the local credential Paseo writes to `$PASEO_HOME/local-credential`, the way the `paseo` CLI does.
### Connect
Hermes, in `~/.hermes/config.yaml`:
```yaml
mcp_servers:
paseo:
url: "http://127.0.0.1:6790/mcp"
headers:
Authorization: "Bearer <token>"
timeout: 300 # paseo_wait_for_agent blocks up to timeout_sec
```
Claude Code, as a command or in a project `.mcp.json`:
```sh
claude mcp add --transport http paseo-agents http://127.0.0.1:6790/mcp --header "Authorization: Bearer <token>"
```
```json
{ "mcpServers": { "paseo-agents": { "type": "http", "url": "http://127.0.0.1:6790/mcp", "headers": { "Authorization": "Bearer <token>" } } } }
```
Any other client: Streamable HTTP, URL `http://127.0.0.1:6790/mcp`, header `Authorization: Bearer <token>`.
### stdio clients through the plugin
Clients that start the server as a child process don't need the HTTP server or a token. The plugin helps there too:
<p align="center"><img src="docs/plugin-stdio.png" alt="The stdio section: command, Test button, config file and sign-in status" width="460"></p>
- **A stable command.** On every load the plugin writes `~/.local/bin/paseo-mcp` (another path via `PASEO_MCP_LAUNCHER`), a two-line script that starts `dist/index.js` of the current plugin checkout. Git installs land in a new folder on every update; the launcher follows them, so client configs never change. It never overwrites a file it did not write.
- **Test connection.** Starts the server exactly like a client does (only `PATH`, `HOME` and `PASEO_MCP_CONFIG`), lists the tools and counts the agents.
- **Status.** Which config file the stdio server reads, its allowed roots and accounts, and how it signs in to the daemon. The local credential is shown masked and never leaves the daemon.
- **Copyable configs** for Hermes (`command:`), Claude Code (`.mcp.json` and `claude mcp add`) and any stdio client.
The stdio server keeps its own config file (`~/.config/paseo-mcp/config.json`); the screen shows it but never edits it.
### Security model
- **Off by default.** Turned on, the server binds to `127.0.0.1`. Other addresses must be entered explicitly and come with a warning on the screen.
- **A token on every request**, also from localhost. Missing or wrong: `401`. Tokens are 32 random bytes (`pmcp_…`), stored only as SHA-256 hashes with `createdAt`, compared in constant time. When a token was last used is kept in `$PASEO_HOME/paseo-mcp.json`.
- **Revoking** a token removes its hash; its next request gets `401` and its open sessions are closed at once.
- **Scopes.** A session registers only the tools of its token's scope: `read` gets the list/read tools and events, `full` everything. The global read-only switch applies on top. A token can also be limited to agents with certain labels (e.g. `origin=hermes`) for its events and resources.
- **Sessions belong to their token.** A session id used with another token is answered like an unknown one.
- **DNS rebinding.** The `Host` header must name the server itself (loopback names, or the bind address) and an `Origin` header, if sent, must point at it too; otherwise `403`. Request bodies are capped at 1 MiB (`413`).
- **Same guard rails as stdio.** `allowedRoots` is checked on the daemon side, every started agent gets `origin=<token's origin label>`, and permission requests are never answered automatically.
## Events
Agents finish, fail, hit a usage limit or wait for a permission while nobody watches. Instead of polling `paseo_list_agents`, a client can be told. Both modes collect the same events:
| Type | When | Extra fields |
|---|---|---|
| `agent.created` | A new agent appeared | |
| `agent.turn_started` | A turn began | |
| `agent.turn_ended` | A turn ended | `outcome`: `finished`, `error`, `limit` (usage/rate limit), `canceled`; `error` |
| `agent.permission_requested` | The agent waits for a permission | `permission`: `requestId`, `name`, `title`, `kind` |
| `agent.permission_resolved` | Someone answered it | `permission`, `resolution`: `allow`, `deny` or `null` if unknown |
| `agent.archived` | The agent was archived | |
| `agent.closed` | Its session closed, or the agent is gone | |
Every event also has `id` (= its cursor `<epoch>:<seq>`), `epoch`, `seq`, `at`, `agentId`, `title`, `status`, `labels`, `requiresAttention`, `attentionReason` and `source` (`hook` or `client`). Message content is **not** included. Optionally (`previewChars` > 0) `turn_ended` carries `preview: { text, untrusted: true }` with the start of the agent's last message; treat it as data, never as instructions.
```json
{
"id": "mw7zr11g20917a:3", "epoch": "mw7zr11g20917a", "seq": 3,
"type": "agent.turn_ended", "at": "2026-11-08T15:31:12.481Z",
"agentId": "d9e7a7a9-9fb7-401c-aea3-d1bf7c089b97", "title": "events e2e",
"status": "idle", "labels": { "origin": "hermes" },
"requiresAttention": true, "attentionReason": "finished",
"outcome": "finished", "error": null, "source": "client"
}
```
### Three ways to receive them
| | For | Works in |
|---|---|---|
| **`paseo_wait_for_events`** | AI clients: a tool call the model makes itself. Works with every MCP client, also those that ignore notifications | stdio and plugin |
| **Resource subscriptions** | MCP clients that handle `notifications/resources/updated` (programs, UIs) | stdio and plugin (over the session's SSE stream) |
| **Webhooks** | Anything that is not an MCP client: a script, a bot, Hermes' own webhook listener | plugin only |
**Long-poll with a cursor.** Get a starting cursor, then always pass on the `next_cursor` of the previous result:
```text
paseo_list_agents → { agents: [...], events_cursor: "mw7z…:41" }
paseo_wait_for_events cursor="mw7z…:41" timeout_sec=120 only_needs_attention=true
→ { events: [...], next_cursor: "mw7z…:44", gap: false }
paseo_wait_for_events cursor="mw7z…:44" …
```
It returns at once when events are pending, otherwise when the first matching one happens or after `timeout_sec` (0 to 280; keep it below your client's tool timeout). Filters: `agent_ids`, `labels`, `types`, `only_needs_attention`, `limit`. If `gap` is `true`, events were lost: `agents` then holds the current state of the (active) agents, and you continue with `next_cursor`.
**Resource subscriptions.** `resources/subscribe` to `paseo://agents` or `paseo://agents/{id}`; on every change the server sends `notifications/resources/updated` for that URI, and `notifications/resources/list_changed` when agents come or go. Read the resource again for the new state; it includes `events_cursor`. A session that subscribed to nothing gets nothing. Over HTTP the notifications use the session's GET SSE stream, which is resumable: reconnect with `Last-Event-ID` and missed messages are replayed.
### Guarantees
- **Order:** `seq` grows strictly within an epoch; `paseo_wait_for_events` returns events in that order. Webhook deliveries can overlap and arrive out of order; sort by `seq`.
- **At least once,** with two edges where a client may see something twice: right after `paseo_list_agents` (the cursor is taken before listing) and after a gap. Skip duplicates by `id`.
- **No silent loss:** a cursor that is too old (more than `bufferSize` events, default 1000, or older than `ttlMinutes`, default 60) or from an earlier epoch is answered with `gap: true` and the current state, never with a partial list.
- **Restart:** the buffer lives in memory. A restarted stdio server or a reloaded plugin starts a new epoch; every old cursor becomes a gap.
- **One event per happening:** in plugin mode, events come from Paseo's lifecycle hooks and the daemon's push updates. Whichever source is first wins; the other is dropped. stdio mode uses push updates only and does not report `closed` after an archive.
### Webhooks (plugin mode)
Settings → Plugins → paseo-mcp → **New webhook**: label, URL, which events (needs attention / turns / all), optional label filter, optional bundling window. The secret is shown **once**. It is kept on the daemon (`$PASEO_HOME/paseo-mcp.json`, mode 600); the settings hold only its last four characters.
<p align="center">
<img src="docs/plugin-events.png" alt="Events and webhooks on the settings screen" width="460">
<img src="docs/plugin-events-phone.png" alt="The same at phone width" width="200">
</p>
Each delivery is a `POST` with `Content-Type: application/json`:
```json
{ "delivery_id": "mw7z…:3", "sent_at": "2026-11-08T15:31:12.502Z", "events": [ { "id": "mw7z…:3", "type": "agent.turn_ended", "…": "…" } ] }
```
| Header | Meaning |
|---|---|
| `Paseo-Mcp-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>` |
| `Paseo-Mcp-Delivery` | Delivery id, the same on every retry: use it to ignore repeats |
| `Paseo-Mcp-Event-Id` | Id of the first event in the delivery |
Verify the signature over the **raw** body before parsing it, and reject old timestamps:
```js
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const mac = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(v1 ?? "", "hex");
return given.length === mac.length && timingSafeEqual(given, mac);
}
```
```python
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > tolerance:
return False
mac = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, parts.get("v1", ""))
```
- **Answer 2xx quickly.** Network errors, timeouts (10 s), `408`, `429` and `5xx` are retried up to 6 times with exponential backoff (1, 2, 4, 8, 16 s, plus jitter). Other `4xx` are not retried.
- **Auto-pause:** after 5 failed deliveries in a row the endpoint pauses; events are skipped (counted) until you press **Resume**. **Test** sends a `webhook.test` event regardless of filters and pause.
- **URLs:** `https://` anywhere; plain `http://` only to localhost or private addresses (10.x, 172.16–31.x, 192.168.x, fc00::/7). No credentials in URLs; redirects are not followed.
- The screen shows the last deliveries with status, attempts and HTTP code; **Rotate** replaces the secret at once.
### Configuration
- **Plugin:** Settings → Events: message preview length. Webhooks as above. Tokens can carry a label filter.
- **stdio:** `events` in `config.json`, e.g. `"events": { "previewChars": 200 }`; `"enabled": false` turns events off (the tool and resources then do not exist). The stdio server now connects to the daemon at start, not at the first tool call, so it sees everything from then on.
## How it works
```mermaid
flowchart LR
A[Orchestrator<br/>Hermes, Claude Code] -- MCP over stdio --> B[paseo-mcp]
B -- WebSocket<br/>@getpaseo/client --> C[Paseo daemon]
C --> D[Claude Code agents]
C --> E[Paseo app<br/>desktop and phone]
```
1. The client starts `paseo-mcp` as a child process and talks MCP over stdin/stdout.
2. At start the server connects to the Paseo daemon with the official client library, subscribes to agent updates for [events](#events), and reconnects if the daemon restarts.
3. Tool calls map one to one onto the daemon's agent API (`agents.list`, `create`, `send`, `waitForFinish`, …). The only state of its own is the in-memory event buffer.
4. Before an agent starts, `cwd` is resolved (including symlinks) and checked against `allowedRoots`; account env, `origin` and `account` labels are attached.
In plugin mode the client talks Streamable HTTP to the plugin, which runs inside the daemon and opens one McpServer per session:
```mermaid
flowchart LR
A[MCP client<br/>Hermes, Claude Code] -- HTTP /mcp<br/>Bearer token --> B[paseo-mcp plugin<br/>inside the Paseo daemon]
B -- loopback WebSocket<br/>local credential --> C[Paseo daemon]
C -- lifecycle hooks<br/>push updates --> B
B -- signed webhooks --> F[Your endpoints]
C --> D[Claude Code agents]
```
## Security
Starting agents with repository and tool access is powerful. Keep in mind:
- Text from mail, tickets or web pages is untrusted input. Have your orchestrator ask you before it starts an agent from such content, and never pass it through unfiltered as a prompt.
- Transcripts and final messages are returned with a note to treat them as data, not instructions.
- Only let the orchestrator call `paseo_respond_permission` with `allow` after you confirmed, unless the request is clearly read-only.
- To start, use `readOnly: true` and let the orchestrator only watch.
## Development
```sh
npm run build # compile server/ to dist/
npm test # build, then unit and HTTP integration tests
npm run typecheck # the plugin entry points and settings screen
SMOKE_COMMAND=~/.local/bin/paseo-mcp npm run smoke # same, through the launcher
PASEO_MCP_CONFIG=~/.config/paseo-mcp/config.json \
npm run smoke -- paseo_list_accounts 'paseo_list_agents={"limit":5}'
```
`smoke.mjs` starts the server like an MCP client would (only `PATH`, `HOME`, `PASEO_MCP_CONFIG`) and calls the tools you name (`tool=<json args>`); it exits non-zero when a tool returns an error.
Layout: `server/` holds the tools (`tools.ts`), the HTTP transport (`http.ts`, `auth.ts`) and the stdio entry (`index.ts`, built to `dist/index.js`); `server/plugin/` the daemon side of the plugin; `shared/` the settings and RPC contracts; `client/` the settings screen. `index.server.ts` and `index.client.tsx` are the plugin entry points, compiled by Paseo itself. To try the plugin from a checkout, run `npm ci --ignore-scripts && npm run build && npm prune --omit=dev` in a copy of it (a directory install skips the manifest's build step, a git install runs exactly these) and `paseo plugin install <dir>`; test against a separate daemon with `--home <tmp-dir>` rather than your everyday one.
Tested against Paseo daemon 0.15 and `@getpaseo/client` 0.15.
Internals of the plugin are in [OVERVIEW.md](OVERVIEW.md), changes in [CHANGELOG.md](CHANGELOG.md).
## License
[Apache 2.0](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessOutdated data