Repository navigation
v0.10.0 — Isolated MCP children, bulk tool review
Highlights: downstream MCP children are isolated from /data and from each other (TD-052, TD-059), the image ships uv/uvx and Python for uvx-based registry entries (#156), and Tool Review gains bulk approve (FB-033). This release changes the container's run requirements; read BREAKING before upgrading. protocol and contracts wire types are unchanged apart from the additive bulk-approve route.
BREAKING
- Compose requirements (TD-052/TD-059, #158/#159).
docker-compose.ymlin this repo is the reference; a deployment's compose must match it:user: "0:0"(the entrypoint needs root to prepare per-instance homes, then drops to uid 1000)cap_drop: [ALL]andcap_add: [SETUID, SETGID, KILL, CHOWN, SETPCAP]security_opt: [no-new-privileges:true]read_only: true(with the/dataand/tmpbind mounts)deploy.resources.limits.pids(the reference uses 4096), the container-wide process/thread cap that sits above the per-instance cap below- Without the capabilities, MCP children fail closed (SETUID/SETGID/KILL missing) or the container refuses to start (SETPCAP missing); see the TD-052 entry below.
- New environment variables (TD-059):
MCP_AMBASSADOR_CHILD_UID_RANGE(image default20000-20999, 10000 ids at most, must not contain the server uid/gid). Every downstream instance (shared:<mcp>,user:<id>:<mcp>,discovery:<mcp>) gets its own uid/gid and 0700 home under/tmp/mcp-homes/<uid>. Assignments persist in/data/child-uids.json(0600). When the range is full, the least recently used idle uid is reclaimed and its home wiped, so widen the range if the startup log reports 80%+ assigned. The legacy singleMCP_AMBASSADOR_CHILD_UIDstill works when the range is unset; setting both is an error.MCP_AMBASSADOR_CHILD_NPROC(default256,0disables):RLIMIT_NPROCper instance. It counts threads, not only processes, of every process running under that instance's uid. Browser MCPs (Playwright, Puppeteer/Chromium) and some runtimes with large thread pools can exceed 256: raise it (for example1024) if such an MCP fails withEAGAIN/"Resource temporarily unavailable" or Chromium crashes at launch. Keep it well under the containerpidslimit. Requiresprlimit(util-linux, in the image).
- TD-052/TD-059: downstream stdio MCP children no longer run as the server and cannot read
/data. The image defaultsMCP_AMBASSADOR_CHILD_UID_RANGE=20000-20999, so every instance gets its own uid/gid and 0700 home under/tmp/mcp-homes/<uid>(assignments in/data/child-uids.json); the legacy single uid (MCP_AMBASSADOR_CHILD_UID, children share uid 1001 and/tmp/mcp-home) is used only when the range is set empty andCHILD_UIDis set. The container starts as root only to rundocker/entrypoint.sh, then execs the server as uid 1000 holding CAP_SETUID/SETGID/KILL. A compose withoutcap_drop/cap_addstill starts (the image runs the entrypoint as root with Docker's default caps, which include SETPCAP, so the bounding set is still reduced to SETUID/SETGID/KILL), but it is less hardened: the server warns when it runs withoutno-new-privileges, and children get--no-new-privsregardless (see Security). If SETUID/SETGID/KILL are missing entirely, MCP children refuse to start (fail closed); if SETPCAP is missing the container refuses to start. WithMCP_AMBASSADOR_CHILD_UID_RANGEset to empty andMCP_AMBASSADOR_CHILD_UIDunset isolation is off (children run as the server uid with no capabilities, as before 0.10.0) and the server logs a warning at startup. Child HOME/npm/uv caches moved under/tmp/mcp-homes(legacy:/tmp/mcp-home); the old/tmp/.npm-cacheis no longer used and can be deleted. After a rollback, empty the/tmpcache volume again before rolling forward. - The browser/user API (every cookie-authenticated route, incl. login, session, CSRF token and user endpoints) is served on the admin/SPA listener (9443) only. It is no longer registered on the client port (8443); browsers or scripts that called it on 8443 must use 9443. 8443 now serves only the MCP client protocol and the agent endpoint.
server.trust_proxy/MCP_AMBASSADOR_TRUST_PROXYno longer accept hop counts (Fastify 5.12 disables them because they let direct clients spoofX-Forwarded-*). Usetrue,false, or an IP/CIDR list; a bare number in the env var is rejected at startup.
Added
- Tool Review bulk approve (FB-033):
POST /v1/admin/mcps/:mcpId/tool-pins/approvetakes{items: [{pin_id, expected_hash}], apply_to?, reason?, include_flagged?}(1-200 items) and approves each pin only if its pending hash still equalsexpected_hash; mismatches and every other per-pin refusal are skipped and reported (skipped[].reason:stale,flagged,blocked,not_pending,not_found,wrong_mcp,duplicate,baseline_conflict,corrupt,previously_revoked) instead of failing the request. Pins carrying any flag other than the informational length flags are skipped unlessinclude_flaggedis true; never-approvable flags stay blocked, and previously revoked hashes are never force-approved in bulk. Each approval is audited astool_pin_approvedwith a sharedbulk_id, plus onetool_pin_bulk_approvedsummary event. Admin auth only. The SPA adds "Approve all (N)" per MCP group, row checkboxes with "Approve selected", and a confirmation dialog with the counts; flagged tools are excluded by default and need an explicit checkbox plus a second confirmation. Additive tocontracts(new route, schemas and audit action); the client does not use it. - The runtime image ships
uv/uvx(pinned by digest) and systempython3, so uvx-based registry entries (for example Alpaca) can start (#156).UV_PYTHON_DOWNLOADS=never; caches live under/tmp. scripts/group-grant-loss-report.mjs: read-only pre-upgrade report of active subscriptions that will stop being served once grants are checked at call time (run it on a copy of the DB).- Tool kill switch (ADR-025 M1):
POST /v1/admin/kill-switch/tool:<mcp_id>:<tool_name>disables one tool of one MCP without touching the rest of that MCP or a same-named tool on another MCP. The key uses the catalog entry'smcp_id(a UUID, never the MCP name) and the byte-exact tool name (URL-encode the target when the name contains/,?,#or%). A killed tool is absent from the stdio-client list, the agent list,ambassador__search_toolsanddescribe_tool, and a direct call fails with the existing kill-switch error. Tool names are 1-128 code points; control, bidi, zero-width characters and unpaired surrogates are rejected (400). The contract's switchtypegainstool(additive); no schema migration (thekill_switches.typecolumn is free text) andclient/mcpswitches are unchanged. - Per-tool group policy (FB-032, ADR-025): a group's MCP grant can be
all(default, unchanged),allow(only the listed tools) ordeny(all but the listed tools).POST /v1/admin/groups/:groupId/mcpstakes optionaltool_modeandtools;PUT /v1/admin/groups/:groupId/mcps/:mcpId/toolsreplaces the policy of an existing grant (audited asgroup.mcp_tool_policy.setwith added/removed names);GET /v1/admin/groups/:groupId/mcpsreturnstool_modeandtoolsper grant;GET /v1/admin/mcps/:mcpId/toolslists known tool names (catalog, approved pins and orphaned rule names) for a picker. Tool names are matched byte-exactly (1-256 chars, max 500 per policy). The admin API rejectstool_mode: 'deny'with an emptytoolslist (400, "use tool_mode 'all' to grant every tool") on bothPOSTandPUT, since an empty deny list is exactlyall; existingdenygrants with no tools keep resolving asall. Migration0007_group_tool_grants.sqladdsmcp_group_access.tool_mode(defaultall, so existing grants are unchanged) and themcp_group_access_toolstable. - Progressive tool disclosure (ADR-022): a remote agent grant has a
disclosure_mode(full, the default, orprogressive) chosen by the user at consent or one-shot creation. Inprogressivemode/mcptools/listreturns onlyambassador__search_tools,ambassador__describe_toolandambassador__call_tool; they run through the same scope, allowlist, pin, authorization, kill-switch and audit path as a direct call, and a hidden, pending, revoked, reserved-name or out-of-scope tool answers "Unknown tool" like a nonexistent one. Same-named tools of different MCPs are told apart by an optionalmcpargument (ambiguous names are refused with the candidates). Calls made throughcall_toolare audited withviaanddisclosure_mode; discovery is audited asagent_tool_discoverywith query length and hash only. Search/describe draw from their own rate bucket at twice the per-grant call limit. Migration0005_agent_grant_disclosure_mode.sqladds the column toagent_grantsandoauth_auth_codes; existing grants stayfull. - Admin API for the pin review queue:
GET /v1/admin/tool-pins(+/summary,/:pinId),POST /v1/admin/tool-pins/:pinId/approve|reject|confirm-baseline,PATCH /v1/admin/mcps/:mcpId/pin-policy. Decisions need the pin's currentexpected_hash;apply_to: all_userswrites the all-users baseline pin. Five rejections in 24h hold an MCP's new pending items (approved definitions keep serving). - Pin policy per MCP:
review(default) orauto(first-seen tools auto-approved; later changes still need review).autois allowed only for MCPs created by an admin (newmcp_catalog.origin); registry and imported MCPs always require review. - Re-discovery:
notifications/tools/list_changedfrom a stdio downstream triggers an immediate re-fetch, and all connections are re-fetched everyTOOL_PIN_REDISCOVERY_HOURS(default 6,0disables the periodic run). GET /v1/users/me/agent-endpointreturnsresource_url(the canonical MCP resource URL,nullwhen the endpoint is disabled).
Changed
- Creating or updating an MCP (admin API) or installing one from the registry now returns a 400 ("command not permitted on this server; see MCP_ALLOWED_COMMANDS") when
command[0]is not on the same allowlist spawn enforces (TD-044). Before, e.g. adockerdraft returned 201 but could never run. The check uses the samegetAllowedCommands()as spawn, so it is never stricter; the reason does not echo the value. - Admin API, registry install, catalog reload and tool-discovery error responses no longer echo raw
err.messagefor database errors (TD-036): they pass throughpublicErrorMessage, so on Postgres a failed query answers "Database error" instead of the SQL and bound parameters. A regression test fails on any new rawerr instanceof Error ? err.messageinadmin/,routes/,services/ordownstream/unless it carries atd036-allowreason. A registry install rejected for itsoauth_confignow says "secret binding rejected" instead of the validator text. - zod 3.25 → 4.6 in core, contracts and server (TD-020, clears GHSA-mrhx-c7xc-m3w5, memory exhaustion on oversized constrained collections). Wire contracts are unchanged (pinned by a before/after accept/reject + output snapshot of every exported contracts schema). Visible differences: zod's own validation message text changed (e.g.
Required→Invalid input: expected string, received undefined) in admin 400details, config errors and registry-skip log lines; the config error for an invalid enum value now names the issue codeinvalid_valueinstead ofinvalid_enum_value. Enum/literal values are still never echoed (all three redaction copies handle zod 4'sinvalid_value). - drizzle-orm 0.29.5 → 0.45.4 and drizzle-kit 0.20 → 0.31 (TD-019, clears GHSA-gpj5-g38j-94v9, SQL-identifier injection). Hand-written SQL migrations and the schema are unchanged;
db:generate/db:pushuse the newdrizzle-kit generate/pushcommands anddrizzle.config.tsnow setsdialect: 'sqlite'. A test pins thatdb.session.client(the raw driver handle used by health check, migrations and close) still resolves for SQLite and Postgres. - Dev tooling: vitest 1.x → 3.2.7 (TD-016) with
pnpm.overridesvite >=6.4.4 <7andtinypool >=2.1.2, clearing the tinypool criticals and the vite highs. Test config only (deps.external→server.deps.external); no runtime change. Two moderate vitest/@vitest/mocker advisories (GHSA-82fw-gwwq-j7x9, GHSA-qxg6-89mg-rqv8) remain: the fix is vitest 4.1.12, which needs Node 22+ (CI is Node 20). - Tool descriptions are no longer cut to 500 UTF-16 units when a downstream MCP is discovered; the full text is stored and the 500 code-point cut (never splitting a surrogate pair) happens only when a definition is served. Admin MCP views therefore show full descriptions.
- The
/mcpinsufficient-scope pre-check resolves a tool against the full catalog (same-named tools of different MCPs kept apart) and looks through one level ofambassador__call_tool. - Community-registry entries are reduced to the fields the server reads (name, display, transport, config, auth, isolation, credentials, tags, category, URLs, version, maintainer, verified) when the registry is cached. Any other key (including the ADR-024 sync provenance fields such as
source,official_name,package_integrity,flags) is dropped: never persisted into the catalog and no longer echoed by the admin registry API. Install behaviour is unchanged. - Per-tool group policy is enforced at list and call time with union semantics across a user's active groups: a tool is available if any active group permits it (an unrestricted grant permits all). Order per MCP: kill switch, group policy, the subscription's
selected_tools. A denied tool answers exactly like a nonexistent one ("Tool not subscribed for this client"). The marketplace list and detail show only the tools the user's groups permit. Changing a policy does not void existing tool approvals: the use-time recheck is what enforces it. - Upgrade behaviour for pinning (migration
0004_tool_pins.sql): the first discovery after upgrade runs a one-shot baseline against each MCP's publish-timetool_catalogsnapshot. Matching, unflagged tools are approved assystem:migrationand listed as "baseline, never human-reviewed" until an admin confirms them; everything else ispending_review. Until that first discovery runs, MCPs show no tools. Served descriptions remain truncated to 500 Unicode code points (SEC-M9-05); the pin hash and the admin review cover the full text, and thetruncated_on_serveflag marks tools where the two differ. Residual risk (D6, accepted):title,annotationsandoutputSchemaare not in the publish-time snapshot, so tools using them are listed asbaseline_unreviewedand need admin confirmation anyway. - Pin hardening: heuristics scan every string key/value in
inputSchema,outputSchemaandannotations(8 KB cap per text, linear time, no ReDoS);observeis serialized per (MCP, scope); oversize definitions are stored as a size marker plus the full hash and are never approvable; a name appearing twice in onetools/listhides both (duplicate_name); a corrupt stored definition hides the tool instead of failing startup. - Migrations are tracked (TD-032):
schema_migrations(filename, sha256, applied_at). An existing untracked database is bootstrapped once (0000-0003 re-run idempotently, then recorded); later files run exactly once, each in its own transaction with its record, with no already-exists tolerance. Startup refuses (MigrationIntegrityError) if an applied migration file's hash changed. Hashes are line-ending-normalised (.gitattributesforces LF for*.sql). A new migration containingPRAGMA foreign_keysis refused (it is a no-op inside the transaction, so a table rebuild would cascade-delete children). The bootstrap logs the recorded filenames and hashes at warn.
Fixed
- Approving a tool pin is now serialized with discovery for the same MCP and scope (and the all-users scope for
apply_to: all_users). Before, a discovery that landed while an approval was being written could leave the in-memory pin state (what agents are served) out of step with the database, losing the new pending definition. - A uid reclaimed from an idle instance stays marked
preparinguntil the victim's key is removed and the new lease is taken, instead of only until its home is wiped (TD-064). Previously the uid was released as soon as the wipe finished, and only microtask ordering kept the fast path from leasing it before the victim key was dropped. - A downstream MCP response over the message limit (1MB) no longer hangs the call for 30 seconds, and a response over 10MB no longer kills the MCP process (TD-045). The oversize line is discarded as it streams (memory stays bounded) and only the request it answers fails, quickly, with "Response too large"; an unterminated flood (64MB without a newline) fails the in-flight requests once and the process is left alone. Pending requests are also failed immediately when the process exits, and a write to a dead child (EPIPE on stdin) no longer raises an unhandled error that could crash the server. A stdio MCP process that exits unexpectedly (shared MCPs and per-user instances) is respawned with exponential backoff (1s doubling to 30s) and keeps retrying at that cap; an MCP stopped, removed, updated, restarted or terminated on purpose is not respawned, and a respawn that is superseded mid-start stops the process it started. Exits and respawns are audited (
mcp_process_exited,mcp_respawned,mcp_respawn_failed,mcp_respawn_failingafter 5 failed attempts), and the shared-MCP health check reportsdegradedwhile respawning andunhealthyonce past 5 attempts. - A downstream tool result containing an embedded
resource,resource_linkoraudiocontent block no longer breaks/mcp(-32602 "Invalid tools/call result") or is rejected (TD-041). The response schema now models every MCP content block type and keeps each block's payload plus itsannotationsand non-ambassador/*_meta; unknown fields are still dropped and each block is size-bounded (10MB payload, ~10.25MB per block). - TD-037 / ADR-025 M2+M4: group grants are now evaluated at call time for every MCP (list, search, describe and invoke), via one shared helper also used at subscribe. A user whose group no longer grants an MCP stops being served it even if the subscription row still exists; the row is kept (filter, never mutate), so re-granting restores access. A suspended group grants nothing (ADR-025 §3.5), and the loss-report script labels each affected subscription
no_grantoronly_suspended. The subscription list endpoints gainaccess: 'granted' | 'revoked'. POST /v1/admin/usersnow adds the new user toall-userslike every other creation path (without it the user got 403 on subscribe).- Publishing or archiving an MCP now hot-applies the catalog, so a published shared MCP serves tools without a manual
POST /v1/admin/catalog/applyor a restart. Failures stay pending and the SPA banner still offers Apply. Behaviour change: publish now goes live immediately; the review pause returns with ADR-021 tool-definition pinning. OneCatalogReloadernow serves both listeners, so concurrent applies conflict (409) instead of running in parallel. - Removing a user from their only group (
all-users) returns 409 CONFLICT instead of 403 FORBIDDEN.
Security
- Child hardening (TD-052/TD-059): MCP children are now started with
setpriv --no-new-privs, so a child that execs a setuid-root binary cannot gain capabilities from the inherited bounding set{setuid, setgid, kill}even when the container runs withoutsecurity_opt: no-new-privileges. The image strips setuid/setgid bits from all files in the runtime stage. The server logs a warning (once) when it runs without NoNewPrivs; keepsecurity_opt: [no-new-privileges:true]in compose. With child isolation disabled (MCP_AMBASSADOR_CHILD_UID_RANGEempty andMCP_AMBASSADOR_CHILD_UIDunset) the server logs at startup: "child isolation disabled: MCP children run as the server uid and can read /data". - Bulk approve (FB-033): each pin's hash and state check and its approval are applied with no await in between, so a pin changed after the admin's list is skipped as
stalerather than approved. Flagged pins are never approved unless the request setsinclude_flagged(the UI only sends it after a second confirmation). - Approving a tool definition whose exact hash was revoked earlier now needs
force: true(TD-034):POST /v1/admin/tool-pins/:pinId/approveanswers 409previously_revokedotherwise, and a forced approval is audited (forced_previously_revoked). The review dialog says the definition was previously revoked and offers "Approve anyway". - Per-user MCP pool spawns only what the user may use (TD-040):
spawnForUsernow takes the set of MCP ids the user is granted through an active group AND actively subscribed to (any of the user's clients), and starts nothing else, at all three callers (session register, reactivation, agent-endpointensureUserMcps). Before, every published per-user MCP was started for every user, so an ungranted MCP ran a process (and received that user's stored credentials if one existed) even though calls to it were refused. A user with no grants now has zero per-user processes. Subscriptions are never mutated; re-granting or re-subscribing restores the MCP on the next spawn. The spawnable set also requires the subscribing client to beactive. Because the pool keeps a user's instances once started, every change to what a user may run (subscribe, update or unsubscribe, group grant add/revoke, membership add/remove, group suspend/reactivate/delete, client status change) now terminates that user's per-user instances and, if the user has a live session, respawns the current set; agent-endpoint users re-ensure on connect. A revoked MCP therefore stops (and its loaded credentials with it) instead of running until the next restart. The hook also fires on the user-facing client suspend/reactivate/delete routes. It runs detached and serialized, so admin and user requests never wait on MCP spawns, and it is skipped for a user whose spawnable set is unchanged since the last spawn. It is non-fatal: failures are logged and audited asuser_mcp_resync_failed. - Runner guard (TD-039): one shared validator (
services/runner-guard.ts, dependency-free so community-registry CI can vendor it) rejects runner-changing options in both--flag valueand--flag=valueforms (and inside short-flag clusters) for npx/npm/pnpm/yarn/bunx (--registry,--@scope:registry,--call,-c,-p/--package,--userconfig,--prefix,--cache, ...), uvx/uv (--from,--index-url,--extra-index-url,--find-links,--with*,--python, ...), node (-p/--print,-e/--eval,-r/--require,--import,--loader,--env-file, ...), python (-c), pip, deno (incl.eval/repl) and bun. Only the runner's own option region is inspected: options after the package or script belong to it and are allowed. Applied at MCP create, update, registry install, YAML import and at spawn; a violation is a 400 / rejected install with a generic reason that never echoes a value. - Reserved environment variable names are matched case-insensitively by prefix (
npm_config_*,PIP_*,UV_*,PYTHON*,NODE_*,LD_*,DYLD_*,YARN_*,PNPM_*,BUN_*,DENO_*,GIT_*,XDG_*) plus exactPATH,PATHEXT,HOME,USERPROFILE,SHELL,IFS,ENV,BASH_ENV. They are refused inconfig.env, incredential_schemaproperty keys andenv_varvalues, and inoauth_config.access_token_env_var.NODE_ENVis the one allowed exception.GITHUB_*is not affected. - Default-deny for npx/npm/pnpm/yarn/bunx and uvx/uv: before the package only a short list of boolean options is accepted (
-y,--yes,--no,-q,--quiet,--prefer-offline,--prefer-online,--offline,--no-fund,--no-audit,--no-update-notifier,--ignore-existing; for uv--quiet,--offline,--no-cache,--isolated,--no-progress,--no-config), never with=value. This also closes npm option abbreviations (--reg=),--git,--cafile,--https-proxy. Scanning continues past subcommands (npm exec,pnpm dlx,uv tool run,uv pip install,deno run,bun run,python -m pip) up to the package or script. - Also reserved env names: any
*_PROXY(case-insensitive, incl.NO_PROXY),SSL_CERT_FILE,SSL_CERT_DIR,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLE,JAVA_*,_JAVA_OPTIONS,PERL5*,RUBY*,GCONV_PATH,CLASSPATH. dockeris removed from the default allowed commands (it can mount host paths and run arbitrary images). It is now opt-in viaMCP_ALLOWED_COMMANDS, and its arguments are NOT guarded (hardening tracked as TD-042). pip is default-deny too (it accepts option abbreviations); node also rejects--env-file*,--openssl-config,--experimental-config-file, deno--preload.- Not covered (follow-up): git/URL package specs (
git+https://,github:) and docker arguments (TD-042). - Tool definition pinning (ADR-021): every downstream tool definition is hashed (RFC 8785 canonical JSON + SHA-256) and pinned. Agents only ever see the admin-approved definition, never the live one, on
/v1/tools,/mcptools/list, invocation and the insufficient-scope pre-check; arguments are validated against the approvedinputSchema. A new or changed definition goes to a review queue (pending_review); the previously approved one keeps serving. Flags (injection-like phrases, zero-width/bidi characters, oversize, reserved or duplicate names) are shown to the reviewer and never auto-decide. Audit events carry hashes only. - Query strings are redacted from request logs and 404 responses; admin-key failures are rate limited per IP and format-checked before argon2; OAuth
stateand raw downstream error bodies are no longer logged. - Kill switches persist across restarts (
kill_switchestable) and are enforced in the invocation router; killed tools/clients are hidden from tool listings. - Agent endpoint: deleting an OAuth client now disables it first, then revokes (and retains) its grants and deletes it in one transaction, closes the grants' live
/mcpstreams and audits each asagent_grant_revoked. Disabling a client also closes its live streams. - Agent grants have a
kind(oauth|one_shot); onlyone_shotgrants may have no OAuth client. Anoauthgrant whose client is gone is rejected. Migration0003_agent_grant_kind.sqldefaults existing rows tooauth, so a pre-existing one-shot grant is rejected until re-minted. - Registry install persists only the schema-validated
oauth_config(unknown keys are dropped).