Skip to main content
Glama
yujoonko1

relay-mcp

by yujoonko1
README.md
# relay-mcp

[![CI](https://github.com/yujoonko1/relay-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/yujoonko1/relay-mcp/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/relay-mcp.svg)](https://pypi.org/project/relay-mcp/)
[![Python](https://img.shields.io/pypi/pyversions/relay-mcp.svg)](https://pypi.org/project/relay-mcp/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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