mcp-gate
Officialby RunLit
README.md
# mcp-gate
mcp-gate lets you mount several MCP toolkits behind one server and filter
which tools each caller can see and call — without toolkits knowing about
your auth or tier model.
Capability-gated, multi-toolkit MCP tool serving on top of the official
[`mcp`](https://pypi.org/project/mcp/) SDK.
`mcp-gate` lets you register tools once, tag each with an optional
capability, and serve the same set of tools to different callers who see
different subsets of them — a public tool visible to anyone, and a gated
tool visible only to a caller holding the right capability. It works over
stdio (single local process) and over stateless streamable HTTP (multiple
callers, bearer-token auth), with no branching between the two in your tool
code.
`mcp-gate` has no dependency on any specific web framework, ORM, or user
model. Its only third-party dependencies are `mcp`, `asgiref`, and
`starlette`. A host application (Django, FastAPI, a bare script — anything)
supplies its own `Identity` that resolves a bearer token to a `Caller` and
its capabilities; `mcp-gate` never touches your database or auth system
directly.
## Install
```bash
pip install git+https://github.com/RunLit/mcp-gate.git
```
(This package is not yet published to PyPI.)
## Core concepts
- **`Toolkit`** — a named, passive registry of tools. You decorate plain
functions with `@kit.tool(requires=...)`; the function's docstring becomes
the description the model reads.
- **`ToolSpec`** — the record `Toolkit.tool()` creates: a name, the function,
the required capability (or `None` for public), and a description.
- **`Caller`** — who is asking, and what capabilities they hold.
`ANONYMOUS` is the caller used when no credential was presented, or the
credential didn't resolve to anything.
- **`Identity`** — a protocol with one async method, `resolve(token) ->
Caller`, that turns a raw bearer token (or `None`) into a `Caller`. This is
the one seam a host application implements against its own user store.
Two reference implementations ship in the package:
- **`StaticTokenIdentity`** — an in-memory token table, `{token: (subject,
capabilities)}`. Good for a single-owner standalone deployment or tests;
anything with real users should resolve against a database instead.
- **`OpenIdentity`** — grants a fixed capability set to *every* caller,
ignoring the token entirely.
- **`Gate`** — assembles one or more toolkits and an `Identity` into a real
`mcp.server.MCPServer`, with per-request capability filtering wired in via
`CapabilityMiddleware`.
- **`asgi_app`** / **`run_stdio`** — the two ways to serve a `Gate`.
## Capability-naming convention
A capability is just a string; `mcp-gate` does no parsing or validation of
it beyond exact-match comparison. Two things follow from that:
- **Capability names are global, not toolkit-scoped.** `Gate` merges *tool*
names from every mounted toolkit (colliding tool names are rejected unless
you call `.prefixed()`), but capability strings get no such treatment —
two toolkits that both use `"read_status"` share that capability whether
you meant them to or not.
- Convention: name capabilities `<verb>_<domain>`, snake_case, with the
domain naming the toolkit or resource the tool touches — e.g.
`read_printer`, `control_printer`, `read_solar`. If you mount toolkits from
unrelated domains in the same `Gate` and want their capabilities kept
separate even when the verb matches, prefix the domain further (e.g.
`printer_a:read_status` vs. `printer_b:read_status`) — `mcp-gate` doesn't
care what characters you use, since it only ever compares full strings.
- `requires=None` marks a tool public. There is no wildcard capability and
no implicit inheritance: a caller who should see everything (a superuser,
say) needs every capability name granted explicitly by your `Identity`.
## Worked example
```python
# toolkit.py
from mcp_gate import Toolkit
kit = Toolkit("greeter")
@kit.tool(requires=None)
def greeting() -> str:
"""Say hello. Available to anyone, including anonymous callers."""
return "hello"
@kit.tool(requires="read_printer")
def printer_status() -> str:
"""Report the printer's current status. Requires the read_printer capability."""
return "idle"
```
### Serving on stdio, with `OpenIdentity`
Stdio has no headers and no bearer token — the OS process boundary *is* the
trust boundary. Anyone who can start the process already has whatever access
starting it implies, so `OpenIdentity` (a fixed capability set, no
credential check) is the right identity here:
```python
# stdio_main.py
from mcp_gate import Gate, OpenIdentity, run_stdio
from toolkit import kit
gate = Gate(
name="my-local-server",
toolkits=[kit],
identity=OpenIdentity(["read_printer"]),
)
if __name__ == "__main__":
run_stdio(gate)
```
```bash
python stdio_main.py
```
### Serving over HTTP, with `StaticTokenIdentity`
```python
# http_main.py
from mcp_gate import Gate, StaticTokenIdentity, asgi_app
from toolkit import kit
identity = StaticTokenIdentity({
"super-secret-token-for-rnl": ("rnl", ["read_printer"]),
})
gate = Gate(name="my-http-server", toolkits=[kit], identity=identity)
app = asgi_app(gate) # a plain ASGI app
```
```bash
uvicorn http_main:app --host 0.0.0.0 --port 8000
```
A request with `Authorization: Bearer super-secret-token-for-rnl` sees both
`greeting` and `printer_status` in `tools/list`, and can call either. A
request with no token, or an unrecognized one, resolves to `ANONYMOUS` and
sees only `greeting`; calling `printer_status` anyway raises the same
"tool not found"-shaped error as calling a tool that doesn't exist at all —
gating never leaks which restricted tools exist to a caller who can't use
them.
`asgi_app(gate, **streamable_http_kwargs)` passes any extra keyword
arguments straight through to the SDK's `streamable_http_app()` (`host`,
`transport_security`, `json_response`, `streamable_http_path`,
`max_request_body_size`). This matters once you're behind nginx (or
similar) fronting a real domain: `streamable_http_app()` auto-enables a
DNS-rebinding guard that only allows `Host` headers matching
`127.0.0.1`/`localhost`/`::1`, so a request for your production domain gets
a `421 Misdirected Request` unless you pass a `transport_security` (or
`host`) that allows it, e.g.:
```python
from mcp.server.transport_security import TransportSecuritySettings
app = asgi_app(
gate,
transport_security=TransportSecuritySettings(allowed_hosts=["mcp.example.com"]),
)
```
## Warning: never use `OpenIdentity` over HTTP
`OpenIdentity` grants its fixed capability set to *every* caller and never
looks at the token — it exists for stdio, where the process boundary already
is the trust boundary. Wiring `OpenIdentity` into `asgi_app()` means every
HTTP request, from anyone who can reach the port, gets that full capability
set with no credential check whatsoever. Use `StaticTokenIdentity` or your
own `Identity` (backed by real per-user credentials) for anything served
over `asgi_app()`.
## Running the tests
```bash
pip install -e ".[dev]"
pytest tests/ -v
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues