Configuration schema
Full schema for mcpgw.yaml. The default search path is /etc/mcpgw/mcpgw.yaml; override with --config=/path/to/file.yaml on mcpgw server.
mcpgw validates the entire config at startup and on every SIGHUP. Validation errors are fatal at startup and rejected on reload (the previous config remains in effect).
Top-level keys
| Key | Type | Default | Required | Hot-reload? |
|---|---|---|---|---|
listen | string host:port | — | yes | no |
tls | object | nil | no | partial |
input_rate_limit | object | disabled | no | yes |
license | object | — | yes | no |
default_upstream | string | "" | recommended | no |
upstreams | list of upstream | [] | yes | no |
routes | list of route | [] | no | no |
auth | object | disabled | no | partial |
policy | object | — | yes | yes |
telemetry | object | — | no | partial |
audit | object | — | yes | yes |
proxy_protocol | object | nil | no | no |
tool_search | object | disabled | no | no |
Hot-reload column: “yes” means SIGHUP re-applies; “no” means restart required; “partial” means some sub-keys reload and others do not (called out per-section).
listen
listen: 0.0.0.0:7332
host:port the gateway binds to. Use 0.0.0.0 to accept from any interface; use 127.0.0.1 to restrict to localhost. The gateway exits non-zero if the port is in use.
tls
tls:
cert_file: /etc/mcpgw/tls/fullchain.pem
key_file: /etc/mcpgw/tls/privkey.pem
client_ca: /etc/mcpgw/tls/client-ca.pem # optional — enables mTLS
When cert_file and key_file are set, listen serves HTTPS. When tls is absent, listen serves plaintext HTTP. cert_file and key_file must be configured together.
client_ca enables mutual TLS: every client connection must present a certificate signed by the listed CA file. client_ca requires cert_file and key_file.
On SIGHUP, mcpgw re-reads cert_file, key_file, and client_ca from the current config. If the reload fails, the previous certificate remains active. Changing TLS from disabled to enabled, or enabled to disabled, requires a restart.
input_rate_limit
input_rate_limit:
enabled: true
requests_per_second: 50
burst: 100
Protects the gateway itself from request floods before body read, auth, JSON-RPC parse, routing, policy, or upstream forwarding. Buckets are keyed by the TCP peer address from RemoteAddr; X-Forwarded-For is intentionally ignored.
This limiter is separate from policy.rules[].action: rate_limit. Use input_rate_limit for gateway resource protection and policy rate limits for per-tool quotas.
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | When false, no input throttle is applied. |
requests_per_second | float | — | Token refill rate. Required and must be > 0 when enabled. |
burst | int | — | Bucket capacity. Required and must be > 0 when enabled. |
On SIGHUP, mcpgw replaces the input limiter with the new config. Existing buckets are not preserved across reload.
license
license:
path: /etc/mcpgw/license.jwt
grace_days: 30 # optional, defaults to 30
| Field | Type | Default | Notes |
|---|---|---|---|
path | string | — | Absolute path to the JWT file. Must be 0600 and readable by the mcpgw process owner. |
grace_days | int | 30 | Days after exp during which the gateway fails open. Override per-deployment for compliance. |
See License JWT claims for the JWT structure and Explanation: licensing for fail-open / fail-closed behavior.
default_upstream
default_upstream: filesystem
Logical upstream name (must match an upstreams[].name) used for MCP methods that have no tool_name: initialize, tools/list, resources/list, prompts/list, ping. Without it, spec-compliant clients fail at handshake with 404 no_route.
If unset, methods without a tool name return 404. There is no “broadcast to all upstreams” mode today; multi-upstream tools/list aggregation is on the roadmap.
upstreams
upstreams:
- name: filesystem
url: http://mcp-fs:9000
timeout: 30s # optional, default 30s
max_idle_conns: 32 # optional, default 32
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Unique logical name. Appears in mcp.upstream span attribute. Lowercase recommended. |
url | string | — | Base URL. Scheme must be http or https; host required. Path is the upstream’s MCP endpoint (most servers use /). |
timeout | duration | 30s | Per-request deadline. Includes connection, request, and response read. |
max_idle_conns | int | 32 | HTTP connection pool size for this upstream. |
URLs are validated at startup. default_upstream must reference a known name.
routes
routes:
- match: { tool_prefix: "fs_" }
upstream: filesystem
- match: { tool_prefix: "git_" }
upstream: git
- match: { tool_name_in: ["db_query", "db_schema"] }
upstream: database
Evaluated top-down. First match wins. Routes support the same tool-name matcher fields as policy rules: tool_name, tool_prefix, tool_glob, tool_regex, and tool_name_in. Set at most one matcher per route. Tool calls that match no route and fall outside default_upstream’s remit return 404 no_route.
auth
auth:
enabled: true
header: Authorization
scheme: Bearer
keys:
- id: claude-desktop-prod
hash: "argon2id$v=19$m=65536,t=3,p=2$..."
scopes: ["*"]
created_at: "2026-05-01T14:23:01Z"
expires_at: "2026-08-01T14:23:01Z"
| Field | Type | Default | Hot-reload? | Notes |
|---|---|---|---|---|
enabled | bool | false | yes | When false or absent, /mcp preserves unauthenticated v1 behavior. |
header | string | Authorization | no | Header to read. |
scheme | string | Bearer | no | Prefix before the key. Set to "" to read the raw header value. |
keys[].id | string | — | yes | Human-readable key id. Appears in audit and spans. Must be unique. |
keys[].hash | string | — | yes | Argon2id hash from mcpgw key generate. |
keys[].scopes | list | [] | yes | Reserved for future per-key authorization. Parsed and preserved, but not enforced in this release. |
keys[].created_at | timestamp | zero | yes | Informational. |
keys[].expires_at | timestamp | zero | yes | Optional. Zero means never expires. |
If auth.enabled: true, keys must be non-empty. Missing, invalid, and expired keys return HTTP 401 with JSON-RPC -32005 unauthorized.
Use How-to: enable API-key authentication for the full recipe.
auth.oauth
auth:
enabled: true
oauth:
enabled: true
issuer: https://idp.example.com
audience: mcpgw
jwks_url: https://idp.example.com/.well-known/jwks.json
required_scopes: ["mcp:read"]
jwks_cache_ttl: 5m
leeway: 30s
metadata_path: /.well-known/oauth-protected-resource
public_url: https://gateway.example.com
When auth.oauth.enabled: true, the gateway accepts RFC 6750 Bearer tokens in addition to (or instead of) API keys. The gateway fetches JWKS from jwks_url, caches keys for jwks_cache_ttl, and validates each token for issuer, audience, expiry, and (optionally) required scopes.
auth.enabled: true is required when auth.oauth.enabled: true.
| Field | Type | Default | Hot-reload? | Notes |
|---|---|---|---|---|
auth.oauth.enabled | bool | false | yes | Enables OAuth 2.1 Bearer-token verification. Requires auth.enabled: true. |
auth.oauth.issuer | string | — | no | Required when enabled. Must be an HTTP or HTTPS URL with a non-empty host. Matched against the JWT iss claim. |
auth.oauth.audience | string | — | no | Required when enabled. Matched against the JWT aud claim. |
auth.oauth.jwks_url | string | — | no | Required when enabled. Must be an HTTP or HTTPS URL with a non-empty host. Used to fetch the signing key set. |
auth.oauth.required_scopes | list | [] | yes | Optional. Every listed scope must appear in the token’s scope (or scp) claim. An empty list disables scope enforcement. |
auth.oauth.jwks_cache_ttl | duration | 5m | yes | How long fetched JWKS are cached before re-fetching. |
auth.oauth.leeway | duration | 30s | yes | Clock-skew tolerance applied to exp and nbf checks. |
auth.oauth.metadata_path | string | /.well-known/oauth-protected-resource | no | HTTP path where the gateway serves RFC 9728 protected-resource metadata. |
auth.oauth.public_url | string | derived from listen | no | Base URL advertised in WWW-Authenticate headers and the metadata document. Required in production when listen binds 0.0.0.0; the gateway logs a warning if unset in that case. Must be an HTTP or HTTPS URL with a non-empty host. |
auth.oauth.cimd.enabled | bool | false | yes | Master switch. When false (default), no CIMD fetches occur. |
auth.oauth.cimd.timeout | duration | 5s | yes | Per-fetch HTTP timeout. First-request latency: the CIMD fetch is synchronous in the auth path. The first request from a previously-unseen URL-format client_id can block for up to this duration if the endpoint is unresponsive (the request still completes — errors are silenced — but latency is affected). Subsequent requests are served from cache. |
auth.oauth.cimd.max_body_bytes | int64 | 65536 (64 KiB) | yes | Maximum response body size. Larger responses are rejected (silently — CIMD failures never block authenticated requests). |
auth.oauth.cimd.cache_ttl | duration | 1h | yes | How long fetched metadata is cached. The cache is keyed by URL and bounded by a 1024-entry LRU (least-recently-used eviction on insert; reads promote). The cap is not currently exposed in YAML — change it at the Go API level if a different ceiling is needed. Hot reload (SIGHUP) creates a fresh fetcher with a fresh cache. |
auth.oauth.cimd — Client ID Metadata Documents
When the verified JWT’s client_id (or azp fallback) is an https:// URL, mcpgw fetches that URL and surfaces the resulting client_name field as audit.oauth_client_name and the mcp.auth.oauth_client_name span attribute. CIMD failures are silent: a misbehaving CIMD endpoint cannot block authenticated requests, only degrade audit richness.
auth:
enabled: true
oauth:
enabled: true
# ... other oauth fields ...
cimd:
enabled: true
timeout: 5s
max_body_bytes: 65536
cache_ttl: 1h
Hot reload (SIGHUP) creates a fresh fetcher with a fresh cache. Toggling enabled: false and sending SIGHUP atomically disables CIMD fetching.
tool_search
tool_search:
enabled: true
mode: synthesize
refresh_interval: 60s
max_results: 20
Tool-search synthesis. When enabled in synthesize mode, the gateway intercepts tools/list and tools/call mcp_search, serving them from a refreshed index of upstream tool definitions. In passthrough mode (the default), tools/list is forwarded to the upstream unchanged.
See Enable tool-search synthesis for the operator workflow and Tool-search tradeoffs for design rationale.
| Field | Type | Default | Hot-reload? | Description |
|---|---|---|---|---|
tool_search.enabled | bool | false | no | Master switch. When false, tool_search.mode is ignored and the upstream receives all requests unchanged. |
tool_search.mode | enum | passthrough | no | passthrough (no interception, default — the gateway forwards tools/list to the upstream as-is. Even with enabled: true, the index is NOT built in this mode; this combination is a no-op preserved for forward compatibility with future per-tenant mode toggling.) or synthesize (the gateway answers tools/list from a synthesized stub and tools/call mcp_search from the index). Mode change requires restart — SIGHUP only refreshes the index. |
tool_search.refresh_interval | duration | 60s | no | How often the gateway re-fetches tools/list from each upstream to rebuild the index. |
tool_search.max_results | int | 20 | no | Hard cap on results returned by mcp_search. Caller-provided limit arguments above this value are clamped silently. |
tool_search is not hot-reloadable. Any change to tool_search.mode, tool_search.enabled, refresh_interval, or max_results requires a full restart.
Failing upstreams: negative-cache backoff. A tools/list fetch that errors puts that upstream into exponential backoff (initial 30s, doubling per consecutive failure, capped at 30m). Subsequent refresh cycles skip backed-off upstreams silently — their previous error is still surfaced in lastErrors so metrics scrapers continue to observe the issue, but no HTTP call is made. A successful fetch resets the backoff. Partial-failure semantics are preserved: a single healthy upstream still populates the merged index, and a stuck upstream does not block the merge. The schedule (30s initial / 30m ceiling) is not exposed in YAML.
policy
policy:
default_action: allow
rules:
- id: deny-shell
action: deny
when: { tool_name: "shell_exec" }
- id: rl-fs-write
action: rate_limit
when: { tool_name: "fs_write" }
tokens_per_second: 10
burst: 20
- id: redact-secrets
action: redact
when: { tool_name: "*" }
redact:
- regex: 'Bearer [A-Za-z0-9._-]+'
replacement: "[REDACTED]"
See Policy rule schema for full grammar. Hot-reloadable: SIGHUP re-applies the entire policy block atomically.
default_action controls unmatched tool calls:
| Value | Default | Notes |
|---|---|---|
allow | yes | Preserves v1 behavior: unmatched tool calls pass through. |
deny | no | Explicit allowlist mode. Unmatched tool calls return -32001 policy_denied and audit as rule_id: "default_deny". |
Policy rule action values:
| Value | Description |
|---|---|
allow | Forward the request unchanged. |
deny | Reject the request. HTTP 403, JSON-RPC -32001 policy_denied. |
redact | Rewrite matching byte sequences in the request body before forwarding. Requires a redact[] list on the rule. |
rate_limit | Token-bucket rate limit per (rule, session). HTTP 429, JSON-RPC -32003 rate_limited when the bucket is empty. Requires tokens_per_second and burst. |
strip_app | Parses the tool-call response and removes content blocks where type == "ui" or mimeType starts with "application/vnd.mcp-ui". Applies to both JSON and SSE responses. The stripped audit field records how many blocks were removed. |
Policy when fields:
| Field | Type | Notes |
|---|---|---|
method | string | JSON-RPC method exact match (e.g. elicitation/create, sampling/createMessage). Empty or absent matches any method. |
direction | enum | "" or absent (any direction, default), "client_to_server" (inbound requests only), "server_to_client" (SSE response frames only). Setting "server_to_client" enables SSE-frame inspection on responses from the upstream. |
tool_name | string | Exact tool name. "*" matches any tool. |
tool_prefix | string | Tool name prefix match. |
tool_glob | string | Glob pattern over tool name. |
tool_regex | string | Go RE2 regex over tool name. |
tool_name_in | list | Tool name allowlist; matches if the tool name is in the list. |
Set at most one tool matcher (tool_name, tool_prefix, tool_glob, tool_regex, or tool_name_in) per rule. method and direction may be combined with any tool matcher or used alone.
telemetry
telemetry:
customer:
enabled: true
endpoint: http://datadog-agent:4318
service_name: mcpgw
resource_attrs:
env: production
version: v1.0.0
operator:
enabled: false
endpoint: ""
service_name: ""
customer is the OTLP/HTTP exporter for your observability backend. operator is reserved for a future release — vendor-side anonymous usage telemetry, off by default, opt-in only. Today the operator block is parsed but inert.
| Field | Type | Default | Hot-reload? | Description |
|---|---|---|---|---|
customer.enabled | bool | false | no | |
customer.endpoint | string | — | no | |
customer.service_name | string | mcpgw | no | |
customer.resource_attrs | map[string]string | {} | no |
endpoint accepts a bare host:port; mcpgw appends /v1/traces. To override the path (OTLP-compatible proxies mounted under non-default paths), pass the full URL.
audit
audit:
path: /var/log/mcpgw/audit.jsonl
max_size_mb: 100
compress_rotated: true
sinks:
- type: gcs
bucket: acme-mcpgw-audit
prefix: prod/
flush_interval: 60s
flush_batch_lines: 10000
flush_batch_bytes: 5000000
compress: true
- type: s3
bucket: acme-mcpgw-audit
region: us-east-1
prefix: prod/
flush_interval: 60s
flush_batch_lines: 10000
flush_batch_bytes: 5000000
compress: true
object_lock: true
retention_days: 2555
- type: kafka
brokers: ["kafka-1:9092", "kafka-2:9092"]
topic: mcpgw-audit
partition_strategy: hash
partition_key: session_id
acks: all
compression: zstd
flush_interval: 5s
flush_batch_lines: 5000
flush_batch_bytes: 1000000
tls:
enabled: true
sasl:
mechanism: scram-sha-512
username_env: KAFKA_USERNAME
password_env: KAFKA_PASSWORD
- type: webhook
url: https://siem.acme.com/ingest
method: POST
headers:
Authorization: "Bearer ${SIEM_TOKEN}"
retry:
max_attempts: 5
backoff: exponential
initial_interval: 1s
max_interval: 60s
| Field | Type | Default | Hot-reload? |
|---|---|---|---|
path | string | — | yes |
max_size_mb | int | 100 | yes |
compress_rotated | bool | true | yes |
sinks[].type | string | — | yes |
sinks[].bucket | string | — | yes |
sinks[].region | string | — | yes |
sinks[].prefix | string | "" | yes |
sinks[].object_lock | bool | false | yes |
sinks[].retention_days | int | 2555 | yes |
sinks[].brokers | list | — | yes |
sinks[].topic | string | — | yes |
sinks[].partition_strategy | string | hash | yes |
sinks[].partition_key | string | session_id | yes |
sinks[].acks | string | all | yes |
sinks[].compression | string | zstd | yes |
sinks[].tls.enabled | bool | false | yes |
sinks[].tls.ca_file | string | system roots | yes |
sinks[].tls.cert_file | string | — | yes |
sinks[].tls.key_file | string | — | yes |
sinks[].tls.skip_verify | bool | false | yes |
sinks[].sasl.mechanism | string | "" | yes |
sinks[].sasl.username_env | string | "" | yes |
sinks[].sasl.password_env | string | "" | yes |
sinks[].url | string | — | yes |
sinks[].headers | map | {} | yes |
sinks[].flush_interval | duration | sink-specific | yes |
sinks[].flush_batch_lines | int | sink-specific | yes |
sinks[].flush_batch_bytes | int | sink-specific | yes |
sinks[].retry.max_attempts | int | 3 | yes |
sinks[].retry.backoff | string | exponential | yes |
sinks[].retry.initial_interval | duration | 1s | yes |
sinks[].retry.max_interval | duration | 30s | yes |
mcpgw rotates the file when its size exceeds max_size_mb. Rotated files are renamed to <path>.<unix-millis> and asynchronously gzipped if compress_rotated: true. mcpgw never deletes rotated files.
Audit sinks are additive best-effort copies. The local JSONL file remains canonical; sink failures are logged and do not block /mcp requests. GCS uses Google Application Default Credentials. S3 uses the AWS SDK default credential chain with the configured region. Kafka writes one audit JSONL line per record and uses Kafka-native batching/compression.
proxy_protocol (reserved)
proxy_protocol:
enabled: true
trusted_cidrs:
- 10.0.0.0/24
Reserved for a future release. Today this block is parsed but rejected at startup with a clear error. See How-to: behind a load balancer for the supported alternatives.
Validation rules
The startup validator enforces these invariants. Any failure is fatal; on SIGHUP reload, the previous config remains in effect.
upstreams[].nameis uniqueupstreams[].urlparses and has schemehttp/httpsand a non-empty hostdefault_upstream, if set, references a known upstream nameroutes[].upstreamreferences a known upstream nameinput_rate_limit.requests_per_secondis> 0wheninput_rate_limit.enabledis trueinput_rate_limit.burstis> 0wheninput_rate_limit.enabledis trueauth.keysis non-empty whenauth.enabledis trueauth.keys[].idis uniqueauth.keys[].hashparses as Argon2idauth.enabled: trueis required whenauth.oauth.enabled: trueauth.oauth.issuer,auth.oauth.jwks_url, andauth.oauth.public_url(when set) must usehttporhttpsscheme with a non-empty hostaudit.sinks[].typeisgcs,s3,kafka, orwebhookaudit.sinks[type=gcs].bucketis non-emptyaudit.sinks[type=s3].bucketandregionare non-emptyaudit.sinks[type=kafka].brokersis non-empty and every broker ishost:portaudit.sinks[type=kafka].topicis non-empty and has no whitespaceaudit.sinks[type=kafka].partition_strategyisround_robin,hash,sticky, or absentaudit.sinks[type=kafka].partition_keyissession_id,auth_key_id,tool_name,method,request_id, or absentaudit.sinks[type=kafka].acksis0,1,all, or absentaudit.sinks[type=kafka].compressionisnone,gzip,snappy,lz4,zstd, or absentaudit.sinks[type=kafka].tls.cert_fileandkey_fileare set together and parse as an X.509 key pairaudit.sinks[type=kafka].tls.ca_file, if set, contains at least one PEM certificateaudit.sinks[type=kafka].sasl.mechanismisplain,scram-sha-256,scram-sha-512, or absentaudit.sinks[type=webhook].urlis HTTPS unlessallow_insecure: truepolicy.rules[].idis uniquepolicy.default_actionisallow,deny, or absentpolicy.rules[].actionis one ofallow,deny,redact,rate_limit,strip_apppolicy.rules[].whensets at most one tool matcherpolicy.rules[].when.directionis"","client_to_server","server_to_client", or absentpolicy.rules[].when.tool_globparses as a valid globpolicy.rules[].when.tool_regexcompiles as Go regexppolicy.rules[].when.tool_name_inis non-empty when setpolicy.rules[].redact[].regexcompiles as Go regexp (RE2 syntax)policy.rules[].redact[].jsonpathis not set (rejected; reserved for a future release)policy.rules[].tokens_per_secondis> 0foraction: rate_limitaudit.pathis writable by the gateway userlicense.pathexists, is regular, and has mode0600or stricter
Related
- Tutorial 1 — first traced call — minimal config in context
- Policy rule schema — the
policy:block in detail - Explanation: architecture — what the config drives