Skip to content

PASTE-1994 stdio↔HTTP MCP bridge with OAuth#1

Merged
qubblr merged 10 commits into
mainfrom
paste-1994-implement-bridge
May 28, 2026
Merged

PASTE-1994 stdio↔HTTP MCP bridge with OAuth#1
qubblr merged 10 commits into
mainfrom
paste-1994-implement-bridge

Conversation

@qubblr
Copy link
Copy Markdown
Contributor

@qubblr qubblr commented May 26, 2026

Summary

@pasteapp/mcp is 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.

[Claude Desktop / Cursor / Codex / …] ── stdio ──> @pasteapp/mcp ── HTTP+OAuth ──> Paste.app on 127.0.0.1

Paired with paste-repo #2594 which materializes `mcpPort` in UserDefaults on every MCP server bind so the discovery key is always present.

Architecture

  • `discover.ts` — reads `mcpPort` from Paste's UserDefaults via `defaults read`, probing the App Store sandbox container plist and the Direct/Setapp plist in parallel.
  • `oauth/client.ts` — thin `OAuthClientProvider` adapter on top of `@modelcontextprotocol/sdk`'s `auth()`. The SDK does metadata discovery (RFC 9728 + RFC 8414 + OIDC fallback), DCR (RFC 7591), PKCE (RFC 7636), the `resource` indicator (RFC 8707), and refresh-token rotation. We provide the cache, the browser launcher, and the loopback callback listener. Short-circuits to the cached access token before invoking `auth()` since Paste issues long-lived tokens without refresh; transport's 401 retry triggers a re-auth when one actually goes stale.
  • `oauth/callback.ts` — one-shot HTTP listener on a random loopback port. Generates per-flow `state` and validates it server-side, rejecting favicon probes, scans, and forged callbacks with the wrong state. `shutdown()` force-closes keep-alive sockets so the flow can't hang.
  • `oauth/store.ts` — atomic, mode-0600 JSON cache keyed to `serverURL` (writes via tmp+rename with a per-call random suffix; corrupt JSON treated as absent). Stores SDK shapes (`OAuthTokens`, `OAuthClientInformationMixed`, transient `codeVerifier` that's wiped on `saveTokens`).
  • `transport.ts` — POSTs each NDJSON line with `Authorization: Bearer`, captures `Mcp-Session-Id`, splits SSE per the WHATWG spec (LF/CR/CRLF terminators, multi-`data:` concatenation, BOM stripping, leading-SPACE strip, comments ignored), caps response bodies, and on 401 invalidates the token cache and retries the same frame exactly once. Exits cleanly when stdout is destroyed (no `ERR_UNHANDLED_ERROR` when the host process dies).
  • `fallback.ts` — when discovery returns no port, serves a minimal MCP server with one `paste_status` tool plus an `instructions` field telling the user to start Paste.

Defensive choices worth flagging:

  • `assertLoopbackHTTPURL` in `BridgeProvider.redirectToAuthorization` refuses any `authorization_endpoint` that isn't HTTP(S) on loopback — the AS metadata is attacker-influenceable if the discovered port has been hijacked.
  • Cache mismatch on `serverURL` drops the cached state rather than overwriting, so a future caller that multiplexes flows can't graft one server's tokens onto another's.
  • PKCE `code_verifier` is wiped from disk in the same write that lands the tokens.

Tests

54 vitest tests across 6 files. Coverage targets the bug classes that turned up across three review rounds:

  • Token-store atomicity, mode-0600, corrupt-JSON resilience
  • Callback-server state filtering (DoS resistance), keep-alive shutdown (no hang), timeout
  • SSE WHATWG edge cases (CR, LF, CRLF, multi-line `data:`, BOM, comments)
  • 401 retry exactly once (not three), stdout-destroyed early exit
  • Full OAuth flow against a mock server; cache reuse vs. URL-mismatch invalidation; `invalidate()` → real re-auth; verifier wiped after exchange
  • Loopback URL guard for the browser opener

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

  • `npm run build` clean, shebang preserved, bin executable
  • `npm test` — 54 tests pass
  • Smoke against running Paste: `npm link` + `claude mcp add paste -- paste-mcp`; first run opens browser for OAuth consent, second run uses cached token (~1s)
  • After paste-repo PR ships: end-to-end against Paste 6.6 from Claude Desktop, Cursor, Codex
  • `npm publish --access public` once verified

@qubblr qubblr self-assigned this May 26, 2026
@linear
Copy link
Copy Markdown

linear Bot commented May 26, 2026

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 exists

Paste 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 (@noteplanco/noteplan-mcp) and supports every major client.

What the bridge does

[Any MCP client] ──stdio──> pasteapp/mcp ──HTTP──> Paste.app (127.0.0.1)
  • Launched as a subprocess by the client, speaks stdio MCP
  • Reads Paste's URL + bearer token from a known file that Paste writes when AI Tools is enabled (e.g., ~/Library/Application Support/com.pasteapp.Paste/mcp-endpoint.json, mode 0600)
  • Forwards every MCP JSON-RPC message over HTTP to Paste
  • If Paste isn't running / AI Tools is off / file missing, surface a single tool that returns a helpful message: "Start Paste and enable AI Tools in Settings"
  • Stateless, thin, ~150 lines. No tool logic of its own.

Endpoint file format

Written 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

  • Name: @pasteapp/mcp
  • Language: Node (TypeScript). Matches ecosystem convention (npx runnable).
  • License: MIT
  • Repository: separate public repo — https://github.com/pasteapp/paste-mcp (linked from pasteapp.io)
  • Single entry point: npx pasteapp/mcp

Install instructions to document

Per client, in Help Center:

Client Install
Claude Desktop Config file: command: npx, args: ["-y", "@pasteapp/mcp"]
Claude Code claude mcp add paste -- npx -y pasteapp/mcp
Cursor Edit ~/.cursor/mcp.json with same command
ChatGPT Desktop Settings → Advanced → Edit MCP Config

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

  • Implement the bridge
  • Publish to npm (npmjs.com) under the @pasteapp org
  • Create the public repo with README + install-per-client examples
  • Update Paste's Help Center articles to reference the bridge
  • Update the settings pane's "Connect an AI app" buttons to use this command

Out of scope (separate issues)

  • MCPB wrapping for Claude Desktop Extensions gallery
  • Community directory submissions
  • Anthropic outreach re: Remote Connectors Directory

Review in Linear

@qubblr qubblr force-pushed the paste-1994-implement-bridge branch from 2c247f3 to 198d3c1 Compare May 26, 2026 13:46
@qubblr qubblr changed the title PASTE-1994 Minimal launcher for paste-mcp-stdio PASTE-1994 Pure-JS bridge: discover + OAuth + stdio↔HTTP May 26, 2026
@qubblr qubblr force-pushed the paste-1994-implement-bridge branch from 198d3c1 to fdd20e3 Compare May 26, 2026 14:09
@qubblr qubblr marked this pull request as draft May 26, 2026 14:20
`@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.
@qubblr qubblr force-pushed the paste-1994-implement-bridge branch from fdd20e3 to 9866926 Compare May 26, 2026 14:29
stel and others added 6 commits May 27, 2026 09:00
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.
@qubblr qubblr changed the title PASTE-1994 Pure-JS bridge: discover + OAuth + stdio↔HTTP PASTE-1994 stdio↔HTTP MCP bridge with OAuth May 28, 2026
@qubblr qubblr marked this pull request as ready for review May 28, 2026 07:52
qubblr added 3 commits May 28, 2026 11:01
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.
@qubblr qubblr merged commit 8d79dc4 into main May 28, 2026
1 check passed
@qubblr qubblr deleted the paste-1994-implement-bridge branch May 28, 2026 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants