Skip to content
thelabcornerPublic

About

Replay-safe local coding-agent bridge for URL-reader transports.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

getmcp-url-agent

Build License Version Coverage Node Package Manager TypeScript

Replay-safe local coding-agent bridge for URL-reader transports. The bridge exposes a constrained, auditable filesystem and execution surface over plain HTTPS GET, without assuming a persistent bidirectional channel.

The agent transport is designed for environments where the remote agent can only fetch public URLs. Instead of pretending a GET is a stateful tool call, the runtime treats every inbound fetch as a potentially replayed intent, validates it strictly, and executes at most once. This makes the transport robust to hidden retries, truncation, and encoding quirks that are inherent to URL-reader intermediaries.


Overview

The bridge separates concerns into two loopback-only surfaces:

  • Control surface on 127.0.0.1:47433: operator-facing UI for project scopes, permissions, session lifecycle, and tunnel management. Never tunneled. Never exposed.
  • Data plane on 127.0.0.1:47432: the agent-facing HTTP gateway. This is the only port that is tunneled to the public internet.

The data plane fronts multiple isolated session runtimes. Each session has independent capabilities, journals, staged content, read coverage, and replay state. The control surface orchestrates those runtimes and rotates capabilities without cross-session invalidation.

Why this split exists: tunneling the control UI would expose project scope management and capability rotation to the public internet. By binding the control plane to loopback and tunneling only the data plane, the operator retains local authority over what the agent can see and do, even when the agent operates through a public URL.

Key properties verified in the workspace:

  • Loopback-only control UI, tunneled data plane only
  • 16 concurrent enabled sessions ceiling, 64 persisted session definitions
  • Global cap of 2 foreground terminal processes regardless of session count
  • 1.6 KiB mutation-enabled Init contract ceiling
  • 12 KiB request target ceiling, 8 KiB raw content ceiling, approximately 1 KiB conservative staged chunk for broad compatibility
  • 8-character base62 ephemeral capability tokens
  • Compact routing via bases.r and bases.m
  • Replay-safe mutation kernel with deterministic intent journal
  • Strict query-parameter validation with exact repair URLs
  • Generic plugin transport with progressive tool discovery and the same read-versus-mutation capability split
  • Bundled OpenCode Control plugin for approved-root session inspection, task-like worker delegation, model selection, permission routing, and durable worker groups

Design documents

  • docs/ARCHITECTURE.md describes the current URL-agent runtime, control/data-plane split, capabilities, and transport model.
  • docs/PLAN.md tracks the broader GetMCP implementation plan.
  • docs/TRANSPORT_GYM.md documents the URL transport conformance corpus and hostile-intermediary test model.

Why the URL Is the Entire Control Surface

This is the core idea that makes the bridge different from a conventional local server, and it is intentionally bold.

There is no POST. There is no request body. There is no JSON request body, no multipart form, no WebSocket, and no custom header that carries the operation. Even when a plugin accepts a JSON argument object, that JSON is carried inside the URL's final raw= tail or by a staged blob reference. Every capability check and operation is encoded entirely in the URL itself and executed as a plain HTTPS GET.

That means the runtime can be driven by any agent whose only tool is "fetch a public URL and return the text". If the agent can read a URL, it can code. The URL is the RPC, the URL is the transport, the URL is the audit trail, and the URL is the replay key.

Why this matters in practice:

  • It works through the narrowest possible agent tool surface. Many hosted agents expose a URL reader but not a raw socket, not a body-capable fetcher, and not a plugin system. The bridge meets that constraint directly instead of trying to tunnel a richer protocol through it.
  • It is cache and intermediary aware. GETs can be retried invisibly by infrastructure below the agent. The runtime treats that as normal and makes mutations replay-safe by design, so a hidden retry never becomes a double write.
  • It is encoding independent. No header negotiation, no content type branching. The same raw tail and tiny ~ escape grammar works whether the intermediary rewrites spaces to +, percent-encodes automatically, or truncates the response.
  • It is inspectable. A single URL is a complete, copy-pasteable repro for any operation.

What an encoded call actually looks like

Tokens below are placeholders. Replace <tunnel> with your public origin, <initTok> with the 8-char Init capability, <readTok> with the read capability, and <mutTok> with the mutation capability. No body is sent.

Init bootstrap, the only URL the operator hands out
GET https://<tunnel>/v0/init/<initTok>

Read one file
GET https://<tunnel>/v0/<readTok>/r?path=src/index.ts

Read three files in one round trip, no extra calls
GET https://<tunnel>/v0/<readTok>/r?path=src/app.ts&path=src/sandbox.ts&path=package.json

Search, literally, without regex escaping headaches
GET https://<tunnel>/v0/<readTok>/g?path=src&raw=TODO

Outline before you read, so you never page blindly
GET https://<tunnel>/v0/<readTok>/o?path=src/tool-kernel.ts

Write a new file, content is just the tail of the URL
GET https://<tunnel>/v0/<mutTok>/w?path=hello.py&raw=print("hello")~nprint("world")~n

One precise edit, no body, no diff header
GET https://<tunnel>/v0/<mutTok>/e?path=src/app.ts&old=hello&new=hello%20world
GET https://<tunnel>/v0/<mutTok>/e?path=src/app.ts&oldBlob=old1&newBlob=new1

Bulk patch, many hunks and many files in one GET
GET https://<tunnel>/v0/<mutTok>/a?raw=***%20Begin%20Patch~n***%20Update%20File:%20src/app.ts~n...

Terminal, foreground with bounded wait, or startup-verified background
GET https://<tunnel>/v0/<mutTok>/x?wait=1000&raw=python%20--version
GET https://<tunnel>/v0/<mutTok>/x?bg=1&raw=npm%20run%20dev

