Skip to main content
Glama
qilobit
by qilobit
README.md
# mcp-server — tenant-safe, read-only

A [Model Context Protocol](https://modelcontextprotocol.io) server exposing
operational tools over a **multi-tenant** dataset to any MCP client, with the
tenant bound **server-side** and never present in the published tool schema.

## The argument

An MCP server publishes its tool schema to whatever client connects — Claude
Desktop, an IDE, a third-party agent. That makes the schema a **trust boundary**,
and it changes the calculus compared to an agent you own end to end.

If a tool took a `company` parameter, tenant selection would be delegated to a
model on the other side of the connection: one you did not write, cannot audit,
and whose version can change without telling you. No prompt on your side
constrains it, because the prompt lives over there.

So the tenant is not a parameter. It is read once, at process start, from the
server's own configuration:

```python
TENANT = resolve_tenant()   # from MCP_TENANT_ID; fails hard if unset
```

Cross-tenant access is not "unlikely" or "blocked by instructions" — there is no
argument in which to express it. That is the entire design, and everything below
exists to prove it holds rather than assert it.

This matches how MCP is actually deployed: one server process per user, launched
by the client with its own configuration. Identity belongs to that config, not
to the conversation.

## Verified, not asserted

```bash
MCP_TENANT_ID=acme python scripts/check_isolation.py   # 5 properties
python scripts/check_e2e.py                            # real stdio handshake
```

`check_isolation.py` asserts, against the running server:

| # | Property |
|---|---|
| 1 | No published tool exposes a tenant-like parameter (`company`, `tenant_id`, `merchant`, …) |
| 2 | Every tool is annotated `read_only_hint` |
| 3 | Executing **every published tool** leaks no other tenant's canary |
| 4 | Injecting `company=<other>` into a call returns nothing belonging to that tenant |
| 5 | Without `MCP_TENANT_ID` the server fails to start instead of defaulting |

Check 3 enumerates whatever the server publishes and builds arguments from each
schema, rather than calling a hardcoded list. A hardcoded list would silently
skip exactly the tool someone adds later — which is the case worth catching.

`check_e2e.py` starts the server as a subprocess and drives it through
`initialize → list_tools → call_tool` over stdio: proof it speaks the protocol,
not just that the Python objects behave.

### The checks are themselves checked

`MCP_UNSAFE_MODE=1` publishes a deliberately broken tool that accepts `company`
and honours it. The isolation check must go **red** under it:

```bash
MCP_TENANT_ID=acme MCP_UNSAFE_MODE=1 python scripts/check_isolation.py
# FAIL  no tool exposes a tenant parameter   -> list_orders_unsafe.company
# FAIL  no output contains another tenant's canary -> leaked: ['globex']
```

A guard that has never gone red has not been shown to detect anything. Shipping
the broken variant is what makes the green run mean something.

## Run it

```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
MCP_TENANT_ID=acme python -m server.main    # stdio
```

In an MCP client (Claude Desktop and similar), register one process per tenant:

```json
{
  "mcpServers": {
    "acme-ops": {
      "command": "/path/to/.venv/bin/python",
      "args": ["-m", "server.main"],
      "cwd": "/path/to/mcp-server",
      "env": { "MCP_TENANT_ID": "acme" }
    }
  }
}
```

## Tools

All read-only. None takes a tenant argument.

| Tool | Returns |
|---|---|
| `sales_summary(start_date, end_date)` | Order count and total for a date range |
| `list_orders(status)` | Orders with status `paid` / `pending` / `refunded` |
| `top_customers()` | Customers ranked by total spend |

## Structure

```
mcp-server/
├── server/
│   ├── tenant.py   # the only place a tenant is decided
│   ├── data.py     # synthetic multi-tenant dataset with per-tenant canaries
│   └── main.py     # MCP server; tools + the gated unsafe variant
├── scripts/
│   ├── check_isolation.py   # the 5 properties above
│   └── check_e2e.py         # stdio protocol handshake
```

The dataset is fictional so the repo is clonable and runnable as-is.

## Scope

This is a portfolio implementation of one pattern, not a product. See
[SECURITY.md](SECURITY.md) for what it does and does not cover — notably that
`MCP_TENANT_ID` stands in for a verified token, which is what a real deployment
would use.