Honest accounting of architectural gaps, scale concerns, and OSS-readiness TODOs. This is the inverse of a roadmap: not features we'd like to add, but problems with what's already shipped.
If you're evaluating this project for use: read this file first. The defaults work for the homelab use case it was built for; if your deployment differs, one of these will probably bite you.
The HTTP and container transports now cover the network-credential shapes
(bearer header injection, OAuth pass-through). The remaining gap is the
stdio path: Fanout._spawn writes a per-identity token file at a path the
upstream MCP reads via an env var (secret_env_name in MCPConfig). That's
one of many shapes a spawned MCP can need:
- ✅ File path via env var (stdio;
technitium-mcp-secure) - ✅ HTTP
Authorization: Bearer xxxheader injection — done for the http transport viaauth.type: secret_bearer(per-identity token from the secret backend) - ✅ "Upstream MCP does its own OAuth flow" — done via
auth.type: jwt_passthrough(forward the caller's JWT; withas_agent, the agent proves its own identity to the secret backend) - ❌ Env var with the literal token value (
TOKEN=xxx ./mcp-server) — stdio - ❌ CLI flag (
--token=xxxor--api-key xxx) — stdio - ❌ Config file written at a known path in a specific format (JSON / YAML / TOML / INI) — stdio
Today: a stdio MCP that wants a non-file shape still means forking the upstream
to accept our shape (the technitium-mcp-secure pattern) or writing a wrapper.
Right shape: a stdio TokenInjector Protocol with the remaining shapes as
concrete implementations, declared per-MCP in policy.yaml. ~3 more
implementations cover most spawned MCPs; the rest can write a custom injector
or fork.
Three layers of credential lifetime:
| Layer | Example | Refresh |
|---|---|---|
| 1 (bootstrap, on disk) | Authentik OAuth client_secret for hub identity |
Manual |
| 2 (ephemeral, memory) | bao token via JWT swap | Auto on bao TTL |
| 3 (long-lived, in bao KV) | Technitium API token | Never |
Layer 3 is the gap. The refresher framework exists (in mcp_hub/refresher/)
and is plugin-pluggable, but only ships an oidc_client_credentials source and
a file sink. To refresh a Layer 3 secret like a Portainer self-rotated token,
we'd need a BaoKVSink and a PortainerSelfRotateSource. Mostly unwritten.
We're loud about "no long-lived ambient creds" while quietly relying on sealed long-lived creds in bao. Honest: bao seal is a meaningful mitigation, but rotation is the proper fix.
Resolved. The fanout now supports three transports
(mcps.<name>.transport): stdio (spawn-and-pipe, Fanout), http
(HttpFanout, for HTTP-transport upstreams like Home Assistant's MCP server),
and container (ContainerFanout, opt-in). The http transport carries the
Authorization header via auth.type: secret_bearer / jwt_passthrough
(closing the #1 header shape for the network path). See architecture.md
"Transports."
Eager-or-die works at boot — any child that fails to spawn aborts the
whole hub start. Post-boot, if a child dies (OOM, network blip, upstream
MCP itself crashed, gosu segfault), the hub doesn't respawn it. The
tool keeps appearing in tools/list; calls fail at dispatch with
BACKEND_CHILD_DEAD.
Needs a supervision loop per child: on EOF or non-zero exit, attempt
respawn with bounded backoff; on persistent failure (N retries),
suspend the child + remove its tools from tools/list until manual
intervention.
Mostly resolved. Inbound auth is now IdP-agnostic: OidcJwtValidator
(renamed from AuthentikJWTValidator) discovers JWKS from the issuer's
/.well-known/openid-configuration, resolves identity from the aud suffix
or a configurable identity_claim (sub/azp/client_id), and there are
two non-OIDC modes (static_token, trusted_proxy) for deployments with no
IdP. Keycloak/Okta/Cognito should validate without code changes.
What's still Authentik-flavored:
- The bug-#16621 workaround — Authentik silently caps
access_token_validity, so the bao deployment refreshes itsclient_credentialsJWT every ~10 min. Other IdPs can refresh on the real token lifetime; therotate_everyis just configured conservatively. - The DCR shim (#6) —
/oauth/registeris a static stub tied to the Authentik "interactive" client pattern; not real Dynamic Client Registration.
These are deployment/refresh details, not validation coupling. The second-IdP user hits the refresh cadence and the DCR shim, not the validator.
Our /oauth/register endpoint is a static shim — every caller gets
the same mcp-interactive client_id. Sufficient for "Claude Code on
my laptop" but can't differentiate:
- Claude Code on dev laptop vs prod laptop
- Different MCP clients (Claude Code, Cursor, Copilot) all looking like one client to Authentik
- Clients that should be granted different scopes (read-only vs read-write personas of the same human)
Real DCR needs the Authentik DCR feature flag enabled + actual per-client persistence on our side. ~1 day of work, currently skipped because it wasn't needed for the homelab.
With 1 MCP × 1 identity it's a non-issue. With 5 MCPs × 10 identities it's 50 child processes — meaningful RAM, fork pressure, and PID-table cost. Mitigations not yet built:
- Lazy spawn (only on first call from that identity, cache for some TTL after last call)
- Child pooling per MCP type with per-call identity injection
- In-process MCP support for trusted upstreams (skip the subprocess isolation)
10 federated MCPs × ~100 tools each = 1000 tools returned in one
tools/list response. Some LLMs/clients tolerate this poorly (context
window cost, slower planning, schema-validation churn).
MCP spec supports pagination via cursor parameter. We don't
implement it on the gateway side. At ~50 tools today (Technitium only)
it's fine; at 200+ this will hurt.
Every tools/call re-validates the JWT signature against cached JWKS.
Cache makes the JWKS fetch ~free, but the RSA verification is still
~ms-scale per call. Sessions/cookies would skip the work after first
auth.
Not urgent for homelab traffic; would matter at API-scale loads.
Any policy.yaml change requires container restart. That tears down
all fanout children, drops in-flight HITL approvals, and re-runs every
upstream MCP initialize handshake. Brief downtime is fine for
development but rough in production.
Cleaner: SIGHUP → diff old vs new policy → apply only the deltas (new identities → spawn new children; removed identities → close their children; HITL override changes → apply on next call). Most changes are HITL overrides which need no spawn churn.
HITL prompt notifications and receipt notifications both publish to
mcp-hitl-{identity}. When watching the topic, the operator sees
interleaved "approve this?" and "✓ approved earlier" messages. Cleaner
options:
- Separate
mcp-hitl-{identity}-receiptstopic - ntfy tags-based client-side filtering
- Update-in-place semantics (ntfy doesn't support natively today)
When the hub receives a JWT for an identity that exists in policy but
the requested tool isn't in allowed_tools, the response is
POLICY_REJECTED (correct). But when the JWT's aud strips to an
identity that DOESN'T exist in policy, tools/list returns an empty
list (also correct, no-oracle), and tools/call returns
POLICY_REJECTED for the same reason. Hard to distinguish "you're a
known identity with no tools allowed" from "you're an unknown identity"
— intentional security posture, but operationally confusing during
setup.
A diagnostic mode that logs distinct reasons (visible only in hub logs, not in the response) would help debugging without compromising the no-oracle property.
Unit tests are good (tests/unit/test_*.py covers protocols,
middleware, refresher, approver in isolation with fakes). End-to-end
tests don't exist — the full IdP → secret backend → fanout → upstream MCP
chain is verified manually via curl + actual deploy.
CI runs unit tests; regressions in the integration boundary (e.g., a secret-backend client API change, an OIDC discovery doc field rename) won't catch them. Manual smoke after each deploy is the current safety net.
Resolved / moot for this repo. The published image is generic and bakes
no MCP servers — the Dockerfile is a two-stage hub-only build (no Node, no
git clone). Deployments that bake in stdio MCPs do so in their own thin
FROM ghcr.io/nick-pape/mcp-gateway overlay, where pinning the upstream is the
overlay's concern. http/container MCPs need nothing baked at all.
A top-level README.md now exists, but there's no single walkthrough that
takes a new adopter from zero to a running multi-identity deployment for a
given backend set. The codebase comments and architecture.md are
thorough but read as design docs, not "first deploy" docs.
Table-stakes for OSS launch.
version: 1 since day one despite adding fields like
oidc_discovery_url, token_refresh:, topic_template, the
interactive identity pattern. No clear "this field was added in
v1.2." Future schema migrations will be painful without a real
versioning story.
Either bump version on each breaking change (and document the migration path) or commit to "additive-only forever" and document which fields are required vs optional.
Resolved 2026-05-22 (#18): committed to additive-only — version stays 1
while changes are additive (new optional fields, new placeholders); a genuinely
breaking change bumps it and the loader gates on it. Documented on
HubConfig.version; placeholders ({identity}/{mcp}) are unified via
mcp_hub.templating.
README.md, architecture.md, landscape.md, CHANGELOG.md, and
KNOWN_LIMITATIONS.md (this file) are all good but fragmented. A unified
docs site (mkdocs / Docusaurus / hand-rolled GitHub Pages) would help
discoverability and let us link sections between docs cleanly.
These are calls we made on purpose, not gaps. Listing for clarity:
- No multi-region / HA story. Single hub, single bao, single Authentik. Acceptable for homelab; not a goal.
- No built-in user model. Identities come from external IdP via JWT validation. We will not add a Peta-style internal users table. The federated-IdP model is a deliberate differentiator.
- No GUI admin console. Configuration is
policy.yaml. We will not add a web UI for editing it; that's a separate (optional) product. - No autoscaling / clustering. One hub instance per deployment.
- Python only, no Rust/Go rewrite. The performance-sensitive parts are I/O-bound; Python's fine.
If any of these matter to you, you probably want a different tool.