Releases: pacnpal/mcpelevator
Release list
v1.6.0 - Usage stats: what's being called, and what never is
Usage stats: what's being called, and what never is
You could always rename a tool or rewrite its description, but there was no way to tell
whether the change helped, or whether anything had ever called that tool to begin with.
1.6.0 counts every call the data plane serves and reports it per server, per tool, and
across the whole instance. The number that matters most is usually zero. A tool sitting
at 0 / never is the one worth renaming.
Per-server, per-tool and instance-wide usage stats (#127, closes #126)
Counting happens where a call is actually served, so all three exposed surfaces land in
the same counters:
| Surface | Tool identified by |
|---|---|
/s/<slug>/mcp |
the JSON-RPC body (tools/call → params.name) |
POST /s/<slug>/rest/<tool> |
the path, and only when the bridge serving the request really exposes REST |
/g/<name>/mcp |
the hub's <slug>_<tool> namespace, credited to the member that owns the tool, and only to members the hub is actually serving |
One rulebook (backend/app/usage/attribution.py) decides what counts as a tool call, so
the three surfaces can't drift apart.
Non-tool traffic is counted separately instead of being thrown away. initialize,
tools/list and the SSE GET get their own bucket. That split is the useful part. A
server with connections but zero tool calls means clients are showing up and the model
is choosing not to call anything, which is exactly when a better name or description is
worth trying. A server with nothing at all is a different problem with a different fix.
Traffic that never reached a bridge is never counted. Unknown slug, refused auth,
nothing running, a POST off the group's /mcp endpoint, a REST path on a bridge with no
REST surface. That's the same rule idle bookkeeping already follows. The dashboard
playground doesn't count either, since the panels report what clients did, not what you
did while testing.
How it's stored
Counters accumulate in memory and a background task folds them into
(server, tool, UTC hour) buckets every few seconds. The data plane pays no database
write per request, and usage bookkeeping can never fail a request it only observes. A
hard crash loses at most one flush interval, which is acceptable for statistics and the
reason this is not an audit log.
A bucket holds a server id, a tool name, an hour, a count, and when that count was last
written. The "last call" the panels show is that flush time, not the exact moment of the
call. Arguments and results are never stored.
Tool names come from the client, so cardinality is capped at three layers: the parsed
body by size and element count, the stored name by length (applied after the group
namespace is stripped, so a long slug can't cost a real call its row), and the pending
map by a ceiling that refuses new keys while established ones keep counting. Past any of
those caps, calls pool into one (other tools) row rather than getting dropped, so the
counters stay bounded and a caller still can't make its traffic invisible. Tools a
running server exposes are exempt from the cap.
Retention is the new usage_retention_days setting, default 30, 0 to keep forever.
The same background task prunes hourly. A requested window is clamped to retention, so
asking for a year on 30-day retention returns 30 days instead of 335 days of zeroes that
would read as genuine quiet, and both dashboards say so when a range gets shortened.
Buckets are dropped with their server, and any orphan left behind by a delete/flush race
is swept. Reasoning in docs/adr/0003-usage-counters-at-the-serving-edge.md.
The screens
/usage is the instance-wide dashboard. Stat tiles for tool calls, other requests,
servers with traffic, and tools called. Calls over time as bars, an area line, or split
by server as small multiples on one shared scale. An activity grid bucketing weekday by
hour into your own timezone, with the daily axis labels left in UTC because a daily
bucket spans a UTC day rather than sitting on your timeline. Breakdown tabs for tools
and servers, with search, four sorts, a used-only filter, and either a table or
proportional bars. Sorting by least calls is the fast way to the tool worth renaming.
Every row links to its server.
Each server's detail page carries the same panel scoped to that one server, so a
never-called tool sits at 0 / never right beside the rename editor. Rows key off the
name the bridge is currently serving, so a rename staged in the editor above doesn't
relabel history before you hit Apply.
Charts are LayerChart, the Svelte 5 charting library that
shadcn-svelte's own charts are built on, so scales, axes, tick formatting, the hover
tooltip and resize come from the ecosystem instead of hand-rolled geometry. It reads its
colours from CSS variables, so app.css points those at this app's tokens and the charts
inherit the existing zinc-and-emerald design rather than importing a second one. The
activity punchcard and the proportional row bars stay plain DOM, where a charting library
would add nothing.
API and settings
GET /api/usage?days=7
GET /api/servers/<id>/usage?days=7
Both go through the same visibility policy as every other route, so a member's totals sum
over the servers they own and never the whole box. Both re-authorize after the pre-read
flush and are served no-store. Instance rollups are SQL GROUP BY aggregates rather
than a Python fold over raw buckets, and both endpoints share one window helper, so "the
last 7 days" can't mean two different things on two pages.
Retention lives in Settings → Security → Usage retention.
Versioned GitHub footer (#128)
The running version moved out of the health indicator and into a footer next to a GitHub
link. The footer version refreshes off the health poll, so an SPA tab left open across an
upgrade picks up the new number as soon as the control plane comes back, instead of
showing the version it loaded with.
Connecting from a stdio-only MCP client
The README now covers clients that can only launch a local command, using
mcp-remote as the bridge to a /s/<slug>/mcp or
/g/<name>/mcp URL. It includes the bearer-token form that keeps the space-containing
header value in env to avoid argument-quoting bugs in Windows clients, and the
standalone mcp-remote-client command for testing an endpoint with the host client out
of the way.
Upgrade notes
- No migration step.
usage_bucketis a new table, created on startup by the
existingcreate_allpath. Nothing to run by hand. - No new environment variables. Retention is a runtime setting in the UI, default 30
days. - Counting starts at upgrade. There is no history to backfill, so the panels read
empty until traffic arrives. A tool showing0 / neveron day one doesn't mean
anything yet. - No bridge restarts. Usage is not part of the launch spec or
config_hash, so
nothing bounces on upgrade.
Full Changelog: v1.5.2...v1.6.0
License
MIT © pacnpal
v1.5.2 - Schema dialect normalization for proxied tools
Schema dialect normalization for proxied tools
The MCP TypeScript SDK hardcodes "$schema": "http://json-schema.org/draft-07/schema#"
into every generated tool's inputSchema/outputSchema, with no config option for the
upstream server to change it. A strict client whose validator only accepts 2020-12 (what
the MCP spec itself targets) refuses every tool such a server advertises through
mcpelevator — even though the schema itself usually has no draft-07-specific keywords,
only the wrong dialect pointer. 1.5.2 adds an opt-in per-server toggle that rewrites the
dialect in place when it's safe to, and separately fixes a connection-pool exhaustion bug
that could take the whole control plane offline, login included.
Normalize schema dialect toggle (#124, closes #123)
New per-server toggle, normalize_schema_dialect (Server form → Exposure → "Normalize
schema dialect"). When on, the bridge rewrites $schema from draft-07 to 2020-12 on a
proxied tool's parameters/output schema — nothing else about the schema moves, and a
schema declaring no $schema has none injected.
It's applied inside _ToolTransform._scrub (app/bridge/host.py), the same place that
already strips the reserved identity key — so it reaches every surface that resolves
tools through this proxy (MCP tools/list, REST/OpenAPI, the group hub), composes with
hide/rename policy, and is part of config_hash (toggling it restarts the bridge to
re-apply). Plumbed end-to-end: Server.normalize_schema_dialect (forward-migrated
column) → every runner's ProcessSpec → the bridge spec JSON → _tool_transform →
ServerCreate/ServerUpdate/ServerDetail → the create/edit form and server-detail page.
The compatibility guard
The rewrite is refused whenever relabeling would mean something other than renaming the
dialect. _has_incompatible_draft07_construct dispatches over four hazard families,
checked recursively but only through positions the JSON Schema grammar declares as
sub-schemas:
- Removed or changed constructs — tuple-form
items,dependencies(except beside a
$ref, where draft-07 was already ignoring it as a sibling and no 2020-12 vocabulary
evaluates it either, so nothing is lost by relabeling). - Dormant assertions — anything added after draft-07 that asserts
(unevaluatedProperties,dependentRequired,prefixItems,$dynamicRef, …).
draft-07 ignores what it doesn't recognize; the relabel switches them on.minContains/
maxContainsare conditional: inert without a siblingcontains, so those normalize. - Reference resolution —
$anchor/$dynamicAnchor/$vocabulary;$idin a
subschema, beside a$ref, or with a non-empty fragment; a$refwhose target sits
somewhere the walk doesn't independently inspect (only#/$defs/…and
#/definitions/…targets are trusted, since those containers are walked
unconditionally). - Non-portable values — a keyword 2020-12 knows and draft-07 doesn't, carrying a value
2020-12's meta-schema rejects ({"contentSchema": 7},{"$defs": {"T": 7}},
{"deprecated": "yes"},{"$recursiveAnchor": true}, …). draft-07 never inspects an
unknown keyword's value, so the wrong shape is free until the relabel makes the name
meaningful.
Every shape was verified against the published 2020-12 meta-schemas rather than assumed.
Two deliberate scope calls, documented at their definitions:
formatis not guarded. 2020-12's default meta-schema requires the
format-annotationvocabulary, so a client that asserted formats under draft-07 may
stop. It's excluded because draft-07 never guaranteed assertion (its own spec makes
it optional and opt-outable), and because guarding it would refuse most real
TypeScript-SDK tool schemas — the population this toggle exists for.$reftargets are not resolved. A full JSON Pointer resolver would mean cycle
handling, escaping, and external documents in thetools/listpath, for a shape
generated schemas don't emit.
Two availability bugs were also closed here: a malformed non-string $schema raised
TypeError, and a deeply nested schema (~500+ levels) raised RecursionError — either
would have taken tools/list down for the whole server once the toggle was on. Both now
fail to the safe answer (leave the schema under draft-07) instead of raising.
Fixes and hardening
- Stopped long-lived requests from exhausting the DB connection pool (#122). Every
/apirequest could 500 withsqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached— including login, since the failure surfaced at the allowlist
middleware, the first DB touch of any control-plane request. The real cause: a request
holds its session for its whole lifetime, and the SSE log stream
(GET /api/servers/{id}/logs) holds one for as long as a viewer leaves the tab open. A
handful of open log views (or concurrent playground tool calls, which can run up to
300s) could park every one of the pool's 15 connections and block the next request for
the full 30s timeout. Fixed by dropping the connection pool for the SQLite engine
(opening a local SQLite connection costs microseconds; the pool bought nothing and
imposed a hard ceiling) and by releasing the log stream's session before it starts
streaming, since the stream re-validates visibility on its own short-lived sessions
afterward. No API, schema, or dependency changes.
Upgrade notes
- No migration and no new environment variables.
normalize_schema_dialectarrives
via the existing forward-onlyADD COLUMNpath and is nullable; a pre-column row reads
as off and hashes as off, so no bridge restarts on upgrade. - Nothing changes until you opt in. The toggle is off by default; existing servers
keep advertising their upstream's declared dialect exactly as before. - The DB pool fix is transparent — no config to set, no behavior change beyond no
longer exhausting under sustained SSE/playground load.
Full Changelog: v1.5.1...v1.5.2
License
MIT © pacnpal
v1.5.1 - OAuth client identity: probed CIMD with DCR fallback, and explicit modes
What's Changed from 1.5.0
- fix(oauth): make a failed sign-in say why (WARNING-level reasons + reason code on the redirect) by @pacnpal in #120
- fix(oauth): send the client id once, not once per channel by @pacnpal in #121
Full Changelog: v1.5.0...v1.5.1
1.5.0 Notes:
OAuth client identity: probed CIMD with DCR fallback, and explicit modes
Signing in to a remote OAuth server offered the instance's CIMD URL-based client id
whenever the base was https — but an https base doesn't prove the client-metadata
document is publicly fetchable. An instance behind an auth-gating proxy (Cloudflare
Access, an oauth2-proxy, HTTP basic auth) serves the document fine to the operator's
signed-in browser while answering the provider's unauthenticated server-side fetch
with a 401 — and the failure only surfaced after the browser had been sent away, as
the provider's opaque "Client metadata is temporarily unavailable" page. 1.5.0 makes
the sign-in verify reachability first and fall back to Dynamic Client Registration
when the document is gated, and adds an explicit client-identity choice — instance-wide
and per-server — for deployments where the probe can't know better.
CIMD self-probe with automatic DCR fallback (#119)
Before offering the URL-based client id, begin_authorization now fetches its own
/api/oauth/client-metadata.json exactly the way the authorization server would:
unauthenticated, no cookies, no redirects.
- Only a definitive bad answer withholds the offer — a stable gate shape (401/403,
a login redirect, 404) or a 200 whose body isn't this instance's document. The flow
then falls back to DCR silently, so a gated instance signs in with no infra
change; a warning log names the likely gate and the remedy. - Inconclusive evidence keeps the offer. A connection failure proves nothing —
plenty of deployments can't hairpin their own public hostname from inside the
container while the provider reaches it fine — and neither does a transient status
(408/429/5xx). A deadline that expires before response headers classifies as a
connection failure; one that expires after them means the document can't be
delivered in a provider-compatible budget, which withholds. - Bounded hard. The probe is wall-clock-capped (
asyncio.wait_for, 8s) and reads
raw, identity-encoded bytes capped at 64 KiB — no decompression ever runs, so a
stalling or bomb-shaped response can't hold/oauth/authorizeopen or balloon
memory. - Cached. Conclusive verdicts are kept per URL for 5 minutes — every server on an
instance shares one metadata URL, so consecutive sign-ins don't re-pay the
round-trip. Inconclusive answers are never cached.
Explicit client-identity modes, instance-wide and per-server (#119)
The probe errs on the side of offering, and some deployments know better in either
direction — so the choice can now be pinned.
- Settings → Upstream OAuth client identity (
upstream_oauth_client_mode):
auto(the probed default),CIMD(always offer the URL client id, probe-free —
for documents that are public even though the container can't confirm it), orDCR
(never offer it). - Per server: a new
oauth_client_modecolumn (inherit|auto|cimd|
dcr), edited via a Client identity select in the server form's client-credentials
section.inherit(the default) follows the Settings choice; a static client id
bypasses all of it. - A pinned mode actually takes effect. Forced
cimdignores a stored DCR
registration (the SDK only consults the URL client id when no client is seeded);
forceddcrnever offers the URL, seeded or not. - Like
idle_timeout_s, the column is a sign-in concern only: outsideconfig_hash
(changing it never bounces a bridge) and outside the OAuth signature (never wipes
working tokens).
Fixes and hardening
- A persisted CIMD identity is no longer reused as a registered client. A prior
CIMD sign-in stores client info whoseclient_idis the metadata URL; seeding it
bypassed the SDK's CIMD/DCR decision and would have resent a gated client id on
every re-authentication. It's excluded from reuse — there's no registration quota to
protect, the SDK recreates it locally for free — and stale stored identities
self-heal on the next sign-in. - Second-click superseding now covers the whole begin. The flow registers in
_PENDINGbefore its first await, so an Authenticate click (or a delete/config-edit)
landing while a flow is parked in the probe cancels it; a superseded flow refuses to
start driving (409) instead of racing the winner's token promotion.
Upgrade notes
- No migration and no new environment variables.
oauth_client_modearrives via
the existing forward-onlyADD COLUMNpath; pre-column rows read asinherit, and
nothing entersconfig_hash, so no bridge restarts on upgrade. - Gated instances start working, everything else is unchanged. Under the default
auto, an instance whose metadata document is publicly fetchable keeps using CIMD
exactly as before; one behind an access gate now completes sign-in via DCR instead
of failing at the provider. To use CIMD behind a gate, exempt
/api/oauth/client-metadata.jsonfrom it (the document is public by design and
carries no secrets) or pinCIMDexplicitly.
Full Changelog: v1.4.1...v1.5.0
License
MIT © pacnpal
v1.5.0 - OAuth client identity: probed CIMD with DCR fallback, and explicit modes
OAuth client identity: probed CIMD with DCR fallback, and explicit modes
Signing in to a remote OAuth server offered the instance's CIMD URL-based client id
whenever the base was https — but an https base doesn't prove the client-metadata
document is publicly fetchable. An instance behind an auth-gating proxy (Cloudflare
Access, an oauth2-proxy, HTTP basic auth) serves the document fine to the operator's
signed-in browser while answering the provider's unauthenticated server-side fetch
with a 401 — and the failure only surfaced after the browser had been sent away, as
the provider's opaque "Client metadata is temporarily unavailable" page. 1.5.0 makes
the sign-in verify reachability first and fall back to Dynamic Client Registration
when the document is gated, and adds an explicit client-identity choice — instance-wide
and per-server — for deployments where the probe can't know better.
CIMD self-probe with automatic DCR fallback (#119)
Before offering the URL-based client id, begin_authorization now fetches its own
/api/oauth/client-metadata.json exactly the way the authorization server would:
unauthenticated, no cookies, no redirects.
- Only a definitive bad answer withholds the offer — a stable gate shape (401/403,
a login redirect, 404) or a 200 whose body isn't this instance's document. The flow
then falls back to DCR silently, so a gated instance signs in with no infra
change; a warning log names the likely gate and the remedy. - Inconclusive evidence keeps the offer. A connection failure proves nothing —
plenty of deployments can't hairpin their own public hostname from inside the
container while the provider reaches it fine — and neither does a transient status
(408/429/5xx). A deadline that expires before response headers classifies as a
connection failure; one that expires after them means the document can't be
delivered in a provider-compatible budget, which withholds. - Bounded hard. The probe is wall-clock-capped (
asyncio.wait_for, 8s) and reads
raw, identity-encoded bytes capped at 64 KiB — no decompression ever runs, so a
stalling or bomb-shaped response can't hold/oauth/authorizeopen or balloon
memory. - Cached. Conclusive verdicts are kept per URL for 5 minutes — every server on an
instance shares one metadata URL, so consecutive sign-ins don't re-pay the
round-trip. Inconclusive answers are never cached.
Explicit client-identity modes, instance-wide and per-server (#119)
The probe errs on the side of offering, and some deployments know better in either
direction — so the choice can now be pinned.
- Settings → Upstream OAuth client identity (
upstream_oauth_client_mode):
auto(the probed default),CIMD(always offer the URL client id, probe-free —
for documents that are public even though the container can't confirm it), orDCR
(never offer it). - Per server: a new
oauth_client_modecolumn (inherit|auto|cimd|
dcr), edited via a Client identity select in the server form's client-credentials
section.inherit(the default) follows the Settings choice; a static client id
bypasses all of it. - A pinned mode actually takes effect. Forced
cimdignores a stored DCR
registration (the SDK only consults the URL client id when no client is seeded);
forceddcrnever offers the URL, seeded or not. - Like
idle_timeout_s, the column is a sign-in concern only: outsideconfig_hash
(changing it never bounces a bridge) and outside the OAuth signature (never wipes
working tokens).
Fixes and hardening
- A persisted CIMD identity is no longer reused as a registered client. A prior
CIMD sign-in stores client info whoseclient_idis the metadata URL; seeding it
bypassed the SDK's CIMD/DCR decision and would have resent a gated client id on
every re-authentication. It's excluded from reuse — there's no registration quota to
protect, the SDK recreates it locally for free — and stale stored identities
self-heal on the next sign-in. - Second-click superseding now covers the whole begin. The flow registers in
_PENDINGbefore its first await, so an Authenticate click (or a delete/config-edit)
landing while a flow is parked in the probe cancels it; a superseded flow refuses to
start driving (409) instead of racing the winner's token promotion.
Upgrade notes
- No migration and no new environment variables.
oauth_client_modearrives via
the existing forward-onlyADD COLUMNpath; pre-column rows read asinherit, and
nothing entersconfig_hash, so no bridge restarts on upgrade. - Gated instances start working, everything else is unchanged. Under the default
auto, an instance whose metadata document is publicly fetchable keeps using CIMD
exactly as before; one behind an access gate now completes sign-in via DCR instead
of failing at the provider. To use CIMD behind a gate, exempt
/api/oauth/client-metadata.jsonfrom it (the document is public by design and
carries no secrets) or pinCIMDexplicitly.
Full Changelog: v1.4.1...v1.5.0
License
MIT © pacnpal
v1.4.1 - Failure hints and the mcp<2 compatibility pin
Failure hints and the mcp<2 compatibility pin
A terminally failed activation usually surfaces as something generic — "readiness
timed out", "bridge exited rc=1" — while the real cause sits in the log backlog. 1.4.1
teaches the supervisor to recognize known failure signatures and say what to do about
them, and ships the first fix it recommends: a per-server toggle that holds a uvx
server to the Python mcp 1.x SDK line.
Also in this release: the open Trivy code-scanning alerts are cleared, cryptography
is bumped, and the screenshot pipeline is repaired.
Failure-signature hints (#115)
New app/supervisor/hints.py maps log signatures to actionable operator
recommendations. The unit appends the first match to last_error, so the hint reaches
the API and the UI through the existing field with no schema change.
- Only the final attempt's lines count as evidence. An earlier retry may have
failed on the signature while the terminal failure was something else; the hint has
to describe what actually killed the activation. - Phase-aware. The setup script runs in its own shell with the child env, where a
launch-argv remedy can't reach — so setup-phase and launch-phase log sections are
scored separately, and a setup script that merely prints a traceback can't put
launch advice on an unrelated later failure. - Signatures carry real evidence. The first entry matches the mcp 2.0 SDK import
break by known removed-symbol pairs, not anyImportErrorundermcp.*— a loose
pattern would recommend a downgrade to servers where a downgrade can't help. - Recommendations are only ever actions the operator can take on that server. The
hint sees the runner, the launch shape and the current pin state, so it never
suggests a toggle that's already on, or one the service would refuse to save; a
dockerrow is told to use or rebuild an image instead, since only the image selects
what's installed inside the container.
"Pin mcp SDK < 2" toggle for uvx servers (#115)
uvx re-resolves a package's own (often unbounded) mcp>=… constraint on every cold
start, so a server that predates the SDK's 2.x line dies at import. The new per-server
pin_mcp1 toggle launches it as uvx --with "mcp<2" … until upstream ships a fix.
- The pin lives in the launch argv, not in your stored arguments — it's injected at
spec-build time. The UI mirrors the same placement rule, so the form preview and the
server-detail Configuration card show the command that actually runs (with the<
quoted the way a shell needs it). - Placement is only ever applied where it's certain.
uvxtakes--withleading;
auvlauncher takes it only after a leadingtool run/runsubcommand. For any
other shape the save is refused with the workable alternatives named, rather than
silently launching the original unpinned argv — a toggle that quietly no-ops sends
the operator in circles. - uvx-only, and forced off elsewhere, including on conversion away from the uvx
runner, so a runner change can't carry a pin that no longer means anything. - Part of the launch spec (
config_hash), so toggling it restarts the bridge.
A pre-column row (NULL) hashes identically to off, so upgrading bounces nothing. - Imported configs launched via
uvx.exe/uv.exenow classify as the uvx runner, so
a Claude-Desktop-on-Windows config gets the toggle too.
API: pin_mcp1 on create, PATCH /api/servers/<id>, and the detail response;
carried through clone. UI: a toggle in the server form's uvx section (Edit server).
Fixes and maintenance
- Cleared the open Trivy code-scanning alerts (#118). npm is bumped to 11.19.0, and
two dependencies bundled inside npm that no published release has re-vendored yet
(checked 11.19.0 and 12.0.2) are overlaid with their patched versions in the image:
brace-expansion5.0.9 (CVE-2026-14257, CVE-2026-69152) andip-address10.3.1
(CVE-2026-69192) — semver-patch swaps with identical dependency sets, so the
replacement is drop-in and folds into the next npm bump. Two remaining alerts are for
packages vendored insidepipin thepython:3.14-slimbase image (msgpack
GHSA-6v7p-g79w-8964,setuptoolsCVE-2025-47273); neither is reachable from the
control plane, and no published pip vendors a fixed version, so they're recorded in a
new.trivyignore.yamlwith written justifications and an expiry of 2026-10-13
that forces re-triage rather than letting the suppression become permanent. cryptography49.0.0 → 50.0.0 (#116), a transitive dependency, via the lockfile.- Screenshot pipeline repaired (#114, #117). The uvx Time demo seed pins
mcp<2so
it actually starts, and the capture waits for the seeded HF.co server rather than the
removed Upstream Weather entry. UI screenshots indocs/screenshots/are refreshed.
Upgrade notes
- No migration and no new environment variables.
pin_mcp1is added via the
existing forward-onlyADD COLUMNpath and is nullable; NULL reads as off and hashes
as off, so no bridge restarts on upgrade. - Nothing changes until you opt in. The pin is off by default and applies only to
uvxservers; hints are additive text appended tolast_erroron a terminal
failure. - The pin is a stopgap, not a fix. It holds the 1.x line so a server keeps working
while its maintainer catches up with the mcp 2.x SDK; drop the toggle once upstream
ships a compatible release.
Full Changelog: v1.4.0...v1.4.1
License
MIT © pacnpal
v1.4.0 - Tool names and descriptions
Per-tool name and description overrides
Some MCP servers ship tool names and descriptions that models handle badly. On an
open-source server you can rebuild it; on a closed-source or paid HTTP endpoint you
can't. 1.4.0 lets an operator relabel any tool in place — rename it, rewrite its
description, or both — applied to every surface the server exposes, with no upstream
rebuild.
Existing installs are unaffected until you use it: the new column is nullable, so
servers with no policy serve their tools exactly as the upstream declares them.
Per-tool overrides (#113, closes #112)
A new tool_overrides map on the Server row — upstream tool name →
{name?, description?} — with both fields optional and independent.
- A renamed tool answers to its new name only, exactly as if the upstream had been
rebuilt. No alias and no two names for one tool, so clients pointed at the old name
must be updated (called out in the UI and README). - Keyed by the upstream name, which is the stable identity: an override survives
being renamed again, and a rename never orphans its own description. The bridge
stamps a renamed tool's pre-rename name into_meta, the discovery probe lifts it to
upstream_name, and the UI keys rows off that rather than reversing the rename map —
an exposed name isn't unique. - The labels change and nothing else. Schema,
_meta,icons,execution(MCP
task support) and dispatch remain the upstream's, including open-ended input schemas
and the argument forwarding that goes with them. - Hiding wins over renaming, and hiding also frees a tool's name so another tool
may be renamed onto it. - A rename never takes a name that's already answered to. If a live tool holds the
target the rename goes inert and both tools stay reachable under their own names; the
rest of the policy still applies. Symmetrically, a rename whose source has vanished
upstream doesn't reserve its target. - Every surface at once: MCP
tools/listandtools/call, the REST routes (a
renamed tool is served at its new path segment and appears that way in the generated
openapi.json), and the group hub.
One mechanism, not two
Rather than add a second per-tool rewriting path beside the disabled_tools
middleware from #105, both controls are now applied by a single FastMCP
ToolTransform in the bridge, which deletes the hand-rolled
DisabledToolsMiddleware. Hiding is unchanged in behaviour: a disabled tool drops out
of tools/list and is refused on call with the identical Unknown tool error.
The transform copies the upstream tool with new labels instead of rebuilding it —
a rebuild drops icons and execution, replaces rather than merges _meta, and
regenerates the input schema and the argument-forwarding closure from it. It also owns
name resolution, so list_tools and get_tool can never disagree about which tools
exist, and strips the reserved identity key from every upstream tool so an upstream
can't forge an identity the UI would key policy off.
Persistence and validation
Overrides are part of the launch spec (config_hash), so a change restarts the bridge
and persists as ordinary desired state. Values are trimmed, blank fields and no-op
entries are dropped, and keys are sorted, so re-submitting the same policy in a
different order doesn't bounce the bridge.
Rename targets are validated (≤64 chars of [A-Za-z0-9_.-], bare dot-segments
refused) because the name has to survive as a REST path segment and a model-facing
function name. Colliding renames and rename chains are refused at write time, while a
rename involving a hidden tool is allowed because the bridge applies exactly that.
Unknown override fields are rejected at the HTTP boundary, so a typo can't read as a
saved override that does nothing. Sizes are bounded per description and across the
whole map, and both write paths refuse a config too large to hand the bridge in one
environment variable — checked before committing, so an accepted write can never take
a running endpoint offline.
API and UI
PATCH /api/servers/<id>
{"tool_overrides": {"do_thing": {"name": "run_report", "description": "Runs the report."}}}
Send {} to restore every tool's upstream labels. Both fields are optional on the way
in and on the way out: an override of one field is stored and echoed carrying only that
field. Accepted on create, echoed on GET, and carried through clone.
In the UI, each tool row on the server detail page gains Name and Description
fields, staged alongside the existing enable/disable switches and saved by the same
single Apply — one PATCH, one bridge restart for the whole batch. A name collision
is flagged as an early signal but doesn't block the write, since the bridge decides
name ownership against the live tool list. A row for a name that isn't in the live list
— a hidden tool or a stale override key — stays editable but offers no playground.
Fixes and maintenance
- Tool names are matched exactly.
disabled_toolsandtool_overrideskeys are no
longer trimmed on write. The key is the upstream tool's identity, so rewriting it
meant a policy could be saved against a tool that doesn't exist; a tool whose real
name carries whitespace can now be hidden or relabelled. - Dependency bumps (#111).
postcss8.5.16 → 8.5.25 andundici7.28.0 → 7.29.0
in the frontend dev toolchain.
Upgrade notes
- No migration.
tool_overridesis added via the existing forward-only
ADD COLUMNpath and is nullable, so legacy rows read as{}— no behaviour change
for servers that don't use it. - No new environment variables. Overrides are per-server config managed in the
UI/API. - Renaming is client-visible. A renamed tool stops answering to its upstream name,
so any client, prompt, or script referencing the old name needs updating. - Exported
.mcpbbundles are unaffected — per-tool policy is a property of this
instance's proxying, not of the packaged server.
Full Changelog: v1.3.1...v1.4.0
License
MIT © pacnpal
v1.3.1 - Add mcpb generation
What's Changed
- ci(trivy): suppress the linux-libc-dev kernel-header CVE class by @pacnpal in #108
- feat: downloadable .mcpb bundles for local stdio servers by @pacnpal in #109
Full Changelog: v1.3.0...v1.3.1
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 pinne...
v1.2.3 - Docker per-server run options
What's Changed
Full Changelog: v1.2.2...v1.2.3
v1.2.2 - OAuth consent prompt + test polish
What's Changed
- Send
prompt=consentforoffline_access; review polish by @pacnpal in #96 - Add httpx2 for starlette TestClient by @pacnpal in #95
Full Changelog: v1.2.1...v1.2.2
v1.2.1
What's Changed
- Handle upstream registration rate limits cleanly by @pacnpal in #92
- Request
offline_accessby default for upstream OAuth by @pacnpal in #93 - Give bridge-dependent supervisor unit tests a real start timeout by @pacnpal in #94
Full Changelog: v1.2.0...v1.2.1
v1.2.0 - Upstream Oauth, Unified MCP grouping, Per server setup
What's Changed
- Suppress unactionable Go stdlib CVEs in the upstream docker CLI binary by @pacnpal in #75
- Add upstream OAuth authentication for remote MCP servers by @pacnpal in #76
- Add a scheduled/on-demand Trivy scan so suppressions apply between releases by @pacnpal in #78
- Derive config_hash with scrypt instead of a truncated SHA-256 by @pacnpal in #79
- Allow manual dispatch of the Docker build-and-push workflow by @pacnpal in #77
- Fix OAuth authorize URLs with pre-existing queries; run sign-in in a popup by @pacnpal in #80
- Unified MCP endpoint with server picker (#82) + build toolchain in the image (#81) by @pacnpal in #83
- Replace the /s/all aggregate with a /g group registry by @pacnpal in #84
- feat(auth):
oauthprovider — RFC 9728 resource server for an external authorization server by @Doonut in #85 - Add per-server setup scripts and startup recovery by @pacnpal in #88
- Fix Docker workflow concurrency key by @pacnpal in #89
- Block shell-wrapped Docker command bypass by @pacnpal in #87
- chore(deps): bump mcp from 1.28.0 to 1.28.1 in /backend in the uv group across 1 directory by @dependabot[bot] in #91
New Contributors
Full Changelog: v1.1.0...v1.2.0