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 starts it, hands it a prompt, and it calls a
model and does the work itself. 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 to run and sends the result back unchanged — the thinking happens on the caller's side. If what you want is "I reason, and I borrow this machine's hands", use this bridge.
ssh host 'omp acp' is no substitute: 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;
session_start/session_shutdownare one per session. A latersession_startreuses the running server; asession_shutdownonly releases the port once the registry holds no liveMainsession — otherwise any subagent finishing would take the port down with it. Release uses a non-forcedstop(): new connections are refused, while a request already in flight finishes and gets its audit pair. The whole lifecycle is held by the entrypoint lifecycle unit tests (docs/testing.md).The catalog, the exposure gate and execution read one registry: the host's
Mainsession. The catalog is that registry as-is, plus the bridge's ownxfer_*tools (three of them now); 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 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 the takeover switch's call: Host inference takeover.
Install
Two paths, and what separates them is whether the machine has omp yet: with a
host already installed, the single omp install command is the whole thing; on a
fresh machine, the one-shot script installs the host first and then runs that
same command.
With omp (the machine already has omp)
omp install https://github.com/adam-ikari/a2mcp.gitThis lands a checkout of the whole repository (measured 2.3 MB on disk, docs/
and test/ included); the bridge itself only ever reads extensions/ and
src/.
To remove it:
omp plugin uninstall a2mcpUpgrading from v0.3.0 or earlier, retire the old name first — the plugin lock records by package name, and with both entries registered the host loads both:
omp plugin uninstall pi-a2a-extAfter retiring the old name the config file and token are generated fresh, so
every remote mcp.json has to be re-copied.
The URL has to be the full https://….git: a GitHub owner/repo shorthand is
rejected as an invalid package name, and a .tgz path or URL is not accepted
either (the host tries to link it and fails with ENOTDIR). The package-name
spec — omp install a2mcp — is a reserved shape, not an available one: the
name is not on the registry, so that command fails in dependency resolution.
The bootstrap scripts already accept both shapes and derive version pins
accordingly; the name works the day the package is published, and not before.
One-shot install (fresh machine)
Linux
curl -fsSL https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/bootstrap.sh | sh -s -- --dry-run # plan only
curl -fsSL https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/bootstrap.sh | shmacOS (identical to Linux)
curl -fsSL https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/bootstrap.sh | sh -s -- --dry-run # plan only
curl -fsSL https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/bootstrap.sh | shWindows (PowerShell, runs in memory; nothing is saved to disk)
powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/bootstrap.ps1 | iex"To pass a switch (-DryRun and friends) use the scriptblock form, which keeps
the arguments out of the string that gets executed:
& ([scriptblock]::Create((iwr -UseBasicParsing https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/bootstrap.ps1).Content)) -DryRunThe script runs three steps: with no omp on PATH it calls the official
installer at the pinned version; then the one omp install above — the
extension has always had that single install command; then the host's own
omp plugin doctor closes it out. Remaining flags: --upgrade re-runs the
installer, --reinstall forces re-registration, --uninstall takes the
registration back (token and audit stay), --host-version <version> picks the
version and sends no request. The host self-updates at startup by default —
"installed at the pin" is not "stays at the pin". The script writes one file
outside the installer: a placeholder model config when it installed the host
itself and ~/.omp/agent/models.yml did not exist (create-only; a machine with
omp already on PATH gets nothing — Troubleshooting says why it exists).
Either path ends the same way. Start the host and 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). The bridge generates
its own config and token, so nothing is set up per machine; a machine with no
model configured opens a session anyway — the bridge fills the empty slot with
the placeholder model (Troubleshooting).
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 |
Only those fields: the four top-level ones plus an optional xfer block. There is no tool allow/deny list and no file sandbox — unknown fields are ignored, the bridge makes no permission decision for the host. host_llm is the exception: it gates the host's own inference, not what the caller may execute — that is decided by the token and the host's own tool permissions.
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 malformed field (port not an integer or out of range, host empty, token shorter than 32 characters or containing whitespace, host_llm neither deny nor allow) makes the extension refuse to start and names the field. The one exception is an absent token: it is generated and written back to the config. 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): 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 (the line carries the tunnel URL while one is open)/a2mcp rotate— rotate the token (update the remotemcp.jsonafterwards)/a2mcp token— print the full token (the status line only shows a prefix)/a2mcp prompt— print the remote setup in one block: the URL a remote client can use (the public tunnel when one is open, the loopback origin otherwise), the full token, and the English prompt to hand the remote agent. The URL and the token go into the remote client'smcp.json; the prompt goes into the agent. The prompt carries neither the URL nor the token — a token inside a prompt would put it in a model's context, which is a wider place than a config file/a2mcp tunnel— open a cloudflared quick tunnel in front of the bridge's port and print its public URL (https://<random>.trycloudflare.com). One tunnel fronts one port, so the MCP endpoint and any open/xfer/share both ride it. Runtime state, never in the config; see Cross-machine forwarding/a2mcp tunnel stop— close the tunnel/a2mcp incognito— toggle incognito mode. Its meaning is two sentences: the host-side omp does not record the call, and the bridge's audit log goes silent — no entry in the host's session transcript and nothing in the host's stdout render stream (both written from the bridge's render events, which the mode stops emitting), and no record of anything else the bridge did either (calls, shares, the tunnel, the instance, an arming): the gate is one switch insrc/audit.ts, where every record is written, so "silent" cannot mean "silent except for the few paths that happened to be wired". Only the switch's own two lines survive, written just outside the silent window, so the stretch says why. The tool's own side effects are beyond it — a command that wrote a file leaves the file. Only the operator can reach the switch: a caller who could silence its own record would leave the log worthless as evidence. Runtime state: it dies with the bridge
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.
Two ways in
What separates them is whether your client already speaks MCP.
Your client speaks MCP. Point it at the bridge with the mcp.json above and stop there; do not write a client. The host's tools land in your tool list natively, and the handshake, the session header, and the reconnect are the MCP library's job, not yours.
Your client can run code but is not an MCP client. A coding agent handed the endpoint as text, a CI job, a script that has to drive tools/call itself: take scripts/mcp-call.py, a zero-dependency Python 3 reference client, and call the bridge through it instead of re-deriving the handshake each time.
curl -fsSL https://raw.githubusercontent.com/adam-ikari/a2mcp/HEAD/scripts/mcp-call.py -o mcp-call.py
python3 mcp-call.py --url http://127.0.0.1:<port>/ --token <token> list
python3 mcp-call.py --url http://127.0.0.1:<port>/ --token <token> schema read
python3 mcp-call.py --url http://127.0.0.1:<port>/ --token <token> call bash '{"command":"uname -a"}'list prints each tool with the first line of its description; schema <name> prints that tool's whole catalog entry — name, description, input schema — so you can read what a tool wants without writing a client to ask for it; call <name> <json> runs one tool and prints its text output. The exit code is the signal — non-zero on a bad token, a transport error, or a result flagged isError. Every call carries a timeout (default 30s, --timeout to change), because a tool awaiting approval on a host with no UI hangs instead of erroring. --url/--token also read A2MCP_URL/A2MCP_TOKEN. The script is not in the install tarball, only at that raw URL, and it is a reference implementation — adapt it freely rather than treating it as a supported surface.
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; a stale or guessed tag comes back file changed between read and edit, and File not found means the path itself is missing.
The block below is the text /a2mcp prompt prints, and the same text the endpoint returns as MCP instructions on initialize — a client that shows instructions hands it to its model with nothing for the operator to paste. It carries neither the URL nor the token — those go into the remote client's mcp.json, not into the prompt.
You are connected over MCP to a live agent session on another machine. Its tools run there for real and return results verbatim, errors included; nothing is reasoned about on that side — you decide what runs, it only executes.
- Read each tool's input schema in `tools/list` before calling. This host's `edit` takes a hashline patch (`*** Begin Patch`, `[PATH#TAG]`, `PUT N.=M:`, `*** End Patch`), where `TAG` is the 4-hex snapshot from your latest `read` — not `oldString`/`newString`. A stale or guessed `TAG` is refused as `file changed between read and edit`, and a patch that does not start with `[PATH#TAG]` is refused for its format; `File not found` means the path itself is missing. Re-read after each edit.
- One fact at a time: call, read the result, then decide. Use only the tools in `tools/list`; absolute paths; assume nothing about the host's `PATH`; do not install packages, create environments, or compile — run only what is already there.
- Set your own timeout on every call: a permission prompt may never be answered.
- What you run there stays there. The token is the only gate: never echo it into commands, files, or messages.
Files move over the bridge's own WebDAV route, never through tool arguments. Use an existing WebDAV client (`curl`, `rclone`), not one of your own. `xfer_share {"path": "/absolute/host/path"}` serves one host file or directory for `ttlSeconds` (default 3600, max 2147483). The share is bidirectional: GET downloads, PUT/PATCH uploads, same route and token — to send a file, PUT into the share; do not expose your own machine. Set `$ROUTE` to `publicUrl` when it is not `null` (the operator's existing tunnel, shared with MCP); otherwise use your bridge URL plus `/xfer/` — the reply's `url` is loopback, for the host itself. One share at a time — a second replaces the first; `xfer_status` reads it, and `xfer_stop` closes whatever share is open, whoever opened it (one slot, one shared token).
Send `Authorization: Bearer $A2MCP_TOKEN`, read from the environment; paths after `$ROUTE/` are relative to the share. Download: `curl -fL -C - -H "Authorization: Bearer $A2MCP_TOKEN" "$ROUTE/file" -o LOCAL`; `-C -` resumes, `416` means complete. Upload: `curl -fL -T LOCAL -H "Authorization: Bearer $A2MCP_TOKEN" "$ROUTE/file"`; to resume, PUT then PATCH `X-Update-Range: append`. `PROPFIND` lists. DELETE/MOVE are refused; uploads never overwrite a non-empty file (403) — delete it with `bash` first. Over a `publicUrl`, Cloudflare refuses a request body over 100 MB: split with `split -b 64m FILE FILE.part.`, PUT each part, join on the host (`cat FILE.part.* > FILE`), check `sha256sum`, remove the parts, resend only an interrupted one. Never move bytes through `read`, `bash`, or arguments (`read` truncates ~150 KB, `bash` ~768 bytes); never paste megabytes into a command.
When `publicUrl` is `null`, ask the operator for `/a2mcp tunnel` or a forward of the bridge port — not a reverse tunnel. Do not start your own tunnel or second file server. If your side cannot speak MCP, clone `https://github.com/adam-ikari/a2mcp` and run `scripts/mcp-call.py` (not in the installed package); for files, use a standard WebDAV client.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>/.
The second form is a tunnel, run from the host session: /a2mcp tunnel starts a cloudflared, points a random https://<random>.trycloudflare.com hostname at the bridge's port and prints the URL, which the remote side writes straight into mcp.json's url. A quick tunnel fronts exactly one origin, so this is the port's public face as a whole: tool calls and the /xfer/ file route go through the same hostname and the same token. The shape to state plainly: with the tunnel open the endpoint is on the public internet and the Bearer token is the only gate. It is runtime state, the same class as the takeover switch: nothing is persisted, a restart clears it; /a2mcp tunnel stop closes it, and the bridge tears it down when the port goes away (no Main session left), so a public hostname never outlives the bridge that opened it. A cloudflared missing from PATH is fetched by the pinned version and sha256 in xfer.cloudflared, the exact same rules as Moving files. Each open and close writes one audit record (tool: "tunnel"), the teardown included — a public hostname whose open has no matching close is one that died with the bridge; the token never enters the record.
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, 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 Security and boundaries).
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, all runtime state, nothing persisted: the model row in the host's agent is swapped for a deny copy that only counts and only fails (a denied turn makes exactly one attempt, retryable: false, and does not even run the provider's key-retrieval program); globalThis.fetch is wrapped in a destination-keyed tripwire — any request whose URL host matches a baseUrl from the model configuration is refused, and this layer covers every fetch-based inference request (task subagents, web search, commit messages included); the only two non-fetch lanes (the cowork direct connection and the Codex websocket) have their provider names declared behind the deny — 2 of seventy-plus on a stock install. Why not shadow the whole registry: the registry's only undo primitive is "this extension unloaded", and shadowing everything would permanently delete what other extensions registered (measured against the real registry on 2026-10-13). A noise layer mutes the settings that would otherwise keep the host busy (auto-backgrounding, retries, advisor, auto-compaction, and so on), also runtime overrides; with the takeover armed, a remote 3-second bash runs to completion and ends there.
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 from the snapshot; 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). The endpoint is deliberately not a tool: tools/list stays the host registry verbatim. A toggle never persists — restart returns to the operator's config. Arming is redone on every session_start, because a rebuilt session comes with a registry nothing has shadowed. /a2mcp shows the current state (host_llm=deny denied=2).
Degraded. When the switch is on but the live session never got the deny row, the process-wide tripwire still stands and the two non-fetch lanes are bare: the reply carries degraded: true and /a2mcp prints host_llm=deny (tripwire only). The refusal check runs before the old session is restored precisely so that a failed re-arm does not also tear the tripwire down on its way out.
Residuals (each measured; the full list is in docs/protocol.md under 宿主推理切换): a configuration that holds a transport on a provider the deny does not claim refuses arming — nothing is half-armed; a provider registered at runtime does not come back after restore, the host must restart; the host's non-inference catalog and metadata GETs leave the machine even when the hostname is a tripwire key; deny is not a network sandbox — curl from a bash tool runs in another process; a provider added while the bridge runs waits for the next arming to enter the snapshot; a denied turn still stays in the session history — the takeover limits what the host may do, not what it remembers.
Moving files
Files move over WebDAV. xfer_share hands a host path to dufs, a WebDAV file server, which listens on 127.0.0.1 at a free port, and the bridge reverse-proxies that port under /xfer/ on its own endpoint. The reply is the route (http://127.0.0.1:<port>/xfer/), the file server's port and the expiry — and no second credential. The Bearer token that calls tools/list is what authorizes the route: the proxy replaces whatever Authorization the caller sent with the basic-auth pair the bridge invented for this share, and the www-authenticate header the file server answers with is stripped on the way back. So mount it with any WebDAV client that can send Authorization: Bearer <token> — rclone, curl, or your own HTTP client: GET to download, PUT to upload, PROPFIND for a listing, Range to resume. A browser cannot send that header, so it gets the 401 like anyone without the token. DELETE and MOVE come back 403 — the bridge asks the file server for upload only. The share closes at the TTL (default 3600s), on xfer_stop, or when the bridge stops; a second share replaces the first. xfer_status and xfer_stop take no arguments. The bytes do not go through JSON-RPC on this path; the bridge ships no byte protocol of its own.
Nothing leaves the machine on its own: the route is loopback, and the only public hostname is the one the operator opens with /a2mcp tunnel. While that tunnel is up, xfer_share and xfer_status return it as publicUrl — the same /xfer/ route under the public hostname. While it is down, publicUrl is null; reach the route over an SSH port forward to the bridge port, or ask the operator to run /a2mcp tunnel. The tool reports that hostname; it never opens one.
Use a client that already exists. The route is plain WebDAV, so curl, rclone, or any WebDAV client moves the bytes; there is no bridge-specific file client to install. With the token in A2MCP_TOKEN and the base in ROUTE — the share's publicUrl when it reports one, otherwise your bridge URL plus /xfer/:
# download, resuming a partial file
curl -fL -C - -H "Authorization: Bearer $A2MCP_TOKEN" "$ROUTE/path/to/file" -o LOCAL
# upload
curl -fL -T LOCAL -H "Authorization: Bearer $A2MCP_TOKEN" "$ROUTE/path/to/file"
# list
curl -fL -X PROPFIND -H "Depth: 1" -H "Authorization: Bearer $A2MCP_TOKEN" "$ROUTE/"-C - asks for the bytes after the local file's end, so an interrupted download continues where it stopped; a 416 means the local copy is already complete. An interrupted upload restarts from zero in a standard WebDAV client. dufs also accepts PATCH with X-Update-Range: append, but that header is not WebDAV and curl does not send it, so plan an upload as all-or-nothing unless the client can append. The bridge sets no size limit: it removed its 128 MB request-body cap, because a PUT that stops at a size the caller cannot predict is the same failure as a truncated file. The cost is memory — Bun buffers one request body per in-flight upload — and the token holder is already trusted with the host's shell. One limit sits outside the bridge: when $ROUTE is a publicUrl, Cloudflare is in front of it and refuses a request body over 100 MB on Free/Pro (200 MB on Business). Responses are not capped, so downloads are unaffected at any size. For a larger upload, split it below the edge and send the parts as ordinary PUTs:
split -b 64m FILE FILE.part.
for p in FILE.part.*; do
curl -fsS -T "$p" -H "Authorization: Bearer $A2MCP_TOKEN" "$ROUTE/$(basename "$p")"
doneThen join the parts on the host with its own bash tool — cat /shared/FILE.part.* > /shared/FILE && rm /shared/FILE.part.* and sha256sum /shared/FILE, with /shared replaced by the shared directory. split names parts so that lexical order is file order. Each part is an ordinary PUT, so an interrupted one is retried alone: delete that non-empty part on the host and send it again; the others stay. The loopback and SSH-forward routes have no edge limit, so prefer them when the file is large and they are reachable. A share lives for the default hour; a longer transfer loses the route mid-way, and reopening the share lets a download resume. An upload never overwrites a non-empty remote file, because dufs runs with --allow-upload only; remove the remote file on the host first.
Keep the token in an environment variable rather than in the command line: argv is visible in ps.
The token is the capability: whoever holds it can read and write that path until the share closes. The share adds no credential of its own to hand out — the per-share basic-auth pair stays inside the bridge's proxied requests, out of every tool result, audit record and doc.
The bridge finds the file server itself — PATH first, and when nothing answers there it fetches a build pinned by version and sha256 (dufs 0.46.0, five platform rows, in src/obtain.ts; every hash re-checked by bun run test:obtain against the bytes GitHub actually serves) into its own directory (~/.local/bin on POSIX, %LOCALAPPDATA%\a2mcp\bin on Windows). It never asks for latest, and never takes a checksum from the server that gave it the bytes. A configured value containing / or \ is used as that path and nothing is downloaded. The bridge does not touch a file it did not install: it refuses to overwrite and prints that file's sha256. What was installed is accounted in <agentDir>/a2mcp.installs.json. cloudflared sits in the same table, under the same rules, and is fetched by the tunnel command rather than by a share.
win32-arm64 has no row, and the reason is upstream's: cloudflared ships no Windows ARM64 build, and while one xfer_share needed both binaries the two tables were pinned together. A share runs on dufs alone now, so that platform could gain a file-server row, and it has not. The two win32 rows are verified to the byte, not to execution — the asset URLs answer, the zip hashes match, the entry reads out through the code path a Windows host will call, the .exe is a PE within the download cap, all inside test:obtain; the first xfer_share on Windows is also the first execution of those bytes.
A share that has to fetch waits for the fetch; the reply's installed names the version pulled (null when it was already on PATH). Every call writes one audit record (tool: "xfer"), and each fetch pairs with an install record. The record that closes a share also carries a tally of what crossed the route — requests, bytes, uploads — so "the share was open" and "someone moved something over it" are two different questions and only the first one used to be answerable. The bytes are counted from the two content-lengths, so the number is a floor, and they say how much moved, never who: the token is shared.
Commands and mounted tools
The host passes two kinds of things through unchanged, and the bridge assumes nothing about this machine's setup.
Commands (bash). Existing commands on this machine are not omp tools. bash is in tools/list, so a remote client runs them directly; whatever is on PATH is reachable. Do not install packages, create virtualenvs or containers, or compile — that builds a new environment on the host instead of using the tools already there. Look before assuming a tool exists:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "bash", "arguments": { "command": "uname -a" } } }Mounted tools (xd://). The host can mount some tools as virtual devices — they are not in tools/list — driven through the path 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. Calling xd:// directly through tools/call is refused (not exposed): a mount point is not a tool name, and only read / write reach 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. Keep the config file at0600and out of version control.The bridge makes no permission decision:
tools/listis the host'sMainsession registry passed through verbatim, plus thexfer_*tools the bridge serves itself (three of them now); it includeshiddentools and tools the host currently has disabled for its own model — whatever permissions omp has are the permissions the bridge has. To tighten anything, configure the host; the bridge has no second list.Execution goes through the host's native tools: the bridge injects the real settings and UI, so the host's approval gate applies as usual. The exception is the bridge's own
xfer_*tools: the bridge dispatches them itself and they never pass that gate — a token holder can put any path the bridge can read behind the/xfer/route, which is a read and a write the host was not asked to approve. What bounds it is the same token, the loopback bind, and one audit record per call.A remote call can wake the host's own agent: a long command past the host's command timeout (measured default: 300 seconds) is turned into a background job, and the completion notice wakes the host session to reason, spending the host's tokens. The countermeasure is the takeover switch — see Host inference takeover.
Calls and the list share one source:
tools/callonly accepts names that appear intools/list; aliases and unregistered names are refused, and a refusal does not distinguish "filtered" from "nonexistent".POST /host-llmis the path where the bridge has an opinion of its own: it reaches into the host session directly (swapping registry rows, overriding settings, replacing the processfetch), and it pays for that with auditing (tool: "host_llm") and documentation; what it limits is the host's own inference, and it acts only on the word of whoever holds the token.Loopback-only by default; if you really 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, with the least-recently-seen evicted; every hit refreshes the timer.Audit log: every remote
tools/callwrites a start and a done JSONL record, paired by a UUIDidgenerated inside the bridge, withsidattributing the call to a client session. Thatidis also thetoolCallIdthe host recorded the call under in its own session transcript (~/.omp/agent/sessions/) — one call leaves two halves of a record, and each half was missing something the other half had: the transcript keeps the full arguments and never the outcome, the audit log keeps the outcome and the attribution but folds long arguments, so the two join by lookup instead of by a timestamp guess; a failed call'sdonerecord also carries anerrorfield — the tool's own message, first 200 characters, only onisError: true, with a token echoed back by the tool cut down to<token>— because the outcome of a remote call exists nowhere else; a call writes astartand adone, while the bridge's own doings (a share, a tunnel, an arming, an instance, a switch) write oneeventeach — sophase == "done"is exactly the completed calls; while/a2mcp incognitois on, the whole log is silent — the switch is the operator's, the remote side cannot reach it, and the switch's own two lines are written just outside the silent window so the gap says why; the bridge's own lifetime is in there too — abridgerecord at bind and at teardown — so the port in a tunnel or share record can be read against an instance that is still up, and astartwith no matchingstopis an endpoint that died with its host; the log sits at~/.omp/agent/a2mcp.<UTC date>.log, one file per day, mode0600— and the bridge sets no bounds on it: no size cap, no file count, nothing deleted. How long evidence is kept is the administrator's call; the bridge's part is to write each day's records where they can be found and kept, which is the shape logrotate and "copy it out before you need it" both expect. A call that spans midnight has its two records in two files, so pair byidacross them. Long strings in the args are recorded as<len, first 8 of sha256>at any nesting depth — the host'seditputs the file body at the third level, and a redactor that stops two levels deep misses it. Writes are serialized inside the process: one line at a time, so two records cannot interleave.startreaches the disk before the tool runs anddonebefore the response goes out: a 200 means the line is already there. Unauthorized attempts write nothing here at all; those attempts go to the host's stderr. Astartwith nodonemeans the call hung (typically an approval waiting for a UI that never comes). The full shape and numbers are indocs/protocol.mdanddocs/testing.md.If the configured port is busy, the bridge falls back to an ephemeral port and warns — update 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, no call cancellation; always routed to the host's Main session; 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 for the host; the catalog is not filtered on permission grounds. 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
First start on a machine with no model configuration: the host still opens a session, and the bridge registers a placeholder provider
a2mcp/offlineatsession_startand selects it. Its baseUrl points ata2mcp.invalid, an RFC 6761 reserved domain that can never resolve; the whole thing is runtime state — no file is written, and an operator's already-selected model is never overwritten, because the bridge only fills an empty slot. The MCP surface is therefore ready out of the box: the notification bar broadcasts and remoteinitialize/tools/listanswer 200. The placeholder does not infer. A prompt on the host comes back as a connection-refused soft failure — measured on 18.12.1:Couldn't connect to a2mcp.invalid (connection refused)— and real inference always means/loginand/model. Older hosts stop atNo model selected(older still:No models available) on such a machine; that is the prompt-time gate, and upgrading the host is the fix. What the placeholder cannot reach is the headless start (omp --mode rpc): it dies insidecreateAgentSession, before any extension code runs, so only a~/.omp/agent/models.ymlthat exists before boot saves it. The one-shot scripts write exactly that file on exactly one branch: the script installed the host itself and the file was absent (create-only — an existing file is not read or touched, and a machine that already hadompon PATH gets nothing). A manually installed host that wants headless can write the same shape, documented under 占位模型 indocs/protocol.md. Probes run this unreachable shape daily on Linux and macOS; on Windows the script itself ran for the first time on 2026-10-19 (already-installed-host branch), and theC:\Users\<user>\.omp\agent\models.ymlseeding path has never been executed there.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 (a notice on
/a2mcp,[a2mcp] takeover failed to arm: …on the host's stderr, and onehost_llmaudit record carrying anerror): the model configuration contains atransporton a provider outside the claimed names (pi-native, or any transport that short-circuits before the API dispatch) — the bridge cannot get in front of it, 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): an installed@oh-my-pi/pi-*out of sync with the pin is restored bybun install; a localomp --versiondisagreeing with the pin means syncing the two exact versions inpackage.json. To deliberately run against a 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 (281: protocol/auth/config/exposure gate/host hand-off/entrypoint lifecycle/audit/version guard/takeover shapes/arming transaction/host-llm route/xfer/instructions/obtain shapes incl. the win32 ones/placeholder model/the two bootstrap scripts)
bun run test:obtain # the pinned binaries: fetches both for this platform, HEADs all ten asset URLs, reads the two Windows rows as bytes, 41 checks (needs no local omp; writes only inside its own /tmp directory)
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, 36 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, 11 narratives / 85 steps, ~90s (needs a local omp)
bun run test:install # published-package self-containment + a real host over MCP, 55 items (needs a local omp; fetches ~6.4 MB from GitHub and once installs the package from the registry — that group info-skips while the package is unpublished)
bun run test:mcp-call # the reference client scripts/mcp-call.py against a real host + a fake 404 server, 7 checks (needs a local omp and python3; no python3 → the whole probe info-skips, exit 0)
bun run test:bootstrap # the one-shot installer's decisions, 47 items, ~9s (everything but the last group runs against a recording stub omp and a fake curl; 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/* packages are pinned to exact versions in devDependencies, kept in step with the host omp, and used only for type checking and unit tests. At runtime the host's omp:legacy-pi-shim redirects those imports to its bundled copies — module-level singletons like AgentRegistry.global() only work when shared from that one copy. Update the version numbers when upgrading omp; the guard runs in bun test.
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.
Host any HTTP API as a standard MCP server; auto-distribute to 12+ mainstream agent platforms.
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.-
- AlicenseNot 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.MIT
- 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.-