fastmcp-gateway
OfficialREADME.md
# fastmcp-gateway
[](https://pypi.org/project/fastmcp-gateway/)
[](https://pypi.org/project/fastmcp-gateway/)
[](LICENSE)
[](https://github.com/Ultrathink-Solutions/fastmcp-gateway/actions/workflows/ci.yml)
**Progressive tool discovery gateway for MCP.** Aggregates tools from multiple upstream [MCP](https://modelcontextprotocol.io/) servers and exposes them through 4 meta-tools, enabling LLMs to discover and use hundreds of tools without loading all schemas upfront.
```text
LLM
│
└── fastmcp-gateway (4 meta-tools)
├── discover_tools → browse domains and tools
├── get_tool_schema → get parameter schema for a tool
├── execute_tool → run any discovered tool
│ ├── apollo (upstream MCP server)
│ ├── hubspot (upstream MCP server)
│ ├── slack (upstream MCP server)
│ └── ...
└── refresh_registry → re-query upstreams for changes
```
## Why?
When an LLM connects to many MCP servers, it receives all tool schemas at once. With 100+ tools, context windows fill up and tool selection accuracy drops. **fastmcp-gateway** solves this with progressive discovery: the LLM starts with 4 meta-tools and loads individual schemas on demand.
## Install
```bash
pip install fastmcp-gateway
```
Requires Python 3.11+. Built on [FastMCP](https://gofastmcp.com) 4 and the MCP Python SDK v2 (`fastmcp>=4.1,<5`).
## Quick Start
### Python API
```python
import asyncio
from fastmcp_gateway import GatewayServer
gateway = GatewayServer(
{
"apollo": "http://apollo-mcp:8080/mcp",
"hubspot": "http://hubspot-mcp:8080/mcp",
},
refresh_interval=300, # Re-query upstreams every 5 minutes (optional)
)
async def main():
await gateway.populate() # Discover tools from upstreams
gateway.run(transport="streamable-http", port=8080)
asyncio.run(main())
```
### CLI
```bash
export GATEWAY_UPSTREAMS='{"apollo": "http://apollo-mcp:8080/mcp", "hubspot": "http://hubspot-mcp:8080/mcp"}'
python -m fastmcp_gateway
```
The gateway starts on `http://0.0.0.0:8080/mcp` and exposes 4 tools to any MCP client.
## How It Works
1. **`discover_tools()`** — Call with no arguments to see all domains and tool counts. Call with `domain="apollo"` to see that domain's tools with descriptions. Returns each tool as a Python-style function signature by default (`apollo_search(query: str, limit: int = None) -> any`) — useful when the LLM will subsequently write code against the listed tools. Pass `format="schema"` to receive the JSON summary instead; for a whole domain (`domain=` with no `group` or `query`), each tool in it also carries `parameters`, its input JSON Schema exactly as `get_tool_schema` returns it, so one call lists a domain with every schema (the answer carries every visible tool's full schema, so a large domain gives a large answer), and `original_name`, the name the tool's own server gives it (equal to `name` unless the gateway renamed the tool because another domain registered the same name).
2. **`get_tool_schema("apollo_people_search")`** — Returns the full JSON Schema for a tool's parameters, for when a signature is not enough (parameter descriptions, nested objects' required fields and bounds). The name must match exactly; an unknown name returns `tool_not_found` with up to three similar names as suggestions.
3. **`execute_tool("apollo_people_search", {"query": "Anthropic"})`** — Routes the call to the correct upstream server and returns `{"tool": ..., "result": ...}`, or a structured error whose `code` says what to do next (see [Error Handling](#error-handling)). Pass the optional `domain` (`execute_tool("people_search", {...}, domain="apollo")`) to pin the call: the name is then resolved among that domain's tools only, by `original_name` or `name`, at call time, so a guessed prefix or a name cached before a re-registration cannot reach another domain's tool. A name that domain has no tool you can use for is `tool_not_found` (with `details.domain`) and nothing is dispatched. Without `domain`, resolution is by exact `name` as before. The refusal of a name hidden from the caller is part of this lookup, not an access boundary: execution is gated by `before_execute` hooks and by the upstream's own authorization. A pinned refusal carries `suggestions: []`.
4. **`refresh_registry()`** — Re-query all upstream servers and return a summary of added/removed tools per domain. Picks up an upstream that was unreachable at startup. A change to an already-loaded upstream's tools (names, descriptions or input schemas) is refused, and the previous tools stay in place, until an operator acknowledges the new schema digest with `POST /registry/servers/refresh?expected_digest=<digest>` (body `{"domain": "..."}`).
LLMs learn the workflow from the gateway's built-in system instructions and only load schemas for tools they actually need. Each meta-tool's description (what it does, when to use it, what each parameter means, what comes back and what an error looks like) is written for the model that reads it; `tests/test_meta_tool_descriptions.py` keeps it free of implementation detail.
## Configuration
All configuration is via environment variables:
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GATEWAY_UPSTREAMS` | Yes | — | JSON object: `{"domain": "url", ...}` or `{"domain": {"url": "...", "allowed_tools": [...], "denied_tools": [...]}}` (see Access Control) |
| `GATEWAY_NAME` | No | `fastmcp-gateway` | Server name |
| `GATEWAY_HOST` | No | `0.0.0.0` | Bind address |
| `GATEWAY_PORT` | No | `8080` | Bind port |
| `GATEWAY_INSTRUCTIONS` | No | Built-in | Custom LLM system instructions |
| `GATEWAY_REGISTRY_AUTH_TOKEN` | No | — | Bearer token for upstream discovery |
| `GATEWAY_DOMAIN_DESCRIPTIONS` | No | — | JSON object: `{"domain": "description", ...}` |
| `GATEWAY_UPSTREAM_HEADERS` | No | — | JSON object: `{"domain": {"Header": "Value"}, ...}` |
| `GATEWAY_REFRESH_INTERVAL` | No | Disabled | Seconds between automatic registry refresh cycles |
| `GATEWAY_TRACE_HEALTH_ROUTES` | No | `true` | Emit an OTel span per `/healthz` / `/readyz` request (see Health Endpoints) |
| `GATEWAY_MAX_SCHEMA_DEPTH` | No | `5` | Registry-ingest schema-nesting-depth cap, `1`-`50` (see Schema Validation) |
| `GATEWAY_UPSTREAM_CLIENT_MODE` | No | `legacy` | Protocol era the gateway's upstream connections negotiate: `legacy`, `auto`, or `2026-07-28` (see Protocol Eras) |
| `GATEWAY_HOOK_MODULE` | No | — | Python module path for execution hooks: `module.path:factory_function` |
| `GATEWAY_REGISTRATION_TOKEN` | No | — | Shared secret for dynamic registration endpoints (see below) |
| `GATEWAY_CODE_MODE` | No | `false` | Enable the experimental `execute_code` meta-tool (see Code Mode) |
| `GATEWAY_CODE_MODE_MAX_DURATION_SECS` | No | `30` | Per-run wall-clock cap for `execute_code` |
| `GATEWAY_CODE_MODE_MAX_MEMORY` | No | `268435456` | Per-run heap memory cap (bytes) for `execute_code` |
| `GATEWAY_CODE_MODE_MAX_ALLOCATIONS` | No | `10000000` | Per-run allocation cap for `execute_code` |
| `GATEWAY_CODE_MODE_MAX_RECURSION_DEPTH` | No | `200` | Per-run stack depth cap for `execute_code` |
| `GATEWAY_CODE_MODE_MAX_NESTED_CALLS` | No | `50` | Max number of upstream tool calls one `execute_code` run may make |
| `GATEWAY_CODE_MODE_AUDIT_VERBATIM` | No | `false` | Emit raw code body at DEBUG in audit logs (PII-sensitive) |
| `LOG_LEVEL` | No | `INFO` | Logging level |
### Per-Upstream Auth
If your upstream servers require different authentication, use `GATEWAY_UPSTREAM_HEADERS` to set per-domain headers:
```bash
export GATEWAY_UPSTREAM_HEADERS='{"ahrefs": {"Authorization": "Bearer sk-xxx"}}'
```
Domains without overrides get no extra headers: the gateway does not forward the incoming MCP request's headers (such as `Authorization`) to upstreams. To pass a caller's identity on, set `extra_headers` from an execution hook (see Execution Hooks).
## Protocol Eras
MCP has two protocol eras: the `initialize` handshake (protocol versions up to `2025-11-25`) and the sessionless `2026-07-28` era, which opens with `server/discover` and carries per-request metadata instead of a session.
**Serving.** The gateway serves clients of both eras on every transport, with no configuration. A `2025-11-25` client gets a session; a `2026-07-28` client does not. Both see the same meta-tools, the same domain-aware instructions, and the same results, with MCP's camelCase names on the wire (`inputSchema`, `structuredContent`, `isError`, `readOnlyHint`).
**Upstream connections.** Each connection the gateway opens to an upstream (one per registry fetch, one per tool call) negotiates its era by `upstream_client_mode` (`GATEWAY_UPSTREAM_CLIENT_MODE`):
| Mode | Opens with | Reaches |
|------|-----------|---------|
| `legacy` (default) | the `initialize` handshake, as earlier releases did | every server that serves the handshake: handshake-era servers (such as FastMCP 3) and dual-era servers (such as FastMCP 4) |
| `auto` | a `server/discover` probe; adopts `2026-07-28` when offered, else falls back to the handshake | servers of either era, at the cost of one extra request on each connection to a handshake-era server |
| `2026-07-28` | that version, without probing | only servers that serve the `2026-07-28` era |
```python
gateway = GatewayServer(upstreams, upstream_client_mode="auto")
```
The default stays `legacy` because a handshake-era upstream (for example one on FastMCP 3) rejects the probe, so `auto` spends an extra request on every tool call to it, and because a dual-era upstream serves the handshake exactly as before. Under `auto` or `2026-07-28`, the gateway lists an upstream's tools before each call that negotiates the `2026-07-28` era, so it can send the `Mcp-Param-*` headers that era requires for a tool argument marked `x-mcp-header`.
The upstream sees exactly the caller's `_meta` on every forwarded call: the client stack's own trace context and progress token are not added, and a caller-supplied `traceparent` is kept. The protocol-reserved `io.modelcontextprotocol/` keys describe a single connection, so the gateway does not forward a caller's; a `2026-07-28` upstream connection adds its own.
## Dynamic Upstream Registration
When `GATEWAY_REGISTRATION_TOKEN` is set, the gateway exposes REST endpoints for runtime upstream management — add, remove, and list upstream MCP servers without restarting.
### Endpoints
All endpoints require `Authorization: Bearer <token>` matching the configured token.
**Register an upstream:**
```bash
curl -X POST http://gateway:8080/registry/servers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain": "apollo", "url": "http://apollo-mcp:8080/mcp", "description": "Apollo.io CRM"}'
```
Response: `{"registered": "apollo", "url": "...", "tools_discovered": 12, "tools_added": ["search", ...]}`
**Deregister an upstream:**
```bash
curl -X DELETE http://gateway:8080/registry/servers/apollo \
-H "Authorization: Bearer $TOKEN"
```
**List registered upstreams:**
```bash
curl http://gateway:8080/registry/servers \
-H "Authorization: Bearer $TOKEN"
```
### Python API
```python
gateway = GatewayServer(upstreams, registration_token="secret-token")
```
When the token is not set (default), the registration endpoints are **not** mounted — existing deployments are unaffected.
### Thread Safety
All registry mutations (populate, add, remove, refresh) are serialized with an `asyncio.Lock` to prevent concurrent corruption.
## Access Control
Restrict which downstream tools are exposed through the gateway using per-upstream allow/deny lists with glob matching. Policies are applied during registry population — blocked tools never enter the registry, so every meta-tool (`discover_tools`, `get_tool_schema`, `execute_tool`, search) sees the filtered view automatically.
### Configuration (env var)
Extend `GATEWAY_UPSTREAMS` values with optional `allowed_tools` / `denied_tools` lists. Simple string values still work as before.
```bash
export GATEWAY_UPSTREAMS='{
"apollo": {
"url": "http://apollo:8080/mcp",
"allowed_tools": ["apollo_search_*", "apollo_contact_*"]
},
"hubspot": {
"url": "http://hubspot:8080/mcp",
"denied_tools": ["*_delete"]
},
"linear": "http://linear:8080/mcp"
}'
```
### Configuration (Python API)
Pass per-upstream filters inline, or build an `AccessPolicy` and pass it explicitly.
```python
from fastmcp_gateway import AccessPolicy, GatewayServer
policy = AccessPolicy(
allow={
"apollo": ["apollo_search_*", "apollo_contact_*"],
"hubspot": ["*"],
},
deny={"apollo": ["*_delete"]},
)
gateway = GatewayServer(
{"apollo": "http://apollo:8080/mcp", "hubspot": "http://hubspot:8080/mcp"},
access_policy=policy,
)
```
### Semantics
Patterns use `fnmatch.fnmatchcase` (case-sensitive `*` / `?` globs). Matched against both the registered tool name and its `original_name` so collision-prefix renames can't bypass policy.
- **`allow`**: when non-empty, only domains listed here are exposed, and only their tools matching at least one pattern. Domains absent from a non-empty `allow` map are fully denied. Leave empty to allow every domain by default.
- **`deny`**: always applied after `allow`. A tool matching a `deny` pattern is blocked even if it also matches an `allow` pattern.
When both object-shaped `upstreams` and an explicit `access_policy=` are provided, the explicit argument wins.
## Schema Validation
Every upstream tool's `inputSchema` is validated during registry population before the tool is registered: the root must be a JSON object with `"type": "object"`, `additionalProperties: true` is rejected at the root, `$ref` is rejected at any depth, and nesting deeper than a configurable cap is rejected. A tool that fails validation is skipped (with a `WARNING` log) rather than aborting the whole upstream — one malformed tool can't take down its siblings.
The cap counts levels of data, not JSON wrappers. Each field named in `properties` is one level below the object that carries it, and the schemas under `items`, `prefixItems`, `anyOf`, `oneOf` and `allOf` sit at the level of the schema that carries them, so an array of models, an optional field (`{"anyOf": [X, {"type": "null"}]}`, as Pydantic encodes `X | None`) and a tagged union cost what the model, the field or one branch costs. A flat object of scalar fields measures 2, and each level of fields nested inside another adds 1. A rejection names the depth measured (up to 50). Separately, a schema whose raw JSON nests more than 100 levels is rejected whatever its depth, which bounds the validator's recursion over upstream-controlled input.
The nesting-depth cap defaults to **5** and is available-but-discouraged to raise: it exists to bound schema complexity for LLM consumers, not to reject any particular upstream. Prefer flattening an over-deep schema at the source, and raise the cap only deliberately.
```python
gateway = GatewayServer(
{"apollo": "http://apollo:8080/mcp"},
max_schema_depth=8, # default is 5
)
```
Or via `GATEWAY_MAX_SCHEMA_DEPTH` when running the CLI entry point. Must be an integer in **[1, 50]** — `fastmcp_gateway.sanitize.MAX_ALLOWED_SCHEMA_DEPTH` is the authoritative upper bound if this number ever moves. An invalid value fails loudly at startup rather than silently falling back to the default. The upper bound of 50 isn't arbitrary: the ingest-time depth/`$ref` recursion itself becomes a stack-overflow risk on adversarial input well before four-figure nesting depths, so the cap can't be raised without limit even for a deployment that genuinely needs deeper schemas.
## Execution Hooks
Hooks provide middleware-style lifecycle callbacks around tool execution and discovery. Use them for authentication, authorization, token exchange, audit logging, or result transformation.
### Python API
```python
from fastmcp_gateway import GatewayServer, ExecutionContext, ExecutionDenied
class AuthHook:
async def on_authenticate(self, headers: dict[str, str]):
token = headers.get("authorization", "").removeprefix("Bearer ")
return validate_jwt(token) # Return user identity or None
async def before_execute(self, context: ExecutionContext):
if not has_permission(context.user, context.tool.domain):
raise ExecutionDenied("Insufficient permissions", code="forbidden")
signed_context = context.request_meta # Read-only, request-bound MCP metadata
# Inject headers for the upstream server
context.extra_headers["X-User-Token"] = exchange_token(context.user)
gateway = GatewayServer(upstreams, hooks=[AuthHook()])
```
### CLI (env var)
Point `GATEWAY_HOOK_MODULE` at a factory function that returns a list of hook instances:
```bash
export GATEWAY_HOOK_MODULE='my_package.hooks:create_hooks'
```
### Hook Lifecycle
For each `execute_tool` call:
1. **`on_authenticate(headers)`** — Extract user identity from request headers. Last non-None result wins across multiple hooks.
2. **`before_execute(context)`** — Validate permissions, inspect the isolated read-only `request_meta`, mutate arguments, or set `extra_headers`. Raise `ExecutionDenied` to block.
3. **Upstream call** — The untouched inbound MCP metadata is forwarded exactly; absent metadata remains absent. The protocol-reserved `io.modelcontextprotocol/` keys describe the caller's own connection, so they are left out of `request_meta` and of what is forwarded (see Protocol Eras). `extra_headers` merge with highest priority over static `upstream_headers`.
4. **`after_execute(context, result, is_error)`** — Transform or log the result. Each hook receives the previous hook's output.
5. **`on_error(context, error)`** — Observability only (exceptions in hooks are logged, not raised).
All methods are optional — implement only the ones you need.
### Tool Visibility Hooks
The `after_list_tools` hook phase lets you filter tool lists before returning them to clients — useful for per-user access control:
```python
from fastmcp_gateway import ListToolsContext
class AccessControlHook:
async def after_list_tools(self, context: ListToolsContext, tools: list) -> list:
# Filter tools based on user permissions
return [t for t in tools if has_access(context.user, t.domain)]
```
Hidden tools also return `tool_not_found` from `get_tool_schema` to prevent information leakage.
## Code Mode (Experimental)
**Experimental, off by default.** Code mode exposes a fifth meta-tool, `execute_code`, that runs LLM-authored Python in a [Monty](https://github.com/pydantic/monty) sandbox. Every registered tool is pre-bound as a named async callable inside the sandbox, so the model can chain calls — and use `asyncio.gather` to fan out — in a single round-trip without intermediate payloads passing through the agent's context window.
> **Not for analytical workloads.** The Monty sandbox is sized for small-payload cross-tool chaining (dozens of rows, kilobytes of JSON). Large-payload data analysis belongs in a dedicated analytics server with a full Python sandbox.
### Install the extra
```bash
pip install "fastmcp-gateway[code-mode]"
```
### Enable
```python
from fastmcp_gateway import GatewayServer
async def may_use_code_mode(user, context) -> bool:
return user.id in {"alice", "bob"} # bind this to your policy engine
gateway = GatewayServer(
{"crm": "http://crm:8080/mcp", "analytics": "http://analytics:8080/mcp"},
code_mode=True,
code_mode_authorizer=may_use_code_mode, # optional; any authenticated caller allowed when None
)
```
Or via env vars:
```bash
export GATEWAY_CODE_MODE=true
export GATEWAY_CODE_MODE_MAX_DURATION_SECS=30
export GATEWAY_CODE_MODE_MAX_NESTED_CALLS=50
```
### How the LLM uses it
Call `discover_tools(domain="crm")` (or with a `group=`/`query=`) to get readable Python signatures first — this is the default rendering for those modes; the no-argument call returns the JSON domain summary, and `format="schema"` opts any mode back into JSON. Then write code that calls those functions:
```python
# What the LLM emits as the `code` argument to execute_code:
people = await crm_search(query="Anthropic", limit=5)
emails = [p["email"] for p in people["people"]]
{"count": len(emails), "emails": emails}
```
### Safety guarantees
- Every nested tool call goes through the same `before_execute` / `after_execute` hook pipeline as a direct `execute_tool`, so access policies and audit hooks apply unchanged.
- Only tools surviving `after_list_tools` filtering are bound into the sandbox namespace — unauthorized tool names never appear as callables, so an attacker can't enumerate them by reading the sandbox scope.
- Outer-request headers and user identity are captured once at the boundary and closed over in each wrapper; the sandbox's worker thread never reads the auth ContextVar directly.
- Resource limits (duration, memory, allocations, recursion depth, nested-call count) apply to every run.
- Audit: the default `code_mode.invoked` INFO record carries `code_sha256`, `tool_names_invoked`, `step_count`, and `duration_ms`. Raw code is only emitted at DEBUG when `code_mode_audit_verbatim=True` — enable only for debugging; raw LLM code is PII-sensitive.
### Constructor reference
| Parameter | Default | Description |
|---|---|---|
| `code_mode` | `False` | Master switch; gates `execute_code` registration |
| `code_mode_authorizer` | `None` | Async `(user, context) -> bool` per-call permission check |
| `code_mode_limits` | `CodeModeLimits()` | `max_duration_secs=30`, `max_memory=256 MiB`, `max_allocations=10M`, `max_recursion_depth=200`, `max_nested_calls=50` |
| `code_mode_audit_verbatim` | `False` | Emit raw code at DEBUG (PII-risk; leave off in prod) |
## Observability
The gateway emits OpenTelemetry spans for all operations. Bring your own exporter (Logfire, Jaeger, OTLP, etc.) — the gateway uses the `opentelemetry-api` and will pick up any configured `TracerProvider`.
Key spans: `gateway.discover_tools`, `gateway.get_tool_schema`, `gateway.execute_tool`, `gateway.refresh_registry`, `gateway.populate_all`, `gateway.background_refresh`.
Each span includes attributes including `gateway.domain`, `gateway.tool_name`, `gateway.result_count`, and `gateway.error_code` for filtering and alerting.
## Error Handling
All meta-tools return structured JSON errors with a `code` field for programmatic handling and a human-readable `error` message:
```json
{"error": "Unknown tool 'crm_contacts'.", "code": "tool_not_found", "details": {"suggestions": ["crm_contacts_search"]}}
```
Error codes: `tool_not_found`, `invalid_arguments`, `domain_not_found`, `group_not_found`, `execution_error`, `upstream_unauthorized`, `upstream_insufficient_scope`, `upstream_error`, `refresh_error`.
`invalid_arguments` fires when `execute_tool`'s `arguments` don't match the target tool's declared schema -- an unknown key, a required key missing, or a top-level value outside its declared bounds or allowed values (`enum`/`const` for a string, `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum` and a fractional `integer` for a number, `minLength`/`maxLength`, `minItems`/`maxItems`) -- checked locally before the call ever reaches the upstream server. A value of the wrong type, `null`, a nested object's members and a property declared through `anyOf`/`oneOf`/`allOf` are left to the upstream, which may coerce or accept them. The error's `details` carry the tool's full expected signature (the same rendering `discover_tools(format="signatures")` produces), so a caller that guessed wrong gets the correction in the first error instead of a second blind guess:
```json
{"error": "Invalid arguments for 'apollo_people_search': missing required argument(s) 'query'.", "code": "invalid_arguments", "details": {"tool": "apollo_people_search", "domain": "apollo", "signature": "apollo_people_search(query: str) -> any\n Search for people by name, title, company, or other criteria"}}
```
`invalid_arguments` also fires when the upstream server itself rejects the arguments with a JSON-RPC `-32602` (Invalid params) error. Some MCP server frameworks report a call that fails the tool's input schema this way instead of as an `isError` result. The envelope then carries the upstream's own message as `error`, `details.upstream_error_code`, and the same `details.signature`:
```json
{"error": "Tool 'people_search' parameter validation failed: limit: Too big: expected number to be <=100.", "code": "invalid_arguments", "details": {"tool": "apollo_people_search", "domain": "apollo", "upstream_error_code": -32602, "signature": "apollo_people_search(query: str, limit: int = None) -> any\n Search for people by name, title, company, or other criteria"}}
```
`upstream_error` fires when the upstream server answers the call with an error: an `isError` tool result (the upstream's text becomes `error`), or a JSON-RPC error response with any other code (its message is in `error`, its code in `details.upstream_error_code`). Either way the upstream received and judged the call.
`execution_error` is reserved for failures where no answer arrived: the upstream could not be reached, the request timed out, the session dropped, or the HTTP exchange failed (for example a 5xx). Whether an error arrived is decided by where it came from, never by its code or text: an upstream that answers with `-32000` "Connection closed" gets `upstream_error`. Its message and `details` (`tool`, `domain`) are fixed; the underlying exception is recorded on the `gateway.execute_tool` span and passed to `on_error` hooks.
`upstream_unauthorized` and `upstream_insufficient_scope` fire when the upstream server refuses the call rather than failing it -- an upstream that enforces per-tool authorization answers 401 or 403. A 403 carrying an RFC 6750 `insufficient_scope` challenge (`WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`) becomes `upstream_insufficient_scope`, with the challenge's `scope` in `details.required_scope` (`null` when the challenge names none); any other 401 or 403 becomes `upstream_unauthorized`. Both carry `details.upstream_status`. Neither succeeds on retry without a change to the caller's credentials or grants, which is what sets them apart from `execution_error`:
```json
{"error": "Tool 'apollo_people_search' was refused by upstream server 'apollo': insufficient scope.", "code": "upstream_insufficient_scope", "details": {"tool": "apollo_people_search", "domain": "apollo", "upstream_status": 403, "required_scope": "people:read"}}
```
## Tool Name Collisions
When two upstream domains register tools with the same name, the gateway automatically prefixes both with their domain name to prevent conflicts:
```text
apollo registers "search" → apollo_search
hubspot registers "search" → hubspot_search
```
The original names remain searchable via `discover_tools(query="search")`.
## MCP Handshake Instructions
After `populate()`, the gateway automatically builds domain-aware instructions that are included in the MCP `InitializeResult` handshake (and, for `2026-07-28` clients, in the `server/discover` result). MCP clients immediately know what tool domains are available without calling `discover_tools()` first:
```text
You have access to a tool discovery gateway with tools across these domains:
- **apollo** (12 tools) — Apollo.io CRM and sales intelligence
- **hubspot** (8 tools) — HubSpot CRM for contacts, companies, and deals
Workflow: discover_tools() → execute_tool() — discovery renders each tool as a callable signature.
Call get_tool_schema() only when a signature is not enough.
```
Instructions are automatically rebuilt when the registry changes during background refresh or dynamic registration. Custom `instructions=` passed at construction time are never overwritten.
## Health Endpoints
The gateway exposes Kubernetes-compatible health checks:
- **`GET /healthz`** — Liveness probe. Always returns 200.
- **`GET /readyz`** — Readiness probe. Returns 200 if tools are populated, 503 otherwise.
Each request to these routes creates an OpenTelemetry span (`gateway.healthz` / `gateway.readyz`) by default. Under kubelet-style probing every few seconds, that's a root trace born and discarded on every cycle — set `trace_health_routes=False` (or `GATEWAY_TRACE_HEALTH_ROUTES=false`) to disable span creation for these two routes entirely; the response bodies and status codes are unaffected.
## Docker & Kubernetes
See [`examples/kubernetes/`](examples/kubernetes/) for a ready-to-use Dockerfile and Kubernetes manifests.
```bash
# Build
docker build -f examples/kubernetes/Dockerfile -t fastmcp-gateway .
# Run
docker run -e GATEWAY_UPSTREAMS='{"svc": "http://host.docker.internal:8080/mcp"}' \
-p 8080:8080 fastmcp-gateway
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture overview, and guidelines.
## License
Apache License 2.0. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessSyncing