omp A2A Bridge
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@omp A2A BridgeShow me the output of 'df -h' on the host"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
a2mcp
This is an omp extension. Once installed, the omp running on this machine also serves an MCP endpoint (MCP is the standard protocol for agents calling external tools): any program that speaks MCP — another omp, Claude Code, a curl script — can call this machine's tools (read, bash, edit…), and the commands run for real in this machine's shell, on this machine's files. The bridge itself never issues a model request: request in, tool run, result back — the reasoning is on the caller's side. It listens on 127.0.0.1 by default; reaching it from another machine means SSH port forwarding.
The name states the mechanism: a2mcp — Agent2MCP, one agent's tool surface handed to another agent over HTTP. The A2A in the old name is a different protocol, and this code implements none of it: no agent card, no task state machine, no contextId or taskId. What is on the wire is MCP 2025-11-25, and the semantics are "the caller names a tool, the host runs it".
Which one to use
The host ships omp acp: an editor (Zed, say) starts it, hands it a prompt, and it
calls a model and does the work itself, streaming the result back to the editor.
If you want a local agent that works for you, use that.
This bridge does no work. What arrives is a tool name and arguments; the bridge hands them to the host and sends the result back unchanged — the thinking happens on the caller's side. So if what you want is "I reason, and I borrow this machine's hands", use this bridge.
Neither substitutes for the other. ssh host 'omp acp' is no help either: what
starts there is a different agent doing its own reasoning, not your model.
| this bridge | |
who reasons | omp | the caller |
transport | stdio | Streamable HTTP + Bearer |
who connects | a code editor | any MCP client |
Related MCP server: mcp2api
How it works
graph TB
subgraph Remote["🖥️ remote omp (MCP client)"]
Client["🔌 MCP client<br/>mcp__omp-host__read"]
end
subgraph Host["🏠 host omp (MCP server)"]
direction TB
Serve["⚙️ src/server.ts<br/>Bun.serve · auth + protocol<br/>tools/list · tools/call"]
Bridge["🌉 src/bridge.ts<br/>Main session registry"]
Tools["🛠️ host's real tools<br/>read · bash · edit"]
Serve -->|"getToolByName().execute()"| Bridge
Bridge --> Tools
end
Client -->|"HTTP/JSON-RPC<br/>2025-11-25 · Bearer token"| Serve
classDef client fill:#FFE66D,stroke:#F08C00,color:#000
classDef server fill:#4ECDC4,stroke:#0B7285,color:#fff
classDef tool fill:#A8DADC,stroke:#1864AB,color:#000
class Client client
class Serve,Bridge server
class Tools toolThe extension starts
Bun.serveonsession_startand implements MCP2025-11-25'sinitialize/tools/list/tools/call, answering with plain JSON (no SSE).The endpoint is one per process; the host's
session_start/session_shutdownare one per session (a task subagent's runner, an ACP session, a persisted revive each get their own). A latersession_startreuses the running server (two in flight at once bind only one endpoint), and asession_shutdownonly releases the port once the host registry holds no liveMainsession (AgentRef.sessionis null exactly when it is parked or aborted, andtools/callalready refuses that state) — otherwise any subagent finishing would take the endpoint down with it. A shutdown that lands mid-bind has no port to stop yet, so the start re-reads the registry when the bind finishes and gives up what it just bound ifMainis gone. Releasing uses a non-forcedstop(): the listener closes at once and new connections are refused, while a request already in flight finishes and gets its audit pair — a forced close would leave atools/callwith half its record.The catalog, the exposure gate and execution read one registry: the host's
Mainsession (getAllToolInfos(), which is exactly what the host wirespi.getAllTools()to). Execution then resolves the name through that same session'sgetToolByName. With no liveMain(parked or aborted, before the port is released) the catalog falls back to the runner the entry is pinned to instead of reporting an empty registry.The catalog is the host registry as-is, plus the bridge's own three
xfer_*tools; nothing else is filtered or added (see Security and boundaries).The bridge itself issues no model requests: one remote call is "receive request, run tool, answer". But execution lands in the host's real session, and the session-level side effects a tool leaves behind can wake the host's own agent — and that inference spends the host's tokens (see Security and boundaries). Whether the host may reason at all is itself controllable from the remote side: Host inference takeover.
Install
On any machine, from the git URL:
omp install https://github.com/adam-ikari/a2mcp.gitThat installs it into ~/.omp/plugins/node_modules/a2mcp. To remove it:
omp plugin uninstall a2mcpUpgrading from v0.3.0 or earlier: uninstall first. The plugin lock keeps its
record under package.json:name, and this round changed the name. Installing the
new URL while pi-a2a-ext is still registered leaves both entries in place, and
the host loads both — two ports, two tokens, two takeover arms in one process,
where the second arm's snapshot is the only one left to restore from.
omp plugin uninstall pi-a2a-ext
omp install https://github.com/adam-ikari/a2mcp.gitNothing reads ~/.omp/agent/a2a-bridge.json any more and the bridge does not look
for it: the config file is ~/.omp/agent/a2mcp.json now, with a token generated
fresh, so every remote mcp.json has to be re-copied. a2a-bridge.log and
a2a-bridge.installs.json stay where they are; the new generation writes its own
two files. Leftover A2A_BRIDGE_* environment variables are ordinary variables
now — nobody reads them.
That is the only install path. omp install itself also takes a directory and
npm specs, but this extension does not offer a local install: what you install is
the repository behind the git URL above. The pi.extensions field in
package.json is what tells omp which entry file to load, so do not remove it.
The URL has to be the full https://….git — a GitHub owner/repo shorthand is
rejected as an invalid package name, and pointing it at a .tgz fails with
ENOTDIR.
No omp on the machine yet? The repo ships a bootstrap script, and the host layer is all it manages:
sh scripts/bootstrap.sh --dry-run # print the plan, write nothing
sh scripts/bootstrap.shIt does three things: with no omp on PATH it runs the official installer
(curl -fsSL https://omp.sh/install | sh, or --binary --ref v<version> when it can
read a pin from the package.json next to it); then it runs the one omp install <git-url> above; then it checks itself with the host's own omp plugin doctor and
compares the version the host reports against that pin. A host already on PATH is
left alone (--upgrade to re-run the installer), an existing registration is skipped
(--reinstall to force it), and --uninstall removes only the registration, leaving
the token and the audit log.
Installing the extension still has one path, and the last thing the script does is
run it. It symlinks nothing, offers no local install, validates no module graph and
generates no token. The host checks for its own updates at startup by default, so
"installed at the pinned version" is not "stays at the pinned version". The coverage is
the test:bootstrap section of docs/testing.md (28 checks); the
host-install half is never executed on a development machine, which is why what that
probe tests is the script's decisions.
All of the above was measured, and bun run test:install checks the package,
installs it into a temporary HOME, starts a host there, and drives the bridge
over MCP as a remote client. Since host 18.11.0 — re-checked on 18.12.1 —
~/.omp/plugins resolves by HOME, and what loads is the registration in
omp-plugins.lock.json — a bare symlink loads nothing. So that half is
self-contained, and it leaves no registration on your real machine. See
docs/testing.md.
Nor hand-symlink into ~/.omp/agent/extensions/:
ln -s "$PWD/extensions/a2mcp.ts" ... only works when $PWD happens to be
the repo root. Run it from anywhere else and it links a path that does not exist,
the bridge never comes up, and the host says nothing about it.
On first start the bridge generates its own config and token under
~/.omp/agent/, so there is nothing else to set up per machine.
After starting the host omp, the notification bar shows:
a2mcp listening on http://127.0.0.1:<port> (token <first 6 chars>…)<port> is the actual listening port (random by default).
Configuration
The config file is ~/.omp/agent/a2mcp.json, generated on first start with mode 0600 (created at that mode, so there is no permission window):
{ "port": 0, "token": "<base64url 32B>", "host": "127.0.0.1", "host_llm": "allow", "xfer": { "dufs": "dufs", "cloudflared": "cloudflared" } }Field | Meaning |
|
|
| Bearer token, generated on first start (43 characters); a hand-written one under 32 characters is refused |
| Listen address, default |
|
|
| the HTTP file server that serves the shared path, default |
| the tunnel binary that publishes the share, default |
Only those fields: the four top-level ones above, plus an optional xfer block. There is no tool allow/deny list and no file sandbox — the bridge makes no permission decision of its own, so adding such fields to the config has no effect (unknown fields are ignored). host_llm is not a tool permission either: it gates the host's own inference, not what the caller may execute — the caller holds the token and runs whatever the host exposes.
A2MCP_CONFIG overrides the config path; A2MCP_AUDIT overrides the audit log path; A2MCP_INSTALLS overrides where the bridge keeps its record of binaries it fetched.
Validation is fail-closed: a present-but-malformed field (port not an integer or out of range, host not a non-empty string, token shorter than 32 characters or containing whitespace) makes the extension refuse to start and report an error. The one exception is a token that is absent (or not a string at all): it is regenerated and written back to the config, so it stays stable across restarts. A token somebody typed is never replaced by one the bridge chose — that would break the remote mcp.json with no explanation anywhere. The floor sits below what generateToken emits (43 characters), so it catches a placeholder, not a secret copied from elsewhere.
Config edits take effect on the next host restart; to change only the token at runtime, use /a2mcp rotate (immediate, and the old token stops working at once).
Commands
Inside a host session:
/a2mcp— show the current listen address, port and token prefix/a2mcp rotate— rotate the token (update the remotemcp.jsonafterwards)/a2mcp token— print the full token (the status line only shows a prefix)
Remote connection example
The remote omp's mcp.json:
{
"mcpServers": {
"omp-host": {
"type": "http",
"url": "http://127.0.0.1:<port>/",
"headers": { "Authorization": "Bearer <token>" }
}
}
}Once connected, the remote side sees tools like mcp__omp-host__read and mcp__omp-host__bash and can call them directly.
Prompt for the remote agent
A remote agent will call the host's tools as if they were the same-named tools it already knows, and tools/list returns the host's registry verbatim — on this machine edit is a patch syntax ([PATH#TAG] plus PUT N.=M:), not oldString/newString, and a guess gets back File not found. Put the block below in its session or system prompt, replacing <port>, <token> and the tool prefix with the names from your mcp.json.
You have been given the tools of another machine (the host) through the MCP server omp-host.
- These tools really execute on the host: bash runs in the host's shell, read/write/edit read and write files on the host's disk. Paths are host paths, not paths in your own environment — when unsure, run bash first and look at $HOME and pwd.
- The tool list and each tool's schema come from tools/list, and it holds exactly what the host currently has enabled. Read a tool's schema before calling it; it is not necessarily the same shape as the same-named tool you have used elsewhere.
- The host issues no model requests and has no agent working for you: bash/read return what they return, and the reasoning is yours.
- The host may require approval for some tools; with no interactive UI on the host, such a call hangs rather than erroring. Put a timeout on every call.
- If one command says it, do not split it across several round trips.Cross-machine forwarding
Loopback-only by default, never exposed to the network. Forward the port over SSH from the remote machine:
ssh -L <localport>:127.0.0.1:<port> user@hostThen set mcp.json's url to http://127.0.0.1:<localport>/.
Approval
Remote calls reuse the host's approval gate (ExtensionToolWrapper) entirely and open no additional permissions:
The host's default
approvalModeisyolo→ straight through, no approval step.If the host configures a tool as
promptintools.approvaland the host has an interactive UI (TUI), the remote call raises an Approve/Deny prompt; once the user confirms, it continues and the result returns by the same path.With no interactive UI (measured in rpc mode; print takes the same no-UI path but is untested), a
promptapproval neither executes the command nor returns: the request hangs indefinitely (measured ≥90s) while the host waits for a UI answer that can never arrive. Callers must impose their own timeout; a hung call leaves astartrecord with no matchingdonein the audit log (see below), so it is detectable.
Host inference takeover
The measured wake-up chain (see Security and boundaries) showed the other half of the same fact: the host session can spend its own tokens without being asked. The takeover switch hands that decision to whoever holds the token.
deny means the host's own session cannot make a model request. Three layers, divided by which door a given inference request finally leaves through — a division read off the host's own source, not assumed:
A deny API plus the model row the host is holding.
a2mcp-denyis registered as a custom API whosestreamSimpleonly counts and fails, and the row the host's agent holds is swapped for a deny copy of itself. The host's own turn is therefore refused before a request is built: one attempt, one stderr line, and the errora2mcp takeover: host inference is disabled in this mode. The text matches none of the host's retry triggers — measured on a live host, a denied turn makes exactly one attempt and ends withretryable: false. The deny copy carries no credential resolver, so a refused turn does not even run the provider's!commandkey program.A destination-keyed tripwire on the host process's
globalThis.fetch: any request whose URL host matches abaseUrlfrom the model configuration is refused. pi-ai resolves its fetch asoptions.fetch ?? globalThis.fetch, reading the global binding per call, so this covers every fetch-based inference request — including callers that build their own registry (task runners, web search, commit messages) instead of asking the session's.Registry shadowing, for the two providers whose requests skip fetch. Two lanes are not fetches: the cowork lane (
provider: anthropicwithapi: anthropic-messages, where the host swaps in a rawnode:httpsclient) and the Codex websocket lane (api: openai-codex-responses, where the host forwards theproviders.openaiWebsocketssetting on every provider call). Those provider names go behind the deny; on a stock install that is 2 of 73.
Why not "shadow every provider". The host's registerProvider(name, …, sourceId) records sourceId as that name's owner and, when someone else owned it, clears the incumbent's runtime state before taking over; the only undo the public API offers is "this extension unloaded", not "undo that one call". Shadowing everything therefore deletes every provider another extension registered — once per arming, never restored. Measured 2026-10-13 against the real registry: a two-row provider an extension had registered went to zero rows after one arm/restore cycle. So the table above claims a name only where a lane forces it, and restore checks the claimed providers against the pre-arm snapshot instead of assuming the undo was clean, naming any that did not come back.
A noise layer mutes the settings that would otherwise keep the host busy and chatty (auto-backgrounding of long commands, host-side retries, advisor, TTSR, prewalk, autolearn, cache warming, auto-compaction) — runtime overrides, nothing persisted. Measured: with the takeover armed, a remote 3-second bash runs to completion instead of becoming a background job.
Who controls it. host_llm in the config decides the startup state (allow when unset). At runtime, POST /host-llm with {"enabled": false} arms the deny and {"enabled": true} restores what the takeover snapshotted (the deny API is unregistered, claimed providers restored, the original model row put back, the fetch reference and settings overrides rolled back); the reply is {enabled, denied, degraded}. Same Bearer token as everything else, and every toggle writes one audit record tagged tool: "host_llm" — a refused arming writes one too (sid: null, since no caller asked) plus a stderr line, because a toast scrolls away and an audit record does not. The endpoint is deliberately not a tool: tools/list stays the host registry verbatim. /a2mcp shows the live state (host_llm=deny denied=2). A toggle never persists — restart returns to the operator's config. Arming is redone on every session_start, whether the config or a remote caller turned it on: a rebuilt session comes with a registry nothing has shadowed, and a listening port says nothing about the registry's state. The arm line names the claimed providers (takeover armed: 2 provider(s) shadowed (anthropic, openai-codex), …) — a claim takes a name away from whoever held it and the registry cannot give it back, so that line is the only place the operator can see which names the bridge holds.
Degraded. A refusal while the switch is on is not the same as a refusal at startup. The switch stays on and the process-global tripwire stays installed, but the session that is live now never got the deny row or the claims — so its two non-fetch lanes are unguarded. The reply carries degraded: true, /a2mcp prints host_llm=deny (tripwire only), and stderr plus the audit say so. The refusal check runs before the old session is restored precisely so that a failed re-arm does not also tear down the tripwire on its way out.
What it gives up, and what stays outside the promise.
A
transport(pi-nativeand anything else that dispatches before the api check) runs inside the host ahead of any extension-registered implementation, so the bridge cannot get in front of it. The takeover refuses to arm when the configuration contains one on a provider it does not claim — visible error, nothing half-armed. Where deny is truly required, the config gets fixed, not the bridge.Provider ownership cannot be handed back. If one of the two claimed providers was registered at runtime by another extension (rather than configured in
models.yml), that registration does not survive the restore: the undo primitive has no way to replay it. Restore names it, and recovering it means restarting the host.A request that does not go through
globalThis.fetchis not this bridge's to refuse. The registry captures its own fetch at construction, so the host's catalog and metadata GETs to a provider's own baseUrl leave the machine even while that host is a tripwire key — measured, one non-POST per takeover-probe run, printed separately from the inference count. Non-inference egress is deliberately not blocked; what is deliberately not true is "a keyed host is always refused".The tripwire watches the host process.
curlfrom abashtool runs in another process —denyis not a network sandbox and does not claim to be. (Measured: a denied-state task sub-agent resolves its own model through its own freshly built registry, so its request is refused at the fetch boundary — one local refusal, nothing leaves the machine.)A provider added to
models.ymlwhile the bridge runs is not in the snapshot until the next arming, and/loginhands model ownership back to the host, lifting the shadow on a claimed provider.A host that swaps its model row back to a real row while armed loses nothing — it lands on the tripwire — but restore will not swap it back to the deny copy: that was the operator's choice.
title.generatorhas no off value in its enum (fork | tiny), so the host still attempts session titles; those attempts fail closed at the deny, and their error notices can appear in the session.Denied turns are turns: they leave an error message in the host's session history. The takeover limits what the host may do, not what it remembers.
Moving files
Files move over plain HTTP: xfer_share hands a host path to dufs (sigoden/dufs, a single static binary) and a cloudflared quick tunnel publishes it under a public hostname. The bridge ships no byte protocol of its own — the bytes never travel over JSON-RPC.
xfer_share takes { path, ttlSeconds? } — the file or directory to serve — and returns a URL with HTTP basic auth credentials embedded (https://omp:<secret>@<random>.trycloudflare.com/), plus the local port and the expiry. Download with curl -u omp:<secret> -O <host>/<name>, upload with curl -u omp:<secret> -T <local> <host>/<name>; Range requests resume, WebDAV clients work. The share closes at the TTL (default 3600s), on xfer_stop, or when the bridge stops; a second xfer_share replaces the first.
xfer_status and xfer_stop take no arguments.
The URL is a capability: whoever holds it can read and write that path until the share closes.
Prerequisites: dufs and cloudflared. The bridge finds both itself — PATH first, and when nothing answers there it fetches a pinned build into ~/.local/bin and uses that. Both are pinned by version and sha256 in src/obtain.ts: dufs 0.46.0 and cloudflared 2026.10.1, four platform rows each, every hash re-checked against the bytes GitHub actually serves by bun run test:obtain. It never asks for latest, and it never takes a checksum from the same server that gave it the bytes.
The fetch is one more opinion from a bridge that otherwise forwards: it had already picked dufs as the file server, so what it decides here is only where the bytes come from. A path is the exit — write xfer.dufs as "/srv/bin/dufs" and the bridge uses that file as given and downloads nothing. A bare name is the request to look, and to fetch if looking finds nothing. It will not overwrite a binary it did not install: an existing ~/.local/bin/dufs that is not the build this bridge wrote is refused, with its own sha256 printed, and the account of what was installed sits in <agentDir>/a2mcp.installs.json.
A share that has to fetch waits for the fetch, and says so: the reply carries installed with the version pulled (null for anything that was already there). Every call is audited as tool: "xfer", one record per call, with action: install on the fetches.
Devices
The host exposes devices in two shapes, and both pass through unchanged.
Toolchains (bash). adb, idf.py, serial tools and flashers are not omp tools — they are commands on this machine. bash is in tools/list, so a remote client runs them directly:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "bash", "arguments": { "command": "adb devices -l" } } }Execution lands in the local shell, so whatever is on PATH is reachable: with the Android SDK installed, adb is callable; with ESP-IDF installed, idf.py flash is. Port monitors and register pokes work the same way.
Mounted devices (xd://). The host mounts tools as virtual devices. They are not in tools/list — they are driven through the path argument of read / write:
{ "name": "read", "arguments": { "path": "xd://" } } // list mounted devices
{ "name": "read", "arguments": { "path": "xd://debug" } } // docs + JSON schema
{ "name": "write", "arguments": { "path": "xd://debug", "content": { … } } } // executeThe listing is decided by the host at runtime — it depends on which extensions are installed and which devices are attached. Measured on this machine: xd://debug (a DAP debugger), xd://lsp, xd://ast_edit.
Calling xd:// directly through tools/call is refused (not exposed): a device is not a tool name, and only the path of read / write reaches it. That is the host's shape; the bridge does not rewrite it.
Security and boundaries
The token is full tool-execution authority (under the default config): the host's default
approvalMode: yolomeans anyone holding the token can execute any exposed tool (includingbash) directly in the host session with no approval step; only tools the host configures asprompthave a gate that can stop them (see Approval for the no-UI case). Keep the config file at0600and out of version control.The bridge makes no permission decision:
tools/listis the host'sMainsession registry (getAllToolInfos()), passed through verbatim with no filtering, plus the threexfer_*tools the bridge serves itself. It therefore includeshiddentools and tools the host currently has disabled for its own model — whatever permissions omp has are the permissions the bridge has. There is no second list such asdeny: two lists can disagree, and no code defines which wins. To tighten anything, configure the host's own tool permissions; the bridge stays out of it.Execution goes through the host's native tools:
tools/callroutes togetToolByName().execute()on the host'sMainsession with the realsession.settingsandExtensionContext uiinjected, so the host's approval gate (ExtensionToolWrapper) applies as usual. The bridge implements no approval logic of its own. The threexfer_*tools are the exception: the bridge dispatches them itself (see Moving files), so they never reachgetToolByName()and never pass that gate — a token holder can publish any path the bridge can read to a public URL with no approval step in between, and whoever holds that URL can write to it as well.A remote call can wake the host's own agent. The bridge never issues a model request, but the host owns execution: a remote
bashthat exceeds the host's command timeout (measured default: 300 seconds) is converted by the host into a background job, and when the job finishes the host injects a completion notice into the session ("Background job … has completed. Resume your work…"). The host session's own agent loop wakes on that notice, starts inferring and running its own local tools. Measured chain on a Windows host: call timed out and became a background job (04:35:23) → completion notice entered the session (04:36:27) → the host's first assistant messages with real usage (04:36:31, model stack/medium, first turn at 8,309 input tokens), dozens of turns after that. This is not the bridge reasoning — it is a consequence of the tool landing in the host session, and the tokens spent are the host's. The host's side of this is a switch, not a fate: with the takeover armed (host_llm: "deny"orPOST /host-llm), the woken session makes zero model requests — see Host inference takeover.Calls and the list share one source:
tools/callonly accepts names that appear intools/list; aliases (such asxd://bash) and unregistered names are refused, and a refusal does not distinguish "filtered" from "nonexistent" (so it does not leak whether a name exists).POST /host-llmis the bridge's path that is not a tool. It reaches into the host session directly — swapping registry rows, overriding settings, replacing the processfetch— so the bridge holds an opinion, bypasses the host's tool dispatch, and pays for it with auditing (tool: "host_llm", one record per toggle) and documentation. What it limits is the host's own inference, and it acts only on the word of whoever holds the token. See Host inference takeover.Loopback-only by default; if you really do expose it, the firewall is your responsibility.
Sessions are mandatory: every message except
initializemust carryMcp-Session-Id(missing → 400, unknown or idle past 24h → 404). At most 64 sessions are tracked, with the least-recently-seen evicted beyond that; every hit refreshes the idle timer.Audit log: every remote
tools/callwrites two JSONL records —{ts,id,sid,phase:"start",tool,args}at dispatch and{ts,id,sid,phase:"done",tool,isError,args}on completion (paired by the sameid;sidis that call'sMcp-Session-Id, so under a shared token each call is attributable to a client session; the args summary is truncated to 1KB) — to~/.omp/agent/a2mcp.log, mode 0600, rotating to.1past 512KB. Theidis a UUID the bridge generates for pairing; the caller's JSON-RPC id is not written, so the log cannot be searched by it. Strings longer than 120 characters in the args are recorded as<len:N,sha256:first 8>so the log never holds payloads — at any nesting depth, not just the top levels: the host'seditargs put the file body atargs.edits[0].oldText, and a redactor that stops two levels deep writes it out. Nesting past 32 levels is replaced by<max-depth>rather than walked. This is the bridge's own log: the host session still sees the args it was handed, and its transcript is the host's to keep. Records reach the disk through one queue per process, becausestat-then-rotate-then-append is three steps: two calls in flight together both read a size past the cap and both rotate, and the secondrenameoverwrites the.1the first one just filled. Measured on the unqueued version over six runs: the worst left one record in each of the two files, 570 of the 572 records the test had put on disk being in neither. Audit write failures never affect the call, and they do not pass unnoticed either: the first failure for a log path prints[a2mcp] audit record not written to <path> (<cause>)to the host's stderr, then every hundredth failure, because a log that cannot be written looks exactly like a bridge that was never called.startreaches the disk before the tool runs anddonebefore the response goes out, so a 200 means the line is already there and a caller can read the log without polling for it. That was the other way round before — records rode one process-wide queue the caller never waited on — and it showed up only when the disk was slow enough for the queue to matter; measured cost of waiting is ~99 µs per record (stat+appendFile+chmod, serialized), which is nothing against a call that runs a bash command. Unauthorized attempts write nothing here at all — they go to the host's stderr, first one and then every 50th — since handing pre-auth traffic the one lever that ages real records out of a rolling window costs more than the missing line is worth.A
startwith nodonemeans the call was dispatched but never completed (typically: an approval hung for want of a UI). Two catches on reading that signal, both measured: rotation can split one call's pair across.1and the current file, so pair byidin both files; and only ONE generation is retained, because the next rotation overwrites.1— the evidence of a hung call ages out of the log after enough later traffic. Read it while it is still there.If the configured port is busy, the bridge falls back to an ephemeral port and warns (update the port in the remote
mcp.json).
How this differs from computer use (guessing pixel coordinates on a screenshot) is in docs/computer-use.md.
The full wire contract — request/response shapes, processing order, session lifecycle and the error-code table — is in docs/protocol.md.
Online docs (GitHub Pages, published on push): https://adam-ikari.github.io/a2mcp/
v1 boundaries:
Tools only (
tools/list+tools/call); no resources, no prompts.No SSE push, no call cancellation.
Always routed to the host's
Mainsession.Static Bearer token, no OAuth.
No concurrency or rate limiting.
Design trade-off (read this before adding a feature):
The bridge sends the call and adds no meaning on the way. A capability the host's own
read/bash/editcan express gets no new tool; paths are not judged here; the catalog is not filtered on permission grounds — whatever permissions omp has are the permissions the bridge has. Give the bridge one opinion of its own and it becomes a second omp.A bridge-owned tool bypasses the host's approval gate, so it would need its own sandbox. That is one decision with two halves, and they have to go together: dropping only the sandbox leaves a hole nobody guards.
Troubleshooting
401
unauthorized: the token does not match. The remotemcp.json'sAuthorizationheader must equal the config'stoken; update the remote side after/a2mcp rotate. The host's stderr says[a2mcp] N unauthorized attempt(s)for the first failure and every 50th after it; those attempts stay out of the audit log on purpose (see the audit bullet under Security and boundaries).The bridge refuses to start with
field 'token' must be at least 32 characters: somebody wrote a short token intoa2mcp.json. Put a real secret there (or delete the field and let the bridge generate and persist one); the bridge will not swap out a token it was handed.The bridge refuses to start with
field 'host_llm' must be 'deny' or 'allow': the field was written with anything else ("false","ALLOW", a boolean). The value decides the takeover's startup state; a fuzzy one is refused, not guessed (see Host inference takeover).Takeover fails to arm (
a2mcp takeover failed to arm: … transport 'pi-native' …on/a2mcp, on stderr as[a2mcp] takeover failed to arm: …, and as onehost_llmaudit record with anerror): the model configuration contains atransporton a provider the deny does not claim, which the bridge cannot honestly intercept, so arming is refused and inference continues under the host's own rules. Remove or reconfigure that provider if deny is required.takeover restore left N provider(s) unregistered … restart the host to recoveron the host's stderr: one of the two claimed providers had been registered at runtime by another extension rather than configured inmodels.yml, and the registry's undo cannot replay somebody else's registration. The deny itself is back in place; only that provider is missing until the host restarts.No audit records, and no call failed: the log path is unwritable —
$A2MCP_AUDITpointing into a directory that does not exist, a permission error, or a full disk. Host stderr carries[a2mcp] audit record not written to <path> (<cause>). Fixing the path does not recover what was already lost.400
missing mcp-session-id/ 404unknown session: every message exceptinitializeneeds the session header. Sessions are in-memory in the host process — they all die when the host restarts and are reclaimed after 24h idle; runinitializeagain for a new session (a well-behaved MCP client library does this automatically).Cannot connect / wrong port: if the configured port is busy at host startup, the bridge falls back to a random port and warns in the notification bar — use the actual port shown there or in
/a2mcpto updatemcp.json.A call never returns: the host has no interactive UI and the tool's approval is
prompt(see Approval) — the command does not execute, but neither does the call return; the caller must impose its own timeout, and the audit log shows astartwith nodonefor that call (see the audit-log bullet under Security and boundaries).500
internal error: an internal server fault; the response body is deliberately detail-free (to avoid leaking), and the real cause is in the host's stderr as[a2mcp] internal error: ….Config edits do not take effect: external edits to
a2mcp.json(such asport) need a host restart; only/a2mcp rotateapplies live at runtime.bun testversion guard fails (development): either the installed@oh-my-pi/pi-*is out of sync with the pin/lock (bun installrestores it), or the localomp --versiondisagrees with the pin (update the two exact versions inpackage.jsonwhen you upgrade the host). The host comparison used to warn only, which let the pin drift unnoticed; it fails now, and is skipped only when there is no host onPATH. To run against a deliberately different host, setA2MCP_SKIP_HOST_VERSION_CHECK=1— it prints a loud SKIPPED rather than passing silently.
Development
bun install
bun run typecheck # type check
bun run lint # lint + format check (Biome; fix with bunx biome check --write .)
bun run test:tree # repo shape: no symlink points off this machine, 3 checks, no deps, no local omp (runs before bun test on purpose)
bun test # unit tests: test/*.test.ts (186: protocol/auth/config/exposure gate/host hand-off/entrypoint lifecycle/audit/version guard/takeover shapes/arming transaction/host-llm route/xfer)
bun run test:registry # drive the takeover against the host's real model registry, 28 checks, seconds (needs no local omp, no credentials)
bun run test:smoke # real E2E (needs a local omp; no model credentials)
bun run test:hardening # real-host hardening checks, 34 items (needs a local omp)
bun run test:approval # approval-boundary discriminating check, ~95s (needs a local omp)
bun run test:takeover # host inference deny/restore against a counting sink, ~60s (needs a local omp)
bun run test:scenario # end-to-end scenarios, 8 narratives / 41 steps, ~45s (needs a local omp)
bun run test:install # published-package self-containment + a real host over MCP, 48 items (needs a local omp; fetches ~46 MB from GitHub)
bun run test:bootstrap # the one-shot installer's decisions, 28 items, ~9s (groups 1-4 run against a recording stub omp; the last group needs a local omp)
bun run website # local docs site preview (VitePress); port is printed (5173 upward, next free if taken); first run `cd website && bun install`What each test covers, the preconditions for the real-host checks, and how to read their verdicts (including the approval check's VERDICT A/B/C semantics) are in docs/testing.md.
Dependency note: @oh-my-pi/pi-coding-agent and @oh-my-pi/pi-ai are pinned to exact versions in devDependencies, kept in step with the host omp version, and used only for type checking and unit tests. Do not load them from node_modules at runtime — the host omp's omp:legacy-pi-shim redirects those imports to the same modules bundled inside the host, which is what lets module-level singletons like AgentRegistry.global() be shared; update both version numbers when upgrading omp. bun test includes a version guard: an installed devDep that differs from the pin fails outright, and so does a local omp --version that differs from the pin (it warned only, until 2026-10-07; a warning nobody had to act on was the whole blind spot).
File layout:
Path | Responsibility |
| Extension entry point: starts the server on |
|
|
| Tool catalog and execution (both through the AgentRegistry |
| Config load/save, field validation, token generation |
| Bearer token verification (timing-safe comparison) |
| Audit log for remote calls (two-phase JSONL |
| Host inference takeover: deny api plus the host's current model row, destination fetch tripwire, provider shadowing limited to the non-fetch lanes, noise overrides, snapshot and restore |
Change history: CHANGELOG.md.
License
MIT, see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Remote MCP server exposing SMI Aware tools, resources, and skills over Streamable HTTP.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Hosted MCP runtime where your agent publishes its own tools by tool call and keeps working.
MCP server exposing the Backtest360 engine API as tools for AI agents.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables remote access to the MemPalace MCP server via HTTP, supporting bearer token authentication and concurrent clients while exposing all mempalace tools.-
- FlicenseNot gradedqualityCmaintenanceConverts any MCP server into simple HTTP endpoints, enabling AI agents that only support HTTP (like ChatGPT Custom GPT or web_fetch) to use MCP tools.-
- AlicenseNot gradedqualityCmaintenanceEnables external AI applications to access MCP tools by providing a user-hosted HTTPS endpoint that forwards requests to a paired desktop client.MIT
- FlicenseNot gradedqualityAmaintenanceEnables MCP-compatible AI clients to invoke CLI-driven agent tools over Streamable HTTP, including shell execution, file operations, patching, image viewing, web search, and nested agent tasks, with permission modes and real-time progress streaming.-