Skip to main content
Glama
ngojclee

remote-agent-connector

by ngojclee

Remote Agent Connector

Typed Remote Agent connector for Business MCP Hub using the VeilBrowser Fleet pattern:

Business MCP Hub -> remote-agent-mcp-connector (/mcp)
                 -> remote-agent-wss-relay (/relay)
                 -> client Remote Agent Connector

The connector service exposes 23 typed tools through Streamable HTTP MCP. It also has a dedicated /relay endpoint for device WebSocket connections and a SQLite device registry for capabilities, health, connection state, and revocation.

Every tool handler is asynchronous and awaits the inner device command before returning a JSON-ready object. MCP clients therefore receive a normal result or MCP error envelope, never an unresolved coroutine.

Required environment variables

REMOTE_AGENT_DATABASE_URL=postgres://... or sqlite:///...
REMOTE_AGENT_MCP_BEARER_TOKEN=...
REMOTE_AGENT_HUB_DELEGATION_SECRET=...
REMOTE_AGENT_OPERATOR_BEARER_TOKEN=...
REMOTE_AGENT_HUB_AUDIENCE=remote-agent-connector
REMOTE_AGENT_PRIVATE_MCP_URL=http://remote-agent-mcp-connector:3030/mcp
REMOTE_AGENT_PUBLIC_RELAY_URL=wss://10.21.4.101:3051/relay
REMOTE_AGENT_BIND_HOST=0.0.0.0
REMOTE_AGENT_BIND_PORT=3030
REMOTE_AGENT_ALLOWED_HOSTS=remote-agent-mcp-connector:3030,127.0.0.1:3030,localhost:3030,10.21.4.101:3051

For local development only, set REMOTE_AGENT_ALLOW_SQLITE_DEV=1, REMOTE_AGENT_ALLOW_INSECURE_HTTP=1, and REMOTE_AGENT_ALLOW_INSECURE_HTTP=1.

Optional:

REMOTE_AGENT_REQUIRE_SIGNED_ASSERTION=0
REMOTE_AGENT_REQUEST_TIMEOUT_SECONDS=120   # 1-600
REMOTE_AGENT_RELAY_HANDSHAKE_TIMEOUT_SECONDS=15   # 1-60

Unset or 0 keeps Phase 2 in shadow mode: a v4 caller whose Hub has no signing material still reaches the device with the Phase 1 statement object. Set 1 only after the Hub publishes key material and the Windows lane verifies cryptographically; then an unsigned v4 call fails closed.

Related MCP server: AnythingLLM MCP Server

Two clocks

REMOTE_AGENT_REQUEST_TIMEOUT_SECONDS is the relay wait: how long the connector holds a device call open before it reports device_timeout. It is bounded at 600 seconds, and the only thing it has to fit inside is the Hub's upstream read timeout. If the Hub gives up first, it returns a failure while the device keeps executing the command, which is worse than a short ceiling.

The signed app assertion is a separate budget. A device judges freshness when the request frame arrives, not when it produces the response, so the assertion only has to outlive delivery from Hub to device. That is why the Hub can keep a 180 second assertion lifetime while the connector waits far longer for a slow command. The two numbers were previously tied together, which capped legitimate long commands at the assertion lifetime for no security gain.

The relay waits at most REMOTE_AGENT_RELAY_HANDSHAKE_TIMEOUT_SECONDS (15 seconds by default) for the first enrollment/authentication frame. This bound does not apply to heartbeats or command responses. A client that reaches the Python relay receives a fixed diagnostic with error_contract, code, stage, retryable, and message; the payload never includes exception text, tokens, public keys, signatures, challenge values, request bodies, credentials, or filesystem paths. The stable contract is:

relay_tls_failed          # local/client-side mapping; no relay frame exists
relay_upgrade_failed
relay_challenge_timeout
relay_challenge_invalid
enrollment_token_invalid
enrollment_token_expired
enrollment_token_consumed
connector_id_mismatch
enrollment_signature_invalid
enrollment_challenge_invalid
device_already_enrolled
device_revoked
relay_authentication_failed
relay_ready_failed
relay_protocol_error

An HTTP WebSocket upgrade or reverse-proxy failure happens before the Python handler can send a relay frame. The baked Nginx front returns the bounded relay_upgrade_failed JSON diagnostic instead. Transport TLS remains strict; the timeout is not a TLS bypass.

The canonical error vocabulary is published in contracts/remote-agent-relay-errors-v1.json. Python relay frames carry the same error_contract identifier and the Nginx upgrade response carries it as well. relay_tls_failed is transport-only: a certificate failure occurs before HTTP/WebSocket application data exists, so the client or operator must surface that code locally rather than expecting a relay error frame. The operator can read the same closed vocabulary at GET /operator/relay-errors/v1 with the operator bearer.

Operator CA publication

Set the optional path below to a public CA certificate mounted read-only into the connector. The file must contain exactly one CA certificate with BasicConstraints CA=TRUE; it must not contain a private key:

REMOTE_AGENT_PUBLIC_CA_CERT_PATH=/etc/remote-agent/ca/remote-agent-ca.crt

An operator can read the versioned publication route with the existing operator bearer:

GET /operator/relay-ca/v1

The response contains only the normalized public certificate, SHA-256 fingerprint, subject/issuer and validity metadata. If the path is unset, missing or invalid, the route returns a bounded 503 code: relay_ca_artifact_unconfigured, relay_ca_artifact_unavailable or relay_ca_artifact_invalid. The route is publication-only: neither the connector nor a client auto-trusts a certificate fetched from it. Verify the fingerprint out of band and install the CA explicitly on approved clients.

Relay certificate issuance

This service is also the CA signer. Point it at the matching CA private key and the address the relay actually serves:

REMOTE_AGENT_CA_PRIVATE_KEY_PATH=/etc/remote-agent/ca/remote-agent-ca.key
REMOTE_AGENT_RELAY_IP=10.21.4.101
REMOTE_AGENT_RELAY_CERT_VALIDITY_DAYS=90

With the key unset, publication keeps working and issuance answers relay_ca_signer_unconfigured. The CA key is never returned by any route and never leaves this container; the relay host and every client hold public material only.

POST /operator/relay-certificates/v1
Authorization: Bearer <REMOTE_AGENT_OPERATOR_BEARER_TOKEN>

{"csr_pem": "<PEM-encoded certificate signing request>"}

The signer refuses a CSR unless it is exactly one IP: SAN matching REMOTE_AGENT_RELAY_IP, carries CA=FALSE and serverAuth, has no clientAuth, and uses a key at least 2048-bit RSA or 256-bit EC. Issued leaves are capped at 397 days. The Business MCP Hub exposes the same operation as POST /admin/api/remote-agent/relay-certificates for the renewal runner.

scripts/renew_relay_certificate.py is that runner. It generates the leaf key and CSR on the relay host, requests a certificate, verifies the answer against the published CA, backs up the current pair, swaps atomically, reloads nginx, and re-checks the live handshake. It restores the backup if anything after the swap fails. Run it from cron; it does nothing until the leaf is inside --renew-before-days.

Two guards keep the separation honest:

  • The connector refuses to send a frame whose assertion is already expired, as app_assertion_expired_at_dispatch. Delivering it would only earn a deny.

  • A per-call timeout_s larger than the relay wait is refused as timeout_exceeds_relay_window, so a caller can never ask for a command the transport would abandon mid-run.

The device applies its own default when timeout_s is absent, so a caller that needs a long command has to say so explicitly.

In-flight collision guard

Transport retries must preserve the same idempotency_key. A same-key retry with the same intent replays the first result, while the same key with a different intent is refused as idempotency_conflict. A newly generated key is a new operation, not a retry of the original operation.

The connector therefore holds a second guard keyed on the request content instead: (connector_id, tool, canonical digest of arguments), independent of the caller's idempotency key. The window runs from dispatch until a terminal response, a cancel, or the relay timeout. A second identical call inside that window is refused with command_in_flight.

Guarded tools are the mutating ones: files.write, files.delete, files.move, files.mkdir, files.upload, terminal.execute, ssh.execute, skills.materialize, skills.execute, mcp.call. Read verbs are never guarded, and nothing here dedups by arguments across time, so git status before and after an edit both really run.

command_in_flight is a busy signal, not a permission failure and not a device failure. It is safe to retry once the first call settles.

The connector forwards the existing key as the relay frame's top-level idempotency_key field. It is not added to arguments, so the device receives the same business argument shape and can apply its own replay protection before dispatching a mutation.

Cancel

POST /operator/requests/{connector_id}/{request_id}/cancel sends one cancel frame over the device's existing relay WebSocket. It names a single request_id and carries no arguments, no assertion and no authority, so it cannot reach anything the original request did not already reach.

The connector resolves its own waiting caller with cancelled immediately and frees that request's collision slot. A device that has not implemented cancel ignores the frame, so this is safe to deploy before the Windows lane supports it. The full wire contract lives in .docs/contracts/remote-agent-relay-control-v1.json.

Tool catalog

connector_health
files_list
files_stat
files_search
files_read
files_write
files_delete
files_move
files_mkdir
files_upload
files_download
terminal_execute
terminal_stream
ssh_execute
ssh_list_profiles
skills_list
skills_materialize
skills_execute
mcp_list_servers
mcp_call
mcp_health
skills_health
connector_restart_mcp

The Hub exposes these as agy_connector__<tool_name>, which matches the agy_connector.* tool contract used by agy2api. The connector id, audience, service name, and environment variables stay remote-agent / REMOTE_AGENT_*; only the MCP namespace carries the consumer-facing prefix.

Authenticated agent status

The connector exposes an operator-only status route:

GET http://remote-agent-mcp-connector:3030/agents
Authorization: Bearer <REMOTE_AGENT_OPERATOR_BEARER_TOKEN>

It returns only enrolled devices with a fresh online relay instance. Stale instances are marked offline before the response. Each entry is sanitized to device_id, platform, capabilities, health, and connected_at; public keys, enrollment tokens, relay signatures, and filesystem paths are never returned.

The private operator device inventory additionally returns the redacted public_key_fingerprint, capability profile, enrollment state, and heartbeat timestamps for Admin device management. It never returns the public key material itself or any enrollment secret.

For all 23 MCP tools, agy2api passes the selected device_id as profile_id. The connector resolves that value as the exact enrolled device and optionally accepts instance_id to pin one live process. The capability tier (read_only, read_write, or full_agent) is separate from profile_id.

The public WSS endpoint is separate from MCP:

wss://10.21.4.101:3051/relay

MCP remains authenticated Streamable HTTP at /mcp; WSS is only for device relay traffic and is not a generic MCP-over-WebSocket transport.

Images

publish-ghcr.yml builds and pushes both production images to GHCR:

ghcr.io/ngojclee/remote-agent-connector:latest      # /mcp connector
ghcr.io/ngojclee/remote-agent-connector:relay-latest # TLS WSS relay front

The relay image bakes deploy/remote-agent-relay.nginx.conf; only the TLS certificate and key are mounted at runtime. Migrations install inside the Python package, so no build tree needs to be bind-mounted into the container.

Windows pairing and revocation

Pairing is operator-controlled and one-time:

  1. The operator calls POST /operator/enrollment-tokens with connector_id, capability_profile (read_only, read_write, or full_agent), display_label, and a short expires_in_seconds value.

  2. The Windows connector opens the outbound WSS wss://10.21.4.101:3051/relay. The server sends a challenge. The client generates its Ed25519 key locally and sends the signed enroll payload containing the one-time enrollment token and its public key.

  3. The server consumes both token and challenge, stores only the public-key identity and the approved capability profile, then issues a second challenge for normal authenticated relay operation.

  4. The client signs the second challenge, receives ready, and sends heartbeats. The MCP connector routes every tool call by the exact profile_id supplied by agy2api, which is the enrolled device id.

Enrollment tokens, private keys, signatures, and operator bearer values are never returned by GET /agents and must not be placed in logs or source control. To revoke a device, the operator calls POST /operator/devices/{device_id}/revoke; the connector immediately marks the device revoked and closes its live instance. A revoked record may be purged separately with DELETE /operator/devices/{device_id} after revocation has been verified.

Signed app assertions (Phase 2)

The Business MCP Hub is the only issuer of an application identity statement. It signs a short-lived Ed25519 assertion per device call and sends it as X-MCP-Hub-App-Assertion. The connector never holds the signing root, so it cannot forge or edit the statement; it only:

  1. parses the envelope strictly,

  2. requires client_id, app_id, scopes and nonce to equal the HMAC identity it already verified,

  3. requires connector_id to equal the call's profile_id target,

  4. uses the envelope's request_id as the relay request_id, so the assertion is bound to exactly one frame,

  5. forwards the envelope verbatim inside app_assertion.

remote_agent_connector/app_assertion.py is the reference device-side verifier and the executable form of the contract: pinned root, keyset verification, key-state and expiry checks, binding comparison, and replay rejection. The Windows connector implements the same rules in Rust.

The shared byte-level vector lives in tests/fixtures/app_assertion_vector.json and mirrors .docs/contracts/app-identity-v2.json in the Hub repository. Change one and the other suite fails. Those keys are test-only and must never be used as live signing material.

v3 callers, and v4 callers whose Hub has no signing material while the require flag is unset, keep the application identity fields omitted. Every device call still carries its existing idempotency_key as top-level relay metadata.

Device liveness

Whether a device is online is derived from the age of its last heartbeat, not from the stored state column. A row left behind by a crash, a container restart, or a dropped relay therefore cannot be reported as live or routed to.

The window is five heartbeat intervals, so 75 seconds of silence at the current 15 second cadence. That is deliberately wider than REMOTE_AGENT_HEARTBEAT_TIMEOUT_SECONDS, which is the transport timeout rather than a liveness verdict: one lost packet or a short blip must not retire a healthy session. If the configured timeout is larger than five intervals, the configured value wins.

A sweep closes overdue rows by setting state = 'offline' and disconnected_at to the last heartbeat, never to the sweep time, so a reaped row stays distinguishable from a clean disconnect and never claims a device outlived its silence. The sweep runs on every inventory and routing read.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    A
    maintenance
    Enables MCP-compatible AI agents to execute typed live-app actions over WebSocket, allowing agents to directly interact with real application state without browser automation or scraping.
    21
    -
  • A
    license
    A
    quality
    C
    maintenance
    Enables MCP-compatible clients to interact with AnythingLLM, providing tools for workspace management, chat and thread operations, document operations, vector search, and system inspection.
    34
    6
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables agents to connect to remote MCP servers once, access their tools through a compact MCP endpoint, pair a CLI inside sandboxes, and create watches that turn command or tool output into pollable structured events.
    2
    -