@black942026/harness-acp-bridge-mcp
v0.1.12
Published
Stdio MCP proxy for the harness-acp-bridge HTTP server: exposes its operations as tools without owning harnesses, state, or scheduling.
Maintainers
Readme
@black942026/harness-acp-bridge-mcp
Stdio MCP proxy for the harness-acp-bridge server.
It exposes the server's HTTP operations as MCP tools. It owns no harness, state, or
scheduling: most tools are a single POST /api/<name>, while send_message/wait submit (or
resume) once and then poll message_status until the message is terminal. If no compatible
server is listening on the configured loopback address, the proxy starts the installed
server package as a managed background service and reuses it. Harness processes, scheduling,
persistence, SSH/Docker launch, template storage, and authentication all remain the server's
responsibility.
MCP client --stdio JSON-RPC--> this proxy --HTTP POST /api/<name>--> bridge serversend_message keeps the server's asynchronous contract but waits for the terminal result in
the same tool call, exactly like the Pi client. A second tool, wait, resumes waiting for a
known session_id/message_id without resubmitting.
Tool-call timeout: the proxy sends no progress notifications (so no output body leaks through them). A
send_message/waitis therefore one long-running tool call; configure the MCP client's per-call timeout to cover the full turn, andHARNESS_ACP_BRIDGE_TIMEOUT_MSto cover a single HTTP request.
Install
npx -y @black942026/harness-acp-bridge-mcpRegister it as a stdio MCP server; it has no CLI arguments or admin flags.
Configuration
Environment variables win over the optional JSON file (config.example.json, selected with
HARNESS_ACP_BRIDGE_MCP_CONFIG). The only accepted JSON keys are baseUrl, timeoutMs,
autoStart, serverEntry, configPath, and startupTimeoutMs.
| Variable | Default | Purpose |
| --- | --- | --- |
| HARNESS_ACP_BRIDGE_URL | http://127.0.0.1:8787 | Base URL of the server. http/https only; no credentials, query, or fragment. |
| HARNESS_ACP_BRIDGE_TIMEOUT_MS | 120000 | Per-request HTTP timeout. |
| HARNESS_ACP_BRIDGE_AUTOSTART | true | Start a local server when the configured one is unreachable. |
| HARNESS_ACP_BRIDGE_SERVER_ENTRY | — | Explicit server module (or path / file: URL) to import when starting a local server. Must export HarnessHttpServer/loadConfig (the server package index), not the hacpd CLI (dist/main.js). A relative path is resolved against the proxy's startup cwd. When set it is final: it is never silently ignored, and no PATH/dependency fallback is probed. |
| HARNESS_ACP_BRIDGE_CONFIG | — | Server YAML config path passed to the started server (also honored as a client setting). |
| HARNESS_ACP_BRIDGE_STARTUP_TIMEOUT_MS | 30000 | How long to wait for a started server to answer ping. |
| HARNESS_ACP_BRIDGE_MCP_CONFIG | — | Optional JSON settings file. |
| HARNESS_ACP_BRIDGE_CLIENT_DIR | ~/.harness-acp-bridge-client | Client-owned directory for the start lock, server log, and pid registry. |
Local-server entry selection
When a local server must be started, the entry is chosen in this order:
HARNESS_ACP_BRIDGE_SERVER_ENTRY/ JSONserverEntry— an importable server module. Explicit and final; a wrong value fails withstartup_failedand is never silently skipped.hacpdfound onPATH(a fixed-name lookup, never a caller-supplied command). A standard npm global install exposes a.binsymlink to the package'sdist/main.js; the proxy resolves the symlink, and — because the CLI runs the server in-process and does not read stdin — starts it asnode --import <bundled watchdog> <hacpd>so the child PID is the answering server PID (exact ownership proof) and a supervisor exit (includingSIGKILL) reaches the server as a stdin EOF, stopping it instead of orphaning it. The CLI's own module resolution is preserved.- Otherwise the installed
@black942026/harness-acp-bridge-serverdependency's module entry.
A hacpd that is not a Node script (any other shebang, e.g. a shell wrapper) cannot carry the
in-process watchdog or give an exact PID, so it is refused with startup_failed (and the log says
so) rather than run unobserved. A found hacpd that starts but never answers ping, or answers
incompatibly, likewise fails hard — the resolver never switches to the next candidate after a
startup failure. PATH entries that are empty mean the cwd and relative entries are resolved
against the client's startup cwd (never the supervisor's); an unset PATH disables the
hacpd probe entirely (it is never read as the cwd), so a project-local hacpd is never run by
accident.
The proxy never accepts a command or argv pass-through, so a caller cannot make it launch
anything but the configured server module or the fixed hacpd PATH lookup through the managed
path. The internal launch variables (..._SERVER_MODULE / ..._SERVER_COMMAND) are cleared and
exactly one is set per launch, so an inherited value cannot hijack the spawn.
Timeout keys (MCP ⇄ Pi)
| Concept | MCP | Pi extension |
| --- | --- | --- |
| Per-request HTTP timeout | timeoutMs / HARNESS_ACP_BRIDGE_TIMEOUT_MS | requestTimeoutMs / HARNESS_ACP_BRIDGE_TIMEOUT_MS |
| Startup wait (until ping answers) | startupTimeoutMs / HARNESS_ACP_BRIDGE_STARTUP_TIMEOUT_MS | startupTimeoutMs / HARNESS_ACP_BRIDGE_STARTUP_TIMEOUT_MS |
Both clients share HARNESS_ACP_BRIDGE_URL / _AUTOSTART / _SERVER_ENTRY / _CONFIG /
_STARTUP_TIMEOUT_MS / _TIMEOUT_MS, all with the same precedence: environment > JSON >
default. Each keeps its own default timeout value. The Pi-only interactionTimeoutMs /
pollIntervalMs have no shared environment variable.
Local server lifecycle
pingthe configuredbaseUrl. A server answeringpingwithprotocol_version: 4is reused as-is. An older protocol (e.g. a still-runningprotocol_version: 3daemon, whosecreate_sessionstill referenced a template) is treated as incompatible and rejected, but it is never restarted, replaced, or killed.- If the address answered but is not a compatible bridge server (an error envelope, a
different protocol version, or a non-JSON listener), the proxy reports
incompatible_serverand starts nothing — a foreign service is never treated as "absent". - If nothing is listening and
autoStartis on, the proxy starts the server only for a loopback address. A non-loopbackbaseUrlis never spawned, locally or over SSH. - Starts are serialized with a per-address lock in the client directory, then re-checked, so
two clients racing to start the same instance converge on one server (the server's own
state_dirlock and port binding are the final backstop). - The started server runs as a child of a detached logging supervisor. The supervisor holds
the server's real stdout/stderr fds as pipes and appends only redacted bytes to the log, so it
survives the proxy and is never killed by it — it cannot tear down another client's
sessions.
<client dir>/servers.jsonrecords the actual server pid, the supervisor pid, and the log path. Ownership is proven, not guessed: the proxy spawns the supervisor with a fresh nonce and claims the server only when the answering ping pid equals the supervisor's recordedserver_pidand the private proof file carries that nonce. A peer that won the race is reused; a foreign listener is never treated as absent and never killed. shutdown/reloadrelease only the client's own transport. They never close server sessions or stop a reused instance.
Tools
Eleven operations plus search_template, create_template, and wait:
| Tool | Behavior |
| --- | --- |
| ping | Liveness, protocol version, PID, uptime, live session count, config fingerprint. |
| search_template | Find launch templates by exact name, query (case-insensitive substring of name/harness/description), and/or harness; returns each match in full. Starts nothing. |
| create_template | Durably register a launch template from {template: {...}}. A client alias for the server's register_template — there is no POST /api/create_template. Duplicate names are rejected (template_exists) and never overwritten. Starts nothing. |
| create_session | Create a session-owned harness from explicit parameters: required model_id and cwd, plus optional harness, command/args/env, thinking_level, permission_mode, target/remote_host, runtime, and the Docker fields (docker_image or docker_id, container_policy, login_dirs, mounts, ports, host_network). There is no template parameter. |
| close_session | Stop the harness and close the session (idempotent). |
| authenticate | Start or resume the harness authentication flow; the proxy elicits every question and polls until terminal. |
| auth_status | Return the current authentication workflow shape, or probe harness authentication when no flow exists. |
| set_session_options | Change model_id / thinking_level / permission_mode on an idle session. |
| send_message | Submit a prompt and synchronously wait for the terminal result in this call. |
| cancel_message | Cancel the active message and wait for it to settle. |
| message_status | State, terminal flag, pending questions, unsupported events, errors, and result. |
| answer_question | Answer a reverse request by session_id/request_id (option_id or object content); message_id is omitted for session-auth questions. |
| wait | Resume waiting for a known session_id/message_id; never resubmits. |
create_template/search_template carry MCP annotations that reflect reality: both have
openWorldHint: false (neither reaches a harness); search_template is readOnlyHint: true, and
create_template is a non-destructive server write (readOnlyHint: false, destructiveHint:
false, since a duplicate name is rejected rather than overwritten). delete_template is
deliberately not exposed as a tool.
Launch templates
search_harness is removed; select a harness via launch templates or explicit session parameters.
The server stores launch templates (model/thinking/instruction/command/argv/env/Docker defaults), but templates are
information only: create_session never references one. The intended workflow:
- The user names or describes a template. Call
search_templatewith an exactnamewhen known, otherwise withquery/harness. If several match, ask the user which one before proceeding. - Read the returned template's full content — the proxy never guesses it.
- Map non-null
model→model_id,thinking→thinking_level; user overrides win. Ask only for a missing model orcwd. No model/thinking candidate lists are required in server YAML. - Follow
special_instructionsthroughout; the calling LLM executesbefore_startbefore startup andafter_startafter successful startup and any required authentication. These are natural-language prompts, not proxy/server hooks, and remain subject to user approval and safety rules. Report failures/unavailable tools rather than claiming actions ran. - Map Docker
network_modeto sessionhost_network(host→ true;none/proxy→ false) and pass the returnedenv. Dropname/description/model/thinking/network_mode/proxy_port/before_start/after_start/special_instructionsand inapplicable fields, then callcreate_sessionwith only allowed explicit fields. Omit invalid nulls (localremote_host, missingcwd) and re-check runtime/target combinations. The proxy never merges a template or executes its instructions.
To create a template, call create_template with a {template: {...}} object. Only the fields
the server can register are accepted: name (required), harness (required), and optional
description, model, thinking, before_start, after_start, special_instructions,
runtime, target, remote_host, permission_mode, command, args, env,
cwd, docker_image, container_policy, login_dirs, mounts, ports, network_mode, proxy_port.
Unknown fields are rejected. Prefer collecting and confirming the values with the user first; the
acp-create-template skill (published with the Pi extension package, and portable to MCP hosts)
documents that interview. Registering a template never creates or starts a session or executes
instruction fields. All five new fields are nullable/optional; model/thinking must be nonblank when
set. Existing templates load with null defaults. Remove the entire obsolete harnesses section
from server YAML; all built-in adapters are available without declarations. Select the harness via
templates or explicit session parameters; omitted harness defaults to Codex (overridable with
default_harness). No YAML file is needed when all service settings use defaults.
Docker network modes
Templates use network_mode: none | host | proxy, not the old host_network (rejected).
none is ordinary bridge without proxy values; host enables host network without proxies or
published ports. proxy requires only proxy_port (1–65535): registration sets identical lowercase
http_proxy/https_proxy to http://host.docker.internal:PORT, and no_proxy to
localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,.local,.internal.
It replaces stale proxy aliases while keeping unrelated env; the service need not be Xray.
Pass this returned env to create_session, map mode to its host_network boolean, and omit the
metadata network_mode/proxy_port. Confirm host-address/port reachability from the target Docker
container and HTTP-client/CIDR support; do not infer it from a successful env round-trip.
Re-register old templates; no old-template migration is implemented.
Authentication
authenticate takes only { session_id }; the proxy drives the flow and never lets the caller pick a
method or a token. It elicits the server-provided method_id selection (the candidates, including the
Not supported in harness_acp_bridge ones, are shown in the question message; only supported ids are
offered), then elicits each follow-up question: the CodeBuddy “open this URL, then confirm”
(opened: boolean), an Agy token, a Codex device code, and so on, in as many rounds as the server
sends. Confirming a URL is not success — polling continues until the flow is terminal
(ready/failed/cancelled; a rate limit is authentication_rate_limited). Each answer is sent as a
session-scoped answer_question (no message_id) after re-checking the request_id. Without
elicitation the tool returns waiting_input with the full questions for a manual answer_question
followed by authenticate. Aborting only detaches the local wait; a decline/cancel/timeout is a real
answer. create_session (and any op) that needs login returns a structured authentication_required
error whose detail keeps session_id and next_action — the model then calls authenticate; the
proxy never auto-authenticates.
Reverse requests
While send_message/wait runs, a reverse permission or information request is answered with
an MCP elicitation when the connected client advertised the capability: offered permission
options are mapped back to their exact optionId, and nothing is approved by default (a
decline maps to reject, a cancel to cancel, and an unoffered selection is refused). When the
client cannot elicit, the tool returns waiting_input with the original questions so the
model can call answer_question and then wait. Aborting wait only stops local waiting
and leaves the remote message running; a later wait can resume it. Aborting send_message
still cancels the remote message, matching the Pi client. Server timeout, question-answer,
and JSONL error policies are unchanged.
There is no incremental-output tool (live_output is gone). send_message/wait return only the
terminal status (whose result is the server's final answer); the proxy sends no progress
notifications, so no output body leaks through them.
Each success returns the server's data object as JSON text. A failure returns an MCP tool
error (isError: true) whose text is the envelope
{ "ok": false, "error": { "code", "message", "detail" } }, preserving the server error code and
its detail (including a structured authentication_required).
Privacy
The proxy is a thin client: the real HTTP response is what it returns. It only sanitizes the copies it persists, and only common credential shapes.
- The detached bootstrap is a logging supervisor: it launches the actual server with stdout
and stderr as pipes (never the log file) and redacts both streams — independently, with
bounded cross-chunk state — before appending to
server-<addr>.log. Because the real fds are pipes, every output path is covered:console,stream.write,fs.writeSync(1/2),fs.writev, native modules, dynamic-import and runtime diagnostics. A JS-levelwritepatch cannot see fd-level writes; the pipe boundary can. - Redaction lives entirely in the detached supervisor, never in the client, so it keeps working after the client exits while the server stays alive. At EOF both held tails are flushed (masked) before the log fd is closed, and existing log bytes are never rewritten (append only).
- A redactor or disk error never falls back to raw bytes: a redactor exception drops the chunk, and a disk write failure stops the owned server and exits non-zero rather than retrying forever or letting the server block on a full pipe.
logTail/registry read-time masking is defense in depth only, never the security boundary.servers.jsonis sanitized before its atomic write; the top-level map keys are base URLs (already credential-free) and stay intact so registry and ownership lookups keep working. The log file is mode 0600 in a mode-0700 client directory, and the launcher proof file is mode 0600.- Rules are the canonical server files copied verbatim:
src/redact-core.tsandsrc/redact.tsare byte-identical toserver/src/redact/redact-core.ts(sha256b07ef16e39483b008c0f03e3da05dc194c87fa93080b53accf34317586d2a6c8) andserver/src/redact/redact.ts. The build emitsdist/redact-core.js+dist/redact.js, so nothing is hand-ported. To re-sync: copy the two files oversrc/and rebuild. - A parity harness (
npm run parity) loads this package's build and the sibling server build and asserts byte-exact output for both the non-streaming and the streaming APIs across a representative battery (report:/tmp/redaction-parity-mcp.log). The Pi extension pins itself against the same server build with its own copy, so all three agree transitively. - Unknown/unrecognized text — including counters like
max_tokensand ordinary CJK/emoji text — is preserved verbatim. This is not a guarantee that every unknown secret is removed.
Develop
npm ci
npm run check # clean + build + typecheck + tests
npm pack --dry-runThe package and its tests do not import or require the Pi extension checkout or dependencies.
npm run check starts a real server and its fake ACP harness (no paid model, Docker, or SSH) and
drives it through real stdio MCP clients with and without elicitation capability, including
autostart, reuse, answer+wait, cancel, the authentication flow (unauthenticated
create_session → method selection → URL/token answer → auth_status success), and the template
flow (create_template → search_template by name/query/harness → expand the allowed fields into
an explicit create_session with an inherited-then-overridden cwd and command/args/env, plus
duplicate/invalid/unknown templates and client-side rejection of a missing cwd, a template
reference, or launch internals). Harness
commands come from the explicit command/args/env or the execution target's PATH, so the
integration suite installs PATH wrappers around the fake harness for the template-less cases.
Integration and
lifecycle suites resolve the in-repo server build (../server/dist) first and fall back to the
installed package; because the client requires protocol_version: 4, they are skipped until the
server is rebuilt with the new contract. Point ACP_BRIDGE_SERVER_DIR at a server checkout
(defaults to ../server).
License
MIT
