Upstream MCP Proxy

September 22, 2026 · View on GitHub

Labby can act as an MCP gateway, proxying tool calls and resource reads to upstream MCP servers. This lets a single Labby instance aggregate tools from multiple MCP servers behind one authenticated endpoint.

Upstream servers are first-class providers in the merged MCP tool catalog. After discovery, their tools appear in list_tools() beside built-in Labby tools. Callers do not need a separate tool or namespace to invoke proxied upstream tools themselves.

If gateway-wide [code_mode].enabled = true, raw upstream tools are hidden from list_tools() and exposed through the primary synthetic codemode tool. That mode is documented in GATEWAY.md.

Labby also exposes a separate gateway management surface for editing and reloading upstream definitions. That management surface is documented in GATEWAY.md.

Gateway-managed protected MCP routes are a different mode: they publish an inline public MCP route with Labby-owned OAuth protected-resource metadata and proxy the whole Streamable HTTP MCP route to a backend. Use GATEWAY.md — Gateway-Managed Protected MCP Routes for that setup instead of [[upstream]] tool merging.

The reusable upstream pool lives in crates/labby-gateway/src/upstream/; crates/labby/src/dispatch/upstream.rs is the Labby product compatibility and adaptation boundary. The runtime proxy path described in this document is wired into the MCP surface. The HTTP API exposes /v1/gateway for gateway management and authenticated /v1/palette/execute for bounded, contract-checked launcher calls.

Palette execution receipts

The authenticated POST /v1/palette/execute response includes an executionMode in its receipt: exact for a contract-checked upstream MCP tool call, or labby_action for a registered Labby action. This identifies the launcher dispatch path, not the tool's internal implementation, model usage, retry count, or a separate durable audit record. Existing request IDs remain correlation identifiers. Older servers may omit this additive field; desktop launch history preserves known modes without inventing a mode for old receipts.

What Operators Configure

To proxy an upstream server through Labby, prefer labby server add and labby server set. For offline editing, first identify the selected installation root: LABBY_HOME when set, otherwise ~/.labby. Edit only its config.toml, optionally provide bearer-token env vars in its .env, then start labby serve normally. Labby does not merge a second XDG or current-directory configuration authority.

Labby will:

  1. seed enabled upstream names into the gateway catalog at startup without opening connections
  2. connect to an upstream lazily on first code mode, exact tool execution, Code Mode call, or explicit gateway test path that needs live discovery
  3. merge discovered tools into its own MCP catalog after that upstream is first contacted
  4. serve the combined catalog through whichever MCP transport you expose from Labby

OAuth upstreams are discovered only when Labby has upstream OAuth runtime state and an explicit subject for selecting the token set. Subject-less discovery deliberately skips OAuth upstreams so a user-specific token view is not cached globally.

That means the client connects only to Labby:

  • labby mcp for stdio clients such as Claude Desktop
  • labby serve for streamable HTTP MCP clients over TCP or a configured Unix-domain socket

The client never connects directly to the upstreams once Labby is acting as the gateway.

Configuration

Upstream servers are configured in config.toml using [[upstream]] array entries.

HTTP Upstream

[[upstream]]
name = "remote-lab"
url = "https://lab2.example.com/mcp"
bearer_token_env = "LABBY_UPSTREAM_TOKEN"
proxy_resources = true
expose_tools = ["search_repos", "github_*"]

Unix-Socket Upstream

Unix-socket upstreams speak the same Streamable HTTP protocol as TCP upstreams. socket_path selects the local connection endpoint, while url supplies the request path and Host authority.

[[upstream]]
name = "cortex"
transport = "unix_socket"
socket_path = "/run/labby/cortex.sock"
url = "http://cortex.local/mcp"
bearer_token_env = "CORTEX_MCP_TOKEN"

[upstream.headers]
x-labby-tenant = "infrastructure"

Filesystem paths work on Unix targets. Linux also supports abstract @name notation, for example socket_path = "@cortex-mcp". The gateway and upstream must share the same socket namespace, directly or through a bind mount. Unix sockets are same-host transports; cross-node and Tailscale traffic remains HTTP/TCP.

Unix-socket upstreams use rmcp's native UnixSocketHttpClient for socket I/O, HTTP/1.1 framing, Streamable HTTP sessions, and SSE. Labby keeps only a thin gateway adapter around that client so the established defensive SEP-2243 Mcp-Method/Mcp-Name derivation remains identical to the TCP HTTP path. The configured url is split deliberately: its authority becomes the HTTP Host header, while only its path and query are sent as the request target (for example http://cortex.local/mcp?tenant=infra becomes POST /mcp?tenant=infra).

The same gateway policies remain in force as for HTTP/TCP: bounded ordinary response bodies, bounded SSE events, request/discovery deadlines, one bounded lifecycle fallback from server/discover to legacy initialize, bearer or OAuth credentials, custom headers, relay notifications, and explicit cancellation. The relay-cancellation side channel uses the same rmcp-backed Unix client rather than a second socket implementation.

A failed Unix socket degrades only that upstream. Startup/refresh discovery records an unhealthy catalog entry and sanitized last_error, while healthy upstreams remain connected and routable. With [gateway].auto_reconnect = true, the standard reprobe loop retries the failed socket with the same jitter/backoff and circuit-breaker rules as other non-OAuth upstreams. Structured logs use transport = "unix_socket", redacted targets, classified failure kind, and connect/reprobe timing; filesystem socket paths are not emitted as log targets.

Stdio Upstream

[[upstream]]
name = "local-server"
command = "my-mcp-server"
args = ["--port", "5000"]
proxy_resources = false

Stdio upstreams execute a local child process on the host running labby. Testing or reconciling a stdio definition requires admin authority and passes the spawn guard. Those actions are not destructive solely because they mutate restartable configuration or spawn a restartable child; permanent or hard-to-recover loss is the separate destructive criterion. Clients must use the flags in the generated action catalog as the policy authority. See GATEWAY.md.

The Linux host service requires filesystem isolation for every stdio child. Each child receives a private temporary directory and read-only access to the system runtime plus its resolved executable and safe absolute arguments. It cannot read Labby's home, user credential/configuration trees, /proc, or another upstream's temporary directory; only /dev/null is exposed from /dev. Other platforms fail closed when this required host-service sandbox is requested.

Persistent or additional input paths are explicit, audited opt-ins in the upstream's env table:

[[upstream]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]

[upstream.env]
UPSTREAM_STATE_DIR = "/var/lib/labby-upstreams/filesystem"
UPSTREAM_READ_ONLY_PATHS = "/srv/data"

UPSTREAM_STATE_DIR grants that one existing absolute directory read/write access; it must not be inside LABBY_HOME or the service user's home. Point a package runner's cache variable (for example npm_config_cache) at the same directory when it needs a writable cache. UPSTREAM_READ_ONLY_PATHS is a platform path-list of existing absolute inputs. It rejects the user/Labby home and overlapping credential trees such as .ssh, .aws, .gnupg, .config, and .labby. Absolute command arguments are subject to the same rejection. Grant only the narrow directory needed by that upstream; these values expand the child filesystem trust boundary and should be reviewed like executable or command changes.

Config Fields

FieldTypeRequiredDescription
namestringyesStable identifier, used as the tool-name prefix, skill origin label, OAuth state key, and route reference. Must be non-empty, unique, and URI-safe (ASCII letters, digits, -, _, .). With proxy_skills enabled it must also be lowercase letters, digits, and interior hyphens.
display_namestringnoOperator-facing label shown in the web UI instead of name. Presentation only: it never affects routing, tool names, auth, or exposure. 1-80 characters, single line.
lifecyclestringnoHandshake override. auto (default) tries server/discover and falls back to initialize when the peer proves it is legacy. initialize always opens with initialize, for legacy servers that exit on an unknown first request and so leave nothing to classify.
transportstringUnix socket onlyExplicit http, websocket, stdio, or unix_socket. Legacy URL/command inference remains supported when omitted.
urlstringHTTP/WebSocket/UnixNetwork URL, or for unix_socket, the HTTP(S) request URI and Host authority.
socket_pathstringUnix socketFilesystem socket path, or Linux abstract @name notation.
headerstablenoCustom headers for HTTP and Unix-socket requests. Inline Authorization is forbidden; use bearer_token_env or OAuth.
commandstringstdioCommand to run for stdio transport.
argsstring[]noArguments to pass to a stdio command.
envtablenoEnvironment variables injected into a stdio child process.
bearer_token_envstringnoName of an env var holding a bearer token for HTTP or Unix-socket transport. Not the token itself.
proxy_resourcesboolnoWhether to proxy resources from this upstream. Default: true.
proxy_promptsboolnoWhether to proxy prompts from this upstream. Default: true.
proxy_skillsboolnoWhether to aggregate this upstream's Agent Skills (SEP-2640). Default: false, unlike the other proxy_* flags — see below.
expose_toolsstring[]noOptional allowlist of tool names/patterns to expose from this upstream. Supports exact names and * wildcards. An empty list exposes nothing; omit the key to expose all.
expose_resourcesstring[]noOptional allowlist of bare upstream resource URIs/patterns to expose. Same matching rules as expose_tools. An empty list exposes nothing; omit the key to expose all.
expose_promptsstring[]noOptional allowlist of prompt names/patterns to expose. Accepts the bare or {upstream}/{name} spelling. An empty list exposes nothing; omit the key to expose all.
expose_skillsstring[]noOptional allowlist of skill names/patterns to expose. An empty list exposes nothing; omit the key to expose all.

When transport is omitted, an HTTP/WebSocket url or stdio command preserves legacy inference. unix_socket must be explicit and requires both socket_path and an HTTP(S) url; it cannot also configure command.

Config File Locations

Labby loads process environment over the selected installation root's .env, then its config.toml, then built-in defaults. LABBY_HOME selects that root; otherwise it is ~/.labby.

So a typical gateway setup looks like:

$LABBY_HOME/config.toml (normally ~/.labby/config.toml)

[mcp]
transport = "http"
host = "127.0.0.1"
port = 8765

[[upstream]]
name = "remote-lab"
url = "https://lab2.example.com/mcp"
bearer_token_env = "LABBY_UPSTREAM_TOKEN"
proxy_resources = true
expose_tools = ["gateway", "search_*"]

[[upstream]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]
proxy_resources = false

$LABBY_HOME/.env (normally ~/.labby/.env)

LABBY_UPSTREAM_TOKEN=replace-me
LABBY_MCP_HTTP_TOKEN=replace-this-too

Config Validation

Validation runs before discovery. Invalid entries are skipped with a warning during startup discovery. The runtime gateway management surface rejects invalid mutations before writing them to disk.

ConditionResult
Empty nameSkipped
Duplicate nameStartup keeps the first and warns; runtime gateway mutations reject the write
Name contains /, ?, or #Skipped
URL scheme does not match the selected transportSkipped
URL uses bind-all address (0.0.0.0, ::)Skipped
socket_path without transport = "unix_socket"Skipped
Unix socket missing socket_path or HTTP(S) urlSkipped
Abstract @name socket on a non-Linux targetSkipped
Custom header name/value is invalid, or attempts to set AuthorizationSkipped
Transport configures conflicting fields such as both url and commandSkipped
No transport can be inferredSkipped

Bearer Token

The bearer_token_env field names an environment variable; it does not contain the token directly. At connection time, the pool reads the env var and sends the token as a bearer header for HTTP and Unix-socket upstreams. For stdio upstreams, the same named variable is injected into the child process after Labby clears the ambient environment and applies its allowlist.

If the named env var is not set, HTTP and Unix-socket connections proceed without bearer auth and log a warning; stdio skips the optional injection. Stdio still rejects OAuth and custom HTTP headers because those require an HTTP transport.

Changing a bearer-token env var does not hot-apply by itself. Use gateway.reload when you want the live pool to re-read bearer_token_env.

Upstream OAuth (authorization_code + PKCE)

OAuth-protected upstream MCP servers are authenticated for a shared gateway credential rather than by a static bearer token. Configuration shape and examples live in CONFIG.md — Upstream OAuth. Operator browser flow lives in GATEWAY.md.

Scope

  • HTTP upstream transport only at the wire level. labby mcp stdio mode now supports native upstream OAuth with the shared trusted subject gateway. It binds a callback listener to 127.0.0.1, opens the authorization URL in the default browser on first use, and waits for the loopback callback. The hosted HTTP mode continues to use the public callback route.
  • Subject-less discovery skips OAuth upstreams. Hosted gateway startup and gateway.reload only seed configured upstream names; live discovery happens later from a lazy path with an explicit subject. Shared background refresh and gateway.test use the explicit shared subject gateway so configured OAuth upstreams can be discovered after an operator completes the upstream OAuth flow. If no credential exists yet, they remain configured but report no discovered capabilities until authorization succeeds. The authorization initiation flow (POST /v1/gateway/oauth/start) requires an authenticated HTTP session.
  • /mcp over HTTP, the hosted web UI, and labby mcp stdio are supported call surfaces. Stdio native OAuth requires LABBY_OAUTH_ENCRYPTION_KEY and uses LABBY_STDIO_OAUTH_CALLBACK_PORT when a fixed loopback port is required by an authorization-server registration; the default 0 selects an ephemeral port.

Stdio Flow

  1. Configure the upstream with an HTTP url and [upstream.oauth]; do not wrap it in mcp-remote.
  2. Start labby mcp. The first connection that needs the upstream opens the provider authorization URL in the default browser.
  3. The provider redirects to the process-local loopback listener. Labby validates the state against the encrypted SQLite store, exchanges the code, and retries the waiting MCP connection.
  4. Later calls reuse the encrypted per-upstream credential and refresh it under the existing manager locks. Concurrent first calls share one browser flow.

The loopback listener is not exposed on the LAN, accepts callbacks only for a pending state created by the current process, and never prints the callback query or authorization code.

Flow

  1. Operator runs POST /v1/gateway/oauth/start { "upstream": "<name>" }; the server returns a JSON { "authorization_url": "..." } body.
  2. Browser navigates to that URL; the upstream AS authenticates the user.
  3. AS redirects to /auth/upstream/callback?code=...&state=...&upstream=<name> on the same origin as LABBY_PUBLIC_URL.
  4. Labby validates the authenticated session, atomically takes the pending state row (DELETE ... RETURNING), exchanges the code for tokens, encrypts the token response with chacha20poly1305, and persists it keyed by (upstream_name, "gateway").
  5. Subsequent /mcp and UI requests find the persisted credential and proxy through a per-(upstream, subject) AuthClient cached in the gateway. The default shared subject is gateway.

CLI examples:

labby server auth login --no-browser chrome-devtools
labby server auth login chrome-devtools --wait
labby server auth status chrome-devtools
labby server auth logout chrome-devtools

Spec-Aligned Invariants

  • PKCE S256-only. The AS metadata must advertise S256 in code_challenge_methods_supported. Missing or plain-only metadata is refused with oauth_unsupported_method; Labby never falls back to plain.
  • RFC 8707 resource. The canonical upstream MCP URL (RFC 3986 §6.2.2 normalized: lowercase scheme + host, normalized percent-encoding, default port elided, trailing slash preserved as configured) is sent on both the authorization request and the token request, byte-identical between the two. Canonicalization runs at config-validation time so the stored URL and the resource wire value are the same string. Mismatched aud claims on the returned token surface as oauth_resource_mismatch. Labby's pinned, provenance-checked rmcp patch also re-emits the same resource parameter on refresh_token grants; the conformance suite asserts authorization, code exchange, and refresh all retain the discovered audience.
  • Issuer binding. After AS metadata discovery, metadata.issuer is required — missing issuer surfaces as oauth_issuer_mismatch. The authorization_endpoint, token_endpoint, revocation_endpoint, and (when present) registration_endpoint and userinfo_endpoint origins (scheme + host + port) must match the issuer origin; any drift surfaces as oauth_issuer_mismatch (RFC 8414 §3.3). A provider's documented split endpoint origins must be declared per upstream with oauth.additional_endpoint_origins; Labby does not hardcode provider exceptions.
  • Provider credential broker. Generic upstream OAuth clients remain per-upstream and per-subject. Google-backed upstreams may instead use the authenticated subject's centralized, encrypted Google provider credential. The broker shares only that subject-bound credential and lifecycle; it does not expose provider tokens to callers.

Per-(upstream, subject) Client Cache

The gateway maintains a DashMap<(upstream_name, subject), AuthClient> built atomically per key. Two subjects calling the same OAuth upstream get two isolated AuthClient instances; one subject's tokens are never visible to another.

Current operator surfaces default to the shared subject gateway, so the common path is one cached AuthClient per upstream for the whole gateway.

The cache stores the client_id each entry was built with. A gateway.reload that changes an upstream's client_id evicts cached entries with a stale client_id; subsequent calls rebuild them. This closes a silent re-bind gap where a config edit would otherwise keep old credentials attached to a new upstream definition.

OAuth-tagged upstreams are never discovered by the subject-less discover_all path. Gateway-owned startup/reload/test discovery uses an explicit subject-scoped path with the shared gateway subject; MCP request paths that need a real user subject use the per-request subject-scoped helpers. The circuit breaker and catalog merging infrastructure applies to static-bearer upstreams; OAuth upstreams are connected through the subject-scoped auth client cache.

Refresh Semantics

Refresh is single-flight per (upstream_name, subject) using a tokio::sync::Mutex keyed on the pair. Lock entries are retained for the lifetime of the process.

The client refreshes expiring tokens and reacts to upstream authorization challenges:

  • Proactive: rmcp refreshes an access token inside its 30-second expiry buffer. Operator status refresh starts inside the five-minute window.
  • Reactive (401): the pinned rmcp HTTP auth client attempts one silent refresh after an authenticated request receives an authorization challenge. It retries the request only if a different access token was returned; a repeated challenge remains an authentication failure. This behavior also applies to MCP POST requests. An authentication rejection is expected to precede dispatch, but a transport error or uncertain completed response is not proof that a tool did not run. Do not add another automatic tool replay at the gateway surface; inspect side effects before manual retry.
  • Successful ordinary credential refreshes are persisted only if the encrypted credential loaded for that exchange is still current. Clear or reauthorization prevents an older response from overwriting the new authority.
  • Google status refresh keeps one account transaction lock through persistence; cancelling the status request does not cancel the admitted refresh task.

On invalid_grant (refresh token revoked or rotated twice), Labby returns oauth_needs_reauth to the caller. The user re-initiates authorization.

oauth_needs_reauth Triggers

A caller sees oauth_needs_reauth in any of these situations:

  • no credential exists yet for (upstream, subject)
  • the refresh token was rejected with invalid_grant
  • decryption of the stored token_blob failed (operator rotated LABBY_OAUTH_ENCRYPTION_KEY)

Recovery is identical in all cases: start a new authorization via POST /v1/gateway/oauth/start.

Token-At-Rest Encryption

Persisted token responses are sealed with chacha20poly1305 AEAD. A fresh 12-byte nonce is generated on every seal() call; the refresh upsert stores the new nonce and must never preserve the previous one. The key is loaded once at startup from LABBY_OAUTH_ENCRYPTION_KEY; see the Upstream OAuth configuration for the current key and callback settings.

Prior Art

The cache implementation still supports per-(upstream, subject) isolation internally, but the current operator-facing flow defaults to the shared subject gateway for all three surfaces.

Discovery

At startup, lab seeds enabled upstream names into the shared gateway catalog without opening upstream connections. Live tool discovery is lazy: the first code mode, exact tool execution, or Code Mode upstream call connects only the needed upstream. Background search-index refreshes use the same bounded discovery concurrency as bulk discovery paths.

Each live discovery attempt gets a 15-second timeout for connection and tool discovery (list_tools()). Failed upstreams are marked unhealthy. Healthy upstreams continue operating. A single failed upstream does not prevent others from connecting later.

After startup, proxied RMCP operations continue to use explicit per-RPC timeouts. Tool calls, prompt reads, resource reads, and discovery/listing operations must fail closed with logged timeout/error events rather than blocking indefinitely behind one hung upstream.

gateway lazy upstream catalog seeded  upstream_count=3
lazy upstream tools connected         upstream=remote-lab tool_count=12
gateway tool index reprobe failed     upstream=broken-server kind=upstream_reprobe_failed

How Routing Works

The combined catalog is exposed as one MCP server, but ownership is still resolved internally.

For each incoming MCP tool call:

  1. Labby checks whether the tool name belongs to a built-in local service
  2. if not, it checks the discovered upstream tool map
  3. if an upstream owns that tool name, the request is proxied there using the original MCP arguments
  4. the upstream result is normalized into Labby's usual success/error envelope shape

This internal precedence rule does not make upstream tools second-class. It is just how collisions are resolved.

Tool Collision Handling

When upstream tools are merged into the lab tool catalog:

  1. Built-in lab services always take precedence. If an upstream exposes a tool named gateway, the upstream tool is silently dropped (with a warning logged).
  2. Cross-upstream duplicates: first discovered wins. If two upstreams expose a tool named my-tool, the second is skipped with a warning.

Upstream tools appear alongside built-in tools in list_tools(). Callers do not need to know whether a tool is built-in or proxied.

Exposure Filtering

Each upstream may optionally restrict which discovered primitives become visible downstream: expose_tools, expose_resources, and expose_prompts. All three compile through the same allowlist matcher and behave identically.

  • an unset allowlist means "expose everything discovered for that capability"
  • exact entries match one name/URI
  • entries containing * use simple wildcard matching
  • malformed allowlists fail closed: the upstream stays connected, but nothing from that capability is exposed until the config is fixed

What each allowlist matches:

FieldMatched against
expose_toolsthe tool name the upstream advertises
expose_resourcesthe bare, upstream-native resource URI — the form reported by gateway.discovered_resources, not the lab://upstream/{name}/… rewrite
expose_promptsthe bare prompt name the upstream advertises, or the {upstream}/{name} namespaced form reported by gateway.discovered_prompts — either spelling works

Every allowlist applies to both discovery and direct access, on the shared catalog path, the OAuth subject-scoped path, and the MRTR relay path:

  1. listing (list_tools, resources/list, prompts/list), so filtered items are never advertised
  2. direct access (tools/call, resources/read, prompts/get) and completion/complete, so a filtered item cannot be reached by name or URI even by a caller that already knows it

Filtering only the listing would be a bypass rather than a restriction, so the direct-access gate is the load-bearing half.

The cached inspection snapshots (gateway.discovered_resources / gateway.discovered_prompts) deliberately stay unfiltered — they are what the admin UI shows while an operator edits the allowlist, and hiding excluded entries there would make the allowlist un-editable.

Resource templates (resources/templates/list) are not filtered by expose_resources, and neither is completion/complete for a Reference::Resource: a template is a URI pattern, not a concrete URI, so there is nothing well-defined to match. Reads of any URI a template expands to are still gated by expose_resources. completion/complete for a Reference::Prompt is gated, because a prompt reference is an exact name.

Circuit Breaker

Each upstream has independent health tracking.

ConstantValue
CIRCUIT_BREAKER_THRESHOLD3 consecutive failures
REPROBE_INTERVAL30 seconds

State Transitions

  • Healthy — upstream is routable. 0 consecutive failures.
  • Unhealthy (below threshold) — upstream has 1-2 consecutive failures. Still routable and included in tool listings.
  • Unhealthy (at/above threshold) — upstream has 3+ consecutive failures. Excluded from tool listings.

What Counts as a Failure

  • Connection errors
  • Tool call errors (is_error responses)
  • Prompt and resource proxy errors
  • Dropped connections
  • Timeouts
  • Response size cap exceeded

Recovery

  • A successful proxied call resets the upstream to healthy (0 failures).
  • Set [gateway].auto_reconnect = true to arm a long-lived recovery task for each enabled non-OAuth upstream. The task probes every 30 seconds with bounded exponential backoff after failures.
  • A failed heartbeat removes the stale connection and starts a fresh MCP transport. This covers stdio child-process restarts and HTTP reconnects.
  • The heartbeat itself never holds the upstream's connect gate; only the reconnect does, and the prompt/resource cache refresh after a reconnect or a gateway.mcp.restart runs once the gate is released, so callers waiting to connect are not parked behind a slow listing.
  • Recovery tasks are disabled by default. Ephemeral gateway.test probes never create background tasks.

Response Size Cap

Upstream responses are subject to a size cap to prevent oversized payloads from consuming memory or being forwarded to callers.

SettingDefault
LABBY_UPSTREAM_MAX_RESPONSE_BYTES10 MiB (10,485,760 bytes); manifest-bound Skills resource responses receive a 24 MiB wire allowance when neither environment nor configuration overrides the cap

HTTP bodies, WebSocket messages, and stdio JSON lines are capped before MCP deserialization. The capability-specific semantic check still runs after parsing; in-process transports rely on that semantic guard. Because one transport connection multiplexes ordinary and Skills calls, its transport ceiling uses the larger 24 MiB default allowance, even without Skills support compiled; ordinary capabilities are still rejected at 10 MiB after parsing. Isolating the pre-parse ceilings would require a dedicated Skills connection or request-aware transport framing.

The upstream HTTP transport also applies a process-wide 80 MiB weighted admission budget: a response reserves its transport maximum while a JSON/error body is read or for the lifetime of an SSE stream. At the 24 MiB default transport ceiling, at most three responses can hold reservations concurrently (eight at the ordinary 10 MiB ceiling). Additional reads wait up to one second without allocating their bodies, then return response_budget_exhausted if capacity remains unavailable, even when long-lived SSE streams hold reservations.

