Per-agent scoped MCP tool proxy — credential isolation, resource scoping, and audit logging for AI agent deployments
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Per-agent scoped MCP tool proxy. One server process per agent — loads only the tools that agent is allowed to use, enforces resource boundaries between agents, holds credentials so agents never see them, and logs every tool call to a structured audit trail.
Multi-agent setups (Claude Code subagents, parallel workers, role-based agents) share the same MCP servers. Every agent sees every tool. Every agent holds credentials. Agent A can read Agent B's data. Audit logging is fragmented across a dozen server processes.檢視完整 README ↗Per-agent scoped MCP tool proxy. One server process per agent — loads only the tools that agent is allowed to use, enforces resource boundaries between agents, holds credentials so agents never see them, and logs every tool call to a structured audit trail.
Multi-agent setups (Claude Code subagents, parallel workers, role-based agents) share the same MCP servers. Every agent sees every tool. Every agent holds credentials. Agent A can read Agent B's data. Audit logging is fragmented across a dozen server processes.
Existing solutions solve pieces:
None combine all four: tool filtering + resource scoping + credential isolation + audit logging.
scoped-mcp was built using the same multi-agent pattern it's designed to secure — a research agent evaluated the problem space, a dev agent implemented the code, each with scoped access to only the resources it needed. It runs in production as part of homelab-agent, a self-hosted Claude Code platform with purpose-built agents for different infrastructure domains.
Agent process (AGENT_ID=research-01, AGENT_TYPE=research)
│
▼
┌─────────────────────────────────────────┐
│ scoped-mcp (one process per agent) │
│ │
│ ① Load manifest for AGENT_TYPE │
│ ② Register allowed tool modules │
│ ③ Inject credentials into modules │
│ ④ Every tool call: │
│ → enforce resource scope │
│ → execute tool logic │
│ → write audit log entry │
└─────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
Backend A Backend B Backend C
(scoped) (scoped) (scoped)
flowchart LR
subgraph agent["Agent Process"]
A["AGENT_ID=research-01<br/>AGENT_TYPE=research"]
end
subgraph proxy["scoped-mcp (single process)"]
direction TB
M["Manifest Loader<br/><i>research-agent.yml</i>"]
R["Module Registry"]
C["Credential Injector"]
EX["Tool Execution<br/>(scope → run → audit)"]
M --> R
R --> C
C --> EX
end
subgraph backends["Backends (scoped)"]
FS["Filesystem<br/><code>agents/research-01/</code>"]
DB["SQLite<br/><code>agent_research-01.db</code>"]
NT["ntfy<br/><code>topic: research-research-01</code>"]
end
ALOG["Audit Log<br/>(JSONL)"]
A -- "MCP (stdio)" --> proxy
EX --> FS
EX --> DB
EX --> NT
EX --> ALOG
pip install scoped-mcp
# Set agent identity
export AGENT_ID="research-01"
export AGENT_TYPE="research"
# Run with a manifest
scoped-mcp --manifest manifests/research-agent.yml
Claude Code settings.json:
{
"mcpServers": {
"tools": {
"command": "scoped-mcp",
"args": ["--manifest", "manifests/research-agent.yml"],
"env": {
"AGENT_ID": "research-01",
"AGENT_TYPE": "research"
}
}
}
}
See examples/claude-code/ for a complete multi-agent setup.
See examples/launcher/ for stdio subprocess launcher templates — required when proxying
MCP servers that need credentials, since stdio subprocesses do not inherit the parent env.
scoped-mcp run supports two transports via --transport (default stdio, unchanged):
| Transport | Process model | Isolation | Auth |
|---|---|---|---|
stdio (default) | one subprocess per turn, spawned by the MCP client | private pipe — no network surface | none needed (implicit) |
http | one long-lived streamable-http process per agent, under PM2 | loopback-only bind | bearer token (required) |
stdio is the default and needs no extra flags — it is what the Quickstart and Claude
Code settings.json examples above use.
http (added v1.6.0) runs scoped-mcp as a persistent streamable-http server so a per-turn client recycle only drops a connection to a warm process — tool discovery no longer re-runs and tools never disappear mid-session. It is intended for one long-lived process per agent, supervised by PM2.
export AGENT_ID="research-01"
export AGENT_TYPE="research"
export SCOPED_MCP_BEARER_TOKEN="$(openssl rand -hex 32)" # required for http
scoped-mcp run \
--manifest manifests/research-agent.yml \
--transport http \
--port 9200 \
--path /mcp # default; --host defaults to 127.0.0.1
HTTP transport constraints:
Authorization: Bearer <SCOPED_MCP_BEARER_TOKEN>.
Missing or invalid tokens are rejected with 401 before any tool dispatch, using a
constant-time compare. Startup refuses to run the HTTP transport if the env var is unset.127.0.0.1; a non-loopback --host is refused.
--port is required under http.session_id
from the MCP connection context, so a single long-lived process still emits distinct
session ids for concurrent clients. The raw MCP session id is mapped to a stable,
non-reversible UUID that never leaks into logs. Stateless clients — one that
negotiates no MCP session id — used to collapse onto the process-global session id,
merging unrelated audit trails; the resolver now falls back to a stable per-connection
id derived from the TCP peer (host:port, itself uuid5-mapped) instead, so distinct
concurrent stateless connections still get distinct trails. (SMCP-16, v1.8.0)_discover_tools()) runs
exactly once, when the process starts. Under stdio, every new client connection was
a fresh subprocess, so a manifest edit took effect automatically on the next session.
Under http, the process is long-lived and a client reconnecting is just a new
connection to the same warm server — a manifest edit (new tool_allowlist entries,
new modules, etc.) has no effect until you run
pm2 restart scoped-mcp-<agent>. scoped_mcp_status surfaces a manifest_stale: true
flag (with a restart hint) once the manifest file's mtime moves past what the running
process loaded — see Module Health below. (SMCP-24)Client settings.json for an HTTP agent points at the URL rather than a command:
{
"mcpServers": {
"tools": {
"type": "http",
"url": "http://127.0.0.1:9200/mcp",
"headers": { "Authorization": "Bearer ${SCOPED_MCP_BEARER_TOKEN}" }
}
}
}
Agent Identity — AGENT_ID (unique instance) and AGENT_TYPE (role) set via environment variables at spawn time. The manifest maps agent types to allowed modules.
Tool Modules — one Python file per backend domain. Each module declares its tools, required credentials, and scoping strategy. The framework handles registration, credential injection, and audit wrapping.
Scoping Strategies — reusable patterns for resource isolation:
PrefixScope — file paths, object store keys, cache keys scoped to agents/{agent_id}/NamespaceScope — key-value operations prefixed with agent's namespace{db_dir}/agent_{agent_id}.dbScopeStrategy for your backend's isolation modelCredential Injection — backend credentials (API keys, DSNs, tokens) loaded once by the proxy process from environment variables or a secrets file. Modules receive credentials through their context — the agent process never sees them.
Logging — two structured JSON-L streams:
session.id; under the long-lived HTTP transport the session.id is resolved per connection so concurrent clients stay distinguishable.Both file sinks use a size-based RotatingFileHandler (v1.6.0) so a long-lived HTTP
process cannot grow an unbounded log — tune with SCOPED_MCP_LOG_MAX_BYTES (default
50 MiB) and SCOPED_MCP_LOG_BACKUPS (default 5). stdio-per-turn behaviour is unchanged.
Module Startup — when an agent connects, scoped-mcp starts all proxied/upstream modules concurrently (asyncio.gather) rather than one at a time. With ~17 upstream modules this cuts cold-start from ~5.5s to under 1s — roughly the time of the single slowest module — and removes the window where tools are briefly unavailable during per-connection restarts (e.g. under CloudCLI's stream-json driver). (v1.3.2)
Fault Isolation — a single module failure does not kill the server. Isolation is applied at three phases (v1.4.0):
failed_imports and discovery continues. Other modules are unaffected.__init__ raises (bad config, missing credential), it is skipped. Other modules still instantiate and register normally.asyncio.gather runs with return_exceptions=True. A startup failure is recorded in module_health; the server yields and remaining modules' tools stay available.Module Health — scoped_mcp_status is always registered regardless of manifest content. Call it at session start to get {modules, failed_count, total_count, healthy} with per-module status values: running, failed_import, failed_init, failed_startup. Set SCOPED_MCP_HEALTH_FILE to a path and the lifespan will write a JSON health report after startup completes — useful for session-start hooks or external health-check scripts that need file-based status without calling an MCP tool. The health file is rewritten on every credential-health transition (see below) and carries a written_at timestamp so an external watcher can detect a wedged process by staleness. Failed modules are reported there as error_type (the exception class name only) rather than the full exception message, since an exception can echo back a dependency URL with inline credentials and the file is a plain on-disk artifact — the full message stays in the ops log and in scoped_mcp_status. (v1.4.0)
Tool Inventory — scoped_mcp_status, GET /health and the health file each report a tool_inventory block naming, per running mcp_proxy module, what that proxy actually registered from its upstream: {tool_count, transport, allowlisted, denylisted, discovered_at}. This exists because a proxy enumerates its upstream exactly once, at __init__, and never widens that set afterwards (_refresh_schemas_from_client deliberately refuses to add a tool, so a compromised upstream can't widen the surface via a refresh) — so a tool added upstream is invisible to a running proxy until the process restarts, and nothing in the process notices. Two agents proxying the same upstream under the same filtering must report the same tool_count; a mismatch means one of them is serving a stale tool set and needs pm2 restart scoped-mcp-<agent>. That comparison deliberately needs no upstream credentials — a fleet-wide drift check shouldn't be handed every upstream's secrets just to call tools/list. allowlisted/denylisted keep it sound: a count difference between a filtered and an unfiltered agent is expected, not drift. discovered_at is stamped at discovery, not derived from process start time, because the two diverge when the self-heal loop re-instantiates a recovered module. Only running modules appear — a module that instantiated but failed startup() discovered tools it can't serve, and its failure is already visible in failed_count. A module that raises from tool_inventory() is reported as {"error_type": ...} rather than dropped — the exception never propagates (this feeds three health reporters and must not be able to fail any of them), and only the exception type is reported, matching the _redact_module_errors convention; the failure is logged once per (module, exception type) per process rather than on every call, since /health is polled every two minutes. GET /health is unauthenticated, so it and the health file carry counts and booleans only — never tool names, schemas, URLs or headers; only scoped_mcp_status includes names, and those are the normalized registered names ([a-zA-Z0-9_]+) rather than the raw upstream strings, since an upstream controls what it calls its tools and this payload reaches an agent's context. Modules with no upstream are absent, and an agent with no proxies gets no tool_inventory key at all. (v1.13.0)
Optional Modules — a per-module manifest flag, optional: true (ModuleConfig), marks a dependency that's expected to be intentionally offline sometimes — e.g. claudebox-ops, which points at a host that's powered off on purpose outside working hours. A failed_import / failed_init / failed_startup on an optional module no longer counts toward failed_count / healthy in scoped_mcp_status, the health file, or GET /health — it's tracked separately under a new offline_optional_modules field instead, so the process stays healthy: true while an optional dependency is down. A healthy↔offline transition of an optional module still fires exactly one low-severity alert via the existing SMCP-26 Matrix→ntfy ops-alert path (comparing against the previous process's state, persisted in the health file) — no repeat spam across restarts while it stays offline, and recovery fires too. Non-optional module failures are unaffected — same degrade-to-503 behavior as before. (v1.10.0, SMCP-31)
modules:
claudebox-ops:
type: mcp_proxy
optional: true # expected to go offline when claudebox is powered down
config:
url: http://claudebox.local:8600/mcp
Module Init Self-Heal — a module whose dependency isn't listening yet no longer stays dead for the life of the process. Two mechanisms cover the same failure from both ends:
url, the registry polls that port until it accepts a TCP connection, bounded by dependency_wait_timeout_seconds (default 30) at dependency_wait_interval_seconds (default 1). A successful connect is the whole test — no status code is required, since an MCP endpoint answers an unauthenticated probe with 401/404/405/406 depending on the server, all of which mean "it's up". This kills the common start-ordering race against a co-located dependency. Only loopback URLs gate startup: a remote dependency may be optional: true and powered off on purpose, so blocking on it would turn a supported state into an outage. On expiry the module falls through to the normal failed_init path — startup is always bounded and never hangs. A shared ceiling also bounds the total time one process spends across every module's gate combined — default 60 seconds, overridable with SCOPED_MCP_DEPENDENCY_WAIT_BUDGET_SECONDS — so a full dependency outage across many modules costs at most one budget's worth of startup delay rather than the sum of each module's individual timeout (a manifest with 9-16 loopback dependencies could otherwise cost minutes before /health even exists to report the problem). A malformed or negative value falls back to the default rather than failing startup, and once the shared budget is drained, remaining gates become no-ops and those modules fall straight through to failed_init, where the background re-init loop below picks them up.failed_init or failed_startup is retried by one asyncio task with exponential backoff (5s → 5min cap), cancelled cleanly on shutdown. On success the module's tools are registered onto its already-mounted child server, its status flips to running with the recorded error cleared, and the health file is rewritten — so /health returns 200 on the very next probe with no restart. failed_import is never retried: the class doesn't exist in this process, and waiting won't change that.Transitions fire one module_init_degraded and one module_recovered ops alert through the same Matrix→ntfy path as the credential alerts — one per transition, never per retry attempt. Optional modules keep their SMCP-31 event names (optional_module_offline / optional_module_recovered) and are not double-alerted. Alert payloads carry the exception type only, never its message, which can embed a credentialed URL.
modules:
system-ops:
type: mcp_proxy
config:
url: http://localhost:8282/mcp
dependency_wait_timeout_seconds: 30 # optional; 0 disables the gate
dependency_wait_interval_seconds: 1 # optional
Credential Health, Self-Heal & Alerting — for credentials.source: vault, scoped_mcp_status and the health file also include a credentials block ({source, token_healthy, consecutive_failures, last_renewal_ok_ts, last_reauth_ts, seconds_to_expiry_est, reauth_enabled}), and top-level healthy goes false when the Vault token is unhealthy — so a process stuck in a permanent renewal-failure loop can no longer report healthy: true. Four layers make a silent credential failure both self-recovering and loud (SMCP-26):
token_max_ttl ceiling that renew-self alone can never exceed. Opt-in via SCOPED_MCP_VAULT_REAUTH=1, and only safe when the AppRole has a reusable secret_id (secret_id_num_uses=0) — re-logging in with a single-use secret_id would burn the only credential. When unset, re-auth is a no-op and the failure surfaces through the layers below.SCOPED_MCP_ALERT_MATRIX_HOMESERVER, SCOPED_MCP_ALERT_MATRIX_TOKEN, SCOPED_MCP_ALERT_MATRIX_ROOM) so it still fires when Vault is the broken dependency. A burst of /mcp 401s (a misconfigured client bearer) also fires one rate-limited alert — the one signal a session-start scoped_mcp_status check can't catch, because a 401'd client never reaches any tool. If no alert channel is configured, a warning is logged once at startup.SCOPED_MCP_ALERT_NTFY_URL (+ optional SCOPED_MCP_ALERT_NTFY_TOKEN). This is a fallback, not fan-out — on the happy path (Matrix accepts) ntfy is never contacted, and the fire-once-per-transition dedup still yields one alert overall. Because ntfy is the one alert path that leaves the host, the token is withheld (never sent) when the configured URL isn't https://, so a misconfigured plaintext URL can't leak it./health endpoint — under --transport http, an unauthenticated GET /health on the existing port returns 200 when healthy and 503 when degraded (booleans/counts only, never token or lease values, and never tool names — see Tool Inventory), so a dumb prober or load balancer can act on the status code alone.OTEL_EXPORTER_OTLP_ENDPOINT is set (and the [otel] extra is installed), two observable gauges (scoped_mcp.credentials.healthy, scoped_mcp.vault.consecutive_renewal_failures) export to your collector for a durable, queryable alert rule. No-op if the endpoint or extra is absent.Manifest Staleness — under --transport http, scoped_mcp_status also reports manifest_path and manifest_loaded_at (when this process loaded its manifest). If the manifest file's mtime has moved since then, the response adds manifest_stale: true and a manifest_stale_hint string telling you to run pm2 restart scoped-mcp-<agent>. This is diagnostic only — it never fails the status call, even if the manifest file has since been deleted or become unreadable. See Transports → HTTP transport constraints for why this class of drift is possible under the long-lived process model. (SMCP-24)
Graceful Shutdown — scoped-mcp installs a SIGTERM handler that calls sys.exit(0), routing cleanup through FastMCP's lifespan finally block and every module's shutdown() hook. This ensures open sockets, Vault token-renewal tasks, and mcp_proxy subprocess handles are released cleanly when Claude Desktop or Claude Code ends a session. Without this, a SIGTERM kill mid-flight could bypass shutdown hooks and leave orphaned processes. (v1.3.4)
# manifests/research-agent.yml
agent_type: research
description: "Read-only research agent"
modules:
filesystem:
mode: read # read-only: read_file + list_dir only
config:
base_path: /data/agents # PrefixScope adds /{agent_id}/ automatically
sqlite:
mode: read
config:
db_dir: /data/sqlite # each agent gets /data/sqlite/agent_{agent_id}.db
ntfy: # write-only — no mode field needed
config:
topic: "research-{agent_id}"
max_priority: high
credentials:
source: env # or "file" with path: /run/secrets/agent.yml
# or: source: vault — see Vault Credentials section
# Optional: pluggable state backend (required for rate limiting and HITL)
state_backend:
type: in_process # default — no external deps
# type: dragonfly
# url: redis://127.0.0.1:6379/0
# Optional: sliding-window rate limits
rate_limits:
global: 60/minute # all tools combined
per_tool:
filesystem_write_file: 10/minute
"mcp_proxy.*": 30/minute # glob — all matched tools share one counter
# Optional: argument-value filtering
argument_filters:
- name: no-credentials
pattern: '(?i)(password|secret|token)\s*[:=]\s*\S+'
fields: [path, query, body]
action: block # or: warn
decode: [base64, urlsafe_base64, url]
# Optional: human-in-the-loop approval (requires state_backend.type: dragonfly)
hitl:
approval_required: ["filesystem_delete_*", "sqlite_execute"]
shadow: ["mcp_proxy.*"] # log-only, return synthetic empty success
timeout_seconds: 300
notify:
type: ntfy # or: log (default), webhook, matrix
topic: homelab-hitl
Manifest fields support ${VAR_NAME} placeholders, expanded from the process environment before YAML parsing:
state_backend:
type: dragonfly
url: "redis://:${REDIS_PASSWORD}@host:6379/0" # always quote substitution sites
credentials:
source: file
path: "${SECRETS_FILE}"
Rules:
${VAR}, not $VAR) to prevent accidental substitution.:, {, or } can corrupt the YAML structure if the field is unquoted.The top-level manifest model rejects unknown fields (extra="forbid"). A misspelled
or stale key fails the manifest at load time rather than being silently ignored — a
deliberate guard against shadowing attacks, where an unrecognized field could mask a
real setting. Every field an agent platform attaches to its manifests must therefore
be modeled explicitly.
Pattern-checked values are matched in full (fullmatch, v1.19.1), so a trailing
newline is refused. This covers agent_type, rate limits, ntfy topics and Matrix
rooms in the manifest, and the agent_id claim in an HTTP bearer token.
Alongside the operational fields (modules, credentials, state_backend,
rate_limits, argument_filters, response_filters, hitl, audit), the model
accepts three platform-metadata fields. scoped-mcp validates and stores them but
does not act on them — they are consumed by the task dispatcher, agent bus, and other
agents on the platform:
| Field | Type | Purpose |
|---|---|---|
max_auto_risk | string | Highest risk tier the agent may auto-approve |
interaction_permissions | {auto_approved: [...], needs_approval: [...]} | Cross-agent task auto-approval lists |
workspace_access | list of entries (below) | Filesystem paths the agent may access |
Each workspace_access entry (added v1.3.3):
| Key | Type | Default | Purpose |
|---|---|---|---|
path | string | — | Filesystem path the agent may access |
access | readonly | readwrite | — | Access mode for the path |
git_backed | bool | false | Path is a git repository |
branch_required | bool | false | Edits must be made on a branch, not the default branch |
workspace_access:
- path: /srv/agents/research-01
access: readwrite
git_backed: true
branch_required: true
- path: /srv/shared/reference
access: readonly
workspace_access was previously tolerated only because the model briefly loosened to
extra="ignore"; modeling it as a typed field lets the top-level model keep
extra="forbid" while still validating the block present in every agent manifest.
flowchart LR
subgraph manifest_r["research-agent.yml"]
MR1["filesystem: read"]
MR2["sqlite: read"]
MR3["ntfy: write-only"]
end
subgraph tools_r["Registered Tools (4)"]
TR1["filesystem_read_file"]
TR2["filesystem_list_dir"]
TR3["sqlite_query"]
TR4["ntfy_send"]
end
MR1 --> TR1 & TR2
MR2 --> TR3
MR3 --> TR4
subgraph manifest_b["build-agent.yml"]
MB1["filesystem: write"]
MB2["sqlite: write"]
MB3["ntfy: write-only"]
MB4["slack_webhook: write-only"]
end
subgraph tools_b["Registered Tools (8)"]
TB1["filesystem_read_file"]
TB2["filesystem_list_dir"]
TB3["filesystem_write_file"]
TB4["filesystem_delete_file"]
TB5["sqlite_query"]
TB6["sqlite_execute"]
TB7["ntfy_send"]
TB8["slack_send"]
end
MB1 --> TB1 & TB2 & TB3 & TB4
MB2 --> TB5 & TB6
MB3 --> TB7
MB4 --> TB8
| Module | Scope | Read tools | Write tools |
|---|---|---|---|
filesystem | PrefixScope — agents/{agent_id}/ | read_file, list_dir | write_file, delete_file |
sqlite | Per-agent DB file — {db_dir}/agent_{agent_id}.db | query, list_tables | execute, create_table |
Notification modules are write-only by design — every agent needs to send alerts, but no agent should see webhook URLs, SMTP passwords, or API tokens.
| Module | Backend | Credential | Scope |
|---|---|---|---|
ntfy | ntfy.sh (self-hosted or cloud) | Server URL + optional token | Topic per agent ({agent_id} template) |
smtp | Any SMTP server | Host, port, user, password | Configured sender + allowed recipients |
matrix | Matrix homeserver | Access token | Room allowlist |
slack_webhook | Slack incoming webhook | Webhook URL | One webhook = one channel |
discord_webhook | Discord webhook | Webhook URL | One webhook = one channel |
| Module | Description | Key config |
|---|---|---|
mcp_proxy | Forward tool calls to an upstream MCP server (HTTP or stdio) | url or command, optional tool_denylist, headers, env |
mcp_proxy connects to upstream MCP servers and re-exposes their tools through scoped-mcp.
Tools are prefixed with the module name (e.g. memsearch-mcp_search_memory). Use tool_denylist
to hide specific upstream tools from the agent.
Header injection — pass custom HTTP headers to upstream streamable-http servers:
modules:
memsearch-mcp:
type: mcp_proxy
config:
url: http://localhost:8493/mcp
headers:
Authorization: "Bearer ${MEMSEARCH_API_TOKEN}"
Header values support ${VAR} substitution (same rules as all manifest fields).
Headers are only applied to HTTP transports — configuring headers on a stdio
transport logs a warning and ignores them. Authorization header values are
automatically redacted from structured logs. Since v1.19.0 so is the value of every ${VAR}
the manifest references, except values under 12 characters, digit-only values and bare URL
origins (by-value redaction; see Audit log files and redaction).
Environment variables for stdio children — pass environment variables to a spawned stdio subprocess:
modules:
agent-bus:
type: mcp_proxy
config:
command: /path/to/python3
args: [/path/to/mcp_server.py]
env:
API_TOKEN: "${API_TOKEN}"
A stdio child does not inherit scoped-mcp's environment — the MCP SDK forwards only a
fixed allowlist (mcp.client.stdio.DEFAULT_INHERITED_ENV_VARS — HOME, LOGNAME, PATH, SHELL,
TERM, USER on POSIX), and only for names actually set in the parent process, so the real
inherited set is a subset of that list; Python adds LC_CTYPE on top, which isn't in the
allowlist at all. env extends that base rather than replacing it — a module declaring
one variable still gets PATH, and does not pick up the rest of the broker's environment.
Anything a proxied stdio server needs must be named here explicitly.
This is the actual operational trap: a child silently missing a credential is
indistinguishable from the upstream feature being disabled. If a proxied stdio server
reports a capability as "not configured," check for an env: block before concluding the
upstream is at fault.
env applies to stdio only and is inert on HTTP transport — configuring it there logs a
warning (mcp_proxy_env_ignored, key names only) since v1.16.0, mirroring the
headers-on-stdio warning above. ${VAR} substitution works the same way here as in every
other manifest field; an undefined variable is a hard startup failure, not an empty string.
Self-healing stdio upstreams (v1.6.0) — a persistent stdio upstream call that fails
with a dead-transport error (broken/closed pipe, subprocess exit) transparently
reconnects once and retries, logging mcp_proxy_reconnect. This matters under the
long-lived HTTP transport, where a dead pipe would otherwise persist until restart. The
reconnect is serialized with a lock so concurrent callers do not race to replace the
client; normal tool errors still propagate untouched so real outages are not masked.
Result content (v1.17.2) — how an upstream CallToolResult reaches the agent:
| Upstream result | What the agent gets |
|---|---|
| Any non-text block (image, audio, embedded resource, resource link) | All content blocks, unchanged — text blocks included |
| Structured data, all-text content | The structured data, converted to plain JSON types |
| Text blocks only | The text blocks joined with newlines |
Non-text content wins over structured data: the proxied tool declares no output schema,
so structured data would reach the agent as JSON text and the image would be lost.
Before v1.17.2 the proxy joined the text and silently dropped every other block — an
upstream returning [image, text] delivered only the text. Every path goes through
response_filters; see Response filtering for what that covers
on content blocks.
| Module | Scope | Read tools | Write tools |
|---|---|---|---|
http_proxy | Service allowlist + SSRF prevention | get | post, put, delete |
grafana | Folder-based (agent-{agent_id}/) | list_dashboards, get_dashboard, query_datasource, list_datasources | create_dashboard, update_dashboard, create_alert_rule, delete_dashboard |
influxdb | Bucket allowlist + NamespaceScope | query, list_measurements, get_schema | write_points, create_bucket, delete_points |
Every module declares its required and optional environment variables. scoped-mcp fails at startup with a clear error listing any missing required keys — it will not start partially configured.
| Module | Required env vars | Optional env vars |
|---|---|---|
filesystem | — | — |
sqlite | — | — |
ntfy | NTFY_URL | NTFY_TOKEN |
smtp | SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD | — |
matrix | MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN | — |
slack_webhook | SLACK_WEBHOOK_URL | — |
discord_webhook | DISCORD_WEBHOOK_URL | — |
http_proxy | — (dynamic; see module config) | — |
grafana | GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN | — |
influxdb | INFLUXDB_URL, INFLUXDB_TOKEN | INFLUXDB_ORG (overrides config.org) |
Credentials are passed in settings.json under env (for Claude Code) or exported
in the shell before running scoped-mcp. They are loaded once at startup, injected
into module contexts, and never returned in tool responses or logged.
For HashiCorp Vault or OpenBao — set credentials.source: vault in the manifest with an
approle block; credentials are fetched once at startup and the client token is
renewed in the background. Requires pip install scoped-mcp[vault]. See
examples/vault/ for a working manifest, AppRole setup script, and policy.
OpenBao is supported with no code or manifest change. OpenBao serves the Vault HTTP
API, so the same hvac client, X-Vault-Token header and AppRole/KV v2 endpoints work
against it; point addr at the OpenBao listener (by convention through
addr: "${VAULT_ADDR}", so the env var name stays VAULT_ADDR). This has been run in
production against OpenBao 2.7.1. The bao CLI is a drop-in for vault in
examples/vault/setup.sh (VAULT_CLI=bao bash examples/vault/setup.sh).
path is relative to the KV v2 mount (secret/ by default): path: "agents/{agent_type}"
reads secret/data/agents/<agent_type>. Give each agent its own AppRole and its own
policy granting read on exactly that path. Don't use a shared wildcard such as
secret/data/agents/*, which lets any agent read every other agent's credentials.
Sealed store at startup. fetch() runs before the server starts, and any failure is
fatal: against a sealed (or unreachable) Vault/OpenBao, scoped-mcp raises CredentialError
and exits 1 in well under a second (about 0.6 s measured). Under PM2's defaults
(min_uptime: 1000 ms, max_restarts: 16) every such exit counts as an unstable restart,
so PM2 stops the process as errored within seconds and does not restart it when the
store is later unsealed. A store with a manual (Shamir) unseal is sealed after every
restart, so this happens on every host reboot. Give Vault-backed processes a restart
policy that keeps retrying:
// ecosystem.config.js
{
name: "scoped-mcp-research",
// ...
exp_backoff_restart_delay: 1000, // 1 s, then x1.5 per crash, capped at 15 s
max_restarts: 16, // PM2 default, shown for clarity
}
PM2 (checked against 6.x) counts an exit as unstable only within min_uptime × max_restarts
(16 s by default) of the process being started; a manual pm2 restart resets that clock.
With a backoff delay,
only the first few crashes land inside that window, so PM2 never reaches max_restarts
and keeps retrying about every 15 s until the store is unsealed. Once the process has
stayed up for 30 s the backoff resets. A fixed restart_delay (for example 10000) works
for the same reason. Don't rely on lowering min_uptime below the crash time; that
shrinks the window and fights a timing you don't control. Alert on the seal state itself,
for example by polling GET /v1/sys/health, rather than waiting for agents to notice.
For integration with a secrets manager such as Vaultwarden, see
examples/vaultwarden/.
┌─ ops-agent (AGENT_ID=ops-01) ────────────────────────────────────┐
│ │
│ 1. influxdb_query(bucket="metrics", │
│ filters=[{"field": "_measurement", │
│ "op": "==", "value": "docker_cpu"}]) │
│ → discovers container X averaging 94% CPU │
│ │
│ 2. grafana_create_dashboard( │
│ title="Container Health", │
│ panels=[{"title": "CPU by Container", ...}]) │
│ → dashboard created in folder agent-ops-01/ │
│ │
│ 3. ntfy_send(title="High CPU: container X", │
│ message="Averaging 94% over last hour.") │
│ → operator gets push notification │
│ │
└───────────────────────────────────────────────────────────────────┘
The agent queried metrics it can see, built a dashboard it owns, and alerted through a channel it's allowed to use. At no point did it see API tokens, access another agent's data, or modify operator dashboards.
# src/scoped_mcp/modules/redis.py
from scoped_mcp.modules._base import ToolModule, tool
from scoped_mcp.scoping import NamespaceScope
class RedisModule(ToolModule):
name = "redis"
scoping = NamespaceScope()
required_credentials = ["REDIS_URL"]
def __init__(self, agent_ctx, credentials, config):
super().__init__(agent_ctx, credentials, config)
import redis.asyncio as aioredis
self._redis = aioredis.from_url(credentials["REDIS_URL"])
@tool(mode="read")
async def get_key(self, key: str) -> str | None:
"""Get a value (scoped to agent namespace)."""
scoped_key = self.scoping.apply(key, self.agent_ctx)
return await self._redis.get(scoped_key)
@tool(mode="write")
async def set_key(self, key: str, value: str, ttl: int = 0) -> bool:
"""Set a key-value pair (scoped to agent namespace)."""
scoped_key = self.scoping.apply(key, self.agent_ctx)
return await self._redis.set(scoped_key, value, ex=ttl or None)
Add it to your manifest:
modules:
redis:
mode: read # only get_key registered
config: {}
See examples/custom-module/ for a full walkthrough and docs/module-authoring.md for the complete contract.
The projects below are the closest real comparators in the 2026 MCP-gateway landscape. All are capable tools — but each targets server-level federation, container isolation, or team/enterprise RBAC. None isolates resources at the per-agent-instance boundary (Agent A cannot read Agent B's files, rows, or buckets even with identical tools), which is scoped-mcp's core design point.
| Capability | scoped-mcp | IBM ContextForge | Docker MCP Gateway | Stacklok ToolHive | Kong MCP |
|---|---|---|---|---|---|
| Tool aggregation | yes | yes | yes | yes | yes |
| Per-agent tool filtering | manifest | RBAC | per-server | RBAC | RBAC |
| Per-agent resource scoping | yes | no | no | no | no |
| Credential isolation | yes | partial | yes | yes | partial |
| Unified audit log | yes | yes (OTel) | partial | yes | yes |
| Read/write modes | yes | no | no | no | per-role |
| Self-hosted, single process | yes | yes | no (containers) | no (containers/K8s) | no |
| Built-in scoped modules | 10 | 0 | 0 | 0 | 0 |
| Primary audience | self-hosted multi-agent | enterprise federation | dev-local / container | platform teams (K8s) | enterprise API teams |
scoped-mcp does not compete with these on OAuth/OIDC, multi-tenant SaaS, or Kubernetes orchestration — see Non-Goals. It occupies the gap they leave: per-agent resource isolation in a single self-hosted process.
scoped-mcp's core value is security — tool scoping, credential isolation, and audit logging. To back that up:
docs/threat-model.md documents the attack surface,
trust boundaries, and what scoped-mcp does and does not protect against.docs/security-audit.md tracks formal internal audits:
v0.1.0 found 18 findings (1 critical, 3 high, 8 medium, 6 low), remediated
in v0.2.0; the v0.2.1 follow-up audit returned clean. Post-v1.0 security
fixes (OTel exception redaction, audit log stdio isolation, ManifestError
secret suppression) are documented in CHANGELOG.md.examples/claude-code/multi-agent-setup.md
includes a step-by-step verification walkthrough — you can confirm filesystem
isolation and credential non-exposure yourself in under five minutes.
The test suite also verifies isolation out-of-band (v1.19.1).
tests/test_isolation_out_of_band.py runs two agents of the same type and then
opens each agent's sqlite file and filesystem root directly with sqlite3 and
pathlib, not through the module, to confirm each agent changed only its own
backend. It also checks the audit log's agent_id against the backend a tool
call actually changed. Suggested by @bhooshanvarma in GitHub #83.agent_id) once left the whole suite
green. Each now fails it. Constant-time compares are pinned structurally, since
a behavioural test can't see timing. The secret-name match in that guard is a
heuristic, not a proof. See CHANGELOG 1.19.1.Six opt-in middleware layers sit on top of the core tool/scope/credential/audit guarantees. All are off by default; enable per-agent in the manifest:
OpenTelemetry tracing (OTEL_EXPORTER_OTLP_ENDPOINT, v0.6) — one span per
tool call with scoped_mcp.* attributes (agent.id, agent.type, tool.name,
call.status). Auto-enabled when OTEL_EXPORTER_OTLP_ENDPOINT is set in the
environment. Tool arguments are excluded from spans to prevent credential leakage.
Works with SigNoz, Grafana Tempo, Jaeger, and Langfuse OTLP ingest. Requires
pip install scoped-mcp[otel].
Rate limiting (rate_limits:, v0.7) — sliding-window per-agent and
per-tool limits with glob patterns. Backed by InProcessBackend (default)
or DragonflyBackend ([dragonfly] extra) for cross-process state.
Vault-backed credentials (credentials.source: vault, v0.8) — fetch
credentials from HashiCorp Vault or OpenBao via AppRole; client token auto-renewed in
the background, with opt-in self-heal re-auth, credential-health surfacing,
an unauthenticated /health probe, and out-of-band degradation alerts
(SMCP-26 — see Credential Health, Self-Heal & Alerting above).
See examples/vault/.
mcp_proxy schema validation + argument filtering (argument_filters:,
v0.9) — proxied calls are validated against the upstream tool's
inputSchema before forwarding; pattern-based argument filters can block
or alert on values, with optional base64/url decoding. See
docs/threat-model.md for the documented limits.
Human-in-the-loop approval (hitl:, v1.1) — operator-gated tool
calls using a reject-then-wait design. When an agent calls an
approval_required tool, the middleware rejects immediately with a
HitlRejectedError containing an approval ID and retry instructions —
the MCP connection stays open. The operator runs
scoped-mcp hitl approve <id>, which writes a one-time pre-approval
token to Dragonfly (60 s TTL). The agent retries the tool call; the
middleware finds and consumes the token and forwards the call upstream.
Shadow-mode tools log a sanitised argument summary and return a
synthetic empty-success without forwarding upstream — useful for
observing agent behaviour before enabling a tool. Pre-approval tokens
carry the approval_id, so once a token is consumed on retry the
middleware resolves the hitl_approvals audit row to consumed
instead of leaving it stuck at approved forever (v1.10.0, SMCP-39) —
fails open on a pre-upgrade plain-string token, skipping only the audit
resolve.
# Core only (filesystem + sqlite + notifications require no extras)
pip install scoped-mcp
# With HTTP client modules (http_proxy, grafana, influxdb, ntfy, matrix, slack, discord)
pip install "scoped-mcp[http]"
# With SMTP support
pip install "scoped-mcp[smtp]"
# With SQLite async support
pip install "scoped-mcp[sqlite]"
# With OpenTelemetry tracing (auto-enabled when OTEL_EXPORTER_OTLP_ENDPOINT is set)
pip install "scoped-mcp[otel]"
# With shared state backend for rate limiting and HITL across processes
pip install "scoped-mcp[dragonfly]"
# With HashiCorp Vault / OpenBao credential source
pip install "scoped-mcp[vault]"
# With the agent session registry (HITL audit trail on agent-postgres)
pip install "scoped-mcp[postgres]"
# HTTP + SMTP + SQLite bundle (does not include otel, dragonfly, postgres, or vault)
pip install "scoped-mcp[all]"
If something isn't working, see Troubleshooting.
MIT
CLI subcommands:
scoped-mcp hitl list # pending approvals
scoped-mcp hitl approve <approval_id> # write pre-approval token
scoped-mcp hitl reject <approval_id> # delete pending key
Requires state_backend.type: dragonfly. Install with
pip install scoped-mcp[dragonfly].
In-session HTTP approval (v1.9.0, SMCP-14 Phase A/B) — a second approve
path that doesn't require a shell on the host. Under --transport http,
gated agents also register three loopback routes: POST /hitl/approve,
POST /hitl/deny, GET /hitl/pending. The intended caller is
matrix-hitl-bot —
the operator replies approve/deny to the agent's notify room in Matrix, and
the bot calls the endpoint on their behalf; the requesting agent is never in
that loop. These routes are unauthenticated by FastMCP's BearerTokenVerifier
(custom routes bypass it), so each handler checks its own bearer against a
dedicated secret, SCOPED_MCP_HITL_TOKEN — distinct from the MCP tool
bearer (SCOPED_MCP_BEARER_TOKEN) and known only to the bot/courier, never
the agent. A missing token env var still registers the routes (so callers get
a clean 503, not a 404) but fails closed until the operator sets it.
On gate-reject the middleware also mints a 256-bit one-time OTP
(hitl:otp:{approval_id}, Dragonfly-only, never posted to the notify room)
for a deferred Phase 2 courier form that presents {approval_id, otp}
instead of the bot's trusted {approval_id}. Approve/deny claim the pending
record atomically (StateBackend.get_delete), so a second call or a race
resolves to already_decided; a Dragonfly error denies (503) rather than
approving — same fail-closed rule as the CLI path above.
Interactive mode (v1.11.0) — a manifest field, hitl.mode: enforce | interactive
(default enforce, no behavior change for existing manifests), controls how a gated
call's approval is resolved. Both modes fire the same notify and the same immediate
reject — they differ only in how the decision comes back:
enforce (default) — resolution comes from an out-of-band channel the agent
cannot write to itself: the matrix-hitl-bot endpoint or scoped-mcp hitl approve <id>. Correct mode for headless / clone-pool agents that run unattended.interactive — for an agent working live in a session with the operator
watching the transcript. Registers a companion tool,
scoped_mcp_hitl_confirm(approval_id, decision), only for interactive-mode
agents that gate tools, so the agent can resolve its own pending request in one
step after an explicit in-conversation approve/deny — no Matrix round-trip. It
reuses the same hitl_endpoint.approve/deny logic as the bot path, and every
resolution is tagged in the audit trail with a resolved_via channel
(matrix_bot / courier / interactive_self_service) so the two paths are
always distinguishable after the fact.Trust tradeoff.
scoped_mcp_hitl_confirmtrusts the agent's own report that the operator approved in the current turn — it does not cryptographically verify an out-of-band decision. Because scoped-mcp runs as one shared long-lived process per agent, the registration gate is a static manifest field: it cannot tell an attended session apart from a headless run of the same agent identity. Only enableinteractivefor agents that are never run headless/unattended — flipping it for an agent that is ever launched headless-auto turns this into a self-approval bypass.enforceremains correct for any agent that might run unattended.
Signed approvals (hitl.signing, v1.17.0) — without signing, an approval
is a pre-approval key that exists; its value is never checked, so anything
that can write the state backend, read SCOPED_MCP_HITL_TOKEN or run the CLI
can approve. With hitl.signing.mode: enforce the value must be an Ed25519
statement over (agent, approval id, tool, full SHA-256 of the arguments, expiry ≤ 120 s),
signed by an approver key the agent cannot read and verified against the public
key named in the manifest. The operator signs with scoped-mcp-approve <id>
in a terminal: it recomputes the argument hash from the stored arguments,
shows them untruncated, unredacted except secret-keyed values, and terminal-escaped, and asks y/N. Under enforce,
scoped_mcp_hitl_confirm and POST /hitl/approve are not registered and
scoped-mcp hitl approve refuses. observe verifies and logs but still
accepts, for rollout; off is the default and today's behaviour. See
docs/manifest-schema.md
and, for what it does not stop (an agent that shares the proxy's OS user
can replace the proxy process), docs/threat-model.md.
Agent session registry (v1.9.0, optional [postgres] extra) — a
fail-open asyncpg DAL (registry_db.py) over a session registry on
agent-postgres, configured via AGENT_REGISTRY_DSN
(e.g. postgresql://registry:***@127.0.0.1:5433/agent_registry). Disabled
by default; unset ⇒ every registry call is a no-op. The first consumer is
the HITL audit trail (hitl_approvals) — it stores only the OTP hash,
never the plaintext. This is deliberately the opposite failure mode from the
Dragonfly-backed gate above: the registry is a paper trail, so a down
database must never block an approval decision. Install with
pip install scoped-mcp[postgres]; apply
migrations/0001_agent_session_registry.sql before setting the DSN.
Session attribution (v1.14.0) — ties a registry row to the run that
produced it. If you deploy scoped-mcp under --transport http behind the
Claude CLI, your agent's .mcp.json must forward two headers:
{
"mcpServers": {
"tools": {
"type": "http",
"url": "http://127.0.0.1:9200/mcp",
"headers": {
"Authorization": "Bearer ${SCOPED_MCP_BEARER_TOKEN}",
"X-Forge-Run-Id": "${FORGE_RUN_ID}",
"X-Forge-Task-Id": "${FORGE_TASK_ID}"
}
}
}
}
Without them, attribution silently does nothing — by design, but invisible unless you know to look for it.
Why headers, not environment variables. Under the HTTP transport, scoped-mcp
is one long-lived process per agent — it never sees the environment of the
CLI child that launched any individual run, so reading os.environ for a
per-run id would find nothing, ever. The CLI's ${VAR} header interpolation
reads that child's environment and forwards it per-request, the same
mechanism SCOPED_MCP_BEARER_TOKEN already uses. The stdio transport is
spawned per session and does inherit the variable directly, so the
environment-variable path is kept there as a fallback.
session_registry in scoped_mcp_status reports one of four states:
disabled (no DSN — nothing wrong), uninitialised (configured, no writer
has run yet), unavailable (configured, but the pool couldn't be built —
approvals are being recorded without attribution), or recording. This
field never affects top-level healthy: the registry is fail-open by design,
since a down database must never block a HITL-gated tool call.
Two things that look like bugs and aren't:
sessions row and a
NULL session_id. Correct — there is no launcher-minted identity to
record.sessions.status stays "active" forever. Read it as "last seen at
last_seen_at", never as "running now". Session closure is tracked
separately (vikunja#602).No API change beyond the session_registry status field — existing tool
signatures are unchanged.
Response filtering (v1.0.2, scope widened v1.17.2) — opt-in post-execution
content scanning. block, warn, or redact modes applied per-field via
ResponseFilterRule entries in the manifest's audit: section. See
contrib/response_filter.py.
text, _meta, extra
fields, URIs, and resource-link name/title/description. Each block is
re-validated into its MCP type after filtering; if redaction leaves a block that no
longer validates (a URI redacted whole, say), the response is blocked rather than
forwarded unfiltered.data and resource blobs are taken out of
the string pass, so a pattern can never corrupt base64.mimeType is text/*, JSON, XML,
+json/+xml, or absent is base64-decoded (4 MiB cap) and checked. A block or
redact match blocks the response, since redacting inside base64 means nothing. A
blob over the cap, or one that is not strict padded standard-alphabet base64, is
blocked as text-blob-unscannable — read that as a format mismatch, not a detected
leak. Blobs labelled binary (application/octet-stream, image/*, ...) are not
scanned.redact with decode. When only a decoded candidate matches (e.g. a
base64-encoded credential), the whole value is replaced with [REDACTED]. Before
v1.17.2 this case only logged response_filter_redacted and forwarded the value
unchanged.content_limits.max_binary_bytes (default
10 MiB, v1.19.0), not screened. The cap is per block, so a result with N blocks can
carry up to N times the cap. Images can carry prompt injection that text filters
cannot see.