Skip to main content
Glama

pi-as-mcp

A one-shot MCP bridge that gives a remote agent two things on your machine:

  1. Pi's coding tools — read, write, edit, ls, grep, find, bash, powershell.

  2. A human channel — the agent talks to you through tool calls, not through the host platform's chat UI.

Built for the Arena case: the agent runs in the cloud, you point it at this bridge, and it works on your computer. Talking through tool calls keeps one model and one context across the whole session and avoids the platform's chat round-trip.

There are no sessions and no persistence. This process is meant to live for one sitting; "state" is a single in-memory message list. That goes for authorization too: a client that outlives a restart simply authorizes again.

The command is pimcp (the project's short name). Run it from the directory the agent should own.

→ Agent index: AGENTS.md — read that before making non-trivial changes.

What it exposes

Tool

Source

Notes

read write edit ls grep find bash powershell

Pi, verbatim

Descriptions, JSON schemas and prompt metadata come from Pi at runtime

human_say

this repo

Agent → human, returns immediately

human_ask

this repo

Ask and block until answered

human_wait

this repo

Block until the human says anything

get_instructions

this repo

Returns the operating prompt verbatim

The full operating prompt is also sent as the MCP instructions field on initialize, and exposed as an MCP prompt named pi_as_mcp_instructions.

Related MCP server: machine-mcp

Architecture

Arena / any MCP client (cloud)
        │  Streamable HTTP + Bearer   (or stdio)
        ▼
   pi-as-mcp  ──────────────►  Pi tool definitions  (imported, not copied)
      │  ▲                        │
      │  │                        └─► your filesystem / shell, confined to --root
      │  └── OAuth 2.1 (--oauth): discovery, registration, PKCE, tokens
      │
      └─► human channel (in-memory) ──SSE──►  http://127.0.0.1:8790  (your browser)
                  ▲
                  └── where an authorization is approved, by hand

The bridge is not an agent. It has no model and no loop: the cloud agent is the only agent. This process is a tool surface plus a message queue with exactly one human in it.

Quickstart

npm install
npm link                # puts the `pimcp` command on your PATH — once

cd /path/to/workdir     # the directory the agent should own
pimcp                   # server + token + public tunnel + OAuth, prints a paste-ready URL
pimcp --human           # ...and the human channel, so the agent can talk to you
pimcp --no-oauth        # ...without the authorization server (the client needs the token)

pimcp roots the server at the current directory, picks free ports, mints a bearer token, opens a Cloudflare quick tunnel, waits for cloudflared to register with the edge, opens the web UI, and prints the MCP URL and token. Ctrl-C stops everything. pimcp --no-tunnel keeps it on localhost; pimcp --help lists the rest.

OAuth is on by default, so a client that speaks MCP's authorization flow needs nothing but the printed URL: it discovers the authorization server, asks for access, and you approve it in the UI. The same startup also mints and prints a client id and secret, for a product that wants them pasted into it instead (Gemini's "custom connected app"). A client that cannot do OAuth still has the bearer token. --no-oauth removes the authorization server entirely.

The human channel is off unless you pass --human. Without it the agent gets no human_say/human_ask/human_wait and no <human> section in the prompt. The UI still runs whenever --oauth is on, because that is where an authorization is approved — it just has no composer and no conversation in it.

Without the wrapper:

# local client (stdio), tools only
node src/index.ts --root /path/to/workdir

# remote client (HTTP). Point Arena at the printed URL.
node src/index.ts --root /path/to/workdir --http --port 8777 --token "$(openssl rand -hex 16)"

# with the human channel and a yes/no gate on shell commands
node src/index.ts --root /path/to/workdir --human --approve bash

# with an authorization server in front of it (the human channel is not required)
node src/index.ts --root /path/to/workdir --http --oauth \
  --issuer https://mcp.example.com

Node 22.6+ is required: the sources are TypeScript and run directly via Node's type stripping. There is no build step.

The default tool list is platform-aware: powershell is exposed on Windows only, because Pi's PowerShell backend throws only available on Windows the moment it is called anywhere else. Pass --tools read,write,edit,ls,grep,find,bash,powershell to override in either direction.

Pointing Arena at it

Arena is in the cloud, so it needs a public URL:

cloudflared tunnel --url http://127.0.0.1:8777
# then use:  https://<printed-host>/mcp
# header:    Authorization: Bearer <your --token>

Quick tunnels get a new hostname every restart — re-paste the URL each time. If you get Cloudflare error 1033 / HTTP 530, you are pointing at a tunnel from a previous run.

OAuth

--oauth turns the bridge into its own OAuth 2.1 authorization server, so a client can be given access without you copying a token into it. The bearer token keeps working at the same time, for clients that cannot do OAuth. pimcp turns OAuth on by default; --no-oauth goes back to a token-only bridge.

Which of the two shapes below applies is decided by the client, not by you.

A client that registers itself

A spec-shaped MCP client does this without being asked:

  1. It calls /mcp, gets 401 with WWW-Authenticate: Bearer resource_metadata="…".

  2. It fetches /.well-known/oauth-protected-resource/mcp, then /.well-known/oauth-authorization-server.

  3. It registers itself at /register (dynamic client registration, RFC 7591) and sends its browser to /authorize with a PKCE code_challenge.

  4. That page has no buttons. It names the client and where it wants to redirect, and says to approve on your own machine. It then waits.

  5. The request appears as a card in the bridge UI — client name, redirect target, scope — with Allow and Deny. Nothing happens until you click one.

  6. Allow: the browser is handed a one-shot code and redirected back. The client exchanges it at /token for an access token (12h) and a refresh token (30d).

A client you configure by hand: Gemini's "custom connected app"

A hosted product asks you for a client id and a client secret, and shows you — behind a button, in the same dialog — the redirect URI it will use. Such a client never registers itself and never learns that this bridge's scope is called mcp.

Plain pimcp is enough. It mints a credential pair on every run and prints it:

  Client credentials — paste these into the client's own OAuth settings. The redirect URI
  is whatever that client tells you it wants; paste it back there, and the first time it
  asks, the card on the approval page names it and your Allow is what permits it.
      client id      pimcp-h05-kLnEyqe0
      client secret  1lrdVL8ehejLeu10VhXx-ADW3xWnwFpVM2Hsy8AjpGY

Paste the id and secret into the dialog (客户端 ID / 客户端密钥) and continue in it. You do not have to name the redirect URI in advance, and that is not a shortcut: the product tells you the address after the bridge is already running, so there was never a moment at which it could have been registered. When the client finally asks, it waits as a card on the bridge UI like any other request — with the address it named printed on the card, under a line that says what your click is about to mean:

That address was not set up in advance — the client sent it just now, and allowing is what allows it (for this run only).

Allow is what permits that address, and the only thing that does. Approving also records it for the rest of the run, so the same address is not flagged again — but a different address from the same client is flagged again, because the permission is per address, not per client. A client with no secret gets no such path at all: it must use PKCE and one of its registered URIs, since its code has nothing else protecting it.

If you would rather have the strict shape — addresses named up front, anything else refused outright — name them yourself, and the credentials are then printed beside them:

pimcp --oauth-redirect https://oauth-redirect.googleusercontent.com/r/<your-app>
      client id      pimcp-ZhySNFlK2i04
      client secret  PAootb1Kkdx75D6ATZxTQoAJ2icfeF3RQzY0xTw-rNM
      redirects to   https://oauth-redirect.googleusercontent.com/r/<your-app>

Those credentials are minted fresh on every run, because nothing here is persisted. To keep a pair across restarts — which is what you want, since the client already holds it — pin both, with or without --oauth-redirect:

pimcp --oauth-client-id pimcp-gemini --oauth-client-secret "$(openssl rand -base64 32)"

What a hand-configured client may do differently, and why:

  • Skip PKCE. Its client secret is what protects the authorization code — the arrangement RFC 6749 §4.1 described before PKCE existed — and the code only goes to its address or to one you approved. A client without a secret must use PKCE S256: that is what the MCP specification requires, and it is the only thing protecting its code. A public client that skips PKCE is turned away with invalid_request.

  • Send its secret in the body or as HTTP Basic. Which of the two a hosted client chooses is not this bridge's decision to make.

What it may not do: get a token without your click, or get one at all without its secret. --oauth-redirect replaces the registration step, not the human.

A scope the bridge does not have is narrowed to mcp rather than refused (RFC 6749 §3.3 permits issuing less than was requested). A hosted client asks for whatever its own platform taught it to ask for — openid, say — and has no way to find out otherwise. The consent page states what was asked for and what will actually be granted.

The decision lives on the bridge UI on purpose. The page at /authorize is served from whatever address the client reached — over a quick tunnel, that is the open internet — and a button there would be an authorization any stranger who found the URL could click. The UI is bound to loopback and can carry --ui-token; a page that only you can open is the only kind that can be allowed to hand away a shell. Note that this is why --oauth starts the UI: the page carrying Allow/Deny is not something you have to want to converse with an agent to need. Without --human you get the UI as an approval console and nothing else — no human_say/human_ask/human_wait, no composer in the page.

Requests wait 10 minutes for you and then expire; at most 8 may be pending at once. Everything is in memory: tokens, registered clients and pending requests all die with the process, so a restart means the client authorizes again.

Reachability is the whole game. The authorization server advertises exactly one issuer (--issuer, or the quick-tunnel hostname, or http://127.0.0.1:<port>), and a client checks it against the address it actually reached. So the URL you give the client has to be the URL the bridge is reachable at, and it must be https — except for a loopback address, which is allowed unencrypted for local use. The bridge logs the issuer it chose, and warns when that is a loopback address a remote client cannot use. A stable Zero Trust hostname is a better fit here than a quick tunnel, whose hostname changes every restart.

The human UI

http://127.0.0.1:8790 is a single self-contained page — no CDN, no build step, and the markdown and LaTeX libraries are vendored in src/human/vendor/, so it works with the network off. It runs with --human, and with --oauth (where it is only an approval console: no composer, no conversation). Without either flag the port is never bound.

  • One timeline. Agent messages, your messages, and every tool call the agent runs on this machine, in the order they happened. Tool rows are compact (bash · echo hi · 42 ms · ✓); click one to expand its output. Failed calls expand themselves. The toolbar hides all tool rows if you only want the conversation.

  • A runtime info panel (☰ info): sandbox root, exposed tools, which tools need approval, the deny list, the wait window, uptime, the MCP endpoint, and whether the tokens are set. A port serving a shell with no token gets a red warning. Token values are never shown.

  • A countdown. human_ask/human_wait block for --block-ms (90s by default) and then return no_message. The header shows the seconds left so you know whether to hurry.

  • Alerts. 🔔 asks for notification permission and turns on a short chime — different tones for a question, a done, and an error — plus a flashing tab title. Off by default.

  • Agent messages render as markdown and LaTeX ($…$, $$…$$, \(...\), \[...\]). They come from a remote model, so the HTML they produce is reduced to a tag allowlist before it touches the page: raw HTML is displayed, never executed.

  • One bubble style. Messages carry a category, but it is not printed on them — a chat does not have message types. The category still picks the alert sound.

  • Authorization cards. With --oauth, a client asking for access appears above the timeline — not in it, because it is not something the agent said — with Allow and Deny. It stays until you answer or it expires in 10 minutes, and it chimes like a question. The client's name is shown as text, never as markup: it chose that name itself.

  • What ran on this machine. Tool calls are listed whenever the UI is up, including the --oauth-only case. Watching a client work while you decide whether to trust it is the point of having the page there at all.

ui.html is read on every request, so changing it costs a browser refresh, not a restart of the bridge. Changes to src/*.ts still need a restart.

The queue

Whatever you type while the agent is busy is queued, and its next human_ask/human_wait takes all of it at once — oldest first, each line stamped [n/total · HH:MM:SS]. A single message arrives verbatim. So three short lines are read as the one thought they are, and a retraction arrives together with the message it retracts instead of a round trip later.

There is no settle window: the first message wakes a blocked call immediately, and anything typed after that waits for the next call. There is no cap on the queue, and nothing is persisted, so a restart clears it.

Configuration

Flags take precedence over environment variables. Environment variables all carry the PI_AS_MCP_ prefix, after the project's name. The defaults below are the server's; pimcp deliberately differs on one of them (it turns --oauth on) and says so in the table row.

Flag

Env

Default

Meaning

--root

PI_AS_MCP_ROOT

cwd

Sandbox root. Every path argument is confined here.

--human

PI_AS_MCP_HUMAN=1

off

Expose the human channel: human_say/human_ask/human_wait and the approval gate. The web UI comes with it, but is not exclusive to it — --oauth starts the UI too.

--tools

PI_AS_MCP_TOOLS

7 on Linux, 8 on Windows

Comma-separated subset to expose. powershell is Windows-only.

--approve

PI_AS_MCP_APPROVE

none

Tools that require human approval, e.g. bash. Needs --human: with no human there is nobody to ask, so the bridge refuses to start instead of auto-approving.

--deny

PI_AS_MCP_DENY

.ssh,.aws,.gnupg,.git-credentials,.netrc,.config/gcloud

Paths refused even inside the root.

--block-ms

PI_AS_MCP_BLOCK_MS

90000

Max time human_ask/human_wait block before returning no_message.

--http

PI_AS_MCP_HTTP=1

off

Serve Streamable HTTP instead of stdio.

--port / --host

PI_AS_MCP_PORT / PI_AS_MCP_HOST

8777 / 127.0.0.1

HTTP listener. A port already in use is a hard error, not a silent fallback.

--token

PI_AS_MCP_TOKEN

none

Bearer token. Set this if you expose the port, unless you use --oauth.

--oauth

PI_AS_MCP_OAUTH=1

off (on in pimcp)

Serve an OAuth 2.1 authorization server in front of /mcp, and run the UI that approves it. Needs --http.

--issuer

PI_AS_MCP_ISSUER

the tunnel hostname, else http://127.0.0.1:<port>

The URL clients reach, and the identity the authorization server advertises. Only with --oauth. Must be https, or a loopback http address.

--oauth-redirect

PI_AS_MCP_OAUTH_REDIRECT

none

The addresses a hand-configured client may return to (comma-separated), named before it ever asks. Without this the address is not known in advance: the first one the client sends is shown on the approval card, and your click is what allows it. Only with --oauth.

--oauth-client-id / --oauth-client-secret

PI_AS_MCP_OAUTH_CLIENT_ID / …_SECRET

minted per run

Use this credential pair instead of minting one, so a restart does not invalidate a pair a client already holds. Both together, and only with --oauth.

--ui-port / --ui-host

PI_AS_MCP_UI_PORT / PI_AS_MCP_UI_HOST

8790 / 127.0.0.1

The web UI. Runs with --human or --oauth; ignored without either.

--ui-token

PI_AS_MCP_UI_TOKEN

none

Token for the UI. Set it if you bind the UI to anything but loopback — with --oauth, the UI is what can grant access here.

Security

This bridge hands a remote model a shell on your machine. It is remote code execution by design, so:

  • Run it in a container or VM, with only one work directory mounted. --root confines path arguments, but a shell can still reach anything the process can.

  • Always set --token when using --http, unless you are using --oauth. Without either, the port is unauthenticated and the bridge logs a warning.

  • With --oauth, the web UI is the authorization authority. Keep it on loopback, or set --ui-token. A UI that others can reach with no token is a way for them to authorize themselves; the bridge warns and the info panel calls it out.

  • A hand-configured client's secret is a credential you now have to keep. It is printed once per run unless you pin it with --oauth-client-id/--oauth-client-secret; anyone who reads it can ask for access, but still cannot get a token without your click.

  • Approving an address you were not expecting is the one decision worth slowing down on. A confidential client's callback address only has to be approved by you once, and the card says so in as many words — but if you did not just start that client, the address on the card is somebody else's, and Allow sends them a code. Their secret is still required to turn it into a token, which is why this is a warning and not a refusal; a client with no secret gets no such path at all.

  • An OAuth access token is a shell until it expires (12h, refreshable for 30 days or until the process ends). Denying a request that has not been answered yet is the only way to refuse — once granted, the way to take it back is to restart the bridge. Keep the run short; that is what this process is for.

  • Use --human --approve bash (and powershell) if you want a gate per command. Every shell command then blocks on a yes/no prompt in the human UI. It fails closed: no answer means denied. The gate needs --human, because with the human channel off there is nobody to ask — the bridge refuses to start rather than let --approve silently mean "allow".

  • Rotate the token per sitting. For one-shot use, quick tunnels that change hostname are fine — they expire on their own.

  • Tool output (file contents, command output) transits the remote platform and may be logged there.

How Pi's prompt and tools were ported

Both are taken from @earendil-works/pi-coding-agent@1.0.0 (MIT) at runtime, so they cannot drift:

  • Tools. The package's root export drags in the TUI and native image codecs, and it has no subpath export for the tool modules. So src/pi-tools.ts resolves the package directory and imports dist/core/tools/index.js directly, then calls createAllToolDefinitions(cwd, …). Only that module graph loads. Descriptions, JSON schemas, promptSnippet and promptGuidelines are used as-is; TypeBox emits plain JSON Schema, which is what MCP wants.

  • Prompt. buildSystemPrompt is not exported, so src/prompt.ts reproduces Pi's algorithm from core/system-prompt.ts: the preamble stays raw, every other section is rendered as <name>…</name>, and the rules list is built by buildRules() — per-tool guidelines in tool order, then global guidelines, then Pi's two closing rules.

Adapted deliberately:

  • The preamble says you reach a remote machine through a bridge rather than claiming to run inside Pi.

  • Pi's docs and skills sections are dropped: they point at Pi's local documentation and there is no Pi session here.

  • The <human> section is new. It replaces humanchat's per-call "MANDATORY RULES" block, which was appended to every tool result; the rules are stated once, in the prompt — with one deliberate exception. A no_message result leads with a plain sentence telling the model to call again, because stating the rule only in the prompt did not hold a real client agent in the wait loop. See the gotcha below.

  • Shell session variables (PI_SESSION_ID, PI_MODEL, …) are switched off via exposeSessionEnvironment: false, since there is no session; this also drops the guideline telling the model to read them.

Shipping it to somebody else

npm run package produces a self-contained directory that runs on a machine with no Node, no npm, no cloudflared, and no network:

npm run package           # for the machine you are on
npm run package:win       # dist/win32-x64   — cross-buildable from anywhere
npm run package:linux     # dist/linux-x64
dist/win32-x64/
  pimcp.cmd        double-click launcher (Windows)
  pimcp.sh         launcher (Linux/macOS)
  pimcp.mjs        the launcher that runs inside the package
  runtime/         the Node runtime — no npm/npx/corepack
  bin/             cloudflared, vendored
  app/
    index.mjs      the bridge *and Pi's tools*, bundled — ~3.1MB
    node_modules/  only Pi's lazy image-resize wasm — everything else is in the
                   bundle
    ui.html        the human UI page
    vendor/        marked + KaTeX, vendored

The recipient runs it from the directory the agent should own:

pimcp.cmd                          # Windows: tunnel + localhost, prints both URLs
./pimcp.sh                         # Linux
./pimcp.sh --human --approve bash  # with the human channel and a shell gate

Every flag the bridge understands works unchanged. On startup it prints an MCP URL you can paste into your client from anywhere on the internet — and because --oauth is on by default, a client that speaks the authorization flow needs nothing else. A bearer token is printed too, for clients that cannot do OAuth. That is the whole "I run it on my machine, you connect from yours" flow. --no-tunnel keeps it on localhost, --no-oauth drops the authorization server.

Four details worth knowing:

  • Pi's tools are bundled into index.mjs, but only in the artifact. src/pi-tools.ts resolves Pi's tool entry at runtime, because Pi's exports map has no subpath for dist/core/tools/* and Node ignores tsconfig paths — so a static import cannot work in the checkout. When bundling, scripts/package.mjs rewrites that expression into a literal import of src/pi-tools-entry.ts, a seam whose tsconfig alias points at the installed package. esbuild can follow a literal, so Pi's ~265KB of tool source gets inlined and the artifact no longer needs its 143MB node_modules. Descriptions and schemas still come from the installed version, so they cannot drift.

  • Nothing but one wasm file is left in node_modules, and that is deliberate. esbuild inlines every static import — Pi's tools, the MCP SDK, and express, which --oauth brings in with the SDK's authorization router — so the SDK's 5.6MB copy beside the bundle is gone; a packaged build with that directory deleted still served initialize, read, bash, human_say and a whole OAuth flow. What remains is @silvia-odwyer/photon-node, the 2.2MB wasm Pi only import()s when an image actually needs resizing, because a bundled wasm blob cannot be lazily loaded. It is copied to the name the bundle asks for rather than the nested path npm installed it at: Node resolves a bare specifier starting from app/index.mjs, and the nested location is not on that path — which is how it was found.

  • cloudflared is vendored, so a tunnel needs nothing installed. It is copied from a local install when there is one (including from inside WSL when cross-building for Linux), and downloaded from the GitHub release otherwise. Without it the package still runs, just without a public URL, and the launcher says so.

  • Quick tunnels rotate their hostname on every restart. The URL is printed each time, so re-paste it. For a stable hostname, run a named tunnel or a Zero Trust hostname instead — the bridge only ever needs http://127.0.0.1:<port>/mcp.

Two packaging traps that cost real time, both found by running the artifact rather than reading the build log:

  • The bundle is ESM, so its CJS dependencies have no require. esbuild's interop shim throws Dynamic require of "child_process" is not supported the moment Pi's bash tool pulls in cross-spawn. A createRequire banner fixes it; format: "cjs" cannot be used because src/index.ts awaits the listener at top level.

  • Externals must list both spellings of a builtin. A CJS dependency writes require("child_process"), and esbuild does not normalise it to the node: form before matching external, so "node:*" alone silently misses it.

  • No single-file .exe. A Node SEA would still need a runtime and the UI assets beside it, and the bridge has to read ui.html per request. A directory with a launcher is the honest shape.

What actually takes up the space

Measured on the Linux package, and the Windows one differs only in ratio:

Part

Size

Why

runtime/node

123 MB

The Node binary itself. Only ~41MB is .text; the rest is .data, symbols and debug info that strip cannot remove. This is the floor for any Node-based artifact.

bin/cloudflared

27–39 MB

A statically linked Go binary, vendored so a tunnel needs nothing installed.

app/

6 MB

index.mjs (~3.1MB: Pi's tools, plus express and the MCP SDK's authorization router for --oauth), Pi's lazily-loaded wasm, the UI page and its vendored libs.

npm, npx, corepack, share/ and lib/node_modules are deleted from the runtime: the bridge spawns node on its own bundle and never runs a package manager. Deleting bin/ alone is not enough — that only removes the launchers, while the 17MB of npm itself lives in lib/node_modules.

Tests

npm test        # config defaults, stdio round-trip, HTTP round-trip + approval gate,
                # the OAuth flow end to end, and both UI probes
npm run typecheck
npm run accept  # end-to-end human acceptance; you reply in the browser

The probes spawn the real server and assert on real MCP traffic, including that a sandbox escape is refused, that an unapproved shell command never runs, and that an authorization code cannot be replayed, a wrong PKCE verifier is refused, and a denied request grants nothing. scripts/oauth-probe.mjs then runs two more servers, because the shape of the client decides the shape of the flow. One is a hosted product's "custom connected app" — a pre-registered confidential client, no --human — driven with no PKCE at all, once with the secret in the body and once as HTTP Basic. The other is the default, the shape plain pimcp produces: credentials printed, no redirect URI known in advance, an address that arrives late and is allowed only by the click on its card, and a public client refused outright for the same address.

Gotchas

  • Blocking over a tunnel. human_ask/human_wait hold the HTTP request open. Cloudflare drops a silent origin after ~100s, which is why the default cap is 90s. Raising --block-ms past that risks a 524; the call also emits MCP progress notifications every 20s to keep the stream warm when the client supplies a progress token.

  • no_message is not an error. It means the human did not answer within the window. The prompt instructs the model to call human_wait again. A model that ignores this will end its turn early.

  • One blocking call at a time. A second concurrent human_ask/human_wait returns immediately with no_message rather than queueing.

  • Stateless HTTP. A fresh Server + transport is created per request. Reusing one transport makes every request after initialize fail with HTTP 500.

  • bash on Windows needs a real bash on PATH; use powershell otherwise.

  • Do not judge a tunnel by knocking on its URL. A local VPN or proxy (fake-IP TUN) can leave your own machine unable to reach its own public URL — ECONNRESET, HTTP 000 from curl, or ENOTFOUND when the proxy also intercepts DNS — while the tunnel is perfectly healthy for remote clients. The reliable signal is cloudflared logging Registered tunnel connection; pimcp waits for exactly that and then reports the local probe as advisory only.

  • A model will happily end its turn on a no_message. Observed with a real client agent: it called human_wait once, read no_message, and ended the conversation — silently dropping the human, which is the exact failure this bridge exists to prevent. Prompt-only instruction was not enough, so a no_message result now leads with an imperative sentence and the <human> section names the failure mode outright ("your turn must not end while you are waiting"). If you touch either, re-test with a real client agent — npm test cannot catch this.

  • Port 8765 is taken by AgentDock on this machine, which is why the HTTP default is 8777.

  • The advertised issuer carries a trailing slash. The SDK writes both the issuer and the resource metadata from URL.href, so a bare origin comes out as https://host/. That is fine as long as the two documents agree — a client resolves the metadata endpoint from authorization_servers[0] and compares the issuer it finds against exactly that string — which is what scripts/oauth-probe.mjs checks. Do not "fix" one of them by hand.

  • --oauth starts the web UI, and that is not the same as --human. The UI is where Allow/Deny lives; the human channel is whether the agent can talk to you. Requiring --human for --oauth is what made a hosted client impossible to add: an authorization needs a click, not a conversation.

  • PKCE is required from a public client and optional for one with a secret. The MCP specification requires S256 from every client, and the SDK's own /token handler enforces it (code_verifier: z.string()), which a client such as Gemini's connector may never send. src/oauth-http.ts therefore serves /authorize and /token itself, mounted before mcpAuthRouter so its versions are shadowed — which is why nothing in them may call next(). Discovery, /register and /revoke are still the SDK's.

  • A scope this bridge does not have is narrowed, not refused. A hosted client asks for whatever its platform taught it (openid, email) and cannot learn otherwise; RFC 6749 §3.3 allows issuing less than requested. What must never happen is granting more, and the probe asserts the scope on the token, not just on the page.

  • A redirect URI nobody registered is decided by the human, not by the bridge. The credentials are printed before any address is known, and a hosted product only shows you its callback address afterwards — so refusing an unregistered address would make that client impossible, and accepting it silently would mean nobody ever looked. It is accepted and printed in full on the same card as Allow/Deny, and only a confidential client gets this: without a secret the exact match is all that keeps a code from being delivered elsewhere. Approving records the address for the rest of the run, so a repeat is not flagged again.

  • Authorization endpoints are rate-limited, and the tunnel sets X-Forwarded-For. Express refuses to key the limiter on a header it has not been told to trust, which shows up as noise or a 500 that only happens behind the tunnel. src/index.ts sets trust proxy 1 for that one hop, and the OAuth probe sends the header so this cannot regress silently.

  • Packaging used to depend on which shell started it. Extracting the Node archive goes through tar, and Git Bash puts MSYS GNU tar first on PATH — which reads D:\… as a remote host (Cannot execute remote shell, then Cannot connect to D: resolve failed) and cannot read a .zip at all (This does not look like a tar archive). PowerShell worked by accident, because tar there is Windows' bsdtar. scripts/package.mjs now names the extractor (%SystemRoot%\System32\tar.exe on Windows) and passes dist/-relative paths, so npm run package behaves the same in Git Bash, PowerShell and cmd. If you see "does not look like a tar archive" on a zip you know is intact, that is this.

History

This repository previously contained HumanChat MCP, a Python human-in-the-loop MCP server (broker + TUI + Web UI). It was replaced by what later became pi-as-mcp; that code was removed from the working tree. The old implementation, including the edits that were uncommitted at the time of removal, is preserved at tag humanchat-final:

git show humanchat-final --stat
git checkout humanchat-final -- src/humanchat/webui_assets.py   # recover a single file

The project was called pi-mcp until it was published, and was renamed to pi-as-mcp to match the repository. The command stayed pimcp, and the environment variables moved from PI_MCP_* to PI_AS_MCP_* — there are no aliases for the old names.

The former pi-mcp/ subdirectory was promoted to the repository root; git log --follow crosses that move.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables external AI (ChatGPT, Claude) to securely access a local workspace via Cloudflare Tunnel, providing file operations, shell commands, Git operations, and more with bearer token authentication and sandbox isolation.
    1
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables web-based AI agents such as claude.ai, ChatGPT and Grok to read and edit files and run allowlisted shell commands on your own machine, plus control Chromium, DevTools, ports, git, SQLite and background tasks. Access is exposed over HTTPS with OAuth 2.1 through a swappable public edge (Tailscale Funnel or a Cloudflare tunnel), with a loopback-only control panel for configuration.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables an AI assistant to delegate coding tasks to local Codex or Claude Code CLIs, monitor their progress, read results, continue sessions, and browse the files in user-approved project workspaces. It exposes these capabilities over Streamable HTTP through a Cloudflare tunnel or locally over stdio, so a remote or cloud client can drive agents running on the user's own machine.
    179 npm
    1
    MIT