Metadata-Version: 2.5
Name: scoped-mcp
Version: 1.15.0
Summary: Per-agent scoped MCP tool proxy with credential isolation and audit logging
Project-URL: Homepage, https://github.com/TadMSTR/scoped-mcp
Project-URL: Documentation, https://tadmstr.github.io/scoped-mcp/
Project-URL: Repository, https://github.com/TadMSTR/scoped-mcp
Project-URL: Issues, https://github.com/TadMSTR/scoped-mcp/issues
License-Expression: MIT
License-File: LICENSE
Keywords: ai-agents,claude-code,credential-isolation,fastmcp,mcp,multi-agent,tool-proxy
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: fastmcp<4.0.0,>=3.2.0
Requires-Dist: jsonschema<5.0,>=4.18
Requires-Dist: pydantic<3.0,>=2.0
Requires-Dist: pyyaml<7.0,>=6.0
Requires-Dist: sqlglot<31.0,>=30.0
Requires-Dist: structlog<27.0,>=25.0
Provides-Extra: all
Requires-Dist: aiosmtplib<6.0,>=5.1.2; extra == 'all'
Requires-Dist: aiosqlite<1.0,>=0.20; extra == 'all'
Requires-Dist: httpx<1.0,>=0.27; extra == 'all'
Provides-Extra: dev
Requires-Dist: asyncpg<1.0,>=0.29; extra == 'dev'
Requires-Dist: hvac<3,>=2.0; extra == 'dev'
Requires-Dist: pytest-asyncio<2.0,>=0.23; extra == 'dev'
Requires-Dist: pytest-cov<8.0,>=5.0; extra == 'dev'
Requires-Dist: pytest<10.0,>=8.0; extra == 'dev'
Requires-Dist: redis<6,>=5.0; extra == 'dev'
Requires-Dist: ruff<0.16,>=0.15; extra == 'dev'
Provides-Extra: dragonfly
Requires-Dist: redis<6,>=5.0; extra == 'dragonfly'
Provides-Extra: http
Requires-Dist: httpx<1.0,>=0.27; extra == 'http'
Provides-Extra: otel
Requires-Dist: opentelemetry-api<2.0,>=1.20; extra == 'otel'
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.20; extra == 'otel'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.20; extra == 'otel'
Requires-Dist: opentelemetry-sdk<2.0,>=1.20; extra == 'otel'
Provides-Extra: postgres
Requires-Dist: asyncpg<1.0,>=0.29; extra == 'postgres'
Provides-Extra: smtp
Requires-Dist: aiosmtplib<6.0,>=5.1.2; extra == 'smtp'
Provides-Extra: sqlite
Requires-Dist: aiosqlite<1.0,>=0.20; extra == 'sqlite'
Provides-Extra: vault
Requires-Dist: cryptography<51.0,>=50.0; extra == 'vault'
Requires-Dist: hvac<3,>=2.0; extra == 'vault'
Description-Content-Type: text/markdown

# scoped-mcp

[![Built with Claude Code](https://img.shields.io/badge/Built_with-Claude_Code-6B57FF?logo=claude&logoColor=white)](https://claude.ai/code)
[![CI](https://github.com/TadMSTR/scoped-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/TadMSTR/scoped-mcp/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/scoped-mcp.svg)](https://pypi.org/project/scoped-mcp/)
[![Python versions](https://img.shields.io/pypi/pyversions/scoped-mcp.svg)](https://pypi.org/project/scoped-mcp/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/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](https://github.com/TadMSTR/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)
```

```mermaid
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
```

---

## Quickstart

```bash
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`:**

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

```bash
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:

```json
{
  "mcpServers": {
    "tools": {
      "type": "http",
      "url": "http://127.0.0.1:9200/mcp",
      "headers": { "Authorization": "Bearer ${SCOPED_MCP_BEARER_TOKEN}" }
    }
  }
}
```

---

## Core Concepts

**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
- 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.
- **Startup** — `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&harr;offline transition of an optional module still fires exactly one low-severity alert via the existing SMCP-26 Matrix&rarr;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)

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

```yaml
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` `401`s (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, and never tool names — see **Tool Inventory**), 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

```yaml
# 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:

```yaml
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 |

```yaml
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

```mermaid
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
```

---

## Built-in Modules

### Storage

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

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

```yaml
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

```python
# 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:
```yaml
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][cf] | [Docker MCP Gateway][dmg] | [Stacklok ToolHive][th] | [Kong MCP][kong] |
|---|---|---|---|---|---|
| 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](#non-goals). It occupies the gap they
leave: per-agent resource isolation in a single self-hosted process.

[cf]: https://github.com/IBM/mcp-context-forge
[dmg]: https://github.com/docker/mcp-gateway
[th]: https://github.com/stacklok/toolhive
[kong]: https://konghq.com/blog/engineering/mcp-tool-governance-security-meets-context-efficiency

---

## 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`](https://gitea.tadmstr.me/host-forge/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.

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

  ```json
  {
    "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:*
  - An interactive (non-dispatcher) session produces no `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) — 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

```bash
# 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](docs/troubleshooting.md).

## License

MIT
