Module Authoring Guide
How to write a custom scoped-mcp tool module.
The Contract
A tool module is a Python class that:
- Subclasses
ToolModulefromscoped_mcp.modules._base - Declares a unique
name(used in manifests and as the tool name prefix) - Declares a
scopingstrategy (orNonefor modules with no resource scoping) - Lists
required_credentialsby key name - Implements async methods decorated with
@tool(mode="read")or@tool(mode="write")
The registry does the rest: discovery, instantiation, credential injection, tool registration with FastMCP, and @audited wrapping.
Scope enforcement is your responsibility
@audited (applied by the registry) provides structured audit logging only — it does NOT call scope.enforce() for you. Every tool method you write MUST either:
- Call
self.scoping.apply(value, self.agent_ctx)and/orself.scoping.enforce(value, self.agent_ctx)on each argument that addresses a backend resource (paths, keys, bucket names), OR - Validate the argument against an explicit allowlist held in
self.configwhen the scope is not a transformable value (e.g. a REST service name, a queue topic, a datasource name).
If neither applies to your module, you have no scope boundary and should declare scoping = None AND gate access at the credential level (the built-in write-only notifiers — Slack, Discord, ntfy — use this pattern: one credential = one channel).
Every new module MUST ship with at least one test that a cross-agent or out-of-scope argument raises ScopeViolation (or the module-specific equivalent) before any backend call is made. See the test_cross_agent_blocked pattern below.
Example: Redis module
# src/scoped_mcp/modules/redis.py
from __future__ import annotations
from typing import ClassVar
from ._base import ToolModule, tool
from ..scoping import NamespaceScope
class RedisModule(ToolModule):
name: ClassVar[str] = "redis"
scoping: ClassVar[NamespaceScope] = NamespaceScope()
required_credentials: ClassVar[list[str]] = ["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 by key (scoped to agent namespace).
Args:
key: Key name. Automatically prefixed with '{agent_id}:'.
Returns:
Value string, or None if not found.
"""
scoped_key = self.scoping.apply(key, self.agent_ctx)
self.scoping.enforce(scoped_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).
Args:
key: Key name. Automatically prefixed with '{agent_id}:'.
value: String value to store.
ttl: Optional expiry in seconds (0 = no expiry).
Returns:
True on success.
"""
scoped_key = self.scoping.apply(key, self.agent_ctx)
self.scoping.enforce(scoped_key, self.agent_ctx)
return bool(await self._redis.set(scoped_key, value, ex=ttl or None))
Registering the module
The registry discovers subclasses automatically by scanning scoped_mcp/modules/. No registration step is needed — just create the file.
Add the module to a manifest:
modules:
redis:
mode: read
config: {}
For agents that should write: mode: write.
Using config
The config dict comes from the manifest’s modules.<name>.config block.
modules:
redis:
mode: write
config:
db_index: 1
key_prefix: "custom-"
Access in __init__:
def __init__(self, agent_ctx, credentials, config):
super().__init__(agent_ctx, credentials, config)
self._db = config.get("db_index", 0)
Handling credentials
Declare required credential keys in required_credentials. The framework resolves them from the environment (or secrets file) and passes them in the credentials dict.
required_credentials: ClassVar[list[str]] = ["REDIS_URL", "REDIS_PASSWORD"]
def __init__(self, agent_ctx, credentials, config):
super().__init__(agent_ctx, credentials, config)
self._redis = aioredis.from_url(
credentials["REDIS_URL"],
password=credentials["REDIS_PASSWORD"],
)
Never pass credential values to the agent in tool responses. The structlog processor redacts keys ending in _TOKEN, _PASSWORD, _SECRET, _KEY from audit logs, but tool return values are not sanitized — that’s your responsibility.
Writing tests
Every module needs tests in tests/test_modules/test_<name>.py. Minimum coverage:
@pytest.mark.asyncio
async def test_get_key_success(agent_ctx):
mod = RedisModule(agent_ctx=agent_ctx, credentials={"REDIS_URL": "redis://test"}, config={})
# ... mock Redis, test happy path
@pytest.mark.asyncio
async def test_cross_agent_blocked(agent_ctx, other_agent_ctx):
# Verify Agent A cannot access Agent B's keys
@pytest.mark.asyncio
async def test_scope_enforcement(agent_ctx):
# Verify ScopeViolation raised for out-of-scope keys
def test_credential_not_in_config(agent_ctx):
mod = RedisModule(...)
assert "REDIS_URL" not in mod.config
Choosing a scoping strategy
| Backend type | Strategy |
|---|---|
| Filesystem, object storage, any prefix-addressable store | PrefixScope |
| Embedded SQL database (SQLite) | Per-agent file — {db_dir}/agent_{agent_id}.db |
| Key-value store, message queue, time-series buckets | NamespaceScope |
| Webhook (single-channel) | None — one credential = one channel |
| REST API with allowlist | Custom — validate against declared services |
SchemaScope was removed in the 2026-04-19 cleanup build — do not reference it. See the 2026-04-16 audit (finding C1) for background.
If none of the built-in strategies fit, implement ScopeStrategy:
from scoped_mcp.scoping import ScopeStrategy, ScopeViolation
from scoped_mcp.identity import AgentContext
class TenantScope(ScopeStrategy):
def apply(self, value: str, agent_ctx: AgentContext) -> str:
return f"tenant/{agent_ctx.agent_id}/{value}"
def enforce(self, value: str, agent_ctx: AgentContext) -> None:
prefix = f"tenant/{agent_ctx.agent_id}/"
if not value.startswith(prefix):
raise ScopeViolation(
f"Resource '{value}' is outside tenant scope for '{agent_ctx.agent_id}'"
)