Skip to content

Commit 584a478

Browse files
authored
feat(harness): add in-house MCP extension (#3270)
1 parent 98a4b67 commit 584a478

33 files changed

Lines changed: 6558 additions & 207 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,6 @@ CLAUDE.local.md
8080
apps/mobile/ROADMAP.md
8181
# Local quill tarball for ChatX thread testing (not committed)
8282
.local-quill/
83+
84+
# pi project-local config (per-developer MCP servers, prompts, etc.)
85+
.pi/

packages/harness/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,37 @@ await session.prompt("What files are in the current directory?");
9898
The SDK reuses whatever credential `harness /login` stored, or accepts a static `apiKey` (a `pha_`
9999
token) for headless use.
100100

101+
## MCP servers
102+
103+
The bundled `mcp` extension (see [`src/extensions/mcp/README.md`](./src/extensions/mcp/README.md))
104+
connects pi to [Model Context Protocol](https://modelcontextprotocol.io) servers and registers
105+
their tools as pi tools named `mcp_<server>_<tool>`.
106+
107+
Servers are configured in `mcp.json` — global (`~/.pi/agent/mcp.json`) and/or project-local
108+
(`.pi/mcp.json`, honored only for trusted projects; project entries override global ones per key):
109+
110+
```json
111+
{
112+
"mcpServers": {
113+
"filesystem": {
114+
"command": "npx",
115+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
116+
},
117+
"linear": {
118+
"transport": "streamable-http",
119+
"url": "https://mcp.linear.app/mcp",
120+
"auth": { "type": "oauth" }
121+
}
122+
}
123+
}
124+
```
125+
126+
Supports stdio, streamable-http, and SSE transports; eager/lazy startup; automatic reconnect;
127+
live tool refresh on `tools/list_changed`; static header auth; and full OAuth
128+
(authorization-code + PKCE with discovery, dynamic client registration, silent token refresh, and
129+
credentials stored under `~/.pi/agent/mcp-auth/`). Commands: `/mcp` (status), `/mcp:start`,
130+
`/mcp:stop`, `/mcp:auth [server] [reset]` (browser flow).
131+
101132
## Entry points
102133

103134
| Import | What |
@@ -112,3 +143,6 @@ token) for headless use.
112143
| `@posthog/harness/extensions/posthog-provider/oauth` | `loginPosthog()`, `refreshPosthog()`, `buildAuthorizeUrl()`, `getRedirectUri()`, `getCallbackPort()` |
113144
| `@posthog/harness/extensions/posthog-provider/gateway` | `getGatewayBaseUrl()`, `getLlmGatewayUrl()`, `resolveRegion()`, `GATEWAY_PRODUCT` |
114145
| `@posthog/harness/extensions/posthog-provider/models` | `resolveModelConfigs()`, `fallbackModelConfigs()`, `DEFAULT_MODEL`, `GatewayModel` |
146+
| `@posthog/harness/extensions/web-access` | web search + fetch tools — `createWebAccessExtension()` |
147+
| `@posthog/harness/extensions/subagent` | subagent orchestration — `createSubagentExtension()` |
148+
| `@posthog/harness/extensions/mcp` | MCP client extension — `createMcpExtension()` |

packages/harness/package.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@
5454
"./extensions/subagent/*": {
5555
"types": "./dist/extensions/subagent/*.d.ts",
5656
"import": "./dist/extensions/subagent/*.js"
57+
},
58+
"./extensions/mcp": {
59+
"types": "./dist/extensions/mcp/extension.d.ts",
60+
"import": "./dist/extensions/mcp/extension.js"
61+
},
62+
"./extensions/mcp/*": {
63+
"types": "./dist/extensions/mcp/*.d.ts",
64+
"import": "./dist/extensions/mcp/*.js"
5765
}
5866
},
5967
"scripts": {
@@ -67,10 +75,11 @@
6775
"@earendil-works/pi-ai": "0.80.3",
6876
"@earendil-works/pi-coding-agent": "0.80.3",
6977
"@earendil-works/pi-tui": "0.80.3",
78+
"@modelcontextprotocol/sdk": "^1.29.0",
7079
"@posthog/shared": "workspace:*",
7180
"lru-cache": "^11.1.0",
72-
"pi-mcp-adapter": "2.10.0",
73-
"turndown": "^7.2.4"
81+
"turndown": "^7.2.4",
82+
"zod": "^4.2.0"
7483
},
7584
"devDependencies": {
7685
"@types/node": "^22.0.0",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# mcp — MCP client extension
2+
3+
Connects pi to [Model Context Protocol](https://modelcontextprotocol.io) servers and registers
4+
their tools as pi tools. In-house replacement for the community `pi-mcp-adapter`.
5+
6+
## Configuration
7+
8+
Config is merged from two `mcp.json` files (project overrides global per key; project servers
9+
replace same-named global servers wholesale):
10+
11+
| Location | Scope |
12+
| --- | --- |
13+
| `~/.pi/agent/mcp.json` | global |
14+
| `<project>/.pi/mcp.json` | project-local — only honored when the project is trusted |
15+
16+
```json
17+
{
18+
"settings": {
19+
"toolPrefix": "mcp",
20+
"requestTimeoutMs": 30000,
21+
"maxRetries": 3
22+
},
23+
"mcpServers": {
24+
"filesystem": {
25+
"command": "npx",
26+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
27+
"env": { "NODE_ENV": "production" },
28+
"lifecycle": "eager"
29+
},
30+
"internal-api": {
31+
"transport": "streamable-http",
32+
"url": "https://mcp.example.com/mcp",
33+
"headers": { "Authorization": "Bearer <api-key>" },
34+
"lifecycle": "lazy"
35+
},
36+
"linear": {
37+
"transport": "streamable-http",
38+
"url": "https://mcp.linear.app/mcp",
39+
"auth": { "type": "oauth" }
40+
}
41+
}
42+
}
43+
```
44+
45+
### Server options
46+
47+
| Field | Type / values | Default | Notes |
48+
| --- | --- | --- | --- |
49+
| `transport` | `"stdio" \| "streamable-http" \| "sse"` | `"stdio"` | |
50+
| `command`, `args`, `env` | | | stdio only; `command` required |
51+
| `url` | URL | | required for streamable-http / sse |
52+
| `headers` | record | | static HTTP headers (API-key auth) |
53+
| `auth` | object | | OAuth config, http/sse only (below) |
54+
| `lifecycle` | `"eager" \| "lazy"` | `"eager"` | eager starts at `session_start`; lazy via `/mcp:start` |
55+
| `requestTimeoutMs` | number | settings value | per-request timeout override |
56+
| `healthCheckIntervalMs` | number | disabled | opt-in ping; reconnects on failure |
57+
58+
### Settings
59+
60+
| Field | Default | Notes |
61+
| --- | --- | --- |
62+
| `toolPrefix` | `"mcp"` | tool names are `<prefix>_<server>_<tool>` (sanitized to `[a-zA-Z0-9_]`, ≤64 chars with hash suffix on truncation) |
63+
| `requestTimeoutMs` | `30000` | default per-request timeout |
64+
| `maxRetries` | `3` | reconnect attempts (fixed 1s/3s/5s/10s/30s schedule) |
65+
66+
## OAuth (`auth`)
67+
68+
Authorization-code + PKCE against the server's advertised authorization server: RFC 9728 /
69+
metadata discovery, dynamic client registration (or a pre-registered client), browser
70+
authorization via a loopback callback, token exchange, and silent refresh on reconnect.
71+
72+
```json
73+
"auth": {
74+
"type": "oauth",
75+
"scope": "read write",
76+
"clientId": "pre-registered-id",
77+
"clientSecret": "",
78+
"redirectUrl": "http://127.0.0.1:19876/callback",
79+
"clientName": "PostHog Code"
80+
}
81+
```
82+
83+
All fields except `type` are optional. Without `clientId`, the client is registered dynamically.
84+
Without `redirectUrl`, the callback server binds an ephemeral 127.0.0.1 port; set it only when a
85+
pre-registered client requires an exact redirect URI (must be an `http://` loopback URL with an
86+
explicit port).
87+
88+
Credentials are stored per server under `~/.pi/agent/mcp-auth/<sha256(name)>.json` (mode 0600),
89+
scoped to the configured server URL — changing the URL invalidates them. Background reconnects
90+
attach and refresh tokens silently but never open a browser or register clients; when a fresh
91+
authorization is needed the connection fails with a hint to run `/mcp:auth <server>`.
92+
93+
The `client_credentials` grant (machine-to-machine, no user) is not implemented — use static
94+
`headers` for that case.
95+
96+
## Commands
97+
98+
| Command | Purpose |
99+
| --- | --- |
100+
| `/mcp` | status summary for all servers |
101+
| `/mcp <name>` | state, retries, last error, tools, recent logs |
102+
| `/mcp:start <name>` | start a server (lazy servers, or after failure) |
103+
| `/mcp:stop <name>` | stop a server and deactivate its tools |
104+
| `/mcp:auth` | list OAuth-enabled servers with auth status |
105+
| `/mcp:auth <name> [reset]` | run the browser OAuth flow (`reset` clears stored credentials first) |
106+
107+
The model can start the OAuth flow itself via the `mcp_auth` tool, which queues
108+
`/mcp:auth <server>` as a follow-up user message ("log in to linear for me" works).
109+
110+
## Bundled skill
111+
112+
The extension contributes an `mcp-servers` skill (`skills/mcp-servers/SKILL.md`) via
113+
`resources_discover`, so the model can handle "install/configure the X MCP server" requests
114+
itself: it knows the config schema, file locations and trust rules, the OAuth setup +
115+
`/mcp:auth` handoff, that `/reload` applies config changes, and how to troubleshoot via
116+
`/mcp <name>`. Only the one-line description sits in the system prompt; the full instructions
117+
load on demand (progressive disclosure).
118+
119+
## Behavior notes
120+
121+
- Tools are registered once and **activated/deactivated** as servers connect/disconnect, so tool
122+
identities stay stable across reconnects (no churn in pi's tool registry).
123+
- Crashed or dropped connections are detected via the client's close event: the server flips to
124+
`stopped`, its tools deactivate, and a background reconnect (re)activates them on success.
125+
- Tool-name collisions (two MCP tools sanitizing to the same pi name) are reported in
126+
`/mcp <name>`; the later definition wins.
127+
- `notifications/tools/list_changed` triggers live tool re-discovery; `tools/list` pagination is
128+
followed per spec (with a 100-page guard).
129+
- Tool annotations (`readOnlyHint`, `destructiveHint`, …) are appended to tool descriptions.
130+
- Tool-call `AbortSignal`s propagate to the SDK (`notifications/cancelled`).
131+
- MCP text/image result content passes through; audio/resource content is described as text.
132+
- Server stderr and `notifications/message` logs land in a per-server ring buffer (`/mcp <name>`).
133+
134+
## Module map
135+
136+
| File | Responsibility |
137+
| --- | --- |
138+
| `extension.ts` | pi wiring: lifecycle, commands, notifications |
139+
| `config.ts` | zod schemas, load + merge of the two `mcp.json` files |
140+
| `server-manager.ts` | connection lifecycle, retries, health checks, transports |
141+
| `tool-bridge.ts` | MCP tools ⇄ pi tools (naming, schema conversion, execution) |
142+
| `schema.ts` | JSON Schema → TypeBox conversion |
143+
| `render.ts` | TUI call renderer (shows tool arguments inline; full JSON when expanded) |
144+
| `auth-storage.ts` / `oauth-provider.ts` / `callback-server.ts` / `auth-flow.ts` | OAuth |
145+
| `skills/mcp-servers/` | bundled skill teaching the model to install/configure servers |
146+
| `test-support.ts` | in-memory MCP server + fake OAuth server (tests only) |

0 commit comments

Comments
 (0)