fastmcp-gateway
OfficialProvides access to tools from HubSpot's MCP server, enabling management of contacts, companies, deals, and other CRM operations.
Provides access to tools from Linear's MCP server, enabling issue tracking, project management, and team workflows.
Provides access to tools from Slack's MCP server, enabling messaging, channel management, and workspace automation.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@fastmcp-gatewayDiscover tools from the hubspot domain"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
fastmcp-gateway
Progressive tool discovery gateway for MCP. Aggregates tools from multiple upstream MCP servers and exposes them through 4 meta-tools, enabling LLMs to discover and use hundreds of tools without loading all schemas upfront.
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 changesWhy?
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.
Related MCP server: MCP Gateway
Install
pip install fastmcp-gatewayQuick Start
Python API
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
export GATEWAY_UPSTREAMS='{"apollo": "http://apollo-mcp:8080/mcp", "hubspot": "http://hubspot-mcp:8080/mcp"}'
python -m fastmcp_gatewayThe gateway starts on http://0.0.0.0:8080/mcp and exposes 4 tools to any MCP client.
How It Works
discover_tools()— Call with no arguments to see all domains and tool counts. Call withdomain="apollo"to see that domain's tools with descriptions. Passformat="signatures"to receive Python-style function signatures (apollo_search(query: str, limit: int = None) -> any) instead of the default JSON summary — useful when the LLM will subsequently write code against the listed tools.get_tool_schema("apollo_people_search")— Returns the full JSON Schema for a tool's parameters. Supports fuzzy matching.execute_tool("apollo_people_search", {"query": "Anthropic"})— Routes the call to the correct upstream server and returns the result.refresh_registry()— Re-query all upstream servers and return a summary of added/removed tools per domain. Useful when upstreams are updated while the gateway is running.
LLMs learn the workflow from the gateway's built-in system instructions and only load schemas for tools they actually need.
Configuration
All configuration is via environment variables:
Variable | Required | Default | Description |
| Yes | — | JSON object: |
| No |
| Server name |
| No |
| Bind address |
| No |
| Bind port |
| No | Built-in | Custom LLM system instructions |
| No | — | Bearer token for upstream discovery |
| No | — | JSON object: |
| No | — | JSON object: |
| No | Disabled | Seconds between automatic registry refresh cycles |
| No |
| Emit an OTel span per |
| No |
| Registry-ingest schema-nesting-depth cap, |
| No | — | Python module path for execution hooks: |
| No | — | Shared secret for dynamic registration endpoints (see below) |
| No |
| Enable the experimental |
| No |
| Per-run wall-clock cap for |
| No |
| Per-run heap memory cap (bytes) for |
| No |
| Per-run allocation cap for |
| No |
| Per-run stack depth cap for |
| No |
| Max number of upstream tool calls one |
| No |
| Emit raw code body at DEBUG in audit logs (PII-sensitive) |
| No |
| Logging level |
Per-Upstream Auth
If your upstream servers require different authentication, use GATEWAY_UPSTREAM_HEADERS to set per-domain headers:
export GATEWAY_UPSTREAM_HEADERS='{"ahrefs": {"Authorization": "Bearer sk-xxx"}}'Domains without overrides use request passthrough (headers from the incoming MCP request are forwarded to the upstream).
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:
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:
curl -X DELETE http://gateway:8080/registry/servers/apollo \
-H "Authorization: Bearer $TOKEN"List registered upstreams:
curl http://gateway:8080/registry/servers \
-H "Authorization: Bearer $TOKEN"Python API
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.
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.
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-emptyallowmap are fully denied. Leave empty to allow every domain by default.deny: always applied afterallow. A tool matching adenypattern is blocked even if it also matches anallowpattern.
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.
Optional fields don't count against the cap twice. Pydantic and most JSON-Schema generators encode Optional[X] / X | None as {"anyOf": [X, {"type": "null"}]}; depth counting treats that two-member null union as transparent, so an optional field costs exactly what the required equivalent costs. Unions with three or more branches, unions with no null member, and null branches carrying their own annotations are counted normally.
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; raise the cap only deliberately, since a much higher value widens the recursion the ingest-time validator performs over upstream-controlled input.
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
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:
export GATEWAY_HOOK_MODULE='my_package.hooks:create_hooks'Hook Lifecycle
For each execute_tool call:
on_authenticate(headers)— Extract user identity from request headers. Last non-None result wins across multiple hooks.before_execute(context)— Validate permissions, inspect the isolated read-onlyrequest_meta, mutate arguments, or setextra_headers. RaiseExecutionDeniedto block.Upstream call — The untouched inbound MCP metadata is forwarded exactly; absent metadata remains absent.
extra_headersmerge with highest priority over staticupstream_headers.after_execute(context, result, is_error)— Transform or log the result. Each hook receives the previous hook's output.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:
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 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
pip install "fastmcp-gateway[code-mode]"Enable
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:
export GATEWAY_CODE_MODE=true
export GATEWAY_CODE_MODE_MAX_DURATION_SECS=30
export GATEWAY_CODE_MODE_MAX_NESTED_CALLS=50How the LLM uses it
Call discover_tools(format="signatures") to get readable Python signatures first, then write code that calls those functions:
# 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_executehook pipeline as a directexecute_tool, so access policies and audit hooks apply unchanged.Only tools surviving
after_list_toolsfiltering 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.invokedINFO record carriescode_sha256,tool_names_invoked,step_count, andduration_ms. Raw code is only emitted at DEBUG whencode_mode_audit_verbatim=True— enable only for debugging; raw LLM code is PII-sensitive.
Constructor reference
Parameter | Default | Description |
|
| Master switch; gates |
|
| Async |
|
|
|
|
| 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:
{"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_error, refresh_error.
invalid_arguments fires when execute_tool's arguments don't match the target tool's declared schema (an unknown key, or a required key missing) -- checked locally before the call ever reaches the upstream server. 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:
{"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"}}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:
apollo registers "search" → apollo_search
hubspot registers "search" → hubspot_searchThe 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. MCP clients immediately know what tool domains are available without calling discover_tools() first:
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() → get_tool_schema() → execute_tool()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/ for a ready-to-use Dockerfile and Kubernetes manifests.
# 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-gatewayContributing
See CONTRIBUTING.md for development setup, architecture overview, and guidelines.
License
Apache License 2.0. See LICENSE.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseAqualityCmaintenanceAggregates multiple MCP servers into a single gateway with unified top-level tools, reducing LLM context usage and enabling IDE compatibility by consolidating many tools into fewer interface functions.416
- Alicense-qualityCmaintenanceAggregates multiple Model Context Protocol servers into a single gateway to provide unified search, description, and execution of tools. It reduces context limit issues by dynamically fetching specific tool schemas only when needed rather than loading all available tools at once.1323MIT
- Alicense-qualityDmaintenanceAggregates tools from multiple MCP servers, acting as a proxy to provide unified access to various AI agents and tools.143MIT
Related MCP Connectors
Hosted MCP endpoint with realistic fake data for prototyping agents. 12 tools, no setup.
Gateway between LLM agents and world data through eight tools and a bundled endpoint catalog.
327 tools, 92 providers. Pay per call via x402 + MPP. One MCP endpoint.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ultrathink-Solutions/fastmcp-gateway'
If you have feedback or need assistance with the MCP directory API, please join our Discord server