Skip to content

Latest commit

 

History

History
279 lines (206 loc) · 11.5 KB

File metadata and controls

279 lines (206 loc) · 11.5 KB

Known Limitations

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.


Architectural / adoption-blockers

1. stdio credential injection has one shape

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 xxx header injection — done for the http transport via auth.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; with as_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=xxx or --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.

2. Layer 3 secrets never rotate

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.

3. Stdio-only upstream MCPs

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."

4. No upstream MCP failure recovery

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.

5. Some Authentik-flavored remnants

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 its client_credentials JWT every ~10 min. Other IdPs can refresh on the real token lifetime; the rotate_every is just configured conservatively.
  • The DCR shim (#6) — /oauth/register is 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.

6. No real Dynamic Client Registration

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.


Scale concerns

7. Fanout per-(MCP × identity) child spawn = N×M memory

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)

8. No tools/list pagination

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.

9. JWT validation cost per call

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.


Operational / polish

10. No live policy reload

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.

11. Receipts share topic with prompts

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}-receipts topic
  • ntfy tags-based client-side filtering
  • Update-in-place semantics (ntfy doesn't support natively today)

12. Per-call JWT mismatch surfaces no-oracle as silent

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.


Testing / quality gaps

13. No integration test suite

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.

14. Build-time git clone of upstream MCPs

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.


OSS-readiness

15. No end-to-end deployment guide

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.

16. policy.yaml schema versioning is informal

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.

17. Documentation lives in scattered markdown files

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.


What we're NOT going to fix (intentional limits)

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.