Skip to content

Repository files navigation

scoped-mcp

Built with Claude Code CI PyPI version Python versions License: MIT

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.


The Problem

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.


How It Works

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
Loading

Quickstart

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.


Transports

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:

  • Bearer required — every request must send 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.
  • Loopback only — the server binds 127.0.0.1; a non-loopback --host is refused. --port is required under http.
  • Per-connection audit identity — each request resolves its own audit 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)
  • Manifest edits require a restart — module discovery (_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}" }
    }
  }
}

Core Concepts

Agent IdentityAGENT_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
  • Per-agent file — e.g. SQLite gives each agent its own database file at {db_dir}/agent_{agent_id}.db
  • Custom — implement ScopeStrategy for 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:

  1. 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 the session.id is resolved per connection so concurrent clients stay distinguishable.
  2. 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_imports and 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.
  • Startupasyncio.gather runs with return_exceptions=True. A startup failure is recorded in module_health; the server yields and remaining modules' tools stay available.

Module Healthscoped_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/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:

  • 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 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.
  • Background re-init loop — after startup, any module left in 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):

  • 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_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.
  • 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 /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.
  • 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 (+ 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), so a dumb prober or load balancer can act on the status code alone.
  • OTel metrics — when 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)


Manifest Format

# 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

Environment Variable Substitution

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:

  • 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.

Top-Level Fields and Strict Validation

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: 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.

Manifest-to-Tools Mapping

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
Loading

Built-in Modules

Storage

Module Scope Read tools Write tools
filesystem PrefixScopeagents/{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

Notifications

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

Proxy

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.

Infrastructure

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

Credentials

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/.


Three-Module Workflow

┌─ 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.


Write Your Own Module

# 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.


Comparison to Existing Tools

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.


Security

scoped-mcp's core value is security — tool scoping, credential isolation, and audit logging. To back that up:

  • Threat model: docs/threat-model.md documents the attack surface, trust boundaries, and what scoped-mcp does and does not protect against.
  • Audit history: 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.
  • Verifiable isolation: the 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.

Optional guardrails

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 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.

    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_confirm trusts 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 enable interactive for 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. enforce remains correct for any agent that might run unattended.

    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.

  • Response filtering (v1.0.2) — opt-in post-execution content scanning. block, warn, or redact modes applied per-field via ResponseFilterRule entries in the manifest's audit: section. Redaction applies to string leaves in structured responses only — never to serialized dict/list blobs. See contrib/response_filter.py.


Non-Goals

  • 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).

Installation

# 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.

License

MIT

About

Per-agent scoped MCP tool proxy — credential isolation, resource scoping, and audit logging for AI agent deployments

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages