MCP proxy

DevBoy can proxy tool calls to upstream MCP servers, exposing their tools alongside its own. This lets you combine tools from multiple MCP servers into a single endpoint.

Quick setup

The fastest way to add a proxy server:

# Add proxy server with token (stored in keychain automatically)
devboy proxy add my-server \
  --url "https://mcp.example.com/api" \
  --token "your-token-here"

# Verify available tools
devboy proxy tools

Or during project initialization:

devboy init --yes \
  --proxy "https://mcp.example.com/api" \
  --proxy-name my-server \
  --proxy-token "your-token-here"

The token is automatically stored in keychain as proxy.my-server.token.

Use case

You have a remote MCP server with additional tools (knowledge base, meeting notes, messengers). Instead of configuring multiple MCP servers in your AI assistant, you configure DevBoy to proxy them all through one connection.

Configuration

Add upstream servers to your config.toml or .devboy.toml:

[[proxy_mcp_servers]]
name = "devboy-cloud"
url = "https://mcp.example.com/api"
auth_type = "bearer"
token_key = "devboy-cloud.token"
transport = "streamable-http"

Store the token in keychain:

devboy config set-secret devboy-cloud.token <YOUR_TOKEN>

Fields

FieldRequiredDefaultDescription
nameyesServer name, used as tool prefix if tool_prefix not set
urlyesServer URL (SSE or Streamable HTTP endpoint)
auth_typeno"none"Authentication type: "bearer", "api_key", "oauth2", or "none"
token_keynoKeychain key for the auth token (not used by oauth2)
tool_prefixnonameCustom prefix for proxied tool names
transportno"sse"Transport protocol: "sse" or "streamable-http"

Transport types

  • sse — Legacy MCP transport. Uses GET for SSE stream, POST for requests. Used by most self-hosted MCP servers.
  • streamable-http — Modern HTTP POST-based transport with mcp-session-id header. Used by hosted MCP services.

OAuth 2.1 authentication (device flow)

For upstream MCP servers that require OAuth 2.1 (per the MCP authorization spec), set auth_type = "oauth2" and log in once with the device flow. The proxy then injects a fresh Bearer per request and refreshes tokens automatically, so sessions survive the access-token TTL without any manual re-configuration.

[[proxy_mcp_servers]]
name = "devboy-cloud"
url = "https://app.devboy.pro/api/mcp?name=devboy-cloud"
auth_type = "oauth2"
transport = "streamable-http"

# Optional — discovered automatically at login if omitted:
# [proxy_mcp_servers.oauth]
# authorization_server = "https://app.devboy.pro"
# scopes = ["mcp:read", "mcp:write"]

Then authorize:

devboy login devboy-cloud
# prints a URL + code to approve in a browser, then stores auto-refreshing tokens

devboy login (1) discovers the authorization server from the upstream's WWW-Authenticate challenge (RFC 9728 → RFC 8414), (2) registers a client if needed (RFC 7591) and caches the client_id, (3) runs the device authorization grant (RFC 8628) — you approve the printed user_code at the verification URL — and (4) stores the access + refresh tokens in the OS keychain.

The proxy refreshes transparently (the refresh token is long-lived and rotated on use, so refreshes are single-flight and persisted immediately). Check state anytime with devboy doctor — it reports each oauth2 proxy as logged in or needs login with the exact command to run. No token_key is needed for oauth2; tokens live under proxy.<name>.oauth.

Multiple servers

You can proxy multiple upstream servers:

[[proxy_mcp_servers]]
name = "devboy-cloud"
url = "https://mcp.example.com/api?name=project-a"
auth_type = "bearer"
token_key = "devboy-cloud.token"
transport = "streamable-http"

[[proxy_mcp_servers]]
name = "internal-tools"
url = "http://localhost:3001/sse"
tool_prefix = "internal"

How it works

  1. On startup, DevBoy connects to each configured upstream server and performs the MCP initialize handshake.
  2. Upstream tools are fetched and exposed with a prefix: <prefix>__<tool_name> (e.g. devboy-cloud__get_issues).
  3. When a proxied tool is called, DevBoy strips the prefix and forwards the request to the matching upstream server.

CLI commands

Add a proxy server

Add a new proxy server without editing the config file manually:

# Basic usage
devboy proxy add my-server --url "https://example.com/mcp"

# With all options
devboy proxy add devboy-cloud \
  --url "https://mcp.example.com/api" \
  --transport streamable-http \
  --token-key devboy-cloud.token

# Overwrite existing proxy
devboy proxy add my-server --url "https://new.example.com/mcp" --force
OptionDefaultDescription
--url(required)Proxy server URL
--transportstreamable-httpTransport type: streamable-http or sse
--tokenToken value (stored in keychain automatically)
--token-keyproxy.{name}.tokenCustom keychain key for token
--auth-typebearer if token, else noneAuth type: bearer, api_key, or none
--forcefalseOverwrite existing proxy with same name

Remove a proxy server

devboy proxy remove my-server

List proxied tools

# Tool names only
devboy proxy tools

# With descriptions
devboy proxy tools --descriptions

Call a proxied tool

# With arguments
devboy proxy call devboy-cloud__get_issues '{"state": "open"}'

# Without arguments
devboy proxy call devboy-cloud__get_project_info

MCP server integration

When running as an MCP server (devboy mcp), proxied tools are automatically included in tools/list and routed via tools/call. No additional configuration is needed on the client side — AI assistants see all tools (both local and proxied) as a flat list.

Transparent routing: local fallback for upstream tools

When the same tool is advertised by both the local ToolHandler and a connected upstream MCP server, DevBoy can optionally dispatch the call locally instead of round-tripping through the upstream. This is useful when:

  • The upstream cannot reach a provider that is available from the developer's network (GitLab / Jira behind corporate VPN).
  • The cloud integration is degraded and you want a local fallback.
  • You prefer lower latency for interactive tools.

The feature is opt-in. By default, every matched call goes to the upstream (cloud has priority).

Enabling

Add a [proxy.routing] section to your config.toml:

[proxy.routing]
# Default strategy for every matched tool.
# One of: "remote", "local", "local-first", "remote-first".
strategy = "local-first"

# If the primary executor errors, retry on the other executor.
# Only meaningful for "local-first" / "remote-first".
fallback_on_error = true

# First-match-wins per-tool overrides (globs with `*`).
[[proxy.routing.tool_overrides]]
pattern = "get_*"
strategy = "local"

[[proxy.routing.tool_overrides]]
pattern = "create_*"
strategy = "remote"       # writes always go upstream

Strategies

StrategyBehaviour
remoteAlways route matched calls to the upstream. Default.
localAlways route matched calls to the local executor.
local-firstTry local first; fall back to upstream on error (if fallback_on_error).
remote-firstTry upstream first; fall back to local on error (if fallback_on_error).

Graceful degradation

If the upstream schema requires arguments the local schema does not declare, DevBoy routes that specific tool to the upstream automatically — regardless of the strategy. This keeps existing calls working even when the two implementations drift. You can inspect such mismatches with devboy proxy status.

Per-server override

A routing block under [[proxy_mcp_servers]] overrides the global policy for that upstream only. Only the fields you set win over the global config — omitted fields keep their global values (a per-server block that just sets strategy does not silently reset fallback_on_error to its default):

[[proxy_mcp_servers]]
name = "devboy-cloud"
url = "https://mcp.example.com/api"
auth_type = "bearer"
token_key = "devboy-cloud.token"
transport = "streamable-http"

[proxy_mcp_servers.routing]
# Inherits global `fallback_on_error` and `tool_overrides`; only strategy changes.
strategy = "local-first"

Supported override fields: strategy, fallback_on_error, tool_overrides. When tool_overrides is set it is prepended to the global list so per-server rules match first.

Secrets cache

Local-first routing means secrets come from the OS keychain on every call. A short-lived in-memory cache prevents repeated keychain prompts without compromising rotation semantics.

[proxy.secrets]
# TTL for the cache, in seconds. Default: 300 (5 minutes).
# Set to 0 to disable caching and always read from the keychain.
cache_ttl_secs = 300
  • Cached values are zeroized on eviction and on process exit.
  • Writing via devboy config set-secret … invalidates the corresponding cache entry immediately.
  • Set cache_ttl_secs = 0 for high-security setups where every prompt should hit the keychain directly.

Telemetry

When routing happens locally the cloud backend loses visibility into usage. DevBoy forwards a minimal event to the configured telemetry endpoint so cloud dashboards stay accurate.

[proxy.telemetry]
enabled = true
endpoint = "https://app.example.com/api/telemetry/tool-invocations"
batch_size = 100            # flush when this many events accumulate
batch_interval_secs = 30    # or at least once per this many seconds
offline_queue_max = 10000   # drop oldest when the offline queue is full
# Optional keychain key for the telemetry auth token.
# Falls back to the first upstream server's token_key when unset.
# token_key = "devboy-cloud.token"

The payload is intentionally minimal — it never contains tool arguments or responses. Only:

  • tool — unprefixed tool name
  • routing_decision — short label (strategy_remote, override_rule, schema_incompatible, …)
  • routing_detail — for override_rule, the glob pattern that matched
  • upstream — prefix when the call went remote
  • statussuccess / error
  • latency_ms — observed latency
  • timestamp_secs — unix epoch seconds
  • was_fallback — true if the primary executor failed and we retried

Set enabled = false or omit endpoint to collect events locally without uploading (useful for CLI debugging).

Observability

devboy proxy status

Prints a human-readable snapshot of the routing table: what is routable locally, what stays remote, which pairs have incompatible schemas, and the currently active override rules. Exit with --json for a machine-readable form.

Structured logs

Every routing decision is emitted at tracing::info level with fields:

tool=get_issues
resolved=get_issues
target=local
reason=strategy_local_first
reason_detail=
has_fallback=true

Filter with RUST_LOG=devboy_mcp::routing=info to see only routing events.

Response metadata

Routing details are currently exposed through tracing logs (tracing::info on every decision) rather than as a _meta.routing object on tools/call responses. Clients should not rely on response-level metadata for routing diagnostics unless and until that behavior is explicitly documented in a future release. For now, capture stderr (2> routing.log) and grep for routing decision records.

Cloud priority — summary of invariants

  • The default strategy is remote; no local routing happens unless the user opts in.
  • Missing upstream schemas disable local routing for that specific tool.
  • Telemetry is on by default so cloud usage statistics remain accurate even when calls execute locally.

Validation rules

Config CLI (devboy config set|get)

Keys under proxy.{routing|secrets|telemetry}.* are a structured schema. Typos surface as explicit errors, not silent fallbacks — both on write and on read:

$ devboy config set proxy.routing.strategy teleport
Error: Configuration error: Invalid routing strategy 'teleport'.
       Allowed (case-insensitive): remote, local, local-first, remote-first

$ devboy config get proxy.routing.nonexistent
Error: Configuration error: Unknown proxy.routing field: nonexistent
# exit code: 1

Provider paths (github.*, gitlab.*, …) keep historical behaviour — unknown fields return (not set) with exit 0 so pre-existing scripts don't break. Only proxy.* paths were tightened.

Type-specific rules enforced by devboy config set:

FieldValidation
proxy.routing.strategyenum (case-insensitive): remote / local / local-first / remote-first
proxy.routing.fallback_on_errorbool — true/false, 1/0, yes/no, on/off (case-insensitive)
proxy.secrets.cache_ttl_secsnon-negative integer (0 disables cache)
proxy.telemetry.enabledsame bool forms as above
proxy.telemetry.endpointURL beginning with http:// or https://, non-empty host, no whitespace. Empty string clears the field.
proxy.telemetry.token_keyarbitrary string; empty clears
proxy.telemetry.batch_sizenon-negative integer
proxy.telemetry.batch_interval_secsnon-negative integer
proxy.telemetry.offline_queue_maxnon-negative integer

Negative integers (-1) are accepted by the CLI argument parser (allow_hyphen_values = true) and rejected by the domain validator with a clear message.

Telemetry endpoint payload

The backend enforces a strict shape on the POST body so malformed events don't create garbage rows in mcp_tool_usages:

FieldRule
eventsArray, 1–1000 items (empty → 400, >1000 → 400)
toolRequired; matches ^[a-z][a-z0-9_]*$ (lowercase + digits + _); ≤128 chars
routing_decisionRequired; ≤64 chars
routing_detailOptional; ≤256 chars
upstreamOptional; ≤64 chars
status"success" or "error"
latency_msNon-negative integer
timestamp_secsNon-negative integer ≤ 4102444800 (2100-01-01 UTC)
was_fallbackOptional boolean

On any validation failure the whole batch is rejected (400 Bad Request) — no partial acceptance. Clients should retry after fixing the payload.

MCP protocol and stdout hygiene

Not every devboy command keeps stdout log-free. The commands whose stdout is reserved for machine-readable output route tracing to stderr:

  • JSON-RPC messages when running devboy mcp
  • Machine-readable status from devboy proxy status --json

For other commands (devboy config get, devboy init, regular interactive subcommands) human-oriented INFO logs stay on stdout by design — do not assume stdout is free of logs when piping into jq, python, or another client. Use 2> /dev/null (or a log file) to suppress/capture the log stream in those cases.

Use RUST_LOG=devboy_mcp::routing=info devboy mcp 2> routing.log to capture routing decisions without polluting the JSON-RPC channel.