Stage large content in ~1 KiB chunks, then reference it, still no body
GET https://<tunnel>/v0/<mutTok>/s?blob=feature&i=0&count=3&raw=<chunk0>
GET https://<tunnel>/v0/<mutTok>/s?blob=feature&i=1&count=3&raw=<chunk1>
GET https://<tunnel>/v0/<mutTok>/s?blob=feature&i=2&count=3&raw=<chunk2>
GET https://<tunnel>/v0/<mutTok>/a?blob=feature&count=3&wait=1000

Poll the intent journal for the real result, even if the first GET was retried invisibly
GET https://<tunnel>/v0/<mutTok>/j?call=auto:write:a1b2c3d4e5f6

Recover a truncated response without re-executing anything
GET https://<tunnel>/v0/<readTok>/z?last=1&part=0

Discover installed plugins without bloating Init
GET https://<tunnel>/v0/<readTok>/k

Discover one plugin's tool schemas
GET https://<tunnel>/v0/<readTok>/k?plugin=opencode-control

Read OpenCode status through the generic plugin route
GET https://<tunnel>/v0/<readTok>/k?plugin=opencode-control&tool=opencode_info&raw={"action":"status"}

Start a replay-safe OpenCode worker. Fetching this exact URL twice still admits the mutation once.
GET https://<tunnel>/v0/<mutTok>/k?plugin=opencode-control&tool=opencode_worker&wait=1000&raw={"action":"start","workdir":"/project","prompt":"Run the targeted test suite and report the result."}

Notice the pattern: the method is always GET, the capability is always in the path, the operation is always a compact route (/r, /g, /o, /t, /p, /w, /e, /a, /x, /s, /j, /z, /k), and when content is needed it lives at the very end of the URL after raw= or is referenced by blob=. There is no second channel. That is the whole surface.

Compact Init makes this even tighter. Instead of repeating https://<tunnel>/v0/<readTok> on every call, the Init contract returns bases.r and bases.m. Each endpoint is then just [baseRef, suffix], so the agent builds URLs mechanically as bases[baseRef] + suffix without ever hand-assembling host or token.

If you are used to local agents that speak over stdio or POST JSON, this feels minimal to the point of surprising. That minimalism is the point, and it is why the same bridge works through public tunnels, through URL readers, and through intermediaries that were never designed to carry a coding session.


Architecture

flowchart TB
    Operator[Operator Browser]
    Control[Control Surface<br/>127.0.0.1:47433<br/>Project Scopes, Permissions,<br/>Sessions, Capability Rotation]
    Gateway[Data Plane Gateway<br/>127.0.0.1:47432]
    Tunnel[Public Tunnel<br/>HTTPS Origin]
    Agent[Remote Agent<br/>URL Reader]

    ToolKernel[Tool Kernel<br/>read, find, grep, outline,<br/>stat, project]
    Journal[Intent Journal<br/>Deterministic Call Identity<br/>Exactly-Once Execution]
    Mutation[Mutation Kernel<br/>terminal, write, edit,<br/>apply_patch]
    PluginHost[Plugin Host<br/>Generic read or journaled mutation dispatch<br/>Call-local authority ceiling]
    OpenCode[OpenCode Control Plugin<br/>sessions, workers, models,<br/>requests]
    Staging[Staged Content Store<br/>Immutable Chunks]
    JournalStore[Journal Directory<br/>Per-Session Persistence]
    Replay[Response Replay Store<br/>Range Fallback / Poll]

    Operator --> Control
    Control --> Gateway
    Gateway --> ToolKernel
    Gateway --> Journal
    Journal --> Mutation
    Gateway --> PluginHost
    Journal --> PluginHost
    PluginHost --> OpenCode
    Gateway --> Staging
    Journal --> JournalStore
    Gateway --> Replay
    Gateway <--> Tunnel
    Tunnel <--> Agent

    style Control fill:#18181b,stroke:#3f3f46,color:#fafafa
    style Gateway fill:#27272a,stroke:#52525b,color:#fafafa
    style Journal fill:#3f3f46,stroke:#71717a,color:#fafafa
    style Mutation fill:#3f3f46,stroke:#71717a,color:#fafafa
Loading

Flow in one sentence: the operator configures scopes and sessions on the control surface, the control surface spawns isolated data plane runtimes behind a loopback gateway, the gateway is exposed through a public tunnel, and the agent interacts only with capability-scoped URLs that map to the tool kernel, the intent journal, or the generic plugin host.

The bridge, the agent transport, the control surface, and the runtime are the only terms used for the system itself. No vendor or model family is assumed in the protocol, and Init responses are intentionally model-neutral.


Quick Start

Prerequisites: Node.js 20 or later, npm, and optionally cloudflared for managed tunneling and rg (ripgrep) for accelerated search.

npm install
npm run build
npm start

Then open the control surface:

http://127.0.0.1:47433

Useful scripts from package.json and vitest.config.ts:

Command Purpose
npm run dev Watch the control plane server via tsx
npm start Start the control plane (same as npm run control)
npm run build Build server (tsc) and UI (vite build) and run scripts/smoke-built.mjs
npm run typecheck Strict server typecheck
npm run typecheck:ui Strict UI typecheck (ui/tsconfig.json)
npm test Run vitest suite (test/**/*.test.ts)
npm run verify typecheck plus typecheck:ui plus test
npm run gym:transport URL-transport conformance gym
npm run gym:transport:audit Larger hostile-intermediary audit matrix

The data plane starts automatically on 127.0.0.1:47432 through the control plane manager. Do not start it manually on a public interface.

Experimental browser companion PoC

GetMCP includes a deliberately narrow browser-companion proof of concept. It demonstrates how a browser extension can pair with the loopback control plane, bind a remote agent conversation to an isolated GetMCP session, keep that authority alive only while the browser is present, and inject the one-time Init bootstrap without weakening the operator API.

This is intentionally a PoC rather than a supported product integration. The implementation is kept because it exercises important protocol and security properties under real browser-extension constraints.

PoC properties:

  • pairing requires explicit local operator approval;
  • paired device credentials are hashed at rest and never appear in agent capability URLs;
  • the browser extension background runtime owns the plaintext bearer credential;
  • companion-owned public capabilities require a short live-browser lease at the data-plane gateway;
  • explicit disconnect invalidates authority immediately and a stale heartbeat cannot resurrect it;
  • runtime rotation, lease expiry, project switching, or explicit reconnect require fresh generation authority and a fresh bootstrap;
  • local project-file search stays inside the selected project's existing ToolKernel sandbox;
  • project file-write and terminal flags remain the single permission model. The PoC does not add a second permission hierarchy;
  • manual GetMCP sessions remain independent of the browser lease mechanism.

For persistent experiments, configure a stable HTTPS public origin through the existing GetMCP setting or GETMCP_PUBLIC_ORIGIN. A configured public origin takes precedence over Quick Tunnel. The hostname is connectivity only; browser lease and capability state remain the authorization boundary.

Companion state is stored next to the GetMCP configuration in companion.json. The file contains device credential hashes and durable binding metadata, not plaintext device bearer credentials.

Optional OpenCode Control plugin configuration

OpenCode Control is bundled and enabled by default, but it is lazy. Starting GetMCP does not scan ports and does not require OpenCode to be running. The plugin connects only when an agent actually calls one of its tools. Set the loopback server explicitly when your OpenCode service does not use the default http://127.0.0.1:4096 origin:

$env:GETMCP_OPENCODE_URL = "http://127.0.0.1:4545"
npm start

Supported configuration variables:

Variable Default Purpose
GETMCP_OPENCODE_ENABLED true Set to false to remove the bundled OpenCode plugin from the runtime.
GETMCP_OPENCODE_URL http://127.0.0.1:4096 Explicit loopback-only OpenCode HTTP origin. No port scanning is performed.
GETMCP_OPENCODE_USERNAME opencode Username used for one-time Basic pairing when a bootstrap password is supplied.
GETMCP_OPENCODE_DEVICE_TOKEN unset Existing revocable OpenCode device token. Preferred for persistent authenticated deployments.
GETMCP_OPENCODE_BOOTSTRAP_PASSWORD unset One-time server password used to pair and obtain a device token for the current GetMCP process. The password is removed from the backend's in-memory credential copy after pairing.
GETMCP_OPENCODE_DEVICE_ID unset Optional paired device identifier used by cleanup-capable integrations.
GETMCP_OPENCODE_ALLOW_EXISTING true Permit execution-oriented control of existing sessions. Existing-session send and resume still require both GetMCP write and shell authority.
GETMCP_OPENCODE_ALLOW_NESTED false Permit delegated OpenCode workers to spawn nested task subagents. Disabled by default.
GETMCP_OPENCODE_MAX_WORKERS 8 Admission ceiling for GetMCP-owned OpenCode workers and swarm members, bounded by the backend to 1 through 16.

The OpenCode client accepts only http://localhost, http://127.0.0.1, or http://[::1] origins. It probes the OpenCode instance identity before credentials are sent, pins later requests with x-opencode-expect-instance, and fails closed if the process behind the port changes. A bootstrap password is never placed in a URL. If the server is authenticated and no valid device token or bootstrap password is configured, opencode_info {"action":"status"} reports needs-auth rather than weakening authentication.


Operator Control Plane

The control surface is the only place where trust decisions are made. It is bound strictly to 127.0.0.1:47433 and requires same-origin plus loopback session cookie for mutations. This is not a deployment option, it is a security invariant.

Through the control UI (Vite plus React, located under ui/), the operator can:

  • Register approved project roots via a folder browser rather than hand-typing absolute paths. Every supplied root is resolved to its canonical realpath and verified to be a directory before persistence.
  • Manage multiple isolated agent sessions concurrently across one or many projects.
  • Independently enable file writes and terminal execution per project. A session inherits only the tools allowed by its project scope.
  • Rotate one session's public capabilities without invalidating unrelated sessions. Changing a project's root or permissions rotates only sessions bound to that project.
  • Configure the HTTPS tunnel origin and start or stop a control-plane-owned Quick Tunnel over HTTP/2 pointed only at http://127.0.0.1:47432. The managed child uses an explicit empty temporary cloudflared config so a user's named-tunnel ingress cannot interfere with Quick Tunnel --url mode. That temporary config is removed on exit. The persistent registry keeps the operator's manual origin as a fallback so a crash cannot persist a dead ephemeral hostname. The runtime never kills a cloudflared process it did not spawn.
  • Copy the current agent Init URL and a generic Init prompt containing the live Init URL.
  • Inspect recent authenticated agent activity, including duration and replay or dedupe metadata, without logging tool content text. Headers and query values that look like secrets are redacted.
  • See the effective mutation tools and URL-transport features for the selected session generation.
  • See installed plugin status, tool count, and connection or authentication state. Plugins are shared integration instances, but every call receives the narrower authority of the GetMCP session that invoked it.

Resource ceilings are explicit and enforced in src/project-registry.ts and src/control-plane.ts:

  • At most 16 sessions may be enabled concurrently. Attempts to exceed this return enabled_session_limit_reached.
  • At most 64 session definitions are persisted. Attempts to exceed this return session_definition_limit_reached.
  • Foreground terminal execution is globally capped at 2 processes via TerminalConcurrencyGate in src/mutation-kernel.ts, regardless of how many sessions are running. Specialized writes for sessions sharing one project serialize through a per-project WriteSerializationGate.

If you tunnel the wrong port, the agent will never reach the data plane. Tunnel 47432, not 47433.


Init URL and Capability Rotation

Each session generation creates three separate compact capabilities:

  • initialization
  • read and search access
  • mutation access when mutation tools or mutation-capable plugins are enabled

Capabilities are 8-character base62 strings generated without modulo bias in src/capability-token.ts. They provide approximately 47.6 bits of entropy, are ephemeral, are never persisted in the project registry, and are never printed in normal daemon logs. Generation is rejection-sampled from crypto.randomBytes to avoid bias.

The public Init URL is the only bootstrap the agent needs. The operator copies it from the control UI and delivers it via the Init prompt.

  • Canonical route: /v0/init/<token>
  • Compatibility alias: /v0/bootstrap/<token> (still accepted, no longer advertised)

The Init response is a compact, generated, model-neutral bootstrap contract. It front-loads:

  • One exact read base plus an optional exact mutation base, followed by compact endpoint suffix references
  • The /project virtual filesystem and scope identity
  • Active permissions
  • Raw and unconverted fetch requirements
  • Minimum read, search, mutation, and plugin-discovery routing rules needed before the second call

Size and compact reference design

The fully mutation-enabled Init contract is regression-capped at 1.6 KiB UTF-8 for the local test origin, including the plugin-discovery endpoint. The current implementation measures about 1.56 KiB locally with the bundled OpenCode plugin enabled. Plugin IDs, status, descriptions, and JSON schemas are intentionally progressive-disclosed through /k instead of being copied into Init.

To avoid retransmitting the same hostname and capability prefix for every tool, Init defines:

  • bases.r: read base
  • bases.m: mutation base when enabled

Each endpoint is then a compact [baseRef, suffix] pair. The exact callable URL is mechanically bases[baseRef] + suffix; only documented placeholders and controls may be changed. The plugin entry is just ['r','/k']. Fetching /k returns the installed plugin summaries, and /k?plugin=<id> returns that plugin's full tool schemas. Runtime next, repair, retry, repeat, poll, staging-status, and response-range URLs remain fully expanded so recovery never depends on reconstructing a base. /manifest, /b, and /z remain supported but are intentionally not advertised in compact Init because normal workflows receive exact URLs when needed.

Rotation and isolation

  • Changing a project root or permissions rotates only sessions bound to that project.
  • Rotating, stopping, or deleting one session leaves unrelated session URLs valid.
  • Staged content, journal state, read coverage, and response replay are in-memory, generation-local, and disappear on scope or capability rotation.

Self-correcting capability errors

For authenticated requests, the agent transport makes protocol mistakes self-correcting rather than opaque:

  • Using a mutation route such as /x on the read capability returns wrong_capability_base plus an exact repair URL.
  • Using a read route on the mutation capability gets the same treatment.
  • Calling a mutation-classified plugin tool through the read /k endpoint returns an exact repair URL on the mutation capability. Calling a read-only plugin tool through the mutation /k endpoint repairs in the other direction.
  • /s?path=... on the read capability is recognized as a common spelling mistake for stat and repaired to /t?path=....
  • Deterministic query typos may return an exact corrected URL.
  • Unknown authenticated routes return structured unknown_route guidance instead of a bare 404.
  • Invalid or unknown capability tokens remain opaque 404s to avoid oracle behavior.

Native absolute paths supplied by the user are accepted only when their canonical target remains inside the approved scope. They are normalized to /project/... in returned results. Absolute paths outside the scope, including symlink and junction escapes, remain rejected via realpath containment checks in src/sandbox.ts.


Tool Surface

Long descriptive routes remain accepted for compatibility, but compact routes are preferred for economy. The control plane advertises compact routes in Init; compatibility routes are intentionally separated under compatibilityEndpoints.

Read-only surface

Tool Compact Route Purpose Notes
read /r Bounded text and directory reads Up to 8 repeated path= parameters per call. Fair-shares text allowance across files. Reports requestedPaths, returnedPaths, allComplete, completePaths, incompletePaths, and unreturnedPaths.
find /f Bounded literal filename and content discovery Recursive by default. Use instead of trial-opening many candidate files.
grep /g Bounded literal-first source search Literal search by default in compact Init. Regex remains available explicitly. Scans one file or one directory.
outline /o Bounded JS/TS source map Returns imports and named declarations with line numbers. Use before paging a large source file.
stat /t Safe path metadata Kind, size, and modification time.
project /p Runtime, toolchain, marker, and script discovery Reports executables, markers, and package.json scripts without spending a mutation.

Why these tools exist: they cover the inspection loop without requiring a shell. When rg is available, grep delegates to it for speed and correctness; otherwise a portable fallback preserves semantics. find and grep are deliberately literal-first to avoid accidental regex cost and to keep URL encoding simple.

Replay-safe mutation surface

Tool Compact Route Purpose Notes
terminal /x Bounded foreground execution or startup-verified bg=1 background launch Captures detectedUrls and detectedPorts from Vite-like startup transcripts.
write /w One new file or intentional whole-file replacement For existing files, requires complete current read coverage. If the read was truncated, follow next until fully observed.
edit /e One exact contiguous replacement in one file Prefers this for single-hunk, single-file changes.
apply_patch /a Preferred bulk multi-hunk and multi-file pathway Parses and preflights every hunk before the first write, rolls back on commit failure. Supports *** Delete File: sections for deletion.
stage /s Immutable replay-safe content chunk staging See URL transport rules below.
blob status /b Staged content status and completeness Returns chunk indexes, missing indexes, byte counts, and preview.
result /j Mutation journal polling Polls by call= for intent state pending, running, succeeded, or failed.