The ordinary cap applies to call_tool and ordinary read_resource; the Skills cap applies to manifest-bound resource reads. skills/list and skills/get also have their own validation and discovery budgets. An explicit environment or configuration cap overrides both response ceilings.

Resource Proxying

Resource proxying is opt-in per upstream via proxy_resources = true.

URI Namespacing

Upstream resources are prefixed to avoid URI collisions with lab's own resources:

lab://upstream/{name}/{original_uri}

For example, if upstream remote-lab exposes a resource lab://gateway/actions, it appears as:

lab://upstream/remote-lab/lab://gateway/actions

Operations

  • resources/list serves the cached per-upstream snapshot for regular upstreams and returns namespaced URIs. Labby refreshes that snapshot with a live resources/list on connect, reconnect, gateway reload, an upstream resources/list_changed, and when discovery finds a connected upstream with no snapshot yet. A snapshot older than 60 seconds on an upstream without a live subscriptions/listen stream is re-listed in the background while its current rows are served. OAuth subject-scoped upstreams are listed over the per-subject connection and cached with it under the same 60-second bound; an upstream resources/list_changed clears every subject's cached catalog for it.
  • Upstream tools/list_changed and resources/list_changed refreshes run on a worker per upstream, off the notification consumer, with a 250 ms coalescing window so a burst from one upstream collapses into one re-list. The downstream list_changed for that upstream is forwarded only after its refresh completes, and one upstream's slow re-list never delays another upstream's notifications. prompts/list_changed travels through the same worker without a re-list, because prompts/list is served live, so that one upstream's notifications stay ordered with each other. Events for the same upstream are serialized: one arriving while that upstream's re-list is in flight waits for that refresh plus the coalescing window, bounded by catalog_listing_timeout.
  • read_resource() strips the prefix, identifies the upstream by name, and forwards the read.

Failed resource listings from individual upstreams are logged as warnings. Other upstreams continue to serve.

The same graceful-degradation rule applies to prompt/resource discovery and reads: one upstream failure must not prevent healthy upstreams from serving partial results.

Skills Aggregation

Skills aggregation implements the accepted SEP-2640 extension. The contract doc pins the exact canonical revision this code was written against; read it before changing anything here.

It is opt-in per upstream via proxy_skills = true, and unlike every other proxy_* flag it defaults to false. That asymmetry is deliberate: a skill is a set of instructions an agent will act on, so aggregating one is a trust decision about the upstream, not a convenience toggle.

[[upstream]]
name = "acme"
command = "acme-mcp-server"
proxy_skills = true
expose_skills = ["refunds"]   # omit to expose all

Enable it from the CLI with:

labby server add acme --command acme-mcp-server --proxy-skills true

Per-origin namespacing

Every aggregated skill is relabelled under the upstream's host-assigned origin, so two upstreams may each publish a skill named refunds without collision:

skill://{gateway-origin}/skill/{upstream-origin}/{upstream-path...}
skill://acme/skill/native/refunds/SKILL.md

The host-assigned gateway origin is the configured upstream name. The original upstream skill URI is preserved after the literal /skill/ marker so routing can recover the exact upstream identity without guessing or colliding with another source.

labby is reserved for Labby's own first-party skills and can never be claimed by an upstream. Nothing is ever deduplicated by skill name — two origins publishing the same name are two distinct skills.

Provenance travels in the entry's _meta under ai.dinglebear.labby/skillOrigin, never in frontmatter. Frontmatter is content the upstream authored; putting provenance there would let an upstream forge its own origin.

Verified reads

A skill entry publishes a per-file sha256 digest. When a client reads one of those files, the bytes are hashed and compared against the digest the entry published before any byte reaches the client:

  • content that does not match its digest → skill_digest_mismatch, zero bytes served
  • a file the manifest does not list → skill_manifest_stale; an unlisted file is a changed skill, not a fetchable one, so it is refused rather than fetched

Both classify as validation / rediscover / same_arguments: never — an agent must refresh the entry rather than replay the identical read, because a changed resource set revokes any approval bound to the previous content.

Digests are an integrity check against drift and corruption, not a security boundary: an upstream that serves malicious content can publish a matching digest for it. The trust decision is proxy_skills.

