Skip to content

Releases: pacnpal/mcpelevator

v1.6.0 - Usage stats: what's being called, and what never is

Choose a tag to compare

@pacnpal pacnpal released this 07 Sep 14:07

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/callparams.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_bucket is a new table, created on startup by the
    existing create_all path. 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 showing 0 / never on 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

Choose a tag to compare

@pacnpal pacnpal released this 20 Aug 19:13
9ec188a

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/
    maxContains are conditional: inert without a sibling contains, so those normalize.
  • Reference resolution$anchor/$dynamicAnchor/$vocabulary; $id in a
    subschema, beside a $ref, or with a non-empty fragment; a $ref whose 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:

  • format is not guarded. 2020-12's default meta-schema requires the
    format-annotation vocabulary, 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.
  • $ref targets are not resolved. A full JSON Pointer resolver would mean cycle
    handling, escaping, and external documents in the tools/list path, 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
    /api request could 500 with sqlalchemy.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_dialect arrives
    via the existing forward-only ADD COLUMN path 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

Choose a tag to compare

@pacnpal pacnpal released this 17 Aug 22:41

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/authorize open 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), or DCR
    (never offer it).
  • Per server: a new oauth_client_mode column (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 cimd ignores a stored DCR
    registration (the SDK only consults the URL client id when no client is seeded);
    forced dcr never offers the URL, seeded or not.
  • Like idle_timeout_s, the column is a sign-in concern only: outside config_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 whose client_id is 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
    _PENDING before 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_mode arrives via
    the existing forward-only ADD COLUMN path; pre-column rows read as inherit, and
    nothing enters config_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.json from it (the document is public by design and
    carries no secrets) or pin CIMD explicitly.

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

Choose a tag to compare

@pacnpal pacnpal released this 17 Aug 14:42
f48bff1

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/authorize open 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), or DCR
    (never offer it).
  • Per server: a new oauth_client_mode column (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 cimd ignores a stored DCR
    registration (the SDK only consults the URL client id when no client is seeded);
    forced dcr never offers the URL, seeded or not.
  • Like idle_timeout_s, the column is a sign-in concern only: outside config_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 whose client_id is 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
    _PENDING before 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_mode arrives via
    the existing forward-only ADD COLUMN path; pre-column rows read as inherit, and
    nothing enters config_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.json from it (the document is public by design and
    carries no secrets) or pin CIMD explicitly.

Full Changelog: v1.4.1...v1.5.0

License

MIT © pacnpal

v1.4.1 - Failure hints and the mcp<2 compatibility pin

Choose a tag to compare

@pacnpal pacnpal released this 09 Aug 13:01

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 any ImportError under mcp.* — 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
    docker row 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. uvx takes --with leading;
    a uv launcher takes it only after a leading tool run / run subcommand. 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.exe now 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-expansion 5.0.9 (CVE-2026-14257, CVE-2026-69152) and ip-address 10.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 inside pip in the python:3.14-slim base image (msgpack
    GHSA-6v7p-g79w-8964, setuptools CVE-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.yaml with written justifications and an expiry of 2026-10-13
    that forces re-triage rather than letting the suppression become permanent.
  • cryptography 49.0.0 → 50.0.0 (#116), a transitive dependency, via the lockfile.
  • Screenshot pipeline repaired (#114, #117). The uvx Time demo seed pins mcp<2 so
    it actually starts, and the capture waits for the seeded HF.co server rather than the
    removed Upstream Weather entry. UI screenshots in docs/screenshots/ are refreshed.

Upgrade notes

  • No migration and no new environment variables. pin_mcp1 is added via the
    existing forward-only ADD COLUMN path 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
    uvx servers; hints are additive text appended to last_error on 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

Choose a tag to compare

@pacnpal pacnpal released this 09 Aug 00:06
3f4e45c

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/list and tools/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_tools and tool_overrides keys 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). postcss 8.5.16 → 8.5.25 and undici 7.28.0 → 7.29.0
    in the frontend dev toolchain.

Upgrade notes

  • No migration. tool_overrides is added via the existing forward-only
    ADD COLUMN path 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 .mcpb bundles 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

Choose a tag to compare

@pacnpal pacnpal released this 02 Aug 13:50
ddcc151

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

Choose a tag to compare

@pacnpal pacnpal released this 21 Jul 12:21

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

Read more

v1.2.3 - Docker per-server run options

Choose a tag to compare

@pacnpal pacnpal released this 18 Jul 23:36

What's Changed

  • feat(docker): per-server run options placed before the image by @pacnpal in #97

Full Changelog: v1.2.2...v1.2.3

v1.2.2 - OAuth consent prompt + test polish

Choose a tag to compare

@pacnpal pacnpal released this 18 Jul 16:58
4b1294e

What's Changed

  • Send prompt=consent for offline_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_access by 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): oauth provider — 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