v1.3.0 - multi-user control plane, tool playground, and idle shutdown
Multi-user control plane, tool playground, and idle shutdown
1.3.0 is the biggest feature release since 1.0. It turns the control plane into a multi-user system (admin/member roles, per-user server ownership, scoped tokens), adds a tool playground so you can invoke any discovered tool straight from the dashboard, and introduces idle shutdown with wake-on-request so memory scales with what's in use rather than what's registered. It also ships a per-server REST/OpenAPI surface, lets operators disable individual tools on any server, advances upstream OAuth with a CIMD client-metadata document and clearer no-DCR errors, and patches a transitive cookie validation CVE.
Every change preserves the zero-config local experience: existing installs and credentials keep working unchanged, and the new behaviors are opt-in.
Multi-user control plane (#101)
Tier-2 multi-tenancy for the control plane: multiple trusted identities on one box, with an admin/member role split, per-user server ownership, and scoped token minting — while the zero-config local experience and every existing credential keep working unchanged.
- One resolver for WHO (
app/auth/principal.py). Enforcement off resolves to a synthetic local admin (zero-config behavior is byte-identical);MCPE_ADMIN_TOKENis an env admin; a pre-multi-user, user-less control token resolves to a legacy admin, so upgrades change nothing; a user-bound control token resolves to that user's role and flags. Dangling user credentials fail closed in both the gate and the resolver. - One policy module for WHAT (
app/auth/policy.py). Server and token visibility, the local-runner permission, and member token-scope limits live in one place; every router calls the predicates and none re-derives a rule. - Users reuse the token machinery — no passwords. An admin creates a user in Settings → Users and mints a login token (
controlscope, bound viaToken.user_id, plaintext shown once). The existing login screen is unchanged. - Ownership is identity, not launch config.
Server.owner_id(NULL = admin-owned, the migration default) is excluded fromconfig_hash, so reassigning an owner never bounces a running bridge. - Scoped visibility and management. Members see and manage exactly the servers they own; non-visible ids and slugs 404 like nonexistent ones everywhere (server CRUD, lifecycle, logs, playground, OAuth routes, and
/api/health/*), so nothing leaks. Members mint data-plane tokens only for their own servers — neverall,control, or group scopes — and see and revoke only their own token rows. - Local-runner permission (off by default) gates
npx/uvx/command/docker— code execution on the box. A restricted member can still start and stop an admin-provisioned local server but not reshape what it executes; their bulk imports skip local entries per-entry while remote entries land. - Admin-only surfaces. Settings writes, groups, and user management are admin-only (settings reads stay open — the add-server form needs them). Deleting a user revokes all their tokens and is refused while they own servers; the last admin login cannot be demoted or deleted (
MCPE_ADMIN_TOKENlifts the guard, mirroring the last-control-token guard).
This is authorization, not isolation. Local runners execute as the mcpelevator process user with access to the data dir. This feature separates management views between mutually trusting users; it does not sandbox server processes. The README Security section documents the trust caveat prominently.
Tool playground — try any tool from the dashboard (#100)
Closes the add → start → verify → copy-URL loop inside the UI: a running server's detail page now offers a Try it panel per discovered tool.
- The readiness probe's cached tool summary now carries each tool's full JSON
input_schema, so argument forms build with no live round-trip. - New
POST /api/servers/{id}/tools/{name}/callinvokes the tool on the bridge's loopback port over a fresh FastMCP client session. MCP error semantics are mirrored: a tool's own failure isis_errorinside a 200; an unreachable bridge is 502, a timeout is 504, and not-running is 409. It is admin-token gated like every/apiserver route — no data-plane bearer needed. - A schema-driven argument form (string/number/boolean/enum inputs, per-field JSON fallback, raw-JSON mode) renders the result: structured content, text blocks, call duration, and a tool-error badge.
Idle shutdown with wake-on-request (#100, ADR-0002)
Memory now scales with what's in use, not what's registered — aimed at the Unraid/NAS deployment with many servers.
- New per-server
idle_timeout_s(NULL = inherit, 0 = never) plus anidle_timeout_sruntime setting as the global default. The default is 0 (off), so existing installs keep today's always-running behavior. Likeauth_provider, it is excluded fromconfig_hash— changing it never bounces a bridge. - The reconciler quiesces a running unit whose idle window passed with no authenticated proxy traffic: the bridge is stopped, the observed state becomes
idle, and the cached tool list is kept for the UI. Any activation request (proxy wake, operator action, config change) clears it. - The
/sproxy marks activity on every authenticated request and, for an idle server, wakes it and holds the request until readiness (bounded byMCPE_START_TIMEOUT_S, bailing early on terminal failure) instead of returning 503./ggroup traffic marks members active but does not wake them. idleis a first-class state end to end: API_live_state, a calm-blue UI pill with slow polling, and a passing/api/health/{slug}status ("idle") so load balancers don't eject a deliberately sleeping endpoint.
Per-server REST/OpenAPI surface (#100)
Ships the roadmap item the schema already scaffolded (rest_openapi was stored but never served). With the exposure enabled, the same supervised bridge serves — behind the identical /s/<slug>/ proxy path, Host/Origin guard, and per-server auth:
POST /s/<slug>/rest/<tool>— the body is the tool's JSON arguments; the response is a stable{is_error, content, structured_content}envelope mirroring MCP semantics. An unknown tool is 404, a non-object body is 400.GET /s/<slug>/rest/openapi.json— OpenAPI 3.1 generated from the live tool list (request schemas and output schema included), with a relativeserversURL that resolves correctly behind the proxy without the bridge knowing its public slug or base.GET /s/<slug>/restlists tools.- REST calls run over in-memory client sessions against the same proxy the MCP surface serves — identical fresh-upstream-session isolation.
Disable specific tools per server (#107, closes #105)
Some MCP servers ship internal-only tools that just waste the model's context. Operators can now hide individual tools per server, from every exposed surface, with the toggles living on the server detail page where the discovered tool list already renders.
- A new
disabled_toolslist on theServerrow drives a FastMCP middleware installed in the bridge, so one implementation covers all three surfaces that resolve tools through the proxy: MCPtools/list(the tools vanish from discovery), the REST/OpenAPI routes, and the group hub (/g/). - Hiding also disables:
on_list_toolsdrops the disabled tools from discovery, andon_call_toolrefuses a disabled tool with the sameNotFoundErroran unknown tool raises — so a client holding a stale list still can't invoke it. - The hide list is part of the launch spec (
config_hash), so changing it restarts the bridge to re-apply the filter and it persists across bridge and container restarts as ordinary desired state. It is normalized (trimmed, deduped, sorted) so reordering the same set doesn't bounce the bridge. - API:
PATCH /api/servers/<id>with{"disabled_tools": ["internal_tool"]}(send[]to expose everything); accepted on create, echoed onGET, and carried through clone. UI: a per-tool toggle switch on the server detail page; default is all tools exposed.
Upstream OAuth: CIMD client metadata and clearer no-DCR errors (#103, #102)
The MCP 2025-11-25 authorization spec deprecates Dynamic Client Registration in favor of CIMD (URL-based Client ID Metadata Documents), and providers that will never offer DCR — GitHub has ruled it out explicitly — are expected to adopt CIMD instead.
- CIMD document (#103). New public route
GET /api/oauth/client-metadata.jsonadvertises this instance's client metadata document alongside the existing paths, with the MCP SDK arbitrating. Client-identity precedence is now: static client id/secret if set, else CIMD where the provider advertises it, else DCR. The document'sclient_idis its own URL,redirect_urisshares the same base, andtoken_endpoint_auth_methodisnone(public client, PKCE-secured). Shared forwarded-proto-aware base derivation (api/util.oauth_public_base) keeps the fetched URL and the validated redirect URI in agreement. Only https bases qualify, so LAN and plain-http instances behave exactly as before. - Actionable no-DCR error (#102). Connecting a
remoteserver to a provider without DCR (for example the GitHub MCP server) used to fail with an opaque 502 — and behind Cloudflare that origin 502 was replaced by the CDN's own error body, so the operator saw nothing useful._classify_begin_errornow maps a registration 404/405/501 to a 400 with an actionable message (register an app with the provider and set its Client ID + Client Secret on the server, then connect again). A 4xx also passes through Cloudflare untouched.
Fixes and maintenance
- Cookie validation CVE (#106). Patches GHSA-pxg6-pf52-xh8x: the vulnerable
cookie@0.6.0was pinned transitively by@sveltejs/kit(a dev dependency). An npmoverridesentry forcescookieto^0.7.0and the lockfile now resolves the single top-levelcookieto the patched 0.7.2. The 0.7.x line only tightensname/path/domainvalidation, so it is API-compatible with SvelteKit's usage. - Over-engineering audit cuts (#98). Applies verified safe cuts from a repo-wide audit — dead code deleted, copy-pasted helpers deduped, unused dependencies dropped — for a net −134 lines and one fewer devDependency, with no intended behavior changes. Includes deleting the never-imported
AuthProviderprotocol, folding two env-flag bootstrap seeders into one helper, collapsing five per-route toast blocks into a shared$lib/toast.svelte.ts, and dropping the unused@sveltejs/adapter-autodevDependency. - Standalone test subsets (#104).
init_db()now runs up front intests/conftest.py, so a test subset likepytest tests/test_oauth.pycreates the schema on its own instead of depending on an earlier test's app startup.
Upgrade notes
- Nothing changes until you opt in. Idle shutdown defaults to off (
idle_timeout_s= 0), the REST/OpenAPI surface and per-server tool disabling are per-server opt-ins, and the multi-user features are inert until an admin creates a second user. Existing control tokens resolve as full admins and existing servers become admin-owned. - Forward-only SQLite migrations. New columns (
idle_timeout_s,owner_id,disabled_tools) are added via the existing forward-onlyADD COLUMNpath and are nullable or defaulted, so legacy rows read sensibly. - No new environment variables. The new knobs are runtime settings managed in the UI/API, not
MCPE_env vars.
Full Changelog: v1.2.3...v1.3.0
License
MIT © pacnpal