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:
- seed enabled upstream names into the gateway catalog at startup without opening connections
- connect to an upstream lazily on first code mode, exact tool execution, Code Mode call, or explicit gateway test path that needs live discovery
- merge discovered tools into its own MCP catalog after that upstream is first contacted
- 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 mcpfor stdio clients such as Claude Desktoplabby servefor 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Stable 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_name | string | no | Operator-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. |
lifecycle | string | no | Handshake 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. |
transport | string | Unix socket only | Explicit http, websocket, stdio, or unix_socket. Legacy URL/command inference remains supported when omitted. |
url | string | HTTP/WebSocket/Unix | Network URL, or for unix_socket, the HTTP(S) request URI and Host authority. |
socket_path | string | Unix socket | Filesystem socket path, or Linux abstract @name notation. |
headers | table | no | Custom headers for HTTP and Unix-socket requests. Inline Authorization is forbidden; use bearer_token_env or OAuth. |
command | string | stdio | Command to run for stdio transport. |
args | string[] | no | Arguments to pass to a stdio command. |
env | table | no | Environment variables injected into a stdio child process. |
bearer_token_env | string | no | Name of an env var holding a bearer token for HTTP or Unix-socket transport. Not the token itself. |
proxy_resources | bool | no | Whether to proxy resources from this upstream. Default: true. |
proxy_prompts | bool | no | Whether to proxy prompts from this upstream. Default: true. |
proxy_skills | bool | no | Whether to aggregate this upstream's Agent Skills (SEP-2640). Default: false, unlike the other proxy_* flags — see below. |
expose_tools | string[] | no | Optional 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_resources | string[] | no | Optional 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_prompts | string[] | no | Optional 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_skills | string[] | no | Optional 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.
| Condition | Result |
|---|---|
| Empty name | Skipped |
| Duplicate name | Startup keeps the first and warns; runtime gateway mutations reject the write |
Name contains /, ?, or # | Skipped |
| URL scheme does not match the selected transport | Skipped |
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) url | Skipped |
Abstract @name socket on a non-Linux target | Skipped |
Custom header name/value is invalid, or attempts to set Authorization | Skipped |
Transport configures conflicting fields such as both url and command | Skipped |
| No transport can be inferred | Skipped |
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 mcpstdio mode now supports native upstream OAuth with the shared trusted subjectgateway. It binds a callback listener to127.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.reloadonly seed configured upstream names; live discovery happens later from a lazy path with an explicit subject. Shared background refresh andgateway.testuse the explicit shared subjectgatewayso 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. /mcpover HTTP, the hosted web UI, andlabby mcpstdio are supported call surfaces. Stdio native OAuth requiresLABBY_OAUTH_ENCRYPTION_KEYand usesLABBY_STDIO_OAUTH_CALLBACK_PORTwhen a fixed loopback port is required by an authorization-server registration; the default0selects an ephemeral port.
Stdio Flow
- Configure the upstream with an HTTP
urland[upstream.oauth]; do not wrap it inmcp-remote. - Start
labby mcp. The first connection that needs the upstream opens the provider authorization URL in the default browser. - 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.
- 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
- Operator runs
POST /v1/gateway/oauth/start { "upstream": "<name>" }; the server returns a JSON{ "authorization_url": "..." }body. - Browser navigates to that URL; the upstream AS authenticates the user.
- AS redirects to
/auth/upstream/callback?code=...&state=...&upstream=<name>on the same origin asLABBY_PUBLIC_URL. - 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"). - Subsequent
/mcpand UI requests find the persisted credential and proxy through a per-(upstream, subject)AuthClientcached in the gateway. The default shared subject isgateway.
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
S256incode_challenge_methods_supported. Missing orplain-only metadata is refused withoauth_unsupported_method; Labby never falls back toplain. - 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 theresourcewire value are the same string. Mismatchedaudclaims on the returned token surface asoauth_resource_mismatch. Labby's pinned, provenance-checked rmcp patch also re-emits the sameresourceparameter onrefresh_tokengrants; the conformance suite asserts authorization, code exchange, and refresh all retain the discovered audience. - Issuer binding. After AS metadata discovery,
metadata.issueris required — missingissuersurfaces asoauth_issuer_mismatch. Theauthorization_endpoint,token_endpoint,revocation_endpoint, and (when present)registration_endpointanduserinfo_endpointorigins (scheme + host + port) must match the issuer origin; any drift surfaces asoauth_issuer_mismatch(RFC 8414 §3.3). A provider's documented split endpoint origins must be declared per upstream withoauth.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_blobfailed (operator rotatedLABBY_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:
- Labby checks whether the tool name belongs to a built-in local service
- if not, it checks the discovered upstream tool map
- if an upstream owns that tool name, the request is proxied there using the original MCP arguments
- 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:
- 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). - 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:
| Field | Matched against |
|---|---|
expose_tools | the tool name the upstream advertises |
expose_resources | the bare, upstream-native resource URI — the form reported by gateway.discovered_resources, not the lab://upstream/{name}/… rewrite |
expose_prompts | the 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:
- listing (
list_tools,resources/list,prompts/list), so filtered items are never advertised - direct access (
tools/call,resources/read,prompts/get) andcompletion/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.
| Constant | Value |
|---|---|
CIRCUIT_BREAKER_THRESHOLD | 3 consecutive failures |
REPROBE_INTERVAL | 30 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_errorresponses) - 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 = trueto 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.restartruns 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.testprobes 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.
| Setting | Default |
|---|---|
LABBY_UPSTREAM_MAX_RESPONSE_BYTES | 10 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/listserves the cached per-upstream snapshot for regular upstreams and returns namespaced URIs. Labby refreshes that snapshot with a liveresources/liston connect, reconnect, gateway reload, an upstreamresources/list_changed, and when discovery finds a connected upstream with no snapshot yet. A snapshot older than 60 seconds on an upstream without a livesubscriptions/listenstream 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 upstreamresources/list_changedclears every subject's cached catalog for it.- Upstream
tools/list_changedandresources/list_changedrefreshes 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 downstreamlist_changedfor that upstream is forwarded only after its refresh completes, and one upstream's slow re-list never delays another upstream's notifications.prompts/list_changedtravels through the same worker without a re-list, becauseprompts/listis 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 bycatalog_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/gatewaywhen 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+paramswrapper. - Upstream errors are normalized into Labby envelopes and usually surface as
upstream_error,network_error,server_error,decode_error, orinternal_error. - HTTP body, WebSocket message, and stdio line limits apply before MCP deserialization; a second capability-specific semantic limit applies after parsing.
Environment Variables
| Variable | Default | Description |
|---|---|---|
LABBY_UPSTREAM_MAX_RESPONSE_BYTES | 10485760 | Maximum 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:
WARNwhen the breaker opens (3+ failures).INFOwhen the breaker resets (successful call after failure).
Tool collision warnings are logged at WARN.
Related Docs
- CONFIG.md —
[[upstream]]config section - MCP.md — upstream tool merging in MCP surface
- ERRORS.md —
upstream_errorkind - TRANSPORT.md — HTTP transport setup