Skip to content

v1.3.0 - multi-user control plane, tool playground, and idle shutdown

Choose a tag to compare

@pacnpal pacnpal released this 21 Jul 12:21
· 36 commits to main since this release

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_TOKEN is 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 (control scope, bound via Token.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 from config_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 — never all, 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_TOKEN lifts 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}/call invokes the tool on the bridge's loopback port over a fresh FastMCP client session. MCP error semantics are mirrored: a tool's own failure is is_error inside a 200; an unreachable bridge is 502, a timeout is 504, and not-running is 409. It is admin-token gated like every /api server 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 an idle_timeout_s runtime setting as the global default. The default is 0 (off), so existing installs keep today's always-running behavior. Like auth_provider, it is excluded from config_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 /s proxy marks activity on every authenticated request and, for an idle server, wakes it and holds the request until readiness (bounded by MCPE_START_TIMEOUT_S, bailing early on terminal failure) instead of returning 503. /g group traffic marks members active but does not wake them.
  • idle is 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 relative servers URL that resolves correctly behind the proxy without the bridge knowing its public slug or base. GET /s/<slug>/rest lists 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_tools list on the Server row drives a FastMCP middleware installed in the bridge, so one implementation covers all three surfaces that resolve tools through the proxy: MCP tools/list (the tools vanish from discovery), the REST/OpenAPI routes, and the group hub (/g/).
  • Hiding also disables: on_list_tools drops the disabled tools from discovery, and on_call_tool refuses a disabled tool with the same NotFoundError an 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 on GET, 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.json advertises 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's client_id is its own URL, redirect_uris shares the same base, and token_endpoint_auth_method is none (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 remote server 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_error now 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.0 was pinned transitively by @sveltejs/kit (a dev dependency). An npm overrides entry forces cookie to ^0.7.0 and the lockfile now resolves the single top-level cookie to the patched 0.7.2. The 0.7.x line only tightens name/path/domain validation, 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 AuthProvider protocol, 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-auto devDependency.
  • Standalone test subsets (#104). init_db() now runs up front in tests/conftest.py, so a test subset like pytest tests/test_oauth.py creates 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-only ADD COLUMN path 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