# PMCP - Progressive MCP [![PyPI version](https://badge.fury.io/py/pmcp.svg)](https://pypi.org/project/pmcp/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) **A gateway that lets your AI coding assistant use dozens of external tools without loading them all up front.** PMCP sits between your assistant (Claude Code, Codex, and others) and the services it can plug into — GitHub, Jira, databases, 90+ more. Instead of loading every tool's full schema before you've asked a question, it offers a short menu first and fetches the detailed schema only when a tool is actually used. ## Why it exists Assistants reach external services through **MCP** (Model Context Protocol) — a common interface for an AI to talk to other software. When an assistant connects to a dozen MCP servers directly, it loads *all* of their tool definitions into context at once. Context is limited and metered: every tool definition costs tokens and crowds out room for the actual work. Loading 50+ tools you may never call is slow and expensive, and adding a new service usually means restarting the assistant. Anthropic has [highlighted context bloat](https://www.anthropic.com/news) as a key challenge with MCP tooling. PMCP is the single connection point that keeps this compact and on-demand. ## Who it's for Developers and teams running AI coding assistants with many connected tools — especially anyone hitting "too many tools" or context-full limits. ## What you get - **Lower token cost** — the assistant sees a compact capability card, not 50+ full schemas, before doing real work. - **Load tools only when needed** — downstream servers stay dormant until first use ("lazy by default"), and eager-start only when listed in `autoStart`. - **Add tools without restarting** — provision a new server on demand from a manifest of 90+. - **One stable connection** — your assistant sees ~26 steady meta-tools instead of a shifting set of many. - **Guardrails built in** — output size caps and optional secret redaction. - **Works out of the box** — core capability matching needs no API key. ## Quick Start ### Installation ```bash # With uv (recommended) uv pip install pmcp # Or run directly without installing uvx pmcp # With pip pip install pmcp ``` ### Upgrading from 2.x 3.0.0 refuses several things 2.7.3 accepted. Most importantly, a repository's `.mcp.json`, `.pmcp/manifest.yaml` and `.mcp-gateway-policy.yaml` are ignored until you approve them with `pmcp trust approve `. [MIGRATING.md](MIGRATING.md) has an upgrade checklist, a section for each breaking change (with a check, the fix and how to verify it), and how to roll back. To stay on 2.x for now, pin `pmcp<3`. > **Capability matching is built-in** — no API key needed. `gateway.request_capability` > uses a pure-Python matcher that can return direct CLI guidance for installed > native tools, MCP server candidates, or registry search guidance. ### Configure with `pmcp setup` PMCP includes a wizard-style helper that can render ready-to-use MCP client config for Claude and OpenCode. The generated config only connects your client to the PMCP gateway. Downstream MCP servers stay lazy until first use unless you add them to `autoStart` in your `.mcp.json`. Use `pmcp setup` to print the generated config: ```bash pmcp setup --client claude --mode stdio # Claude local stdio pmcp setup --client claude --mode http # Claude shared-service HTTP pmcp setup --client opencode --mode stdio # OpenCode local stdio pmcp setup --client opencode --mode http # OpenCode shared-service HTTP ``` Named profiles cover the common modes: ```bash pmcp setup --profile local-stdio pmcp setup --profile shared-local-http pmcp setup --profile authenticated-shared-http pmcp setup --profile ci ``` Write directly into your client config with `--write`: ```bash pmcp setup --client claude --mode http --write ``` Without `--write`, `pmcp setup` prints the config so you can paste it into: - Claude: `~/.mcp.json` - OpenCode: `~/.config/opencode/opencode.json` Use shared-service HTTP mode when running one PMCP service for multiple sessions or clients. Use single-process stdio mode for local testing. ### Shared Service Mode (Manual) If you prefer manual config, point each client to the shared HTTP endpoint: ```json { "mcpServers": { "pmcp": { "type": "http", "url": "http://127.0.0.1:3344/mcp" } } } ``` Why this mode: PMCP uses a singleton lock (`~/.pmcp/gateway.lock`), so multiple local launches can conflict. One shared service avoids lock collisions and keeps tool state consistent. Shared gateway state: - All clients connected to one PMCP HTTP gateway share downstream server connections, pending requests, provisioned tools, and live lifecycle state. - `gateway.refresh(force=true)`, `gateway.disconnect_server(force=true)`, and `gateway.restart_server(force=true)` can cancel or interrupt downstream work started by another client using the same gateway. - `gateway.health` and live `pmcp status --verbose` show startup policy observations for downstream servers without exposing secret values. - `--rate-limit` / `PMCP_RATE_LIMIT` applies per observed source IP on `/mcp`; localhost clients and reverse-proxied clients can share one bucket unless the proxy preserves distinct client IPs. Quick verification: ```bash systemctl --user is-active pmcp curl -sS http://127.0.0.1:3344/health ``` `/mcp` is POST-only as of 2.0.0 — a bare `curl` against it returns `405 Method Not Allowed` with `Allow: POST, DELETE`, so use `/health` for a liveness check. ### Security **HTTP transport is unauthenticated by default.** For any non-localhost exposure, choose an HTTP auth mode and terminate TLS in front of PMCP. `shared-secret` mode is the backward-compatible single-tenant guard. It accepts one static bearer value on `/mcp`: ```bash # Start with bearer auth from the environment PMCP_AUTH_TOKEN=mysecrettoken pmcp --transport http ``` Avoid passing production tokens with `--auth-token`; command-line arguments can be visible in process listings on shared hosts. Clients must then include `Authorization: Bearer mysecrettoken` on `/mcp` requests. `/health` and `/metrics` remain unauthenticated by design; protect them with firewall rules, IP allowlists, or reverse-proxy policy before any non-localhost exposure. `resource-server` mode makes PMCP validate Authorization Server issued access tokens as an OAuth 2.1 Resource Server. Configure the HTTP app with a public issuer, JWKS URL, resource audience, required scopes, and exact allowed origins: ```python create_http_app( mcp_server, auth_mode="resource-server", resource_server_issuer="https://issuer.example", resource_server_jwks_url="https://issuer.example/.well-known/jwks.json", resource_server_audience="https://pmcp.example/mcp", resource_server_allowed_algorithms=("RS256", "ES256"), required_scopes=["pmcp.invoke"], allowed_origins=["https://app.example"], ) ``` PMCP validates token signature, issuer, expiry, not-before, and audience. The audience is bound to the configured `resource_server_audience` (the server's canonical resource URI, per RFC 8707); it is never derived from the request Host header. `resource-server` mode fails closed at startup if the issuer, JWKS URL, or audience is missing, and `resource_server_jwks_url` must be an `https` URL and is rejected when its host is a non-public IP literal or `localhost` (the accepted form is below). Token signatures are only accepted for the operator-configured `resource_server_allowed_algorithms` allowlist (default `RS256`/`ES256`); the token's own `alg` header is never trusted. JWKS is fetched asynchronously and cached, so validation never blocks the event loop; an unreachable JWKS endpoint, or a key set with no usable keys, returns `503` while an invalid token returns `401`. Each JWKS fetch has a 5 s total timeout, and a failed fetch opens a 5 s backoff shared by the requests waiting on it. A token with an unknown `kid` forces at most one refresh per 10 s; others in that window are checked against the cached keys. The `resource` published at `/.well-known/oauth-protected-resource` is the configured `resource_server_audience`, or, when that is unset, the origin of the configured protected-resource metadata URL plus `/mcp`, with a scheme-default port such as `:443` dropped. A path prefix in the metadata URL is not carried over: PMCP serves MCP at `/mcp` on the address it listens on, and that is the public URL only when nothing in front of PMCP rewrites the path. Behind a reverse proxy that strips a path prefix (for example nginx `location /pmcp/ { proxy_pass http://127.0.0.1:3344/; }`), MCP's public URL is `https:///pmcp/mcp`; set `--oauth-audience` to that URL so the token audience names it. That is all a `pmcp` CLI deployment needs: the CLI does not publish protected-resource metadata, so it serves no metadata route. The metadata URL is a `create_http_app` parameter (`protected_resource_metadata_url`) for applications that embed PMCP. There, set `resource_server_audience` to the public MCP URL as well, so the published `resource` names it, and pick a metadata URL whose path reaches PMCP unchanged: PMCP serves the metadata at the URL's literal path, so `https:///pmcp/.well-known/oauth-protected-resource` behind that proxy arrives as `/.well-known/oauth-protected-resource` and returns `404` ([Consiliency/pmcp#334](https://github.com/Consiliency/pmcp/issues/334) tracks that case). Use the RFC 9728 form for a resource with a path, `https:///.well-known/oauth-protected-resource/pmcp/mcp`, and forward that path to PMCP unchanged. An application that mounts PMCP under a `root_path` must route that path to the mounted app as well: mounted at `/pmcp`, it answers at `/pmcp/.well-known/oauth-protected-resource/pmcp/mcp`, not at the URL's own path. The `resource` is never taken from the request `Host`. A forged token, including one whose algorithm does not match the published key's type, gets `401`, never `500`. In resource-server mode over HTTP, PMCP refuses to start, with a one-line error, if the JWKS URL (`--oauth-jwks-url`, `PMCP_OAUTH_JWKS_URL` or `resource_server_jwks_url`) is not an accepted auth URL, or if a required scope is not a single RFC 6749 scope (printable ASCII with no space, quote or backslash; `--required-scope` and `PMCP_REQUIRED_SCOPES` alike), so every `401`/`403`/`503` it later sends can be built. The CLI takes no metadata URL. An application that embeds PMCP and passes `protected_resource_metadata_url` to `create_http_app` gets the same refusal for that URL, in any auth mode; such a URL used to be dropped, with the metadata route silently omitted. An auth URL is accepted only if it is an absolute `https://` URL whose port, if it has one, is a number no greater than 65535, and whose host is in canonical form -- a DNS name of dot-separated labels of 1 to 63 ASCII letters, digits and hyphens, each starting and ending with a letter or digit, the last starting with a letter, so never a number (an IDN in its xn-- form); a dotted-quad IPv4 address with no leading zeros; or a bracketed IPv6 address with no zone id -- and is a public address or a name other than `localhost`. An IPv6 address that embeds an IPv4 address (IPv4-mapped, IPv4-translated, IPv4-compatible, NAT64, ISATAP, 6to4, Teredo) must be public on the embedded address too; the local-use NAT64 prefix `64:ff9b:1::/48` is refused. Because ISATAP is recognised by its interface id, an address whose interface id is ISATAP-shaped and embeds a non-public IPv4 address is refused even under a global prefix: `[2606:4700::5efe:a00:5]` (10.0.0.5) is refused, as it was before, while `[2606:4700::5efe:808:808]` is accepted. A host in any other spelling is refused rather than rewritten, because the HTTP client and a browser would rewrite it first: `https://127.0.0.1/` (full-width digits), `127。0。0。1`, full-width `localhost`, `127%2E0%2E0%2E1`, `127.0.0.1.` (trailing dot), legacy numeric and octal forms such as `2130706433` or `0177.0.0.1`, `127.000.0.1`, `example.123`, `-a.example.com`, `[v1.fe]` and `[fe80::1%25eth0]` are all refused, as are `bücher.example` (write `xn--bcher-kva.example`), underscores and empty labels. PMCP strips leading spaces and C0 control characters, and trailing tabs, CRs and LFs, and nothing else; any control character (U+0000 to U+001F, or DEL) left after that strip is refused, a backslash is refused anywhere in the URL, and, for a JWKS or metadata URL, so is any whitespace character (anything Python's `str.isspace` matches, so a trailing space, a no-break space or U+2028 too). A DNS name is not resolved (see below), and only the exact name `localhost` is treated as loopback: `*.localhost` is a name like any other and passes unresolved. Userinfo such as `user:pass@` is accepted and dropped. Everything else is refused, including plain `http://` to any host, loopback hosts too, and `https://` to `localhost` or a loopback address. (Plain `http://` to `localhost`, `127.0.0.0/8`, `[::1]` or `[::ffff:127.x.y.z]` is accepted in one place only: a URL the operator types into `gateway.auth_connect`.) For example, as a JWKS URL or a metadata URL: | URL | Result | |---|---| | `https://auth.example.com/jwks.json` | accepted | | `https://8.8.8.8/jwks.json` | accepted | | `https://auth.example.com:8443/jwks.json` | accepted | | `https://user:pass@auth.example.com/jwks.json` | accepted (userinfo dropped) | | `http://auth.example.com/jwks.json` | refused: plain http | | `http://127.0.0.1:8080/jwks.json` | refused: plain http | | `http://localhost/jwks.json` | refused: plain http | | `https://localhost/jwks.json` | refused: non-public host | | `https://127.0.0.1/jwks.json` | refused: non-public host | | `https://10.0.0.5/jwks.json` | refused: non-public host | | `/.well-known/oauth-protected-resource` | refused: not an absolute http(s) URL | | `ftp://auth.example.com/jwks.json` | refused: not an absolute http(s) URL | | `https://auth.example.com:abc/jwks.json` | refused: invalid URL | | `https://auth.example.com/key set.json` | refused: whitespace | | `https://xn--bcher-kva.example/jwks.json` | accepted | | `https://127.0.0.1/jwks.json` | refused: host not in canonical form | | `https://127。0。0。1/jwks.json` | refused: host not in canonical form | | `https://127.0.0.1./jwks.json` | refused: host not in canonical form | | `https://2130706433/jwks.json` | refused: host not in canonical form | | `https://[v1.fe]/jwks.json` | refused: host not in canonical form | | `https://127.000.0.1/jwks.json` | refused: host not in canonical form | | `https://example.123/jwks.json` | refused: host not in canonical form | | `https://-a.example.com/jwks.json` | refused: host not in canonical form | | `https://[::ffff:0:a9fe:a9fe]/jwks.json` | refused: non-public host | | `https://127.0.0.1\@auth.example.com/jwks.json` | refused: backslash | In public auth metadata URLs it rejects hosts written as non-public **IP literals** — private, CGNAT, link-local, loopback, multicast, site-local, and unspecified — including IPv4 addresses embedded in IPv6 literals and legacy numeric forms such as `2852039166`. This is a filter on literals only: a DNS name is accepted **without being resolved**, so a name that points at an internal address still passes. PMCP therefore no longer presents such a URL as one it checked: a server-supplied URL is **relayed unverified and presented as such** — `UrlElicitationInfo.url_verified`, `AuthMetadataInfo.verified_urls`, and `AuthChallengeInfo.resource_metadata_url_verified` all default to unverified, and the caveat is carried in the `next_step` an agent follows and in CLI output. Where PMCP *fetches* a URL itself it fails closed instead, requiring a verified public literal ([Consiliency/pmcp#211](https://github.com/Consiliency/pmcp/issues/211)). PMCP is still not an Authorization Server and does not provide dynamic client registration, SSO, RBAC, billing, or a complete multi-tenant identity service. Auth mode and OAuth resource-server parameters are configurable from the CLI or environment (CLI flags take precedence; env values are read only when the flag is unset): | Flag | Env var | Purpose | | --- | --- | --- | | `--auth-mode {none,shared-secret,resource-server}` | `PMCP_AUTH_MODE` | Select the HTTP auth mode. When unset, PMCP infers `shared-secret` if a token is present, otherwise `none`. | | `--oauth-issuer` | `PMCP_OAUTH_ISSUER` | Authorization Server issuer (resource-server mode). | | `--oauth-jwks-url` | `PMCP_OAUTH_JWKS_URL` | Public `https` JWKS URL (resource-server mode). | | `--oauth-audience` | `PMCP_OAUTH_AUDIENCE` | Canonical resource audience, RFC 8707 (resource-server mode). | | `--required-scope` (repeatable) | `PMCP_REQUIRED_SCOPES` (comma-separated) | Scopes every token must present. | | `--allowed-origin` (repeatable) | `PMCP_ALLOWED_ORIGINS` (comma-separated) | Browser Origins permitted on `/mcp`; also enables Host-header validation. | **Origin and Host posture (DNS-rebinding defense).** The `Origin` check runs by default in every auth mode, even when no `--allowed-origin` is configured: a request carrying a browser `Origin` header is rejected with `403` unless the origin is loopback, same-origin with the request `Host`, or explicitly allow-listed. Requests with no `Origin` header — the normal case for non-browser MCP clients — always pass. Configuring `--allowed-origin` (or `PMCP_ALLOWED_ORIGINS`) additionally turns on `Host`-header validation: the request `Host` must be loopback or one of the hosts derived from the configured origins and the gateway's own canonical resource host (`--oauth-audience` / protected-resource metadata URL); other Hosts get `403`. Host validation stays off by default so that reverse-proxy deployments that forward an arbitrary public `Host` keep working; if you enable it behind a proxy, make sure your gateway's public hostname is reachable through the configured origins or audience so the proxied `Host` is accepted. **Assumptions and trust model:** - PMCP binds to `127.0.0.1` by default — not safe to expose publicly without an HTTP auth mode (`PMCP_AUTH_TOKEN` for `shared-secret`, or `resource-server`). - User-scoped and explicitly configured sources (`~/.mcp.json`, an explicit `--config`, an explicit `--policy`) are trusted inputs — treat them like code. A project-scoped `.mcp.json`, `.pmcp/manifest.yaml` or `.mcp-gateway-policy.yaml` checked into a repository is **not** trusted until you approve it with `pmcp trust approve `; until then it is ignored, and approval is keyed to the file's exact bytes, so editing an approved file revokes the approval. See the *v13 trust boundary* section of [`SECURITY.md`](SECURITY.md) for the full model. - Secrets you export in the shell that starts PMCP are inherited by child MCP server processes; protect them accordingly. Keys that PMCP itself loads from a project `.env` or `.env.pmcp` are **not** propagated to spawned servers — only a server's own declared credential is — so a repository's `.env` cannot bleed into every downstream server. **Production background service (Linux systemd):** ```ini # ~/.config/systemd/user/pmcp.service [Unit] Description=PMCP MCP Gateway [Service] Environment=PMCP_AUTH_TOKEN=replace-with-secret-token ExecStart=/usr/local/bin/pmcp --transport http Restart=on-failure [Install] WantedBy=default.target ``` ```bash systemctl --user enable --now pmcp ``` Or with nohup: ```bash cd ~ && PMCP_AUTH_TOKEN=replace-with-secret-token nohup pmcp --transport http > /dev/null 2>&1 & ``` The gateway writes its own log to `.pmcp/logs/gateway.log` relative to its working directory (rotated at 1 MB, 5 backups), so started from `~` as above — or as a systemd user service, whose working directory is your home — it lands in `~/.pmcp/logs/gateway.log`. Don't redirect stderr onto that same file: every line would be written twice. ### TLS / Reverse Proxy PMCP's HTTP transport is **plaintext**. For any exposure beyond localhost, terminate TLS at a reverse proxy and forward to `127.0.0.1:3344`. Keep `--host 127.0.0.1` (the default) so PMCP only listens on the loopback interface. **Nginx** (`/etc/nginx/sites-available/pmcp`): ```nginx server { listen 443 ssl; server_name pmcp.example.com; ssl_certificate /etc/letsencrypt/live/pmcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/pmcp.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:3344; proxy_set_header Authorization $http_authorization; proxy_http_version 1.1; proxy_set_header Connection ""; } } ``` **Caddy** (`Caddyfile`): ``` pmcp.example.com { reverse_proxy 127.0.0.1:3344 } ``` Caddy handles TLS automatically via Let's Encrypt. ### Other MCP Clients PMCP works with any MCP-compatible client. Below are configuration examples for popular clients. #### Codex CLI Add to `~/.codex/config.toml` (verify in the Codex documentation): ```toml [mcp_servers.pmcp] command = "pmcp" args = [] ``` #### Gemini CLI Add to `~/.gemini/settings.json` (verify in the Gemini CLI documentation): ```json { "mcpServers": { "pmcp": { "command": "pmcp", "args": [] } } } ``` > **Note**: Configuration paths and formats vary by client. Verify the exact location and format in each client's official documentation. ### Your First Interaction ``` You: "Take a screenshot of google.com" Claude uses: gateway.invoke { tool_id: "playwright::browser_navigate", arguments: { url: "https://google.com" } } // Then: gateway.invoke { tool_id: "playwright::browser_take_screenshot" } Returns: Screenshot of google.com ``` ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Claude Code │ │ Only connects to PMCP (single server in config) │ └────────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ PMCP │ │ • 26 meta-tools (catalog, invoke, tasks, config, etc.) │ │ • Progressive disclosure (compact cards → full schemas) │ │ • Policy enforcement (allow/deny lists) │ └────────────────────────────┬────────────────────────────────┘ │ ┌────────────────────┼────────────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Explicit │ │ Manifest │ │ Custom Servers │ │ autoStart │ │ (90+ servers │ │ (your own MCP │ │ servers │ │ on-demand) │ │ servers) │ └───────────────┘ └─────────────────┘ └─────────────────┘ ``` **Key principle**: Users configure ONLY `pmcp` in Claude Code. The gateway discovers and manages all other servers. ### Why Single-Gateway? 1. **No context bloat** - Claude sees 26 tools, not 50+ 2. **No restarts** - Provision new servers without restarting Claude Code 3. **Consistent interface** - All tools accessed via `gateway.invoke` 4. **Policy control** - Centralized allow/deny rules ## Gateway Tools The gateway exposes **26 meta-tools** organized into four categories: Tool annotations are preserved as untrusted hints only; policy and safety notes continue to use PMCP's own risk model. When a tool schema omits `$schema`, PMCP reports the JSON Schema dialect as `https://json-schema.org/draft/2020-12/schema`. See [SPEC_COMPLIANCE.md](SPEC_COMPLIANCE.md) for the current MCP specification compliance matrix and next-revision tracking checklist. ### Core Tools | Tool | Purpose | |------|---------| | `gateway.catalog_search` | Search available tools, returns compact capability cards with small metadata such as title, icons, execution hints, and schema dialect, plus additive compact CLI hints, registry candidates, and (with `include_offline=true`) manifest provision candidates (`manifest_candidates`) carrying `provisionable`/`provision_tool`/`requires_api_key`/`api_key_available`/`env_var` so an agent can provision the exact server | | `gateway.describe` | Get detailed schema and richer metadata for a specific tool, including output schema, annotations, execution/task support, icons, and schema dialect | | `gateway.invoke` | Call a downstream tool with argument validation, including task-augmented execution for task-capable tools | | `gateway.refresh` | Reload backend configs and reconnect; refuses while requests or active MCP tasks are pending unless `force=true` | | `gateway.health` | Get gateway and server health status | | `gateway.config_status` | Read effective config and startup/auth status with source attribution | | `gateway.get_startup_policy` | Read persisted `autoStart` and legacy `disableAutoStart` entries by source | | `gateway.set_startup_policy` | Preview or explicitly apply `autoStart` add/remove/set operations against one selected source | ### Lifecycle Tools | Tool | Purpose | |------|---------| | `gateway.connect_server` | Connect or start a known configured, manifest/provisioned, or registered discovered server | | `gateway.disconnect_server` | Runtime-stop a server without editing `.mcp.json` or changing `autoStart` | | `gateway.restart_server` | Runtime-stop then reconnect a server without changing persistent config | ### Capability Discovery Tools | Tool | Purpose | |------|---------| | `gateway.request_capability` | Natural language capability matching that can return direct CLI guidance or MCP server candidates | | `gateway.sync_environment` | Detect platform and available CLIs | | `gateway.provision` | Install and start MCP servers on-demand | | `gateway.update_server` | Update an MCP server package and reconnect it | | `gateway.auth_connect` | Store API-key credentials or acknowledge URL-mode elicitation and retry provisioning | | `gateway.submit_feedback` | Preview/submit technical PMCP feedback issues to GitHub | | `gateway.provision_status` | Check installation progress | | `gateway.search_registry` | Search the cached public MCP Registry metadata for external servers | | `gateway.register_discovered_server` | Register a registry result for provisioning, pinned to its resolved npm version; provisioning needs operator approval (see [Discovered packages](#discovered-packages-need-an-operators-approval)) | ### Monitoring Tools | Tool | Purpose | |------|---------| | `gateway.list_pending` | List pending tool invocations with health status | | `gateway.cancel` | Cancel a pending tool invocation | | `gateway.tasks_list` | List brokered downstream MCP tasks by opaque task ID | | `gateway.tasks_get` | Get current status for one downstream MCP task | | `gateway.tasks_result` | Fetch and process a downstream MCP task result | | `gateway.tasks_cancel` | Cancel a downstream MCP task | `gateway.refresh` is intentionally conservative in shared-service mode. If a downstream request or active MCP task is in flight, refresh returns `ok=false` without disconnecting or reconnecting servers. Use `gateway.list_pending` to inspect active PMCP request IDs and `gateway.tasks_list` to inspect downstream MCP task IDs, then retry with `force=true` only when cancelling that work is acceptable. `gateway.disconnect_server` and `gateway.restart_server` follow the same shared-service disruption policy for the target server: they refuse while that server has pending requests or active MCP tasks unless `force=true`. With `force=true`, only pending requests and active tasks for the named server are cancelled. These controls are runtime-only; they free local resources and update live gateway state, but they do not edit `.mcp.json`, remove server definitions, or change `autoStart`. In HTTP shared service mode, stopping or restarting a downstream server can affect other clients using the same PMCP gateway. MCP task IDs are downstream server identifiers and remain distinct from PMCP pending request IDs such as `server::local_id`. Use `gateway.cancel` only for PMCP request IDs from `gateway.list_pending`; use `gateway.tasks_cancel` for MCP task IDs. Task records are transient in-memory gateway state. PMCP can bind visibility to the server and requestor context it observes, but unauthenticated local transports cannot provide cross-user authorization isolation. ### Auth And Elicitation PMCP reports downstream authorization as structured, non-secret state. Gateway outputs and health rows may include `auth_state` values of `none`, `missing_auth`, `insufficient_scope`, `elicitation_required`, `policy_denied`, or `unknown`, plus optional `next_step`, `auth_methods`, scope names, sanitized metadata URLs, and URL-mode elicitation summaries. Supported flows: - Local API-key servers continue to use env-store credentials. When `gateway.provision` reports `auth_state="missing_auth"` and `auth_mode="api_key"`, call `gateway.auth_connect` with a credential and PMCP stores it in the selected user or project env file. User scope writes `~/.config/pmcp/pmcp.env`; project scope writes `/.env.pmcp`. Project scope is useful for local development and CI workspaces, while user scope is better for credentials that should follow one operator across projects. - Remote bearer headers use env placeholders such as `Authorization: Bearer ${REMOTE_API_TOKEN}`. PMCP resolves placeholders from process env, project env-store, and user env-store values, but status, doctor, health, and feedback output only show required or missing env var names, not the resolved header value. - A tenant-scoped header resolver exists for applications that embed PMCP: it reads only `/.pmcp/tenants//pmcp.env` and reports missing env var names without printing header values. The gateway's own remote connections never select a tenant, and no config key, flag or env var turns tenant mode on (see [Consiliency/pmcp#353](https://github.com/Consiliency/pmcp/issues/353)). - Remote authorization discovery is diagnostic-only. PMCP can preserve and report OAuth Protected Resource Metadata, Authorization Server Metadata, OpenID Connect discovery, Client ID Metadata Document URLs, and declared scopes when a server or `WWW-Authenticate` challenge provides them. - URL-mode elicitation is out of band. PMCP returns a sanitized URL and `elicitation_id`; complete that URL flow outside PMCP, then acknowledge it with `gateway.auth_connect(auth_mode="url_elicitation", elicitation_id=..., consent_acknowledged=true)`. PMCP is not an authorization server and does not implement enterprise SSO, Cross-App Access, DPoP, workload identity federation, or third-party refresh token storage. Do not paste OAuth codes or third-party credentials into URL-mode gateway calls. ### Subordinate MCP Updates - `gateway.update_server` is the phase-1 update path for subordinate MCPs. - `pmcp update ` and `pmcp update --all` call the same gateway update workflow. - Update information is reported on request by `gateway.update_server`; the gateway does not volunteer unprompted "update available" notices. It cannot observe which package version a running server is actually executing, so a volunteered notice could be wrong in either direction (Consiliency/pmcp#150). - **npm package identity comes from npm's own parser, or not at all.** For an `npx`/`npm` server the gateway asks the host npm's own `nopt`, config definitions and `npm-package-arg` which package that command line would run, and **refuses** rather than guess when anything could redirect resolution — a `cwd` inside a node project, an `npm_config_*` variable in the server's env or the gateway's own, or any flag beyond `--yes`/`--package`. A refused server keeps working; it just loses auto-update and version reporting, and refreshes its cached descriptions every cycle. Where node is not installed the gateway falls back to its own flag tables, which is the pre-2.5.2 behaviour (Consiliency/pmcp#195). **The environment across the update's probe window.** `gateway.update_server` probes for a new package version and then re-resolves the server config before restarting anything. Two different kinds of environment change behave differently across that window, and they are not one rule: - A **config-driven** change is **refused**. That means anything resolved into the server's `env` — which includes every manifest credential, since a manifest credential is resolved into the server config at load time. If it changes while the probe is running, `update_server` returns `ok=False` and does **not** restart: the package is fetched but not activated, and no version is recorded. The guarantee is that *the config restarted onto is the config that was probed* (Consiliency/pmcp#151). Your change is not lost — rotate a credential mid-update and it applies on the **next** update, rather than to a process that was probed with the old value. - A **genuinely ambient** variable — one set in the gateway's own environment rather than in a server's config — is **live at spawn**. It can never cause a refusal (both sides of the check derive from the same base environment, so an ambient change affects them equally and cancels out), and the restarted server receives the value in force when it is spawned, not the one in force when the update began. This is the same behaviour as `gateway.connect_server`, `gateway.refresh` and auto-reconnect: every spawn path reads the ambient environment at spawn time. Freezing the ambient environment across an update is deliberately not done (Consiliency/pmcp#162). ### Feedback Telemetry - PMCP can emit failure feedback hints and generate GitHub issue payload previews for agents. - Telemetry is technical-only and warns before submission; payloads include PMCP/tool context. - Disable permanently with `pmcp guidance --telemetry off`. **Preview is the default; posting is opt-in.** `gateway.submit_feedback` builds the exact issue payload and a browser URL an operator can open. PMCP posts nothing itself unless *both* of the following are true, and it never spawns `gh`: 1. **The operator has allowed it.** `pmcp guidance --feedback-submission on` sets `guidance.enable_feedback_submission: true` in `~/.claude/gateway-guidance.yaml`. It is off by default, `pmcp guidance --feedback-submission off` turns it back off, and `pmcp guidance` shows the current value. `enable_telemetry: false` overrides it: with telemetry off nothing is built or sent at all. An agent's `confirm_submission=true` is the *user's* consent to the payload, not the operator's authority to send it, and is not by itself enough to post. 2. **The operator has exported a token.** `PMCP_FEEDBACK_TOKEN` — a dedicated GitHub token with permission to open an issue on the destination repository. `GITHUB_TOKEN` and `GH_TOKEN` are not read on this path. **The token must be exported in the shell that starts pmcp.** PMCP refuses a `PMCP_FEEDBACK_TOKEN` it introduced into its own environment: one stored through `gateway.auth_connect`, one loaded at startup from PMCP's own credential stores (`~/.config/pmcp/pmcp.env`, the project `.env.pmcp`), or one a `.env` in the current checkout supplied. The credential that authorises an outbound post has to come from you, not from something the agent or the repository could have written. **The destination is validated.** The default is `Consiliency/pmcp`, this project's repository. `PMCP_FEEDBACK_REPO` overrides it, under the same rule: an override a checkout's `.env` introduced is refused, so is one that is not `owner/repo` shaped, and a refused destination is never rendered into a URL you are handed to open. Refusals are loud rather than silent. Each one says what to do — the exact `pmcp guidance` command, or the variable to export — and the ones that can still be acted on by hand return the payload and a browser URL. If PMCP sent a request and never saw the response, it says so (`submission_outcome: "dispatched_unconfirmed"`) and hands back a *search* URL, because the issue may already exist. ## Progressive Disclosure Workflow PMCP follows a progressive disclosure pattern - start with natural language, get recommendations, drill down as needed. ### Step 1: Request a Capability ``` You: "I need to look up library documentation" gateway.request_capability({ query: "library documentation" }) ``` For local work where an installed native CLI is the right surface, PMCP returns compact CLI guidance and does not execute the command: ``` gateway.request_capability({ query: "git commits", available_clis: ["git"] }) ``` Returns: ```json { "status": "use_cli", "message": "Use Bash/direct CLI with 'git'. PMCP is recommending the native command here; it is not executing the command or provisioning an MCP server for this path.", "cli": { "name": "git", "description": "Git version control CLI", "available": true, "help_command": ["git", "--help"], "examples": ["git status --short", "git log --oneline -5"], "reason": "Matched query against CLI keywords and examples." }, "recommendation": "Run 'git' directly via Bash/direct CLI. Use gateway.request_capability again only if you need an MCP server." } ``` After `status: "use_cli"`, use Bash/direct CLI. PMCP stops at guidance here: it does not execute the command and does not fetch full native help output for the normal compact path. If PMCP returns server candidates instead, continue with MCP provisioning, `gateway.describe`, and `gateway.invoke`. Returns: ```json { "status": "candidates", "candidates": [{ "name": "context7", "candidate_type": "server", "relevance_score": 0.95, "is_running": true, "reasoning": "Context7 provides up-to-date documentation for any package" }], "recommendation": "Use context7 - already running" } ``` ### Step 2: Search Available Tools ``` gateway.catalog_search({ query: "documentation" }) ``` CLI recommendations are returned separately from MCP tool cards: ``` gateway.catalog_search({ "query": "git" }) ``` Returns: ```json { "results": [{ "tool_id": "github::list_issues", "server": "github", "tool_name": "list_issues", "short_description": "List issues in a repository", "tags": ["github", "git", "search"], "availability": "online", "risk_hint": "low" }], "total_available": 3, "truncated": false, "cli_hints": [{ "name": "git", "description": "Git version control CLI", "available": true, "path": "/usr/bin/git", "help_command": ["git", "--help"], "examples": ["git status --short", "git log --oneline -5"], "reason": "Matched query against CLI name." }] } ``` Use `cli_hints` as recommendations for Bash/direct CLI commands. They are not MCP tools, do not appear in `results`, and cannot be passed to `gateway.describe` or `gateway.invoke`. Start with either `gateway.request_capability` or `gateway.catalog_search`; when PMCP returns `use_cli` or matching `cli_hints`, that is enough context to switch to Bash/direct CLI. Otherwise stay on the MCP path. Registry-backed matches can appear as `registry_candidates` in `gateway.catalog_search` or as `status="candidates"` from `gateway.request_capability`. They are read-only discovery metadata from the MCP Registry cache and may include package identifiers, transport, remote (streamable-http/sse) endpoints for hosted servers, server-card URLs, protected-resource metadata URLs, authorization-server metadata URLs, declared scopes, and placeholder header names. Candidates are deduplicated to the latest published version. PMCP does not install, connect, or pass credentials for a registry result until you explicitly register and provision the selected server. ### Step 3: Get Tool Details ``` gateway.describe({ tool_id: "context7::query-docs" }) ``` ### Step 4: Invoke the Tool ``` gateway.invoke({ tool_id: "context7::query-docs", arguments: { libraryId: "/facebook/react", query: "useEffect cleanup" } }) ``` Downstream tool names and arguments come from the downstream server, not from PMCP; the shipped `context7` and `playwright` entries run the latest published package, so use `gateway.describe` for the current schema. ### Offline Tool Discovery When using `gateway.catalog_search`, you can discover tools from servers that haven't started yet: ``` // Search all tools including offline/lazy servers gateway.catalog_search({ "query": "browser", "include_offline": true }) ``` This uses pre-cached tool descriptions from `.mcp-gateway/descriptions.yaml`, relative to the gateway's working directory. `pmcp refresh` writes to `.pmcp/` by default, so point it at the directory the gateway reads (see [Consiliency/pmcp#352](https://github.com/Consiliency/pmcp/issues/352)): ```bash pmcp refresh --cache-dir .mcp-gateway ``` **Note**: Cached tools show metadata only. Full schemas are available after the server starts (use `gateway.describe` to trigger lazy start). The MCP Registry cache is stored separately, at `$XDG_CACHE_HOME/pmcp/registry-cache.json` (`~/.cache/pmcp/registry-cache.json` when `XDG_CACHE_HOME` is unset); PMCP uses the cache when the public registry is unavailable. Registry candidates can coexist with cached offline tool cards without changing `total_available`. #### Private registry (debugging, opt-in) By default PMCP discovers only from the public MCP Registry and surfaces GA-shaped, latest-version entries. Developers debugging their own **private** MCP servers can opt in with an environment flag (default **off**): ```bash export PMCP_REGISTRY_ALLOW_PRIVATE=1 export PMCP_REGISTRY_PRIVATE_ENDPOINT=https://registry.internal.example/v0/servers ``` The opt-in can also be set with a top-level `"allowPrivateRegistry": true` in `.mcp.json` (project, then user, then `--config`; first one that states it wins). `PMCP_REGISTRY_ALLOW_PRIVATE` overrides the config value only when it is set. The endpoint must be `https://` (plain `http://` only for `localhost` or a loopback address) and must not be a link-local, multicast or unspecified address; otherwise PMCP logs a warning and uses the public registry. When enabled, PMCP fetches from the configured private endpoint and tolerates draft/non-GA `server.json` schema fields, surfacing all versions (including non-latest entries) for inspection. This is a debugging aid, **not for production discovery**; with the flag off, behavior is unchanged. ## Dynamic Server Provisioning PMCP can install and start MCP servers on-demand from a curated manifest of 90+ servers. ### Example: Adding GitHub Support ``` You: "I need to manage GitHub issues" gateway.request_capability({ query: "github issues" }) ``` Returns (if not already configured): ```json { "status": "candidates", "candidates": [{ "name": "github", "candidate_type": "server", "is_running": false, "requires_api_key": true, "env_var": "GITHUB_PERSONAL_ACCESS_TOKEN", "env_instructions": "Create at https://github.com/settings/tokens with repo scope" }] } ``` `requires_api_key` here reflects the **effective** requirement, not merely whether the entry declares one — a server whose manifest entry carries `api_key_optional_when` and whose named variable is set reports `requires_api_key: false` and no `auth_connect` recommendation, even though the underlying entry still has `requires_api_key: true`. See [Private manifest overlay](#private-manifest-overlay) below. ### Provisioning ```bash # 1. Set API key (if required) export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... # 2. Provision via gateway gateway.provision({ server_name: "github" }) ``` ## Optional Eager Startup Packaged manifest servers do not start automatically. They are lazy by default: PMCP can discover or provision them from the manifest, then connect on first use. To eagerly start a server every time PMCP starts, list it in top-level `autoStart`: ```json { "autoStart": ["playwright", "context7"], "mcpServers": {} } ``` Common opt-in choices: | Server | Description | API Key | |--------|-------------|---------| | `playwright` | Browser automation - navigation, screenshots, DOM inspection | Not required | | `context7` | Library documentation lookup - up-to-date docs for any package | Not required | Startup policy decisions are visible through `gateway.health` and live `pmcp status --verbose`. Health rows keep the existing `name`, `status`, `tool_count`, and `error` fields, and may also include: | Field | Meaning | |-------|---------| | `startup_policy` | `eager`, `lazy`, `skipped`, or `unknown` | | `startup_source` | Resolver source such as `project`, `user`, `manifest`, `configured`, or `auto_start` | | `startup_skip_reason` | Machine-readable skip reason such as `policy_denied`, `missing_auth`, or `unknown_auto_start` | | `startup_env_var` | Required environment variable name for missing-auth skips | | `auth_state` | Machine-readable downstream auth state such as `missing_auth`, `insufficient_scope`, `elicitation_required`, or `policy_denied` | | `next_step` | Non-secret suggested next action when an auth state needs operator action | For persistent administration, use the config tools: ``` gateway.config_status({}) gateway.get_startup_policy({}) gateway.set_startup_policy({ "operation": "add", "names": ["playwright"], "source": "project" }) ``` `gateway.set_startup_policy` is preview-only by default. To write, select exactly one `source` or `path` and pass both `"apply": true` and `"dry_run": false`. The writer updates only top-level `autoStart`, preserves unrelated `.mcp.json` keys and server definitions, writes atomically, and returns a refresh next step instead of silently reconnecting servers. Diagnostics report stale `autoStart`, legacy `disableAutoStart` conflicts, policy-denied rows, and missing-auth rows without printing secret values. PMCP negotiates the current MCP protocol version with downstream servers and continues to connect to older supported servers. The local conformance matrix covers negotiated status handling for `2024-11-05`, `2025-03-26`, `2025-06-18`, and `2025-11-25`, with `2025-11-25` preferred for new initialization attempts. `gateway.health` and `pmcp status --json` can include the negotiated `protocol_version` and declared server capabilities when a connected server reports them. Modern MCP task support is conservative. PMCP forwards task-augmented tool calls only when a tool advertises `execution.taskSupport` and the downstream server advertises task capability. Required-task tools fail before dispatch if the server does not advertise task support. Task records are transient gateway state, not durable PMCP storage. The tenant code-mode host contract in `specs/tenant-code-mode-host-contract.md` freezes the PMCP/companion-server boundary for future hosted sandbox execution. PMCP remains the broker; the companion tenant server remains the execution authority. Gateway observability is local and structured. `gateway.invoke` accepts trace context through `_meta.traceparent`, `_meta.tracestate`, and `_meta.baggage` and preserves those string values on PMCP-owned downstream request metadata. The same keys are tolerated on HTTP requests. PMCP does not require or configure an OpenTelemetry exporter. `gateway.health` may include `gateway_diagnostics` and recent `audit_events`. Diagnostics report transport/header compatibility, trace support, audit buffer readiness, auth metadata presence, and rate-limit configuration without secret values. Audit events are bounded in memory and include method/action, server or tool identity, protocol version when known, task ID when present, outcome, latency, auth state, and redacted error text. PMCP's Streamable HTTP endpoint serves two protocol eras on the same `/mcp` route, upstream of clients. A client that negotiates through `initialize` is served the handshake era — `2024-11-05` through `2025-11-25`. A client that instead sends an `MCP-Protocol-Version: 2026-07-28` header together with a `params._meta` envelope carrying `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` is served the modern era — `2026-07-28` — for `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`, and `server/discover`. The modern era is not reachable through `initialize`; it is selected per request by those headers. The modern era has no server-initiated **request** back-channel, so `sampling/createMessage` and `elicitation/create` do not exist at `2026-07-28` — PMCP does not proxy either today, so this is a protocol-era limitation, not a PMCP gap. (Server-initiated *notifications* are a separate mechanism — `subscriptions/listen`, below.) Downstream, PMCP's connections to the servers it proxies are negotiated only at the handshake era described above (`2025-11-25` preferred) — no downstream server is ever reached at `2026-07-28`. A modern-era client's `tools/call` is still proxied live over that handshake-era downstream connection; only the upstream envelope differs. **`GET /mcp` is retired.** It now answers `405 Method Not Allowed` with `Allow: POST, DELETE` instead of accepting a standing connection; there is no persistent GET/SSE channel of any kind, pre-session or otherwise. `GET /health` and `GET /metrics` are unaffected and remain separate, unauthenticated routes. The replacement for server-initiated notifications is `subscriptions/listen` — a long-lived POST stream reachable **only** at protocol version `2026-07-28` — over which a client that opens a subscription receives `notifications/tools/list_changed`, `notifications/resources/list_changed`, and `notifications/prompts/list_changed` as PMCP's own catalog changes — a `gateway.connect_server`, `gateway.disconnect_server`, or `gateway.refresh` call that adds, removes, or updates downstream tools, resources, or prompts, **or a downstream server's own `notifications/*/list_changed`**. A downstream notification is not relayed as-is: PMCP re-indexes that server's catalog first and publishes only once reconciliation confirms something actually moved, so a client that refetches on receipt of the notification sees the new catalog rather than the stale one. Progress and logging notifications from a downstream server are not proxied — catalog-change notifications only. No existing client loses delivered data from the GET retirement — PMCP never published anything on the old GET stream, so this removes a channel PMCP never wrote to, not one clients were receiving events over. The concurrency cap the old pre-session keep-alive shim enforced returns one-for-one as `PMCP_MAX_LISTEN_STREAMS` (default `64`, same as before), now bounding `subscriptions/listen` instead. The old shim's **absolute-lifetime** cap (`PMCP_KEEPALIVE_MAX_SECONDS`) is deliberately **not** replaced — a subscription is long-lived by design, and severing it every N seconds was the defect this release fixes, not a property worth preserving. `PMCP_MAX_KEEPALIVE_STREAMS` and `PMCP_KEEPALIVE_MAX_SECONDS` are both gone; if you relied on either, there is no drop-in replacement for the lifetime cap — what bounds exposure instead is the concurrency cap, the SDK's own per-stream event-backlog cap, and `/mcp` auth whenever `auth_mode` is configured. Servers stopped with `gateway.disconnect_server` remain visible in health as `offline` or `lazy` when PMCP still knows their configuration, and startup policy observation fields are preserved. Example missing-auth health row: ```json { "name": "github", "status": "offline", "tool_count": 0, "startup_policy": "skipped", "startup_source": "manifest", "startup_skip_reason": "missing_auth", "startup_env_var": "GITHUB_PERSONAL_ACCESS_TOKEN" } ``` ## Available Servers The manifest includes 90+ servers that can be provisioned on-demand: ### No API Key Required | Server | Description | |--------|-------------| | `filesystem` | File operations - read, write, search | | `memory` | Persistent knowledge graph | | `fetch` | HTTP requests with robots.txt compliance | | `sequential-thinking` | Problem solving through thought sequences | | `git` | Git operations via MCP | | `sqlite` | SQLite database operations | | `time` | Timezone operations | | `puppeteer` | Headless Chrome automation | ### Requires API Key | Server | Description | Environment Variable | |--------|-------------|---------------------| | `github` | GitHub API - issues, PRs, repos | `GITHUB_PERSONAL_ACCESS_TOKEN` | | `gitlab` | GitLab API - projects, MRs | `GITLAB_PERSONAL_ACCESS_TOKEN` | | `slack` | Slack messaging | `SLACK_BOT_TOKEN` | | `notion` | Notion workspace | `NOTION_TOKEN` | | `linear` | Linear issue tracking | `LINEAR_API_KEY` | | `postgres` | PostgreSQL database | `POSTGRES_URL` | | `brave-search` | Web search | `BRAVE_API_KEY` | | `google-drive` | Google Drive files | `GDRIVE_CREDENTIALS` | | `sentry` | Error tracking | `SENTRY_AUTH_TOKEN` | | `stripe` | Payments and billing | `STRIPE_SECRET_KEY` | | `datadog` | Monitoring and observability | `DATADOG_API_KEY` | | `cloudflare` | Edge network and Workers | `CLOUDFLARE_API_TOKEN` | | `figma` | Design files and components | `FIGMA_API_KEY` | | `jira` | Issue tracking | `ATLASSIAN_API_TOKEN` (also set `ATLASSIAN_SITE_NAME`, `ATLASSIAN_USER_EMAIL`) | | `airtable` | Spreadsheet database | `AIRTABLE_API_KEY` | | `hubspot` | CRM and marketing | `HUBSPOT_ACCESS_TOKEN` | | `twilio` | SMS and voice | `TWILIO_ACCOUNT_SID` | | `...and 70+ more` | Use `gateway.catalog_search` to explore | — | See `.env.example` for all supported environment variables. ## Code Execution Guidance PMCP includes built-in guidance to encourage models to use code execution patterns, reducing context bloat and improving workflow efficiency. ### Guidance Layers **L0 (MCP Instructions)**: Brief philosophy in server instructions (~60-90 tokens) - "Write code to orchestrate tools - use loops, filters, conditionals" **L1 (Code Hints)**: Ultra-terse hints in search results (~8-12 tokens/card) - Single-word hints: "loop", "filter", "try", "poll" (cut to `max_hint_length`, default 8 characters) **L2 (Code Snippets)**: Minimal examples in describe output (~40-80 tokens, opt-in) - 3-4 line code examples showing practical usage **L3 (Methodology Resource)**: Full guide (lazy-loaded, 0 tokens) - Accessible via `pmcp://guidance/code-execution` resource ### Guidance Configuration Create `~/.claude/gateway-guidance.yaml`: ```yaml guidance: level: "minimal" # Options: "off", "minimal", "standard" layers: mcp_instructions: true # L0 philosophy code_hints: true # L1 hints code_snippets: false # L2 examples (default: off) methodology_resource: true # L3 guide enable_telemetry: true # failure feedback hints and issue previews enable_feedback_submission: false # let PMCP POST feedback to GitHub (default: off) # custom_instructions: "..." # optional: replaces the default L0 workflow text ``` **Levels**: - `minimal` (default): L0 + L1 (~230 tokens overhead) - `standard`: L0 + L1 + L2 (~290 tokens overhead) - `off`: No guidance `level` is a preset that sets all four `layers` flags, and it is applied after the file is read, so it overrides whatever `layers:` says: `level: minimal` with `code_snippets: true` still has no L2 snippets. To get L2, set `level: standard`. ### View Guidance Status ```bash pmcp guidance # Show configuration pmcp guidance --show-budget # Show token estimates pmcp guidance --telemetry on|off # Feedback telemetry pmcp guidance --feedback-submission on|off # Let PMCP post feedback (default: off) ``` `pmcp guidance` prints both switches, and each `--` form above persists the decision to `~/.claude/gateway-guidance.yaml` before printing the new state. See [Feedback Telemetry](#feedback-telemetry) for what submission requires. ### Token Budget - **Minimal mode**: ~230 tokens typical workflow (L0 + a 15-card search) - **Standard mode**: ~290 tokens (L0 + search + 1 describe) - **80% reduction** vs loading all tool schemas upfront! ## Configuration ### Config Discovery PMCP discovers MCP servers from: 1. **Project config**: `.mcp.json` in project root (highest priority). The project root is `--project` when given, otherwise the nearest ancestor of the working directory holding a `.mcp.json`, `.git`, `package.json`, `pyproject.toml` or `.pmcp/manifest.yaml`, stopping before `$HOME`. Every project input -- this file, the manifest overlay, the project policy and the project's credentials -- comes from that one project root. A project `.mcp.json` is ignored until you run `pmcp trust approve ` (see [Security](#security)). 2. **User config**: `~/.mcp.json` or `~/.claude/.mcp.json` 3. **Custom config**: Via `--config` flag or `PMCP_CONFIG` env var On a server-name collision the earlier source wins. `PMCP_CONFIG`, `PMCP_POLICY` and `PMCP_MANIFEST_PATH` are honoured only when exported in the shell that starts PMCP; a value set by a `.env` or `.env.pmcp` file is ignored with a warning. ### Private manifest overlay If you built your own MCP servers (or want private provisionable definitions), you can add manifest entries **without editing the shipped manifest**. PMCP merges these overlay files over the built-in manifest, so your servers get first-class treatment in `gateway.request_capability` keyword matching, `gateway.catalog_search` offline discovery, `gateway.provision`, and startup resolution — answering "can I add my own private manifest items?" with yes. Overlay locations, lowest → highest precedence (later overrides earlier, by server name; a same-named entry is replaced whole, not deep-merged): 1. **Shipped manifest** (base) 2. **User**: `~/.pmcp/manifest.yaml` 3. **Project**: `/.pmcp/manifest.yaml` at the served project root -- `--project` when given, otherwise the project root found from the working directory (see the project config above); never `$HOME`'s, which is the user overlay; ignored until you run `pmcp trust approve ` 4. **Explicit**: `PMCP_MANIFEST_PATH` env var (wins over all; honoured only when exported, not when set by a `.env` file) Overlays use the same entry schema as the shipped manifest. Example `~/.pmcp/manifest.yaml`: ```yaml servers: # Local (stdio) server provisioned via a command my-private: description: "My private internal server" keywords: [myprivate, internal widget] command: "npx" args: ["-y", "@me/my-mcp"] requires_api_key: true env_var: MY_TOKEN # Remote server reached over streamable HTTP my-remote: description: "My private remote server" keywords: [myremote, internal api] url: "https://mcp.internal.example.com/mcp" headers: Authorization: "Bearer ${MY_REMOTE_TOKEN}" ``` A server that supports a self-hosted, keyless deployment can declare which `extra_env` variable makes its credential optional via `api_key_optional_when`. Declaring the field alone changes nothing — an operator must separately supply that variable. The shipped `firecrawl` entry already declares `api_key_optional_when: ["FIRECRAWL_API_URL"]`, so supplying the URL is all an overlay needs to do — via a `server_env` patch, **not** a `servers:` block (`servers:` is whole-entry replace: a partial `firecrawl:` entry here would erase its command/install metadata and reset `requires_api_key` to its unset default, turning the credential gate off): ```yaml server_env: firecrawl: FIRECRAWL_API_URL: "http://localhost:3002" ``` Both parties must act — the manifest entry names the variable, and the operator supplies it — so no overlay can unilaterally relax a credential the entry never declared relaxable. A server naming its own credential as its own relaxer is ignored, and an unset, empty, or unexpanded `${VAR}` value fails closed: the credential stays required. Overlay loading is **fail-soft**: a missing file is skipped silently, and a malformed file logs a warning and is skipped; so does a single entry that pmcp could not use: a field some part of pmcp would fail on, such as `keywords: [1]`, an `args` list holding a number, a `command` or `transport` that is not a string, or a CLI alternative with an empty `check_command`. Fields pmcp stores but never reads (`status`), or reads only as true or false (`auto_start`, `requires_api_key`), are accepted whatever their value, as before. A `transport` string without a `url` is accepted as before, whatever the string; with a `url` it must be one of pmcp's transport names (`local`, `remote`, `sse`, `http`, `streamable-http`). The warning names the field, not its value, and does not show the entry's name unless pmcp ships it. A blank field (`description:` with nothing after it) means "not set". None of this crashes the gateway — the shipped manifest always still loads. pmcp re-reads overlay files on every manifest load, so edits apply without a restart, and logs each warning once per change rather than on every load — except that a file that cannot be read or parsed is reported on every load until it is fixed. An overlay's servers are found by their own keywords. Sharing a keyword with another server, or replacing a shipped server, never hides another server from `gateway.catalog_search` or `gateway.request_capability` (within the result limit; ties rank by name; a query that names a server resolves to it). A CLI an overlay adds or replaces is recommended only when no server matches, and is listed after pmcp's own CLIs. > **Security:** a manifest entry can specify an arbitrary `command`/`args` to > run when provisioned — treat an overlay file with the same trust as your own > `.mcp.json`. Policy still applies (denied servers stay denied). ### Pinning a client version To hold an npx-launched server at one version, add to `~/.pmcp/manifest.yaml` (or an approved project overlay): ```yaml server_version: firecrawl: "3.25.5" ``` or `version: "3.25.5"` on a whole `servers:` entry. pmcp writes the pin into the server's args and every install argv, keeping the package name. It must be one exact version: a range, a dist-tag such as `latest`, build metadata or a package spec is ignored with a warning. What happens to an earlier pin depends on where the bad pin sits. Sources apply in order (shipped, user, project, `PMCP_MANIFEST_PATH`), and within one source `servers:` entries replace whole entries before `server_version` patches apply. - **A bad `server_version`:** any pin from an earlier source stands, unless a `servers:` entry for this server in this or a later source replaced it, or a later source set another pin. - **A bad `version:`:** it rides on a whole `servers:` entry, which replaced the earlier entry and any pin with it. So the server is unpinned unless this source's `server_version` or a later source pins it. `server_version: {name: null}` sets nothing; to remove a pin, delete its line. Only npx servers whose package slot is a plain registry name (`pkg`, `pkg@1.2.3`, `pkg@tag`) can be pinned this way, and only if the entry's own env cannot redirect npm: locale and terminal keys, npm's logging/timing settings and the keys pmcp ships for that server are fine; anything else (such as `npm_config_registry` or `NODE_OPTIONS`) makes pmcp ignore the pin. Only a bare `npx` launcher is pinned (not a path such as `./npx` or `node_modules/.bin/npx`; on Windows, `npx.cmd`/`npx.exe` too). The launcher of every `install` argv is judged by the rules of the host pmcp runs on, whatever platform key it sits under, so a `windows:` argv using `npx.cmd` refuses the pin on Linux or macOS. For uvx, pip, cargo or docker, or any other case, pin the version in the server's own `command`/`args` in `.mcp.json` (or the file you pass with `--config`). The pin fixes the npm spec; npm resolves it in your own environment (your `.npmrc`, npm's cache, and a `node_modules` in the directory pmcp runs in; a `.npmrc` there can also set the npm keys allowed next to a pin). `gateway.update_server` does not move a pinned server. `pmcp update` shows it as `[PINNED]` with the version you wrote, except when npm configuration is set where pmcp cannot see past it. That happens in three cases: - the entry itself sets an npm setting such as `npm_config_loglevel`; - the gateway process's own environment sets any `npm_config_*` variable (for example an exported `npm_config_cache`) or `NODE_OPTIONS`, which turns off package detection for the whole process; - the directory the gateway runs in, or any directory above it, contains a `package.json` or a `node_modules` directory (npm would then read that project's `.npmrc` or run a local bin). This covers running pmcp from inside a Node project, or anywhere under a directory tree with a `node_modules` higher up. In each case it prints `[FAILED] … Could not determine a registry package`, and still moves nothing: the pin is still in every argv. ### Adding Custom Servers For MCP servers not in the manifest, add them to `~/.mcp.json`: ```json { "autoStart": ["my-custom-server"], "mcpServers": { "my-custom-server": { "command": "node", "args": ["./my-server.js"], "env": { "API_KEY": "..." } } } } ``` PMCP supports both local command-based and remote URL-based downstream entries from discovered config files. Entries in `mcpServers` make downstream servers available lazily/on demand; they do not by themselves mean the server should be eagerly started. #### `index-it-mcp` code-index pilot For a fleet pilot of the local-first code indexer, add `index-it-mcp` to your `.mcp.json` with a **pinned** version and its operational env. PMCP spawns it over stdio and applies the `env` block **verbatim** on top of the gateway's environment, minus PMCP-stored credentials and keys PMCP loaded from `.env` / `.env.pmcp` (`_connect_stdio` builds the child env with `sanitized_subprocess_env(config.env, project_root)`), so this is the supported channel for the server's configuration: ```json { "mcpServers": { "index-it-mcp": { "command": "uvx", "args": ["--from", "index-it-mcp==", "index-it-mcp", "stdio"], "env": { "MCP_ALLOWED_ROOTS": "/path/to/repo", "SEMANTIC_SEARCH_ENABLED": "true", "SEMANTIC_DEFAULT_PROFILE": "code", "SEMANTIC_EMBEDDING_BASE_URL": "http://localhost:8000/v1", "QDRANT_URL": "http://localhost:6333", "OPENAI_API_KEY": "local-vllm-placeholder" } } } } ``` Notes: - **Pin the version** (`index-it-mcp==`) so a PyPI release-line change can't silently swap the indexer under a running fleet. Replace `` with the operator-approved pin. - **`OPENAI_API_KEY`** is only the token the server presents to a local OpenAI-compatible endpoint (e.g. a vLLM embedding server at `SEMANTIC_EMBEDDING_BASE_URL`); it is not an api.openai.com secret. - **Env must go via `.mcp.json`.** An `env:` key in a shipped-manifest or private-overlay entry is ignored. Manifest entries take non-secret variables only through `extra_env:` (or an overlay `server_env:` patch), which cannot carry a credential; for a server like this one, put its env in the `.mcp.json` `mcpServers` entry. - **Pin the Python interpreter** with `"args": ["--python", "3.12", "--from", …]`. `index-it-mcp==1.2.0` depends on `tree-sitter-languages`, which has no wheel for CPython 3.13; without the pin, `uvx` may pick 3.13 and fail to launch. **Repository registration must use the same storage env as the config.** Before agents get indexed results, each repo must be registered *and* the gateway-spawned server must read the registry the registration wrote. The registry location is resolved from `MCP_INDEX_STORAGE_PATH` / `MCP_REPO_REGISTRY`. If you set those in the `.mcp.json` `env` above but run `index-it-mcp repository register` **without** them, the CLI writes to the default `~/.mcp/repository_registry.json` while the PMCP-spawned server reads `$MCP_INDEX_STORAGE_PATH/repository_registry.json` — so the server reports `repositories: []` and every query falls back to native search (`unregistered_repository`). Register with the **same** env the config uses: ```bash export MCP_INDEX_STORAGE_PATH=/path/to/shared/.indexes export MCP_REPO_REGISTRY="$MCP_INDEX_STORAGE_PATH/repository_registry.json" for repo in /path/to/repo-a /path/to/repo-b; do uvx --python 3.12 --from index-it-mcp==1.2.0 index-it-mcp repository register "$repo" done # Verify the server-side view before trusting results: uvx --python 3.12 --from index-it-mcp==1.2.0 index-it-mcp repository list # expect your repos uvx --python 3.12 --from index-it-mcp==1.2.0 index-it-mcp preflight # readiness check ``` **Check readiness before trusting indexed answers.** The server's status/query tools report a readiness state (`ready`, `unregistered_repository`, `missing_index`, `stale_commit`, …) and, when not ready, `safe_fallback: "native_search"`. Agents should treat any non-`ready` state as non-authoritative and fall back to native search rather than reporting stale/empty index results. The top-level `autoStart` list controls explicit eager startup. Names can refer to servers defined in `mcpServers` or packaged manifest entries such as `playwright` and `context7`. Omit a server from `autoStart` to keep it lazy. The legacy top-level `disableAutoStart` list is still honoured and overrides `autoStart`. `PMCP_LEGACY_MANIFEST_AUTOSTART=1` additionally eager-starts manifest entries that declare `auto_start: true`; no shipped entry does, so it matters only for overlay entries. The same policy is available locally from the CLI: ```bash pmcp config status --json pmcp config startup-policy pmcp config set-startup-policy add playwright --source project pmcp config set-startup-policy add playwright --source project --apply ``` CLI mutation previews by default. `--apply` is required before writing. Writing an approved project `.mcp.json` this way carries its trust approval forward to the rewritten bytes; an unapproved one stays ignored until `pmcp trust approve`. Lazy Excalidraw example: ```json { "mcpServers": { "excalidraw": { "type": "http", "url": "https://mcp.excalidraw.com/mcp" } } } ``` Eager Excalidraw example: ```json { "autoStart": ["excalidraw"], "mcpServers": { "excalidraw": { "type": "http", "url": "https://mcp.excalidraw.com/mcp" } } } ``` #### Remote Downstream Servers You can also configure downstream MCP servers over HTTP/SSE directly in `.mcp.json` using `type: "sse"` or `type: "http"` (or `type: "remote"` for generic remote transport): ```json { "mcpServers": { "acme-sse": { "type": "sse", "url": "https://mcp.acme.dev/sse", "headers": { "Authorization": "Bearer ${ACME_MCP_TOKEN}", "X-Tenant": "${ACME_TENANT_ID}" } }, "acme-http": { "type": "http", "url": "https://mcp.acme.dev/mcp", "headers": { "Authorization": "Bearer ${ACME_MCP_TOKEN}" } } } } ``` - `url` should be the full remote endpoint for that server. - `headers` values support `${ENV_VAR}` interpolation (Consiliency/pmcp#40). - Resolve those environment variables from your shell environment or `~/.config/pmcp/pmcp.env`. **Important**: Don't add `pmcp` itself to this file. PMCP is configured in your MCP client config, not in the downstream server list. #### Tenant Code-Mode Server Registration PMCP can broker a separate tenant code-mode MCP server as a normal downstream server. The contract in `specs/tenant-code-mode-host-contract.md` defines the boundary: PMCP discovers, invokes, monitors, truncates, and redacts through gateway surfaces; the companion tenant server owns sandbox execution, tenant authorization, logs, and artifacts. PMCP does not run scripts itself. Register the hosted server in `.mcp.json` with the configured name `tenant-code-mode`: ```json { "mcpServers": { "tenant-code-mode": { "type": "streamable-http", "url": "https://tenant.example.com/mcp", "headers": { "Authorization": "Bearer ${TENANT_CODE_MODE_MCP_TOKEN}", "X-Tenant-ID": "${TENANT_CODE_MODE_TENANT_ID}" } } } } ``` For local companion-server development, use a replaceable stdio command from that server's checkout: ```json { "mcpServers": { "tenant-code-mode": { "command": "/path/to/tenant-code-mode-server", "args": ["serve", "--transport", "stdio"] } } } ``` The registration is lazy by default. Add `tenant-code-mode` to top-level `autoStart` only when the operator wants PMCP to connect during startup. Discovery and startup use the existing `gateway.request_capability`, `gateway.catalog_search` with `include_offline: true`, `gateway.provision`, and `gateway.invoke` flow. Tenant runs use the existing task broker. Submit long-running work with `gateway.invoke` and non-secret `task.metadata`, `task.ttl`, `task.poll_interval`, `task.requestor_context`, and trace keys such as `_meta.traceparent`; PMCP forwards those fields to the downstream server only when the server and tool advertise task support. `task.ttl` and `task.poll_interval` are in seconds, at most 9,007,199,254,740. PMCP sends them downstream in milliseconds, which is the unit MCP 2025-11-25 uses (`ttl: 300` reaches the server as `ttl: 300000`). The `ttl` and `poll_interval` of every task PMCP returns are converted back from the server's milliseconds to seconds, and may be fractional (`1500` ms is reported as `1.5`). A task's `raw` object keeps the values exactly as the server sent them. The returned downstream MCP task ID is then used with `gateway.tasks_list`, `gateway.tasks_get`, `gateway.tasks_result`, and `gateway.tasks_cancel`. Do not use PMCP request IDs from `gateway.list_pending` or `gateway.cancel` for tenant task operations. `gateway.tasks_result` continues to apply host-side truncation and optional secret redaction to sandbox-shaped logs and diagnostics. ### Credential Scope Management (`pmcp secrets`) PMCP stores secrets in environment files by scope: - `user` scope: `~/.config/pmcp/pmcp.env` - `project` scope: `/.env.pmcp` You can manage both scopes with `pmcp secrets`: ```bash # Store a secret in user scope (shared by all projects); prompts for the value pmcp secrets set API_TOKEN --scope user # Store a secret in project scope (the default scope), reading the value from stdin printf '%s' "$TOKEN" | pmcp secrets set API_TOKEN --stdin --project /path/to/project # Copy all user-scoped secrets into project scope pmcp secrets sync --from-scope user --to-scope project --overwrite # Copy project-scoped secrets into user scope pmcp secrets sync --from-scope project --to-scope user --overwrite ``` Copying out of a project copies credentials only. A project `.env.pmcp` is the repository's, and your user store loads into pmcp's environment at every start (and `pmcp upgrade` runs uv or pip in it), so `sync` copies a name only if it is credential-shaped (`*_TOKEN`, `*_KEY`, `*_SECRET(S)`, `*_PASSWORD`, `*_CREDENTIAL(S)`, `*_PAT`, `*_DSN`, `*_AUTH`) or a server in the manifest declares it as its credential (`POSTGRES_URL`, say). Everything else -- `UV_INDEX_URL`, `PIP_CONFIG_FILE`, `DATABASE_URL`, anything named `PMCP_*`, proxies, `LD_*`/`NODE_*`/`NPM_CONFIG_*` and the like, even when credential-shaped -- is skipped, as is any value containing `${` or spanning more than one line. Each skipped name prints one `pmcp: Not copying from .env.pmcp: ...` line and is listed under `"refused"`; the other keys still sync. Set one yourself with `pmcp secrets set --scope user` if you mean it. Passing the value on the command line (`pmcp secrets set API_TOKEN your-token`) still works but exposes it in `ps` output and shell history. `pmcp secrets check` prints, as JSON, the keys your configured servers require and which are present. Use scope-appropriate values such as `API_TOKEN` and keep the values in the generated `.env` files; PMCP and downstream MCP servers read from these files according to your active mode. For service users, `~/.config/pmcp/pmcp.env` is ideal for shared tokens used by all sessions. ### Policy File Create a policy file to control access and limits: **~/.claude/gateway-policy.yaml**: ```yaml servers: allowlist: [] # Empty = allow all denylist: - dangerous-server tools: denylist: - "*::delete_*" - "*::drop_*" packages: # npm packages; globs match the package NAME only allowlist: - "@acme/*" # Discovered servers provision without a per-version approval denylist: - "*-evil-*" # Refused even when approved, manifest-backed servers included limits: max_tools_per_server: 100 max_output_bytes: 50000 max_output_tokens: 4000 redaction: patterns: - "(api[_-]?key)[\\s]*[:=][\\s]*[\"']?([^\\s\"']+)" - "(password|secret)[\\s]*[:=][\\s]*[\"']?([^\\s\"']+)" ``` Every section has an `allowlist` and a `denylist` of glob patterns; a denylist match always refuses, and a non-empty allowlist admits only what it matches. Besides `servers`, `tools` (`server::tool`), `packages`, `limits` and `redaction` shown above, three sections are available: - `gateway_tools` — which `gateway.*` tools are advertised and callable (for example, allowlist only `gateway.health`, `gateway.catalog_search`, `gateway.describe` and `gateway.invoke`). - `resources` — downstream resources, matched as `server::uri`. - `prompts` — downstream prompts, matched as `server::name`. PMCP looks for the policy at `~/.claude/gateway-policy.yaml` or `~/.claude/gateway-policy.json`, and for a project policy at `.mcp-gateway-policy.yaml` or `.mcp-gateway-policy.json` at the project root (`--project` when given, otherwise the project root found from the working directory -- the same root as every other project input); a project policy is ignored until approved with `pmcp trust approve`, and can only narrow the operator's policy. `--policy` / `PMCP_POLICY` names one explicitly. An explicitly requested policy (`--policy` or `PMCP_POLICY`) is a fail-closed boundary: a missing, unreadable, malformed, or schema-invalid file terminates startup. An automatically discovered policy at a default location is fail-closed too, with one deliberate exception. A discovered file that **parses but is not a valid policy** terminates startup exactly like an explicit one, because falling back would replace it with the default allow-all policy and silently unrestrict the gateway. Best-effort fallback now covers only a file that cannot be read, or that the parser **rejects outright** — which could be anything rather than a policy; that case warns, says that no policy is in effect, and continues. The line between the two is drawn by the parser, not by the file's shape, so it falls in different places for the two formats. In a `.yaml` file a list root, a scalar root and an **empty file** all load cleanly — `yaml.safe_load` returns a `list`, a `str` and `None` — and so all three are fatal. In a `.json` file, a document whose root is valid JSON but not an object (`[]`, `42`, `null`) is likewise fatal, but an **empty `.json` file is not valid JSON at all**, so it takes the warn-and-continue path. If you are testing this behaviour, use an empty `.yaml` file to see the refusal. #### Discovered packages need an operator's approval A server registered with `gateway.register_discovered_server` runs an npm package an agent chose, so it does not start until an operator opts that package in. Registration resolves the package in the npm registry and pins the resolved version into the server's install command and its `args` (`npx -y name@version`); a package that does not resolve to one exact version is refused at registration. After that, `gateway.provision`, `gateway.connect_server` and `gateway.restart_server` refuse the server until one of these holds: - **The exact version is approved.** The refusal message prints the command to run, for example: ```bash pmcp trust approve-package @acme/example-server@1.4.2 ``` Then provision again. The approval covers that one version: a registration that resolves to a newer version needs a new approval. - **The operator's policy allowlists the package name** under `packages.allowlist`, as in the example above. A `packages.denylist` match refuses the package in every case, including when an approval is recorded; such a refusal reports `auth_state="policy_denied"`, and every other package refusal reports `auth_state="unknown"`. Package globs match the name alone. A version-bearing entry such as `pkg@1.2.3` is rejected when the policy loads, because it could never match. A project `.mcp-gateway-policy.yaml` can deny a package but cannot allow one the operator's policy does not. `gateway.update_server` never updates a discovered server, approved or not. To move one to a newer version, call `gateway.register_discovered_server` again with the same `server_name` and `package`, approve the version it resolves, then connect it. A discovered server may declare, in `env_vars` or through `gateway.auth_connect`, only credential-shaped variable names: names ending in `_TOKEN`, `_KEY`, `_SECRET`, `_SECRETS`, `_PASSWORD`, `_CREDENTIAL`, `_CREDENTIALS`, `_PAT`, `_DSN` or `_AUTH`. Names that start with `NPM_CONFIG_`, `NODE_`, `COREPACK_`, `YARN_`, `PNPM_` or `BUN_` (in any case) and names that affect code loading are refused even when credential-shaped, because they change what `npx` fetches or how it runs. Configure a server that needs any other variable in `.mcp.json` instead. Manifest-backed and `.mcp.json` servers need no approval and keep their env var rules. The one exception is the denylist: a `packages.denylist` entry also refuses a **manifest-backed** server whose npm package it names (its `package` field, or a package its `npx` command or install command runs, including one chosen with `-p` or `--package`), at `gateway.provision`, `gateway.connect_server`, `gateway.restart_server` and `gateway.update_server`. While any `packages.denylist` is in force, a manifest entry whose npx packages pmcp cannot determine (an unrecognised npx option such as `--registry` or `-c`, or a package selected as `github:owner/repo` or `./dir`) is refused too, and the refusal names the argument; without a denylist it starts as before. `.mcp.json` servers are not checked against the package lists. Approvals live in `~/.config/pmcp/package_approvals.json`, beside the trust store. Review and remove them with: ```bash pmcp trust list-packages pmcp trust revoke-package @acme/example-server@1.4.2 # one version pmcp trust revoke-package @acme/example-server # every version ``` Every install spawn logs its command at WARNING before it runs. That includes starting a stdio server whose command is `npx`, `uvx`, `pnpx` or `bunx`, in any Windows or POSIX spelling (such as `C:\tools\npx.cmd` or `UVX.EXE`), and every `gateway.update_server` probe. Arguments are redacted except the executable, the flags `-y`, `--yes` and `--quiet`, `--registry` (its name, not its value) and a pinned `name@version`, so an operator can see which package ran without a credential reaching the log. #### Scoped advisor research PMCP v1.20.0 adds the `scoped_advisor_audit.v1` profile for isolated advisor research. Start each seat with the shipped policy, a unique lock directory, and an explicit audit sink: ```bash pmcp \ --policy examples/scoped-advisor-policy.yaml \ --audit-jsonl /run/board/seat-1/audit.jsonl \ --lock-dir /run/board/seat-1/locks ``` The profile exposes only `gateway.health`, `gateway.catalog_search`, `gateway.describe`, and `gateway.invoke`; downstream invocation is limited to the policy's Firecrawl and Bright Data research patterns. MCP resource and prompt surfaces are denied, and scoped catalog results omit native-CLI, registry, and provision candidates. Every invoke must supply `run_correlation_id`, `seat_correlation_id`, and a SHA-256 `evidence_label_digest` together. The append-only audit stores correlations, tool/status/policy/result digests, and a hashed public-source reference—not raw URLs, queries, arguments, credentials, or result bodies—and ends with one fsynced completeness marker. A call whose arguments fail the tool's input schema or its argument model is recorded as a separate `audit.rejection` event (`terminal_status: "invalid_arguments"`) carrying only the tool, the failing JSON path (caller-chosen keys shown as `null`) and the failing schema keyword (`null` for a model rejection). The rejection the caller sees, and the log line, name the same path and a reason taken from the schema, never the rejected value. Only arguments that passed the tool's input schema, and only the top-level keys it declares, are read into any record; a call denied by policy records none of its arguments. Consumers can fail closed on older installations with: ```bash pmcp capabilities --json ``` The capability is active in `gateway.health` only when the exact explicit advisor policy and audit sink are both present. Concurrent seats must use unique `--lock-dir` and `--audit-jsonl` paths. Tenant code-mode hosting uses the same policy fields. This example allows only the tenant server, blocks a high-risk submission tool, bounds output, and adds a tenant artifact redaction pattern without granting access to unrelated MCP servers: ```yaml servers: allowlist: - tenant-code-mode tools: denylist: - "tenant-code-mode::run_script" allowlist: - "tenant-code-mode::get_*" - "tenant-code-mode::cancel_*" limits: max_output_bytes: 50000 max_output_tokens: 4000 redaction: patterns: - "TENANT_CODE_MODE_[A-Z_]+=[^\\s]+" - "artifact_token=[^\\s]+" ``` For hosted tenant auth, keep credentials in PMCP env storage or tenant-scoped project storage and reference only placeholders from config: `${TENANT_CODE_MODE_MCP_TOKEN}` and `${TENANT_CODE_MODE_TENANT_ID}`. Use `pmcp secrets set ... --scope project` or `gateway.auth_connect` to populate env-store values (a key also in your user store wins over the project's; see [MIGRATING.md](MIGRATING.md#a-project-file-supplies-credentials-only)); the gateway does not select a per-tenant env file itself (see [Auth And Elicitation](#auth-and-elicitation)). PMCP diagnostics report missing field or env-var names such as `TENANT_CODE_MODE_MCP_TOKEN`; they must not print token values. Hosted operators should require Bearer auth on `/mcp`, tune `--rate-limit` or `PMCP_RATE_LIMIT` for the deployment, and keep `/health` and `/metrics` behind network controls. `gateway.refresh`, `gateway.disconnect_server`, and `gateway.restart_server` can disrupt in-flight downstream work unless forced by policy; use downstream task IDs with `gateway.tasks_cancel` for tenant run cancellation. PMCP task records are transient, and bounded: at most 100 per server and 1000 in total. To cancel a task pmcp no longer tracks, call `gateway.tasks_get` first, which tracks it again. A forced disconnect, restart or refresh cancels only the tasks pmcp still tracks when it starts. On a remote tenant server, a task that was evicted before then keeps running after the disconnect. Cancel it explicitly first, with `gateway.tasks_get` and then `gateway.tasks_cancel`. Durable sandbox logs, artifacts, tenant authorization, and artifact retention remain responsibilities of the companion tenant server and its deployment controls. ### CLI Commands ```bash # Start the gateway server (default) pmcp # Check server status pmcp status pmcp status --json # JSON output pmcp status --verbose # Include startup policy details when available pmcp status --server playwright # Filter by server pmcp status --pending # Include pending requests pmcp status --probe # Actively connect to each configured server # View logs (reads .pmcp/logs/gateway.log in the current directory) pmcp logs pmcp logs --follow # Live tail pmcp logs --tail 100 # Last 100 lines (default 50) pmcp logs --level error # Filter by level pmcp logs --server github # Filter by server name # Regenerate cached tool descriptions (default cache dir: .pmcp; the gateway # reads .mcp-gateway, so pass --cache-dir .mcp-gateway for offline discovery) pmcp refresh pmcp refresh --server github # Refresh specific server pmcp refresh --force # Refresh even if not stale pmcp refresh --check-versions # Report stale cached descriptions without refreshing # (honours --cache-dir; a local server whose package # cannot be classified reports stale on every run) # Update subordinate MCP server packages (needs a running HTTP gateway at PMCP_GATEWAY_URL) pmcp update github pmcp update --all --json # Upgrade pmcp itself (detects uv tool vs pip --user) pmcp upgrade --dry-run pmcp upgrade --restart-service # Machine-readable capability/version metadata pmcp capabilities --json # Initialize config (interactive) pmcp init # Render client setup snippets pmcp setup pmcp setup --client claude --mode stdio pmcp setup --client opencode --mode http --write # Run diagnostics for lock/mode/http checks pmcp doctor pmcp doctor --project /path/to/project pmcp doctor --timeout 10 # HTTP health probe timeout (default 3s) # Manage project/user secrets pmcp secrets set API_TOKEN --scope user # prompts for the value pmcp secrets sync --from-scope user --to-scope project --overwrite pmcp secrets check # JSON: keys required by configured servers, present/missing # Store a credential for a server and retry provisioning through the running # HTTP gateway (CLI form of gateway.auth_connect) pmcp auth connect github --scope user # prompts for the credential pmcp auth acknowledge my-remote --elicitation-id # Approve, list and revoke trust decisions pmcp trust approve /abs/path/to/project/.mcp.json # a project config file's current bytes pmcp trust list pmcp trust revoke /abs/path/to/project/.mcp.json pmcp trust approve-package @acme/example-server@1.4.2 # a discovered package, one exact version pmcp trust list-packages pmcp trust revoke-package @acme/example-server # every version, or name@version for one ``` ### Gateway options and environment variables Options for the gateway itself (`pmcp` with no subcommand): | Flag | Env var | Default | Purpose | | --- | --- | --- | --- | | `--transport {stdio,http}` | `PMCP_TRANSPORT` | `stdio` | Transport to serve. | | `--host` | `PMCP_HOST` | `127.0.0.1` | HTTP bind address. | | `--port` | `PMCP_PORT` | `3344` | HTTP port. | | `-l`, `--log-level` | `PMCP_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`; `--debug` and `-q` are shorthands. | | `--log-format {text,json}` | — | `text` | `json` emits one JSON object per line. | | `-p`, `--project` | — | discovered | Project root for `.mcp.json` discovery. | | `-c`, `--config` | `PMCP_CONFIG` | — | Extra MCP config file. | | `--policy` | `PMCP_POLICY` | discovered | Policy file (YAML or JSON). | | `--lock-dir` | `PMCP_LOCK_DIR` | `~/.pmcp` | Singleton lock directory. | | `--audit-jsonl` | `PMCP_AUDIT_JSONL` | — | Scoped-advisor audit sink. | | `--auth-token` | `PMCP_AUTH_TOKEN` | — | Shared-secret bearer token (HTTP only). | | `--auth-token-file` | — | — | Read the token from a file (whitespace stripped); cannot be combined with `--auth-token`. | | `--max-concurrent-spawns` | `PMCP_MAX_SPAWNS` | `8` | Child MCP server processes spawned at once. | | `--rate-limit` | `PMCP_RATE_LIMIT` | `0` (unlimited) | Requests per minute per client IP on `/mcp`. | | `--request-timeout` | `PMCP_REQUEST_TIMEOUT` | `60` | HTTP request timeout, in seconds. | The auth and OAuth flags are listed under [Security](#security). Precedence is not uniform: `PMCP_TRANSPORT`, `PMCP_HOST`, `PMCP_PORT` and `PMCP_LOG_LEVEL` override the flag whenever they are set (for the log level, `--debug` and `-q` still win over `PMCP_LOG_LEVEL`), while every other variable above is read only when its flag is absent. `--auth-token-file` wins over `--auth-token`, which wins over `PMCP_AUTH_TOKEN`. Other environment variables: | Env var | Purpose | | --- | --- | | `PMCP_GATEWAY_URL` | Running HTTP gateway that `pmcp status`, `pmcp update`, `pmcp auth` and `pmcp doctor` talk to (default `http://127.0.0.1:3344/mcp`). | | `PMCP_MANIFEST_PATH` | Explicit manifest overlay ([Private manifest overlay](#private-manifest-overlay)). | | `PMCP_MAX_LISTEN_STREAMS` | Concurrent `subscriptions/listen` streams (default `64`). | | `PMCP_STDIO_READ_LIMIT`, `PMCP_REQUEST_CEILING_MS` | [Downstream call tunables](#downstream-call-tunables). | | `PMCP_REGISTRY_ALLOW_PRIVATE`, `PMCP_REGISTRY_PRIVATE_ENDPOINT` | [Private registry](#private-registry-debugging-opt-in). | | `PMCP_FEEDBACK_TOKEN`, `PMCP_FEEDBACK_REPO` | [Feedback Telemetry](#feedback-telemetry). | | `PMCP_LEGACY_MANIFEST_AUTOSTART` | Legacy manifest `auto_start` entries (see [Adding Custom Servers](#adding-custom-servers)). | ### `pmcp doctor` (Recommended before/after upgrades) Use `pmcp doctor` to diagnose common PMCP startup and connectivity issues. It checks: - `lock`: reports whether a gateway holds the singleton lock at `~/.pmcp/gateway.lock` (the file itself stays between runs and is harmless when nothing holds it) - `mode`: detects local command-mode MCP config conflicts when a shared PMCP system service is running - `http`: probes the unauthenticated `/health` endpoint derived from `PMCP_GATEWAY_URL` or `http://127.0.0.1:3344/mcp` - `remote`: detects unresolved remote downstream header environment references - `install`: detects conflicting `uv tool` and `pip --user` installs Example: ```bash pmcp doctor ``` If any checks fail, follow the command in the output and rerun `pmcp doctor`. ### Singleton Lock By default, PMCP uses a global lock at `~/.pmcp/gateway.lock` to ensure only one gateway runs per user. The lock is held by the running gateway and released when it exits (or dies); the file itself is never removed, so a leftover file never blocks a start and never needs deleting. The lock file must be a plain regular file, and a link at its name is refused. The default `~/.pmcp` may be your own symlink (for example into a dotfiles repository): it follows exactly the rule `~/.config/pmcp` follows for the trust store -- accepted wherever it leads, except into a checkout being judged (the project pmcp serves, or the one it was started in). An explicit `--lock-dir` / `PMCP_LOCK_DIR` that is itself a symlink is refused, since a repository can supply that path. This prevents multiple gateway instances from spawning duplicate downstream servers. **Override the lock directory:** ```bash # CLI flag pmcp --lock-dir /custom/path # Environment variable export PMCP_LOCK_DIR=/custom/path pmcp ``` **Per-project lock (not recommended):** ```bash pmcp --lock-dir ./.mcp-gateway ``` ### Downstream call tunables ```bash # Per-line stdout read limit for downstream stdio servers (default 10 MiB). # A single response line larger than this is dropped (failing only that call, # with the server kept connected) rather than disconnecting the server. export PMCP_STDIO_READ_LIMIT=$((20 * 1024 * 1024)) # Absolute backstop for a single downstream tool call (default 600000ms / 10 min). # `timeout_ms` is an INACTIVITY timeout — a call survives as long as the server # keeps producing output; this ceiling caps total wall-clock time for tool calls # so a chatty-but-never-completing call cannot hang forever. export PMCP_REQUEST_CEILING_MS=600000 ``` The per-call inactivity timeout is `gateway.invoke`'s `options.timeout_ms` (default `30000`, range `1000`–`300000`). ## Deprecations - The `mcp-gateway` command no longer exists; the package installs only `pmcp`. - Use `pmcp` for all CLI commands. - Migration examples: - `mcp-gateway refresh --force` -> `pmcp refresh --force` - `mcp-gateway status --json` -> `pmcp status --json` ## Docker ```bash # Using Docker docker run -it --rm \ -v ~/.mcp.json:/home/appuser/.mcp.json:ro \ -v ~/.env:/app/.env:ro \ ghcr.io/consiliency/pmcp:latest # Using Docker Compose docker-compose up -d ``` ## Development ```bash # Clone the repo git clone https://github.com/Consiliency/pmcp cd pmcp # Install with uv (recommended) uv sync --all-extras # Run tests uv run pytest # Run with debug logging uv run pmcp --debug ``` ### Running Tests ```bash # Run all tests uv run pytest # Run with coverage uv run pytest --cov=pmcp # Run specific test file uv run pytest tests/test_policy.py -v ``` ### Project Structure ``` pmcp/ ├── src/pmcp/ │ ├── __init__.py │ ├── __main__.py # python -m pmcp entry │ ├── cli.py # CLI commands (status, logs, setup, doctor, trust, ...) │ ├── server.py # MCP server implementation │ ├── config/ │ │ └── loader.py # Config discovery (.mcp.json) │ ├── client/ │ │ └── manager.py # Downstream server connections │ ├── policy/ │ │ └── policy.py # Allow/deny lists │ ├── tools/ │ │ └── handlers.py # Gateway tool implementations │ ├── transport/ │ │ └── http.py # Streamable HTTP transport │ ├── auth.py # HTTP auth modes (shared-secret, resource-server) │ └── manifest/ │ ├── manifest.yaml # Server manifest (90+ servers) │ ├── loader.py # Manifest loading and overlays │ ├── installer.py # Server provisioning │ └── environment.py # Platform/CLI detection ├── tests/ # 8000+ tests ├── Dockerfile ├── docker-compose.yml ├── .env.example ├── pyproject.toml └── README.md ``` ## Troubleshooting ### Server Won't Connect ```bash pmcp status --probe # actively connect to each configured server pmcp logs --level error ``` Then reconnect it with `gateway.restart_server` or `gateway.connect_server`. (`pmcp refresh` regenerates cached tool descriptions; it does not reconnect a running gateway's servers.) ### Missing API Key `gateway.request_capability` and `gateway.provision` report the variable a manifest server needs (`env_var`, `env_instructions`); `pmcp secrets check` lists the keys your configured servers require and which are missing. ```bash # Set the key in the shell that starts pmcp... export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... # ...or store it through a running HTTP gateway (prompts for the value) pmcp auth connect github --scope user ``` ### Tool Invocation Fails ``` gateway.catalog_search({ query: "tool-name" }) gateway.describe({ tool_id: "server::tool-name" }) gateway.list_pending() ``` If `gateway.refresh` reports pending requests or active MCP tasks, inspect them with `gateway.list_pending()` and `gateway.tasks_list()`, or retry refresh with `force=true` to cancel them before reloading server configuration. If `gateway.disconnect_server` or `gateway.restart_server` reports pending requests or active MCP tasks, inspect `gateway.list_pending(server="")` and `gateway.tasks_list(server_name="")`, or retry with `force=true` to cancel only that server's pending work. ## License MIT