Caching

Upstream skills/list TTL hints are bounded by Labby: 5 seconds minimum, 1 hour maximum, and 5 minutes when the upstream omits a TTL. Cache entries idle for 30 minutes are eligible for eviction. The process retains at most 512 (upstream, subject) entries, so subject-bound upstream load scales with active subjects and can churn beyond that cap. Failed stale refreshes are negatively cached with bounded exponential backoff, and operator views expose refresh age and retry delay.

Degradation

One unreachable upstream is skipped rather than emptying the listing, and per-upstream errors surface in gateway.skills.list rather than being folded into a silent empty result. Per the SEP, an empty or partial listing is never proof that a server has no skills — an unlisted skill may still be loadable by URI.

Labby's own skills

Labby serves first-party skills under the reserved labby origin: those embedded in the binary, plus any operator-provided skill directories under $LABBY_HOME/skills. Operator skills are read and digested in a single pass at startup, so adding one requires a restart — re-reading per request would let a file change between publishing a digest and serving the file it describes, which is exactly the mismatch a conforming client must refuse. A skill is skipped, with a logged reason, if it contains a symlink at any depth, omits SKILL.md, exceeds the size or file-count caps, or its directory name disagrees with its frontmatter name.

What Is Exposed Where

MCP

The upstream gateway is active on both MCP transports exposed by Labby:

  • stdio
  • streamable HTTP at /mcp

If an upstream tool is discovered successfully, MCP clients connected to Labby can call it as a normal tool.

HTTP API

The product HTTP API under /v1/* does not proxy arbitrary upstream MCP tools. It serves built-in Labby routes plus /v1/gateway for gateway management.

Keep this distinction explicit in operator docs:

  • use MCP when you want the upstream gateway behavior
  • use /v1/gateway when you want to manage [[upstream]] entries over HTTP
  • use the rest of /v1/* for Labby's built-in HTTP API surface

End-to-End Setup

1. Configure upstreams

Prefer labby server add/set. For offline editing, add [[upstream]] entries to the selected $LABBY_HOME/config.toml (normally ~/.labby/config.toml).

2. Provide any required secrets

Set bearer-token env vars named by bearer_token_env in the selected $LABBY_HOME/.env or the process environment.

3. Start labby

For local stdio clients:

labby mcp

For network MCP clients:

labby serve

4. Point the client at labby, not the upstreams

Example .mcp.json for stdio:

{
  "mcpServers": {
    "labby": {
      "command": "labby",
      "args": ["mcp"]
    }
  }
}

This is the local stdio bridge: the client does not need an HTTP URL. If a labby serve daemon is already running, labby mcp forwards the session to that daemon; otherwise it starts a standalone local gateway. See the transport guide for explicit-target and fallback behavior.

Example HTTP MCP endpoint:

https://lab.example.com/mcp

5. Verify discovery

Startup logs should show lazy seeding rather than live upstream discovery:

phase="discovery.lazy" upstream_count=3

Then trigger a first search or invoke and verify live discovery for only the requested upstream, for example lazy upstream tools connected upstream=remote-lab.

Then an MCP client connected to Labby should see the upstream tools in list_tools().

Operational Notes

  • Upstream tool schemas are cached from discovery and reused for MCP tool metadata.
  • Upstream calls preserve the original MCP argument payload rather than forcing it through Labby's action + params wrapper.
  • Upstream errors are normalized into Labby envelopes and usually surface as upstream_error, network_error, server_error, decode_error, or internal_error.
  • HTTP body, WebSocket message, and stdio line limits apply before MCP deserialization; a second capability-specific semantic limit applies after parsing.

Environment Variables

VariableDefaultDescription
LABBY_UPSTREAM_MAX_RESPONSE_BYTES10485760Maximum ordinary response size from upstream servers. Without an environment or configuration override, manifest-bound Skills resources use a separate 24 MiB wire allowance for a 16 MiB SEP-2640 binary resource after base64 expansion; an explicit cap overrides both limits.
(per bearer_token_env)Bearer token for each upstream, named in config.

Observability

Discovery events are logged at INFO (success) and WARN (failure/timeout).

Circuit breaker state changes are logged:

  • WARN when the breaker opens (3+ failures).
  • INFO when the breaker resets (successful call after failure).

Tool collision warnings are logged at WARN.