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:
- Aggregation gateways — combine servers, no scoping
- Access control proxies — filter tools per agent, no resource scoping
- Credential proxies — isolate credentials, no tool management
- Enterprise gateways — governance and auth, but cloud and team-oriented
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.ymlClaude 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.1HTTP transport constraints:
- Bearer required — every request must send
Authorization: Bearer <SCOPED_MCP_BEARER_TOKEN>. Missing or invalid tokens are rejected with401before any tool dispatch, using a constant-time compare. Startup refuses to run the HTTP transport if the env var is unset. - Loopback only — the server binds
127.0.0.1; a non-loopback--hostis refused.--portis required underhttp. - Per-connection audit identity — each request resolves its own audit
session_idfrom 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, itselfuuid5-mapped) instead, so distinct concurrent stateless connections still get distinct trails. (SMCP-16, v1.8.0) - Manifest edits require a restart — module discovery (
_discover_tools()) runs exactly once, when the process starts. Understdio, every new client connection was a fresh subprocess, so a manifest edit took effect automatically on the next session. Underhttp, the process is long-lived and a client reconnecting is just a new connection to the same warm server — a manifest edit (newtool_allowlistentries, new modules, etc.) has no effect until you runpm2 restart scoped-mcp-<agent>.scoped_mcp_statussurfaces amanifest_stale: trueflag (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 toagents/{agent_id}/NamespaceScope— key-value operations prefixed with agent's namespace- Per-agent file — e.g. SQLite gives each agent its own database file at
{db_dir}/agent_{agent_id}.db - Custom — implement
ScopeStrategyfor your backend's isolation model
Credential 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:
- Audit log — what agents did. Every tool call, every scope check. Under stdio each entry carries the process-start
session.id; under the long-lived HTTP transport thesession.idis resolved per connection so concurrent clients stay distinguishable. - Operational log — what the server did. Startup, shutdown, config errors.
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):
- Import — if a module file raises on import (missing dependency, syntax error), it is recorded in
failed_importsand discovery continues. Other modules are unaffected. - Init — if a module's
__init__raises (bad config, missing credential), it is skipped. Other modules still instantiate and register normally. - Startup —
asyncio.gatherruns withreturn_exceptions=True. A startup failure is recorded inmodule_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)
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/mcpModule 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:
- Dependency-ready gate — before instantiating a module whose config carries a loopback HTTP
url, the registry polls that port until it accepts a TCP connection, bounded bydependency_wait_timeout_seconds(default30) atdependency_wait_interval_seconds(default1). 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 beoptional: trueand powered off on purpose, so blocking on it would turn a supported state into an outage. On expiry the module falls through to the normalfailed_initpath — startup is always bounded and never hangs. A shared ceiling also bounds the total time one process spends across every module's gate combined — default60seconds, overridable withSCOPED_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/healtheven 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 tofailed_init, where the background re-init loop below picks them up. - Background re-init loop — after startup, any module left in
failed_initorfailed_startupis 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 torunningwith the recorded error cleared, and the health file is rewritten — so/healthreturns200on the very next probe with no restart.failed_importis 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 # optionalCredential 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):
- Self-heal re-auth — when renewal fails with a permission/403 class error or crosses the critical-failure threshold, scoped-mcp mints a fresh token with a full AppRole login. This covers the hard
token_max_ttlceiling thatrenew-selfalone can never exceed. Opt-in viaSCOPED_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. - Out-of-band alert — on each healthy⇄degraded transition scoped-mcp posts a Vault-independent alert to Matrix, configured from plain env (
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/mcp401s (a misconfigured client bearer) also fires one rate-limited alert — the one signal a session-startscoped_mcp_statuscheck 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. - ntfy fallback (v1.8.0, SMCP-27) — Matrix is the primary sink; if it's down or unconfigured, the same alert falls back to an ntfy topic via
SCOPED_MCP_ALERT_NTFY_URL(+ optionalSCOPED_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'thttps://, so a misconfigured plaintext URL can't leak it. /healthendpoint — under--transport http, an unauthenticatedGET /healthon the existing port returns200when healthy and503when degraded (booleans/counts only, never token or lease values), so a dumb prober or load balancer can act on the status code alone.- OTel metrics — when
OTEL_EXPORTER_OTLP_ENDPOINTis 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-hitlManifest 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:
- Only the braced form is expanded (
${VAR}, not$VAR) to prevent accidental substitution. - Undefined variables at startup are a hard error — the agent will not start with incomplete config.
- Expanded values are never written to audit or ops logs.
- Always YAML-quote fields receiving substitution — a secret value containing
:,{, 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.
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: readonlyworkspace_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 |
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.
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.
| 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 — 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 Vault policy.
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:
- Threat model:
docs/threat-model.mddocuments the attack surface, trust boundaries, and what scoped-mcp does and does not protect against. - Audit history:
docs/security-audit.mdtracks 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. - Verifiable isolation: the
examples/claude-code/multi-agent-setup.mdincludes a step-by-step verification walkthrough — you can confirm filesystem isolation and credential non-exposure yourself in under five minutes.
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 withscoped_mcp.*attributes (agent.id,agent.type,tool.name,call.status). Auto-enabled whenOTEL_EXPORTER_OTLP_ENDPOINTis set in the environment. Tool arguments are excluded from spans to prevent credential leakage. Works with SigNoz, Grafana Tempo, Jaeger, and Langfuse OTLP ingest. Requirespip install scoped-mcp[otel]. -
Rate limiting (
rate_limits:, v0.7) — sliding-window per-agent and per-tool limits with glob patterns. Backed byInProcessBackend(default) orDragonflyBackend([dragonfly]extra) for cross-process state. -
Vault-backed credentials (
credentials.source: vault, v0.8) — fetch credentials from HashiCorp Vault via AppRole; client token auto-renewed in the background, with opt-in self-heal re-auth, credential-health surfacing, an unauthenticated/healthprobe, and out-of-band degradation alerts (SMCP-26 — see Credential Health, Self-Heal & Alerting above). Seeexamples/vault/. -
mcp_proxy schema validation + argument filtering (
argument_filters:, v0.9) — proxied calls are validated against the upstream tool'sinputSchemabefore forwarding; pattern-based argument filters can block or alert on values, with optional base64/url decoding. Seedocs/threat-model.mdfor 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 anapproval_requiredtool, the middleware rejects immediately with aHitlRejectedErrorcontaining an approval ID and retry instructions — the MCP connection stays open. The operator runsscoped-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 theapproval_id, so once a token is consumed on retry the middleware resolves thehitl_approvalsaudit row toconsumedinstead of leaving it stuck atapprovedforever (v1.10.0, SMCP-39) — fails open on a pre-upgrade plain-string token, skipping only the audit resolve.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 keyRequires
state_backend.type: dragonfly. Install withpip 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 ismatrix-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'sBearerTokenVerifier(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 clean503, not a404) 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 toalready_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(defaultenforce, 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 orscoped-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 samehitl_endpoint.approve/denylogic as the bot path, and every resolution is tagged in the audit trail with aresolved_viachannel (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.Agent session registry (v1.9.0, optional
[postgres]extra) — a fail-openasyncpgDAL (registry_db.py) over a session registry onagent-postgres, configured viaAGENT_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 withpip install scoped-mcp[postgres]; applymigrations/0001_agent_session_registry.sqlbefore setting the DSN. -
Response filtering (v1.0.2) — opt-in post-execution content scanning.
block,warn, orredactmodes applied per-field viaResponseFilterRuleentries in the manifest'saudit:section. Redaction applies to string leaves in structured responses only — never to serialized dict/list blobs. Seecontrib/response_filter.py.
- Not an enterprise gateway — no OAuth, no multi-tenant SaaS, no Kubernetes. For self-hosters running multi-agent setups.
- Not a policy engine — no prompt injection detection, no tool call classification.
- Not a process manager — one MCP server that an agent connects to. Spawning agents is your orchestrator's job.
- Not E2EE — the Matrix module supports unencrypted rooms only (no libolm dependency).
# 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 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