read, find, grep, stat, write, edit, and apply_patch are project-root contained. terminal starts in a project-contained working directory but is a real host shell, not a filesystem sandbox. A deliberately constructed shell command can name host paths outside /project. Use specialized filesystem tools for filesystem work and reserve terminal for builds, tests, package managers, git inspection, scripts, and application launch.

Tool-level errors are returned as HTTP 200 {ok:false,...} when possible because many URL readers hide useful bodies on non-success HTTP statuses. Journal records preserve structured error metadata so later /j polls do not collapse failures into opaque strings.

Generic plugin surface

Plugins do not add new hardcoded arms to MutationToolName. The data plane exposes one generic compact route, /k, and the plugin backend classifies each operation as read or mutation before execution. This keeps plugin-specific protocol knowledge out of the filesystem mutation switch while preserving the same capability separation and recovery behavior as native tools.

Discover installed plugins
GET <read-base>/k

Discover the OpenCode plugin schema
GET <read-base>/k?plugin=opencode-control

Read-only call
GET <read-base>/k?plugin=opencode-control&tool=opencode_session&raw={"action":"list","workdir":"/project"}

Mutation call
GET <mutation-base>/k?plugin=opencode-control&tool=opencode_worker&wait=1000&raw={"action":"start","workdir":"/project","prompt":"Inspect the failing tests."}

Plugin arguments are a JSON object carried by the same final raw= tail used elsewhere. For larger JSON, stage the serialized object with /s, then call /k?...&blob=<id>&count=<n>. Plugin mutations use the same IntentJournal as native writes and terminal execution. Their deterministic replay identity hashes the plugin ID, tool name, and canonical recursively key-sorted argument object. An invisible retry of the same logical call therefore reuses the existing intent instead of invoking the plugin twice.

The plugin host is shared across GetMCP runtimes so expensive integrations can keep one connection or event pump. Authority is not shared. Every call receives a fresh PluginCallAuthority derived from that session generation's canonical project root and permissions. A plugin can narrow that authority but cannot manufacture a wider GetMCP permission ceiling.

OpenCode Control plugin

The bundled opencode-control plugin exposes four stable tools instead of projecting the complete OpenCode HTTP API into Init:

Tool Read actions Mutation actions Purpose
opencode_info status, capabilities, providers, models, agents, limits, usage none Inspect the live OpenCode service, model catalog, variants, quotas, capabilities, and project usage.
opencode_session list, get, messages, children, selection set_selection, send, turn, pause, resume, abort, background_subagents Inspect or drive approved-root OpenCode sessions with exact message correlation.
opencode_worker list, get, wait, result, swarm_list, swarm_get, swarm_wait start, continue, cancel, swarm_start, swarm_cancel, swarm_continue Create durable task-like delegated workers or bounded batches of independent workers.
opencode_request list reply_permission, answer_question, reject_question Route OpenCode permission and question walls back through the controlling GetMCP agent.

Worker delegation mirrors the security properties of OpenCode's native task tool rather than simply forwarding an unrestricted prompt. A GetMCP-created worker receives a derived permission ceiling. external_directory and OpenSwarm swarm_* execution are denied. If project file writes are disabled, worker edit, write, patch, and refactor permissions are denied. If terminal is disabled, shell execution is denied. Git is exposed only when GetMCP has both file-write and terminal authority. Nested task delegation is denied unless explicitly enabled.

GetMCP workers are durable OpenCode sessions tagged with metadata.getMcp and grouped under a GetMCP-owned OpenCode session group. Initial worker creation and prompt admission use stable request and message identities so ambiguous network failures can be reconciled instead of replayed. Worker results are correlated to the exact user message ID via the assistant message's parentID, not by assuming the last message is the right result. GetMCP's lightweight swarm_* actions are a durable batch handle over independent task-like workers coordinated by the parent agent. They do not create a recursive autonomous scheduler or peer-to-peer worker network.

Existing OpenCode sessions remain externally owned. GetMCP can inspect an existing session only when its canonical working directory is inside the invoking session's approved project root. Prompting or resuming an existing session requires both GetMCP write and shell authority because the existing session keeps its own OpenCode permission state. Pause and abort remain available as safety actions.


Operation Economy Guidance

URL-reader calls cost latency, create more transport failure opportunities, and generate more intermediate results for the model to consume. Correctness comes first, but once required context is known the agent should batch coherent work. The runtime enforces this guidance softly via hints rather than hard failures.

Inspect efficiently

  • Batch up to 8 known paths in one /r call with repeated path= parameters. Multi-read fair-shares its text allowance across requested files so one large first file cannot starve later known paths. Single-file reads keep a 24 KiB cap; multi-read divides the remaining budget after wire overhead.
  • For large JavaScript or TypeScript source, use /o first to map imports and declarations, then read only relevant ranges.
  • Use /f or /g rather than terminal grep or rg or manual source paging when the specialized search can express the query.
  • Read relevant bounded ranges instead of many tiny slices.
  • Gather the context needed for related edits before beginning the mutation chain when practical.
  • If the user already supplied a native absolute path and it resolves inside the active scope, pass it directly to read, stat, outline, and mutation tools. There is no need to rediscover a project-relative spelling.

Route mutations by shape

ONE existing file plus ONE precise contiguous replacement
    -> /e

ONE brand-new file or deliberate whole-file replacement
    -> /w

2+ known edits
2+ hunks in one file
2+ files
create/delete/move mixed with other changes
refactor spanning locations
    -> ONE /a patch

