relay-mcp
by yujoonko1
README.md
# relay-mcp
[](https://github.com/yujoonko1/relay-mcp/actions/workflows/ci.yml)
[](https://pypi.org/project/relay-mcp/)
[](https://pypi.org/project/relay-mcp/)
[](LICENSE)
**Expose any dev tool as a safe, audited MCP server — in about 10 lines.**
```bash
pip install relay-mcp
```
[Model Context Protocol](https://modelcontextprotocol.io) (MCP) is how AI agents and IDEs connect to external tools. Writing an MCP server by hand means hand-rolling JSON schemas, wiring transport handlers, and — critically — deciding what happens when an agent asks to do something destructive.
Relay handles all of that:
- **`@tool` decorator** — turns a plain typed Python function into an MCP tool. Input schema is generated from type hints; parameter docs come from your docstring.
- **Permission tiers** — every tool is `READ_ONLY`, `WRITE`, or `DESTRUCTIVE`. Destructive tools are **denied by default** unless you wire a confirmation hook. Safe by default, not by discipline.
- **Audit log** — every invocation (success, error, timeout, *and* denial) is recorded to SQLite with duration and automatically-redacted sensitive arguments.
- **Argument validation** — malformed calls are rejected with a clear `InvalidArguments` error *before* they reach a confirmation prompt or your function.
## Quickstart
```python
from relay_mcp import RelayServer, PermissionTier
relay = RelayServer("git-tools", audit_path="audit.db")
@relay.tool()
def git_status(repo: str) -> str:
"""Show working tree status.
Args:
repo: Absolute path to the git repository.
"""
...
@relay.tool(permission=PermissionTier.DESTRUCTIVE)
def delete_branch(repo: str, branch: str) -> str:
"""Force-delete a branch. Irreversible if unmerged."""
...
relay.run() # serves over stdio
```
That's a complete MCP server. Register it in Claude Desktop / any MCP client:
```json
{
"mcpServers": {
"git-tools": { "command": "python", "args": ["git_server.py"] }
}
}
```
## Permission model
| Tier | Behavior |
|---|---|
| `READ_ONLY` | Always allowed. Advertised to clients via MCP `readOnlyHint`. |
| `WRITE` | Allowed unless the server runs with `read_only=True`. |
| `DESTRUCTIVE` | Requires a `confirm_hook`; **denied if none is configured**. Advertised via `destructiveHint`. |
```python
def confirm(tool_name: str, arguments: dict) -> bool:
# prompt a human, check a policy service, whatever you need
return input(f"Allow {tool_name}({arguments})? [y/N] ") == "y"
relay = RelayServer("ops", confirm_hook=confirm)
```
Hooks can be sync or async. **Sync hooks run in a worker thread**, so a terminal prompt waiting on a human never stalls other in-flight tool calls. A rejected or missing confirmation raises `PermissionDenied` — the MCP client receives it with `isError: true`, and the denial itself is audited.
For headless or Windows deployments where no terminal is available, use the built-in allowlist hook:
```python
from relay_mcp import allowlist_confirm
relay = RelayServer("ops", confirm_hook=allowlist_confirm("rotate_logs"))
```
## Argument validation
Before the permission check, every call is checked against the tool's generated schema: missing required parameters or unexpected ones raise `InvalidArguments` (audited as `invalid`). A destructive call with a broken payload never triggers a confirmation prompt, and your function never sees a `TypeError` from a bad `**kwargs` expansion. Tools that declare `**kwargs` accept extra keys.
## Timeouts
A runaway tool can't hang the server:
```python
@relay.tool(timeout=30) # seconds
def run_build(project: str) -> str: ...
```
Exceeding the limit raises `TimeoutError` and records a `timeout` audit entry.
## Audit log
```python
for e in relay.audit.entries(status="denied"):
print(e.ts, e.tool, e.detail)
```
Arguments whose names look sensitive (`password`, `token`, `api_key`, ...) are redacted before persistence — recursively, so a secret nested inside a config dict or a list of objects is masked too. The log won't grow forever:
```python
relay.audit.prune(keep_last=10_000) # or older_than_days=30
```
## Architecture
```
agent / IDE (MCP client)
│ stdio (JSON-RPC)
▼
┌─────────────────────────────┐
│ RelayServer │
│ ┌───────────┐ │
│ │ MCP layer │ list_tools / call_tool (official mcp SDK)
│ └─────┬─────┘ │
│ ▼ │
│ validate_arguments ─invalid┼──► AuditLog (sqlite)
│ ▼ │ ▲
│ PermissionPolicy ──denied──┼─────────┤
│ ▼ │ │
│ execute (thread/async) ────┼──ok/err─┘
└─────────────────────────────┘
```
## Design rationale
- **Deny destructive by default.** Most MCP frameworks trust the client to gate dangerous actions. Relay assumes the agent *will* eventually call `delete_branch` with the wrong argument; a missing hook fails closed rather than open.
- **Tiers, not per-tool allowlists.** Three tiers are coarse on purpose: an author can classify a tool correctly in one second, which means the classification actually happens. Fine-grained policy belongs in the `confirm_hook`, where it can be as elaborate as you like.
- **Validate before you ask.** Argument validation runs before the permission check so a human is never asked to approve a call that would have crashed anyway.
- **Sync tools run in a thread** (`asyncio.to_thread`) so a slow subprocess call can't block the event loop and stall the transport.
- **Denials are audited, not just failures.** In security tooling, "what did the agent *try* to do" matters as much as what it did.
- **The execute pipeline is transport-independent**, so the full permission/audit path is unit-testable without spawning a subprocess.
## Examples
- [`examples/git_server.py`](examples/git_server.py) — all three tiers + interactive confirmation
- [`examples/db_server.py`](examples/db_server.py) — read-only server mode + defense in depth (SQLite `mode=ro`)
- [`examples/test_runner_server.py`](examples/test_runner_server.py) — async tools, subprocess timeouts, output truncation
## Development
```bash
pip install -e ".[dev]"
pytest # 32 tests, no subprocess or MCP client needed
ruff check . && ruff format --check .
```
CI runs the suite on Linux, macOS, and Windows across Python 3.10–3.13. Releases publish to PyPI via trusted publishing when a GitHub release is tagged `vX.Y.Z`.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues