PASTE-1994 stdio↔HTTP MCP bridge with OAuth#1
Conversation
PASTE-1994 Publish @pasteapp/mcp — stdio→HTTP bridge as npm package
Ship a thin stdio MCP bridge as an npm package so every major AI client (Claude Desktop, Claude Code, Cursor, ChatGPT Desktop) can connect to the running Paste app with a single config line. Why this existsPaste hosts its MCP server as HTTP on loopback, which is the right transport for cross-client reach but doesn't plug into Claude Desktop's one-click Extensions gallery or ecosystem-standard stdio flows. A tiny npm-distributed bridge closes that gap without compromising the in-app architecture. Pattern precedent: NotePlan ships the same way ( What the bridge does
Endpoint file formatWritten by Paste when AI Tools is enabled, deleted when disabled: {
"url": "http://127.0.0.1:39725/mcp",
"token": "…"
}File permissions must be 0600 (user-only). Package
Install instructions to documentPer client, in Help Center:
The Paste settings pane's "Connect an AI app" buttons should write this same config automatically — no user-visible difference between manual and one-click install. Scope
Out of scope (separate issues)
|
2c247f3 to
198d3c1
Compare
198d3c1 to
fdd20e3
Compare
`@pasteapp/mcp` is a stateless stdio↔HTTP MCP bridge written in TypeScript. It runs the full canonical OAuth dance against Paste's loopback MCP server (RFC 9728 + RFC 8414 + RFC 7591 dynamic client registration + PKCE authorization-code flow), caches the resulting access token on disk, and forwards JSON-RPC frames between stdio (Claude Desktop, Cursor, etc.) and Paste's HTTP `/mcp` endpoint. - `discover.ts` — reads `mcpPort` from Paste's UserDefaults via `defaults read` (App Store container plist or non-sandbox plist, probed in parallel), returns `http://127.0.0.1:<port>/mcp` or null. - `oauth/{pkce,callback,store,client}.ts`: - PKCE per RFC 7636. - A one-shot HTTP callback listener whose `state` token is server-validated (rejects favicon, scans, and forged callbacks with the wrong state). `shutdown()` force-closes keep-alive sockets so the flow never hangs in its `finally`. - Atomic mode-0600 JSON token cache (writes via tmp+rename, per-call unique suffix; corrupt JSON is treated as absent, not fatal). Persists `expires_in` as an absolute `expiresAt` so we don't hand out a token that's about to die. - OAuthClient with injected `fetch`, `openBrowser`, and `startCallbackServer` for full test coverage. Runtime-validates DCR `client_id` and token `access_token` shapes (a 200 with `{}` no longer slips through). `defaultOpenBrowser` refuses any URL that isn't HTTP(S) on loopback — the AS metadata's `authorization_endpoint` is attacker-influenceable if the discovered port has been hijacked. - `transport.ts` — POSTs each NDJSON line with `Authorization: Bearer <token>`, captures `Mcp-Session-Id`, splits SSE responses per the WHATWG spec (LF/CR/CRLF terminators, multi-`data:` concatenation with LF, BOM stripping, single-leading-SPACE strip, comments ignored), caps response bodies at 16 MiB (configurable), and on 401 invalidates the token cache and retries the same frame exactly once. Stops processing as soon as stdout is destroyed/ended — the bridge no longer crashes with `ERR_UNHANDLED_ERROR` when the host process dies. - `fallback.ts` — when discovery returns no port, serves a minimal MCP server (`paste_status` tool + `instructions` field) telling the user to start Paste and enable MCP. 74 vitest tests across 7 files cover PKCE shape, token-store roundtrip + mode 0600 + concurrent-save atomicity + corrupt-JSON + forward-compat back-fill, parallel discovery, callback timeout / favicon / empty-code / wrong-state filtering / shutdown keep-alive race, the full OAuth flow against a mock server (cache reuse, URL-mismatch invalidation, `expires_in` storage and expiry skip, runtime validation of DCR and token responses), URL scheme validation for the browser opener, WHATWG-compliant SSE parsing (CR / LF / CRLF / multi-`data:` / BOM / comments), session id capture, 202 silencing, 401 retry exactly-once, response-size cap, stdin EOF clean exit, and stdout-destroyed early termination. Paired with paste-repo PR #2594 which materializes `mcpPort` in UserDefaults on every MCP server bind so the discovery key is always present.
fdd20e3 to
9866926
Compare
Runs `npm ci`, `npm run build`, and `npm test` (74 vitest tests) on every push to main and every pull request, across a Node 18/20/22 matrix on ubuntu-latest. Tests inject mocks for the macOS-only `defaults`/`open` calls, so Linux runners suffice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the 18/20/22 matrix to one job — the bridge uses stable Node APIs, so cross-version coverage wasn't earning its keep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the hand-rolled metadata discovery + DCR + PKCE + token exchange with `@modelcontextprotocol/sdk`'s `auth()` function. The SDK covers RFC 9728 protected-resource metadata, RFC 8414 / OIDC authorization-server discovery, RFC 7591 dynamic client registration, RFC 7636 PKCE, RFC 8707 `resource` indicator, RFC 9207 `iss` validation, and refresh-token rotation. The bridge owns the `OAuthClientProvider` adapter (mode-0600 JSON cache + loopback callback listener + `/usr/bin/open` wrapper). - `oauth/client.ts` shrinks ~80 lines and gains all the spec compliance items above for free. - `oauth/store.ts` now persists SDK shapes (`OAuthTokens`, `OAuthClientInformationMixed`, transient `codeVerifier`) keyed to `serverURL`. - `oauth/callback.ts` keeps generating its own state and validating it server-side; the provider exposes that state via `state()`. - `oauth/pkce.ts` is gone (SDK generates PKCE itself). - Short-circuits to the cached access token before invoking `auth()`: Paste issues long-lived tokens without refresh, so SDK would otherwise fall through to a fresh authorization on every spawn. Transport's 401 retry calls `invalidate()` when the token actually goes stale. 63 vitest tests pass across the touched files.
Trim 11 tests that either re-tested `@modelcontextprotocol/sdk`'s own behavior (DCR / token-exchange happy paths, error-from-AS), tested constants and shape (PASTE_STATUS_TOOL name / description / schema), or exhaustively enumerated the same URL-validation branch (file://, vscode://, http://0.0.0.0 in addition to javascript: and https://evil). Kept everything that defends against bugs we actually found in review (callback DoS / state filtering, store atomicity and 0600, SSE WHATWG edge cases, 401 retry exactly-once, stdout-destroyed exit, cache short-circuit on cached access token) plus one happy-path smoke per file. 63 → 52 tests, no real coverage loss.
Six small fixes from a second review round on the SDK-backed bridge: - Move `assertLoopbackHTTPURL` from `defaultOpenBrowser` up to `BridgeProvider.redirectToAuthorization`. The AS-supplied `authorization_endpoint` is attacker-influenceable if the discovered port has been hijacked, and the guard belongs to the adapter that owns the invariant — not the default opener (a test-injected `openBrowser` would otherwise bypass it). - `loadCache()` returns a fresh empty state when the on-disk cache was issued for a different `serverURL`, instead of silently grafting another server's `tokens`/`clientInformation` onto a save that only touches one field. Closes a latent race for any future caller that multiplexes flows. - `saveTokens()` drops the PKCE `codeVerifier` from disk in the same write. Verifier is single-use; once tokens land it's just at-rest surface for no purpose. - Drop the misleading "RFC 9207 iss validation" claim from the header comment — SDK 1.21 doesn't enforce it. - Tighten `mergeCache` parameter type to `Partial<CachedOAuthState>` and `saveClientInformation` signature to `OAuthClientInformationMixed` to match the SDK `OAuthClientProvider` interface exactly. - Add two tests: `invalidate()` → next call truly re-runs OAuth (the short-circuit/invalidation interaction we trimmed earlier), and `codeVerifier` is absent from the cache after a successful token exchange. 54 tests pass.
Replace the hard-coded "Paste MCP Bridge" client name with a layered detector so each connected app appears in Paste's MCP & AI Tools list under its own name (Claude Desktop, Claude Code, Cursor, Codex, Windsurf, VS Code) instead of all collapsing to "@pasteapp/mcp". Resolution order, top wins: - `PASTE_MCP_CLIENT` env var — set by Paste's Connect AI Tool button or by `add-mcp` invocations that know the target up front. - Process-tree walk via `ps -p <pid> -o ppid=,args=`, stepping past shell / `node` / `npx` wrappers, matching the first ancestor whose argv contains a known signature. - Fall back to `@pasteapp/mcp`. The detected name must contain one of the substrings Paste's Swift `Client.Kind.inferred(fromClientName:)` recognizes (`claude code`, `claude`, `cursor`, `codex`, `windsurf`, `vscode`); otherwise the client lands as `.custom` with the slug. Includes 11 vitest cases covering each known client, env override, multi-hop walks past wrappers, stop-at-unknown-non-wrapper, and the ps-fails fallback. 65 tests total.
The process-tree heuristic alone catches Claude Desktop, Claude Code, Cursor, Codex, Windsurf, and VS Code reliably on macOS, so the env escape hatch is unused surface — the env was only meant for Paste's Connect AI Tool button to inject an authoritative name, but every install path the README documents (`add-mcp`, manual snippets, the Cursor/VS Code deeplink buttons) sets no env, and would always have fallen through to the heuristic anyway. Removing it keeps the detection contract one-shaped.
Summary
@pasteapp/mcpis a stateless stdio↔HTTP MCP bridge. AI tools spawn it via `npx -y @pasteapp/mcp`; it discovers Paste's loopback URL, runs OAuth, and forwards JSON-RPC frames between an stdio MCP client and Paste's HTTP `/mcp` endpoint.Paired with paste-repo #2594 which materializes `mcpPort` in UserDefaults on every MCP server bind so the discovery key is always present.
Architecture
Defensive choices worth flagging:
Tests
54 vitest tests across 6 files. Coverage targets the bug classes that turned up across three review rounds:
CI runs `build` + `test` on Node 20.
Release
`@pasteapp/mcp` is not published yet. Publish only after pasteapp/paste#2594 ships in a Paste release, so the discovery key is present in users' UserDefaults when the npm package becomes available.
```bash
npm publish --access public
```
Test plan