Relay MCP¶
The relay exposes a separate, authenticated Streamable HTTP MCP server at
https://relay.repowire.io/mcp when OAuth is enabled. It connects remote MCP
clients to existing local agents through the daemon's outbound WebSocket tunnel.
The daemon remains the routing and delivery authority. Its administrative,
localhost-only /mcp endpoint is still blocked by the tunnel.
Connect¶
Update the local Repowire binary, restart its daemon when ongoing work permits,
and enable the relay with repowire setup --relay. Keep the intended agent and
its daemon running. A daemon with the ask_pull_delivery health capability is
required for tracked asks; an older daemon receives no task and returns an
update instruction.
Add the relay MCP URL in the client's remote MCP connection UI and choose OAuth. The client opens the relay consent page. An existing relay browser login can be reused; otherwise enter the relay secret on that page, then approve access. The client receives separate OAuth credentials, never the relay secret.
For the complete skill bundle, install the portable package under
plugins/repowire-remote. Claude Code users can install the companion plugin:
Authenticate the plugin server in Claude Code's /mcp UI. Client installation,
authentication prompts, and write confirmations remain client-controlled.
For a self-hosted relay, replace the URL in the canonical mcp.json and run
scripts/package-remote-plugin.py to regenerate the Claude adapters.
The existing repowire plugin remains the local CLI/MCP skill pack. The remote
pack contains conversation, delegation, review, and planning skills that work
without local shell access. Both portable and Claude formats share its skills/
directory. Generated Claude JSON adapters are checked with:
Tools¶
| Tool | Purpose | Required scope |
|---|---|---|
list_machines() |
Connected machines, including exact daemon IDs | agents:read |
list_agents(daemon_id, circle?) |
Online, addressable agents and current work | agents:read |
ask_agent(daemon_id, peer_id, message) |
Send a task/question requiring a reply | agents:write |
send_message(daemon_id, peer_id, message) |
Send information without requiring a reply | agents:write |
get_reply(daemon_id, request_id, wait_seconds?) |
Retrieve a reply; optionally wait 0–20 seconds | agents:read |
list_requests(daemon_id) |
Outstanding asks from this app connection | agents:read |
Select a machine and the exact peer_id returned by discovery. Display names are
not accepted as message targets. A machine ID never silently falls back to
another connection. Permissions cover all machines sharing the approved relay
secret. Per-machine or per-agent grants are not provided in this version.
ask_agent returns daemon_id, peer_id, request_id, and status: pending.
Save the IDs and call get_reply. A pending wait leaves the work running. A
resolved request can contain a reply, a decline, or a close reason without text;
closure alone is not proof of successful work. send_message returns the daemon's
actual delivery state, including queued delivery where applicable.
If a send times out or its connection drops, delivery may already have happened.
Inspect list_requests before considering a retry. This lists outstanding work
only, so absence is not evidence of non-delivery. There is no automatic resend or
exactly-once guarantee. Every new ask is a new task; include prior context in
follow-ups. Tools expose no spawn, kill, shell, file-read, broadcast, or approval
capability.
Requests live in the local daemon's in-memory ask tracker and follow its retention rules. A daemon restart can lose them. OAuth refresh preserves the connection's asker identity; a newly authorized grant receives a different identity and cannot retrieve the old grant's replies. This version does not push unsolicited agent messages into an idle ChatGPT conversation.
OAuth contract¶
The implementation uses go-oauth2/oauth2/v4 for authorization codes, PKCE,
exchange validation, expiry, and refresh rotation, backed by indexed, transactional SQLite
storage. MCP uses the official Go SDK in stateless JSON-response mode.
- Discovery:
/.well-known/oauth-protected-resource/mcp(also the root metadata path) and/.well-known/oauth-authorization-server. - Public-client dynamic registration:
POST /oauth/register, with exact HTTPS redirect URIs (HTTP loopback callbacks allow a new ephemeral port, with all other components fixed), andtoken_endpoint_auth_method: none. Client registration is rate-limited. - Authorization:
/oauth/authorize, response typecode, PKCES256only, browser-bound consent, andresourceequal to the canonical issuer plus/mcp. - Token exchange:
POST /oauth/tokenwith URL-encoded parameters, includingclient_idandresource. Supportsauthorization_codeandrefresh_token. - Scope:
agents:readis required; addagents:writefor messaging. Omitted scope defaults to read-only. Refresh may narrow scope; reconnect to add messaging access. - Access tokens expire after 15 minutes. Refresh tokens rotate on every exchange. Grants have an absolute 30-day lifetime, after which consent is required again. Refresh works while local daemons are offline.
- Reusing a consumed refresh token revokes its entire grant, including all issued access tokens. Clients must serialize refresh requests. If a refresh response is lost after commit, reconnect rather than replaying the old refresh token.
- Revocation:
POST /oauth/revokewithclient_idand a token, or use/oauth/connectionsafter signing in to the relay. Revocation rejects subsequent requests; it does not cancel tasks already delivered to an agent. - Only credential hashes are persisted. Grants and token rotation commit before credentials are returned. OAuth database failures fail closed.
This version supports dynamic registration, not Client ID Metadata Document fetching or confidential-client secrets. Use DCR when configuring the client. Authorization codes expire after one minute; consent forms expire after ten. OAuth errors and token responses are not cacheable, and tokens are never placed in the MCP URL or plugin package.
Relay identity and migration¶
Relay secrets are bearer capabilities. The full secret's SHA-256 digest defines its namespace; possession of that exact secret connects the browser, daemon, and OAuth grants. First authorization requires a daemon with the matching secret online. No external identity provider or email account is required.
Older relays derived fallback identities from the last eight characters. The
full-secret change prevents different secrets sharing that suffix from sharing a
namespace. Public key registration now always generates a fresh capability;
caller-supplied user_id cannot select an existing owner's identity or retrieve
its secret. Existing configured secrets keep working when daemons reconnect.
In-memory share links must be recreated after a relay restart as before.
Self-hosting¶
Set both variables when starting repowire relay start:
REPOWIRE_RELAY_OAUTH_ISSUER=https://relay.example.com \
REPOWIRE_RELAY_OAUTH_DB=/data/oauth.db \
repowire relay start --host 0.0.0.0 --port 8000
The issuer must be a stable HTTPS origin. HTTP loopback origins are allowed for local development. It is never derived from request Host or forwarded headers. The database is bound to that issuer; changing it requires a new database and new client authorization. Omit the issuer to leave relay MCP disabled.
Persist /data across container replacement and restrict database access. The
Helm chart provisions a retained PVC and uses one replica with a Recreate strategy.
It rejects multiple replicas with OAuth enabled: live tunnel ownership and the
SQLite store are not a distributed service. Back up the database as sensitive
state. Losing it invalidates app registrations, grants, and refresh tokens.
The relay must be reachable by the MCP client, and the agent machine must maintain its outbound relay connection. An offline machine produces a tool error; token refresh itself does not depend on that machine being online.
Registration stores at most 4,096 unused clients with a 24-hour lifetime; the oldest unused registration is evicted when full. Clients with an activated grant remain registered for 90 days from their latest token exchange. If a cached registration expires, the authorization error directs users to remove and re-add the connector. Total redirect URI text is limited to 4 KiB per registration. Consent forms carry signed, browser-bound state using Gorilla securecookie; merely opening a form writes no pending records. Abandoned authorization codes and inactive grants expire after one minute. Token checks perform indexed, read-only lookups. Expiry cleanup runs on writes.
Registration is rate-limited per IPv4 address or IPv6 /64 (five initial requests,
replenishing one per minute), with a separate global ceiling. The default
REPOWIRE_RELAY_OAUTH_PROXY_MODE=direct ignores forwarding headers.
For a Google global external Application Load Balancer with direct instance-group
or zonal NEG backends, set REPOWIRE_RELAY_OAUTH_PROXY_MODE=gclb and
REPOWIRE_RELAY_OAUTH_GCLB_FORWARDING_IP to its forwarding-rule IP (Helm:
oauth.proxyMode and oauth.gclbForwardingIP). The relay checks the socket against
Google's published GFE ranges, then reads only the two entries Google appends to
X-Forwarded-For, verifying the final entry matches that configured IP. The
connecting source determines the bucket. Only when that source is in Cloudflare's
published ranges may CF-Connecting-IP supply the visitor address. Thus callers
bypassing Cloudflare cannot forge their rate-limit identity with that header.
Missing or malformed headers fall back to the last verified source address.
The hosted workflow reads the gateway IP from Kubernetes and enables this mode. Self-hosted defaults and local daemon MCP remain unchanged. A sidecar proxy or regional load balancer needs a different trust configuration; do not enable this mode for either. Provider CIDRs are embedded from published lists checked on 2026-10-07; update them when the providers change their ranges. There is no runtime network dependency or background polling. These addresses affect rate limiting only, never authentication or mesh identity.