For an existing file, /w is intentionally stricter than /e or /a: it requires complete current read coverage of that file. If the read response is truncated, follow its exact next URL until the whole current file has been observed. Coverage accumulates across those pages only while the file fingerprint is unchanged. A later mutation invalidates old coverage. If the intended change is localized, use /e or /a instead of reading an entire large file merely to unlock /w.

Once multiple related edits are already known, do not loop /e once per location. Put the complete coherent change set into one /a call. apply_patch parses and preflights every hunk before the first write and rolls back already-applied writes if the commit phase fails, so one grounded bulk patch is both safer and cheaper in round trips than a sequence of tiny mutations.

The runtime watches for serial-edit patterns. After several successful, distinct /e mutations in a short window (3 edits within 30 seconds), the response may include suggestedBulkPatch: true and an efficiencyHint. This is advisory only: completed edits remain valid. The agent should batch only the remaining already-known related changes into one /a patch instead of continuing the loop.

Verify once after the coherent change set with a targeted multi-read, grep, test, typecheck, or build instead of reflexively verifying after every hunk.


URL Transport Rules

The central protocol goal is that an agent should not become an encoding library merely to use local tools. Every rule below exists to preserve that goal under real URL-reader behavior.

1. Raw mode is mandatory

The bridge may be used only in raw and unconverted response mode (for example raw=true on the URL-fetch primitive). Markdown conversion, extraction, summarized or default crawler output, or any converted Read or View mode is forbidden. Keep raw mode enabled for Init, read, search, stat, project, mutation creation, staging, polling, exact retry, repair, repeat, next URLs, and /z range recovery. If the client has no raw URL-fetch mode, stop and report transport unavailable.

Do not confuse the crawler tool's raw=true transport option with the bridge's raw= query parameter. The former controls how the HTTP response is returned; the latter encodes command, code, patch, or search content inside the request URL. They are independent and both may appear in the same operation.

Preferred endpoint examples contain only required and default-safe parameters. Omit unused optional parameters entirely rather than sending empty placeholders such as timeout= or cwd=. Add an optional key only when you have a real value for it.

2. Raw-tail contents and escape grammar

For shell commands, regular expressions, patches, and short source text, put raw= last in the URL. Everything after raw= is treated as content rather than normal query syntax.

/x?wait=1000&raw=python --version
/w?path=hello.py&wait=1000&raw=print("hello")~nprint("world")~n
/g?path=src&include=*.ts&raw=foo\s~qbar

Some URL readers rewrite spaces as +, so the runtime treats an unescaped + as a space. Use ~q for a literal plus sign. Other query punctuation can remain directly in the raw tail, including &, =, +, ?, |, >, <, (, ), {, }, [, ], quotes, and spaces.

Transport ceilings from src/url-transport.ts and src/native-read.ts:

  • Maximum request target: 12 KiB
  • Maximum raw= content: 8 KiB
  • Conservative staged chunk: approximately 1 KiB for broad URL-reader compatibility. The store accepts larger chunks when the caller's URL tool permits them.

If either inline limit is uncomfortable, use /s staging instead of constructing a longer URL.

Tiny escape grammar (implemented in src/url-transport.ts):

~n   newline
~r   carriage return
~t   tab
~h   #
~p   %
~q   +
~s   explicit space
~a   &
~~   literal ~
~uXXXX     unicode code unit (pair surrogates: ~uD83D~uDE00)
~u{XXXXX}  unicode code point up to 10FFFF

Why these escapes exist:

  • # needs an escape because URL fragments are never transmitted to the server.
  • % has an escape when literal percent characters matter, for example text that literally contains %20.
  • ~q avoids ambiguity between a literal plus and a space rewritten to + by the transport layer.
  • ~a is useful when literal content looks like a control parameter, for example foo~await=1000; otherwise &wait=1000 after raw= is rejected as a likely misplaced parameter rather than silently executed as content.
  • ~uXXXX exists because literal non-ASCII is not reliably carried by every URL-reader transport. Write CO~u2082 or THCa ~u2194 THC rather than pasting raw code points. Surrogate pairs and ~u{1F600} are accepted.

Malformed unicode escapes are never guessed. Bad hex, truncated sequences, lone surrogates, and out-of-range code points are preserved verbatim, consistent with the general rule that an unrecognised ~x sequence reaches disk unchanged. That rule cuts both ways: an invented escape such as ~e9 is not silently interpreted, so verify unfamiliar escapes against this table instead of assuming they decode. Use ~~u2082 when literal text ~u2082 is wanted. Valid percent-encoding is still tolerated if the URL reader adds it automatically; stray percent signs do not crash the decoder. The same ~ grammar is accepted by semantic text and path query fields such as path, cwd, include, edit old and new text, and direct command, content, patch, and search parameters.

3. Staged content transport

For content that should not live in one URL, stage immutable chunks:

/s?blob=feature&i=0&count=3&raw=<chunk 0>
/s?blob=feature&i=1&count=3&raw=<chunk 1>
/s?blob=feature&i=2&count=3&raw=<chunk 2>

Then consume the assembled content once:

/w?path=src/new-file.ts&blob=feature&wait=1000
/a?blob=feature&count=3&wait=1000
/x?blob=feature&wait=1000
/k?plugin=opencode-control&tool=opencode_worker&blob=worker-args&count=3&wait=1000

For edit, old and new text can be staged independently via oldBlob, newBlob, oldCount, and newCount, or the new text can be supplied via raw= while the old text is staged.

Staging limits from src/staged-content.ts:

  • Conservative recommended chunk size: approximately 1 KiB, leaving headroom for clients whose URL tool caps the entire URL near 2 KiB
  • Maximum chunk size: 8 KiB
  • Maximum chunks per blob: 64
  • Maximum assembled blob: 160 KiB
  • Maximum active blobs: 128
  • Maximum aggregate staged content per session: 2 MiB, with oldest unrelated blobs evicted first
  • Inactivity TTL: 30 minutes

Why staging is immutable and strict: chunks are addressed by (blob, index, content). The same replay is deduplicated, but reusing an index with different content fails as staged_chunk_conflict. A declared chunk count is immutable: later count mismatches or indexes outside that count fail immediately rather than waiting until assembly. Assembly refuses missing or extra chunks. Stage acknowledgements identify the accepted index and return an exact statusUrl. Incomplete replies are explicitly snapshots because concurrently submitted chunks may finish in either order; the status URL or a complete final response is authoritative. Staged content is in-memory data plane state and disappears on scope or capability rotation.

4. Replay safety, polling, and failure recovery

A visible URL read does not imply one origin request. Transport characterization proved that some failures and timeouts can cause hidden automatic retries underneath the model-visible tool layer. Therefore mutation GETs are intent creation, not raw side effects:

GET mutation URL
      |
      v
canonical semantic content
      |
      v
deterministic or explicit call ID
      |
      v
IntentJournal (src/intent-journal.ts)
      |
      +-- existing identical intent -> return existing record
      |
      `-- new intent -> schedule exactly one executor
                         |-> MutationKernel for native writes/terminal
                         `-> PluginHost for mutation-classified plugin calls

GET exact returned /j poll URL
      -> pending | running | succeeded | failed

Normal mutation calls do not require call=. Native mutations hash tool plus canonical semantic arguments plus an optional run nonce into a deterministic journal call ID of the form auto:<tool>:<hex>. Plugin mutations use the same mechanism with the generic journal tool name plugin and include plugin ID, plugin tool, and canonical arguments in the hashed payload. Hidden replays of the exact logical operation therefore collapse automatically even if the crawler issues the GET multiple times.

To intentionally perform an otherwise identical operation again, use &run=2 (or any short nonce matching ^[A-Za-z0-9._:-]+$). The agent usually does not need to manufacture that nonce itself: if the same terminal command is requested after file mutations changed project state, the deduplicated response can include an exact repeat URL with a fresh server-generated run= value. Fetch that URL as returned. Explicit call= remains available for advanced workflows but call= and run= may not be combined.

wait=0..1500 allows short work to finish in the first response without weakening replay safety. A numeric value outside that bound is rejected before an intent is created and, when it can be clamped deterministically, returns an exact corrected URL.

Background terminal launches capture a bounded startup transcript before detaching. When that transcript contains loopback URLs such as a development server address, the result exposes detectedUrls and detectedPorts so the caller does not have to guess a port.

5. Response range fallback

The runtime distinguishes semantic pagination from transport and model truncation:

  • If /r itself returns truncated:true plus next, follow that exact next URL. That is file-aware pagination and preserves read coverage semantics.
  • If the URL-reader or model layer unexpectedly truncates any response after the runtime already produced it, use the generic response replay endpoint.

The simplest recovery is:

/z?last=1&part=0

Larger responses also place a small rangeFallback object at the front of the JSON with a stable response ID and exact first range URL. Range responses are plain text beginning with a GETMCP_RANGE {json} metadata line followed by an exact chunk of the original serialized response. Follow the returned next URL until hasMore=false.

Properties from src/native-read.ts:

  • 2304-byte UTF-8-safe parts, intentionally small enough to fit clients that may expose only about 3 KiB from a raw fetch including the range metadata header
  • Optional lower-level offset=<byte>&length=<bytes> recovery with UTF-8 boundary correction
  • Exact original serialized-response reconstruction
  • Range reads do not replace the last=1 pointer
  • Snapshots are in-memory, generation-local, capped at 96 entries and 2 MiB per session, and expire after 15 minutes of inactivity
  • No mutation is re-executed merely to recover a truncated result

6. Strict query-parameter rules

Supplied query arguments are strict. Unknown parameters, conflicting aliases, conflicting content transports, malformed booleans, numbers, enums, ambiguous range modes, and contradictory staging declarations return structured errors instead of being ignored or replaced with defaults. When the runtime can correct the request deterministically, it returns an exact repair URL that should be fetched as returned.

A deliberately small set of predictable agent shorthands is accepted where semantics are unambiguous: query for search text, name for filename discovery, grep for grep text, file for read and stat path, recursive=1 for the already-recursive find operation, and regex=false or regex=0 to request literal grep matching. Compact Init advertises literal grep by default. A grep path may be either one file or a directory. If a regex is invalid, the runtime can return an exact repair URL switching the same search to literal mode. ~q always means a literal plus character, never quoting.

Base64 via content=<base64url(JSON)> on the older generic endpoint remains accepted for compatibility but is intentionally absent from compact Init. Prefer raw-tail or staged blobs. The compatibility decoder is intentionally strict: contents must be canonical base64url containing valid JSON, known fields for the selected tool, and non-conflicting aliases.

For file cleanup and deletion, use one /a patch containing one or more *** Delete File: sections. Do not fall back to terminal rm or Remove-Item merely because there is no dedicated delete route.

7. Structured errors and exact recovery URLs

The runtime normalizes tool and transport failures into a stable agent-facing shape:

errorCode
errorDetail?     concise machine-readable detail
repairHint?      human-readable correction
retryable        whether corrected execution can make sense
agentAction?     e.g. correct_request, stage_content, reread_then_retry, run_foreground

Asynchronous journal records preserve this metadata, so later /j polls do not collapse the failure into an opaque string.

The runtime also returns exact recovery URLs when it can do so safely:

  • poll: continue a pending or running intent
  • next: semantic read or range continuation
  • retry: fresh replay-safe mutation identity after the required recovery action, such as a re-read
  • repair: a diagnostically safer variant, such as the foreground form of a background command that died during startup
  • repeat: intentionally execute an otherwise identical terminal operation after project state changed

When one of these URLs is returned, use it exactly as returned instead of rebuilding it. This preserves raw= ordering, replay identity, and server-selected recovery semantics. Control parameters such as wait, run, call, bg, path, count, and blob must appear before raw=; content after raw= that looks like &wait=1000 is rejected as a likely misplaced parameter unless the & was intentionally encoded as ~a.


Security Model

  • Control surface binds only 127.0.0.1:47433
  • Data plane gateway binds 127.0.0.1:47432 before tunneling
  • Multiple isolated session runtimes may be live concurrently behind that gateway
  • Every session has independent Init, read, and mutation capabilities, journal, staged blobs, read coverage, response replay store, and activity stream
  • Project root and permission changes rotate only sessions bound to that project
  • One session's manual rotation, stop, or delete does not invalidate other sessions
  • Read, Init, and mutation capabilities are separate 8-character base62 tokens
  • Capability tokens are ephemeral and not persisted in project configuration
  • Canonical realpath containment for existing filesystem targets
  • Nearest-existing-ancestor realpath containment for new targets
  • Traversal and symlink and junction escape rejection
  • Complete-read coverage for existing whole-file overwrite, with stale-content rejection and mutation invalidation
  • Multi-file patch full preflight and rollback
  • Specialized writes from sessions sharing one project serialize through a common project write gate
  • Terminal execution across all sessions shares one global machine concurrency gate
  • Likely secret-bearing environment variables stripped from terminal children
  • Terminal concurrency capped, foreground timeout kills owned process trees
  • Obvious catastrophic machine-level terminal commands remain blocked
  • Control-plane mutations require same-origin plus loopback session cookie
  • Plugin read and mutation calls use the same per-session read and mutation capabilities as native tools
  • Plugin mutations are journaled and deduplicated before backend invocation
  • Shared plugin backends receive call-local approved-root and permission authority, not machine-global project authority
  • OpenCode server origins are restricted to loopback HTTP and pinned to an instance identity before credentials are sent
  • OpenCode session discovery and control reject sessions whose canonical working directory is outside the invoking GetMCP project root
  • GetMCP-created OpenCode workers receive derived deny rules for capabilities unavailable to the parent session

Verification and Development

npm run verify
npm run build
npm run gym:transport
npm run gym:transport:audit
  • gym:transport is the permanent URL-transport conformance gym. It is part of the ordinary vitest suite, so npm run verify cannot accidentally bypass it. The gym owns a named nasty-character corpus covering URL grammar, whitespace and control characters, quotes, Windows paths, regex syntax, PowerShell, TypeScript, JSX, JSON, diffs, and Unicode. It runs deterministic grammar-aware fuzz cases and end-to-end server checks across raw writes, staging, semantic edit text, literal search, filenames, patches, and terminal commands. File tests compare exact UTF-8 bytes on disk.
  • gym:transport:audit runs a larger hostile-intermediary matrix and prints a machine-readable report. Override its deterministic defaults with GETMCP_GYM_SEED and GETMCP_GYM_CASES. See docs/TRANSPORT_GYM.md for the corpus contract and extension rules.
  • vitest.config.ts includes test/**/*.test.ts and excludes dist/** and node_modules/**.
  • ui/ holds the control surface frontend built with Vite and React. vite.config.ts and ui/tsconfig.json define its build and typecheck.

Current regression coverage includes filesystem traversal and junction escapes, line-bounded and multi-file reads, literal and regex discovery, raw-tail punctuation and escapes, the permanent transport corpus and hostile-intermediary fuzzing with exact-byte end-to-end conformance, compact aliases, deterministic auto-call replay collapse, intentional run= repeats, staged content replay and conflict and missing-chunk behavior, staged write and terminal and patch execution, stale-read refusal, patch atomicity and rollback preflight, terminal output bounds and secret stripping, scope switching and permission capability rotation, control-plane cross-origin mutation rejection, control-plane folder browsing, authenticated activity telemetry and replay metadata, managed Quick Tunnel lifecycle primitives, model-neutral Init response regression, generic plugin capability-base repair, staged plugin arguments, replay-safe plugin mutation dedupe, OpenCode loopback and instance pinning, approved-root OpenCode session filtering, existing-session authority refusal, and task-like worker permission narrowing.


Contributing

Contributions are welcome. The project aims to stay small, strict, and well-tested.

  1. Fork the repository and create a feature branch.
  2. Keep changes focused: one concern per pull request.
  3. Add or update tests under test/ for any protocol, transport, or security-relevant change.
  4. Run npm run verify and npm run build before submitting. For transport changes, also run npm run gym:transport:audit.
  5. Describe the failure mode your change addresses and why the chosen fix is the minimal correct one.

Please avoid adding new query-parameter aliases or new encoding schemes without discussion. The strict parameter surface and the tiny ~ escape grammar are intentional constraints that keep agents from needing to become URL-encoding experts.


License

MIT. See LICENSE for details.

The core protocol is intentionally generic. The bundled OpenCode adapter is a plugin riding the same generic /k surface rather than a protocol special case. The bridge, agent transport, control surface, and runtime make no assumption about which remote agent is on the other end of the tunnel. That neutrality keeps the protocol focused on replay safety, encoding independence, failure recovery, and economy of round trips rather than on any single vendor's conventions.

About

Replay-safe local coding-agent bridge for URL-reader transports.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages