Kurd
Kurd MCP
Kurd is a high-performance Model Context Protocol (MCP) gateway for Python, powered by Rust.
The Rust data plane handles HTTP serving, JSON-RPC dispatch, tool routing, upstream aggregation, caching, retries, circuit breaking, backpressure, rate limiting, and Prometheus metrics. The Python layer provides the developer API — tool registration, runtime configuration, and an optional enterprise feature set.
Targets MCP protocol revision 2026-07-28. Fully typed (PEP 561).
Contents
Installation
pip install kurdRequires Python 3.10+ and a 64-bit platform. Pre-built wheels are available for Windows, Linux (x86-64, aarch64), and macOS (x86-64, Apple Silicon).
Quick Start
from kurd import Router
from kurd._kurd import start_http_gateway
router = Router()
@router.tool()
async def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
# Blocks until stop_http_gateway() is called or the process exits.
start_http_gateway("0.0.0.0:9200")The gateway starts the following endpoints:
Path | Method | Purpose |
|
| JSON-RPC 2.0 MCP endpoint |
|
| Liveness probe — returns |
|
| Runtime, cache, upstream, and circuit-breaker snapshot |
|
| Prometheus metrics |
|
| List registered upstream servers |
|
| Add or replace an upstream server |
|
| Remove an upstream server |
|
| List all tools (local + upstream) with source label |
|
| Expire the tool-list cache immediately |
|
| List upstream namespaces |
Call the gateway:
curl -s http://localhost:9200/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":3,"b":4}}}'{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","content":[{"type":"text","text":"7"}],"isError":false}}CLI
Kurd ships a kurd command installed alongside the package.
Usage: kurd <COMMAND>
Commands:
serve Start the HTTP MCP gateway
Options:
-h, --help Show this message and exitkurd serve
kurd serve [--host HOST] [--port PORT] [--token TOKEN]Flag | Default | Description |
|
| Bind address |
|
| Bind port |
| — | Bearer token for authentication (overrides |
# Start on port 8000 with no authentication
kurd serve
# Start on a specific address with a bearer token
kurd serve --host 127.0.0.1 --port 9200 --token my-secret
# Use an environment variable for the token
KURD_AUTH_TOKEN=my-secret kurd serve --port 9200Registering Tools
Decorator API
from kurd import Router
router = Router()
@router.tool()
async def search(query: str, limit: int = 10) -> list[str]:
"""Search the knowledge base."""
return [f"result {i}" for i in range(limit)]Type annotations are converted to a JSON Schema inputSchema automatically:
Python type | JSON Schema type |
|
|
|
|
|
|
|
|
|
|
|
|
| schema of |
Parameters with defaults become optional; parameters without defaults are added to required.
Hot-reloading
Replace a tool's implementation at runtime without restarting the gateway:
router.reload_tool("search", new_search_function)Unregistering
router.unregister_tool("search")Introspection
router.list_tools() # -> ["add", "search", ...]
router.list_upstreams() # -> [("github", "http://..."), ...]Mounting Upstream Servers
Kurd aggregates remote MCP servers alongside local tools.
router.mount("github", "http://github-mcp.internal:9300")
router.mount("jira", "http://jira-mcp.internal:9300")Upstream tools are prefixed with the upstream name:
github.create_issue
jira.create_ticketClients discover all tools — local and upstream — through a single tools/list call. Kurd fetches remote tool lists concurrently, caches them with a configurable TTL, and follows pagination automatically.
Unmounting and cache invalidation
router.unmount("github") # stop routing to this upstream
router.refresh_tools() # expire the tool list cache immediatelyUpstream behaviour
Connection pool: persistent HTTP/1.1 connections via Reqwest
Retry: up to 3 attempts with exponential backoff + jitter
Circuit breaker: opens after 5 consecutive failures; resets after 30 s
Timeout: configurable per
RuntimeConfig.upstream_timeout_msPrivate-network policy: loopback/private URLs blocked by default unless
set_allow_private_upstreams(True)is called
Tool Discovery Filtering
Clients can scope a tools/list call with an optional filter parameter — without any server-side configuration needed.
Namespace filter
Returns only tools belonging to a specific upstream:
{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "filter": { "namespace": "github" } }
}Search filter
Case-insensitive substring match across tool name and description:
{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
"params": { "filter": { "search": "file" } }
}Discovery metadata
Every tools/list response includes a _kurd object:
{
"result": {
"tools": [...],
"_kurd": { "available": 12, "returned": 3 }
}
}available is the count after tenant restrictions; returned is the count after the client filter. An LLM agent can use these counts to know whether to refine its query.
Security: client filters always run after per-tenant restrictions. A tenant cannot use
searchornamespaceto enumerate tools outside their allowlist.
Admin API
The Admin API lets operators manage the gateway at runtime without a restart. All admin endpoints accept an optional Authorization: Bearer <token> header.
Set a dedicated admin token
router.set_admin_token("admin-secret")
# router.clear_admin_token() # fall back to MCP bearer token / openOr via the module-level API:
from kurd import set_admin_token, clear_admin_token
set_admin_token("admin-secret")Manage upstream servers
# List
curl http://localhost:9200/admin/servers
# Add / replace
curl -X POST http://localhost:9200/admin/servers \
-H 'Content-Type: application/json' \
-d '{"name": "github", "url": "http://github-mcp.internal:9300/mcp"}'
# Remove
curl -X DELETE http://localhost:9200/admin/servers/githubResponse codes: 201 Created (new), 200 OK (replaced), 404 Not Found (delete miss), 400 Bad Request (invalid URL or empty name).
Inspect tools
# All tools with source label
curl http://localhost:9200/admin/tools
# Reload (expire cache)
curl -X POST http://localhost:9200/admin/tools/reload
# List upstream namespaces
curl http://localhost:9200/admin/tools/namespacesRuntime Configuration
All gateway tunables are collected in RuntimeConfig:
from kurd import Router, RuntimeConfig
router = Router()
router.configure_runtime(RuntimeConfig(
# Concurrency
global_concurrency = 512, # max simultaneous in-flight requests
upstream_concurrency = 64, # max simultaneous upstream calls
python_concurrency = 64, # max simultaneous Python tool calls
upstream_timeout_ms = 30_000,
# Logging
request_logging = False, # structured per-request log lines
# Rate limiting
rate_limiting_enabled = True,
rate_limit_per_ip_rps = 1_000,
rate_limit_global_rps = 10_000,
# IP allowlist (None = allow all)
ip_allowlist = ["192.168.1.0/24", "10.0.0.1"],
# Tool cache
tools_cache_ttl_ms = 30_000,
# Enterprise (all off by default)
enable_dlq = False,
enable_idempotency = False,
secrets_backend = "env",
enable_webhooks = False,
enable_distributed_state = False,
distributed_state_backend = "memory",
redis_url = "redis://localhost:6379/0",
enable_distributed_tracing = False,
))configure_runtime also accepts keyword arguments directly for ergonomic one-liners:
router.configure_runtime(request_logging=True, rate_limiting_enabled=True)Runtime status
status = router.runtime_status()
# {
# "global_active": 3,
# "global_limit": 512,
# "python_active": 1,
# "upstream_metrics": {...},
# "cache": {"hits": 142, "misses": 3},
# ...
# }Security
Bearer token authentication
Set a bearer token before starting the gateway. Requests missing or carrying a wrong token receive 401 Unauthorized.
from kurd._kurd import set_http_bearer_token, clear_http_bearer_token
set_http_bearer_token("my-production-token")
# clear_http_bearer_token() # disable authenticationVia environment variable (loaded automatically at gateway start):
KURD_AUTH_TOKEN=my-production-token kurd serveTokens are compared with a constant-time byte comparison to prevent timing attacks.
IP allowlist
from kurd import set_ip_allowlist, clear_ip_allowlist
set_ip_allowlist(["10.0.0.1", "10.0.0.2"])
clear_ip_allowlist() # allow all IPs againOr through RuntimeConfig.ip_allowlist. Blocked IPs receive 403 Forbidden.
Rate limiting
router.configure_runtime(
rate_limiting_enabled=True,
rate_limit_per_ip_rps=1_000,
rate_limit_global_rps=10_000,
)Rate-limited requests receive 429 Too Many Requests with a Retry-After: 1 header and a retryAfterMs field in the JSON-RPC error body.
Additional safeguards
Safeguard | Details |
Request size cap | 1 MiB hard limit; |
Content-type validation | Must be |
Upstream URL validation | Rejects credentials, fragments, and unsupported schemes |
Private-network policy | Upstream calls to loopback/RFC1918 blocked by default |
CORS |
|
Overload rejection |
|
For internet-facing deployments, terminate TLS at a reverse proxy (nginx, Caddy, AWS ALB) and apply network-level controls there.
Multi-tenancy & Policy Engine
Kurd ships a built-in TenantManager that wires directly into the Rust request hot path. One call to set_policy_engine() activates both tools/call gating and tools/list filtering simultaneously.
Basic setup
from kurd import Router, TenantManager
manager = TenantManager()
# Add tenants with explicit tool allowlists
manager.add_tenant("acme", name="Acme Corp", allowed_tools=["add", "search"], api_key="sk-acme")
manager.add_tenant("devops", name="DevOps Team", allowed_tools=["*"], api_key="sk-ops")
router = Router()
router.set_policy_engine(manager)
# router.clear_policy_engine() # disable, all requests allowed againWhat it enforces
Behaviour | Details |
| Unknown API key or tool outside allowlist → |
| Response contains only the tools the caller may invoke |
Wildcard support |
|
Namespace wildcard |
|
Unknown key | Returns an empty |
Calling with a tenant key
curl http://localhost:9200/mcp \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-acme' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# → returns only ["add", "search"]Prometheus metric
kurd_policy_denied_total # counter — requests blocked by the policy enginePer-tenant quotas and billing
For rate-per-tenant quotas, request tracking, and billing see the Enterprise Features section.
Observability
Structured logging
Enable per-request log lines (goes to stdout in the format chosen by KURD_LOG):
router.configure_runtime(request_logging=True)Control log verbosity via environment variable:
KURD_LOG=kurd=debug kurd serve # debug, info, warn, error
RUST_LOG=info kurd serve # fallback if KURD_LOG is unsetLog level can also be changed at runtime via the logging/setLevel MCP method.
Prometheus metrics
curl http://localhost:9200/metricsMetric | Type | Description |
| counter | Total requests by status ( |
| counter | Requests blocked by the policy engine ( |
| gauge | In-flight requests right now |
| gauge | Highest concurrent request count since startup |
| gauge | Rolling average latency (ms) |
| histogram | Latency distribution (1ms … 5000ms + Inf) |
| counter | Total completed requests counted in histogram |
| counter | Total latency (ms) summed across all requests |
| gauge | Active Python tool invocations |
| gauge | Peak simultaneous Python tool invocations |
| counter | Python tool calls dropped due to concurrency limit |
| counter | Requests forwarded per upstream |
| counter | Successful upstream calls |
| counter | Failed upstream calls |
| counter | Retry attempts per upstream |
| gauge | Average upstream round-trip latency |
| gauge |
|
| gauge | Current in-flight calls per upstream |
| gauge | Peak in-flight calls per upstream |
| counter | Upstream calls dropped due to concurrency limit |
| counter | Tool-list cache hits |
| counter | Tool-list cache misses |
| counter | Cache invalidations (manual or TTL expiry) |
| gauge | Configured limits: |
Prometheus scrape config
# prometheus.yml
scrape_configs:
- job_name: kurd
static_configs:
- targets: ["localhost:9200"]
metrics_path: /metrics
scrape_interval: 15sDatadog
# datadog.yaml
instances:
- openmetrics_endpoint: http://localhost:9200/metrics
namespace: kurd
metrics: ["kurd_.*"]OpenTelemetry
Kurd exports real OTLP spans from the Rust core — no Python OpenTelemetry SDK required.
Quick setup
from kurd import Router
from kurd.telemetry import setup_otel
router = Router()
# Activates Rust-side OTLP export. setup_otel returns an OTELTracer for
# any additional Python-side instrumentation you want.
setup_otel(
service_name = "my-gateway",
endpoint = "http://otel-collector:4318", # OTLP HTTP receiver
)Or directly via the Router:
router.configure_otel("http://otel-collector:4318", service_name="my-gateway")
# router.clear_otel() # disable exportWhat gets traced
Every request that passes authentication, rate limiting, and concurrency checks produces one server-side span.
Spans are exported fire-and-forget (2-second timeout, errors silently dropped) so a slow or unavailable collector never adds latency.
The OTLP JSON payload is sent to
{endpoint}/v1/traces.
W3C traceparent propagation
Every MCP response carries a traceparent header so downstream services and LLM agents can continue the trace:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01If the incoming request already carries a traceparent, Kurd preserves the trace_id and issues a new span_id. Malformed headers start a fresh trace.
OTELConfig.service_version defaults to the installed kurd package version automatically.
Health checks
from kurd.health_checks import HealthCheckManager
hc = HealthCheckManager()
# Register a custom check
async def check_db():
...
hc.register_check("database", check_db, critical=True)
# Kubernetes probes
readiness = await hc.check_readiness() # all critical checks pass
liveness = await hc.check_liveness() # process is running and activeMCP Protocol Compliance
Kurd implements the MCP 2026-07-28 protocol revision.
Supported methods
Method | Behaviour |
| Returns |
| Returns |
| Returns capabilities, supported versions, and server identity |
| Aggregates local + upstream tools; cursor-based pagination; |
| Routes to local Python tool or upstream server; policy engine gate when active |
| Returns empty list with |
| Returns |
| Returns empty list with |
| Returns |
| Returns |
| Applies log level to the tracing filter at runtime |
| Accepted silently — returns |
Modern HTTP headers
When a client sends Mcp-Protocol-Version: 2026-07-28, Kurd additionally validates:
Mcp-Methodheader matches the JSON-RPCmethodfieldMcp-Nameheader matchesparams.namefortools/call
Mismatched headers return -32020. Unsupported protocol versions return -32019.
Enterprise Features
Enable features through RuntimeConfig or by importing the relevant manager class directly.
Multi-tenancy
See Multi-tenancy & Policy Engine above for the full policy engine and tool-filtering setup.
from kurd import TenantManager
manager = TenantManager()
manager.add_tenant(
tenant_id="acme",
name="Acme Corp",
quota_rps=100,
allowed_tools=["add", "search"],
api_key="sk-acme",
)Each tenant receives a unique API key. Quotas and tool ACLs are enforced independently.
Billing
from kurd.billing import BillingManager
billing = BillingManager()
billing.set_pricing({"add": {"per_call": 0.001, "per_latency_ms": 0.0001}})
billing.track_call(tenant_id="acme", tool_name="add", latency_ms=12.5, success=True)
report = billing.get_usage_report("acme", period="2026-08")Supported models: per-request, per-latency, tiered, hybrid.
Request idempotency
router.configure_runtime(enable_idempotency=True)
mgr = router.get_idempotency()
is_dup, cached = mgr.check_idempotent_key("req-abc-123", tenant_id="acme")
if is_dup:
return cached
result = run_tool()
mgr.store_result("req-abc-123", "acme", result)Backed by SQLite with a 24-hour TTL.
Dead-letter queue
router.configure_runtime(enable_dlq=True, dlq_storage_path="/data/kurd/dlq")
dlq = router.get_dlq()
dlq.add_message(request_id="req-123", tenant_id="acme",
tool_name="add", arguments={"a":1,"b":2}, error="timeout")
dlq.register_replay_handler("add", handler)
success, error = dlq.replay_message("dlq_abc123")
stats = dlq.get_statistics(tenant_id="acme")
dlq.cleanup_archived(days=30)Replay uses exponential backoff up to 1 hour.
Secrets management
from kurd.secrets_management import SecretsManager
# Kubernetes in-cluster | HashiCorp Vault | AWS Secrets Manager | env (default)
manager = SecretsManager(backend="vault",
vault_addr="https://vault.example.com",
vault_token="s.xxxxx")
secret = manager.get_secret("db_password")Third-party dependencies (kubernetes, hvac, boto3) are imported lazily — only when the matching backend is activated.
Webhooks
router.configure_runtime(enable_webhooks=True)
hooks = router.get_webhooks()
hooks.register_webhook(
url="https://example.com/hooks",
events=["error", "rate_limit_exceeded"],
tenant_id="acme",
)Deliveries are HMAC-SHA256 signed and logged for audit via get_deliveries().
Distributed state
router.configure_runtime(
enable_distributed_state=True,
distributed_state_backend="redis",
redis_url="redis://localhost:6379/0",
)
state = router.get_distributed_state()
state.set("gateway:version", 42)
state.increment("counters:acme:calls")Use backend="memory" for single-instance deployments.
Distributed tracing
from kurd.distributed_tracing import extract_context
trace = extract_context(incoming_headers)
span = trace.create_span("tool_execution", {"tool": "add"})
span.set_attribute("result", 42)
span.end()Follows W3C Trace Context. Tracing state is available in router.runtime_status() when enabled.
Performance
Benchmarks from a Windows development machine (Python 3.12, release build):
Scenario | Concurrency | Throughput | p50 | p95 | p99 | Errors |
Local Python tool | 10 | 594.5 req/s | 14.9 ms | 23.9 ms | 28.7 ms | 0% |
Local Python tool | 50 | 587.9 req/s | 33.3 ms | 87.8 ms | 119.1 ms | 0% |
Local Python tool | 100 | 556.0 req/s | 18.3 ms | 29.5 ms | 32.4 ms | 0% |
Upstream tool | 10 | 412.2 req/s | 21.8 ms | 36.4 ms | 42.7 ms | 0% |
Upstream tool | 50 | 229.8 req/s | 20.6 ms | 534.6 ms | 549.3 ms | 0% |
Sustained burst | 100 | 573.3 req/s | 73.5 ms | 179.1 ms | 218.5 ms | 0% |
Results depend on hardware, OS, Python version, and network conditions.
python -m pytest tests/test_load.py -q -sArchitecture
Python application
│
▼
kurd.Router ← Python API layer
│
├── Policy engine (set_policy_engine)
│ TenantManager callbacks wired into Rust hot path
│
├── Enterprise modules (optional, lazy)
│ multitenancy · billing · idempotency · DLQ
│ secrets · webhooks · distributed state
│
▼
PyO3 boundary
│
▼
Rust MCP gateway (Axum + Tokio)
│
├── HTTP handler ─────────────────────────────────┐
│ content-type · auth · IP allowlist │
│ rate limiting · concurrency backpressure │
│ CORS · request ID · W3C traceparent │
│ OTLP span export (fire-and-forget) │
│ │
├── Admin API (/admin/*) │
│ server CRUD · tool listing · cache reload │
│ │
├── MCP dispatcher │
│ initialize · ping · server/discover │
│ tools/list (paginated, filtered, _kurd meta) │
│ tools/call (policy gate) · resources · prompts │
│ completion · logging · notifications (202) │
│ │
├── Local Python tools ◄── PyO3 callback │
│ (Rayon-parallel batch parsing) │
│ │
└── Upstream MCP servers │
retry · circuit breaker · cache · metrics ◄┘The Rust layer holds all mutable gateway state in lock-free atomics and RwLock-guarded maps. Python code never touches the hot path after registration.
Development
Prerequisites
Rust stable toolchain (
rustup update stable)Python 3.10+
maturinandpytest
pip install maturin pytestBuild
# Development build (fast iteration)
maturin develop
# Optimised build (benchmarks, pre-release testing)
maturin develop --release
# Release wheel
maturin build --releaseTest
python -m pytest -qThe test suite covers:
JSON-RPC parsing and fast batch parsing (Rayon)
Local sync and async tools
initializehandshake and lifecycle methodstools/listpaginationcompletion/complete,notifications/202, CORS preflightUpstream discovery, routing, and concurrency
Circuit breaker, retry, and timeout behaviour
Tool-list cache hits, misses, and invalidation
Bearer authentication (accepted and rejected)
IP allowlist enforcement
Rate-limit rejection and
Retry-AfterheaderRequest-size and content-type guards
Prometheus metrics output
Load and burst behaviour
Policy engine: allow, deny, unknown key, clear (P0)
Admin API: server CRUD, tool listing, reload, auth token (P1)
Per-tenant tool filtering: wildcard, restricted, unknown key, clear (P2)
Client tool discovery: namespace filter, search, combined,
_kurdmetadata, bypass prevention (P3)OpenTelemetry:
traceparentpresence/format, trace-id propagation, span-id rotation, malformed input, enable/disable (P4)
Linting
cargo check
cargo clippy -- -D warningsEnvironment variables
Variable | Purpose |
| Bearer token loaded automatically at gateway start |
| Tracing filter (e.g. |
| Standard Rust log filter fallback |
Project Structure
kurd-mcp/
├── kurd/
│ ├── __init__.py # Public API + __version__
│ ├── py.typed # PEP 561 marker
│ ├── cli.py # `kurd serve` entry point
│ ├── router.py # Router class + RuntimeConfig
│ ├── telemetry.py # OpenTelemetry integration
│ ├── health_checks.py # Readiness and liveness probes
│ ├── authorization.py # RBAC helpers
│ ├── multitenancy.py
│ ├── billing.py
│ ├── idempotency.py
│ ├── dead_letter_queue.py
│ ├── secrets_management.py
│ ├── webhooks.py
│ ├── distributed_state.py
│ ├── distributed_tracing.py
│ └── ...
├── src/
│ └── lib.rs # Rust data plane (~3500 lines)
├── tests/
│ ├── test_core.py
│ ├── test_http_gateway.py # Integration tests (module-scoped gateway)
│ ├── test_admin_api.py # P1 — Admin HTTP API
│ ├── test_tool_filtering.py # P2 — Per-tenant tools/list filtering
│ ├── test_tool_discovery.py # P3 — Client-requested filter + _kurd metadata
│ ├── test_otel.py # P4 — traceparent / OTLP export
│ ├── test_upstream.py
│ ├── test_load.py
│ ├── test_prometheus_metrics.py
│ └── upstream_server.py # In-process upstream fixture
├── Cargo.toml
├── pyproject.toml
├── LICENSE
└── README.mdContributing
Issues and pull requests are welcome via the GitHub repository.
Before submitting:
cargo check
cargo clippy -- -D warnings
maturin develop --release
python -m pytest -qPlease open an issue before starting large changes.
License
MIT — Copyright © 2024 Semko Kermashani
The name Kurd honors Kurdish identity and heritage. Bezhi Kurd u Kurdistan.