A Model Context Protocol (MCP) server that lets LLM clients (Claude Desktop, Claude Code, VS Code Copilot, Chatbox, etc.) ask natural-language questions about the infrastructure metrics of the FABRIC Testbed — a nation-wide programmable network research infrastructure.
- Grounded by construction — every tool runs a fixed PromQL query authored in a FABRIC Grafana dashboard. The model never writes PromQL; it picks a tool, a variant, and parameters.
- 17 tools — 14 metric topics (CPU, memory, disk, filesystem, network, sockets, netstat, system, time, temperature, link traffic, link status, port traffic) plus 3 discovery tools.
- Two modes — a local stdio process using your own CILogon session, or one served deployment holding a Grafana service-account token.
- Friendly targets —
amstmeans every node at that site,cern worker 2resolves tocern-w2; the server maps them to real instances. - Bounded responses — series, samples, and total points are capped and downsampled to fit an LLM context without losing the time window.
- Production-ready — CredMgr token verification, per-caller rate limiting, structured access logs, Prometheus metrics, and a startup credential probe.
Prerequisites: Python 3.11+, a FABRIC token from portal.fabric-testbed.net (Experiments → Manage Tokens), and — for local mode only — a browser for the one-time Grafana login.
# Remote mode: talk to the deployed server (no Grafana credential needed)
curl -fsSL https://raw.githubusercontent.com/fabric-testbed/fabric_metrics_mcp/main/install.sh | bash -s -- --remote
# Local mode: run the server yourself against your own Grafana session
curl -fsSL https://raw.githubusercontent.com/fabric-testbed/fabric_metrics_mcp/main/install.sh | bash -s -- --localThe installer:
- Creates a virtual environment and installs the package.
- Writes
run_stdio.sh(local) orrun_remote.sh(remote) with your paths baked in. - Prints the MCP client configuration to paste.
- Runs a smoke test against the server.
Note: Remote mode needs nothing but a FABRIC token — the deployed server holds its own Grafana credential. Prefer it unless you are developing the server itself.
Manual setup: see Local mode setup or Remote mode setup.
Both helper scripts speak stdio, so any MCP client can launch them:
run_stdio.sh— runs the server locallyrun_remote.sh— bridges stdio to the deployed HTTPS server, injecting your token
Important: keep the server name short. The model sees
mcp__<server-name>__<tool>, and OpenAI-compatible stacks reject function names over 64 characters.fabric_metrics_list_param_optionsis already 33; a long server name silently drops the longest tools.
claude mcp add fabric-metrics -- /path/to/run_remote.sh{
"mcpServers": {
"fabric-metrics": {
"command": "/path/to/run_remote.sh"
}
}
}{
"servers": {
"fabric-metrics": {
"type": "stdio",
"command": "/path/to/run_remote.sh"
}
}
}{
"name": "fabric-metrics",
"command": "/path/to/run_remote.sh",
"env": {
"FABRIC_TOKEN_LOCATION": "/path/to/id_token.json",
"FABRIC_MCP_URL": "https://<your-host>/mcp"
}
}Every topic tool takes variant (which measurement), the dashboard's parameters, and an optional time window. Call fabric_metrics_list_topics() to route a question.
| Tool | Description |
|---|---|
fabric_metrics_cpu |
CPU utilization, load average, core count (9 variants) |
fabric_metrics_memory |
RAM/swap used, paging, page faults, OOM kills (12 variants) |
fabric_metrics_memory_internals |
Slab, vmalloc, hugepages, writeback, NFS (13 variants) |
fabric_metrics_disk |
Throughput, IOPS, latency, %busy, queue depth (8 variants) |
fabric_metrics_filesystem |
Free space and inodes (3 variants) |
fabric_metrics_network |
Bandwidth, packets, errors, drops, softnet, conntrack (16 variants) |
fabric_metrics_sockets |
TCP/UDP/RAW socket counts (5 variants) |
fabric_metrics_netstat |
Established connections, TCP errors, ICMP, retransmits (11 variants) |
fabric_metrics_system |
Uptime, forks, context switches, entropy, systemd (11 variants) |
fabric_metrics_time |
Clock synchronization and NTP drift (4 variants) |
fabric_metrics_temperature |
Hottest nodes, above-cutoff, by rack (3 variants) |
| Tool | Description |
|---|---|
fabric_metrics_link_traffic |
Rack-to-rack dataplane throughput (1 variant) |
fabric_metrics_link_status |
Busiest/idlest links, per-link rates, alerts (6 variants) |
fabric_metrics_port_traffic |
Per switch-port bandwidth (1 variant) |
| Tool | Description |
|---|---|
fabric_metrics_list_topics |
Every tool, its variants, source dashboard, and parameters |
fabric_metrics_list_param_options |
Allowed values for a parameter, resolved live |
fabric_metrics_list_sites |
FABRIC site/rack codes that have node data |
Note:
variantis an argument, never a tool name. There is nooom_killertool — it isfabric_metrics_memory(variant="oom_killer", ...).
There are two independent boundaries. The caller's token is never forwarded to Grafana.
| Boundary | How it works |
|---|---|
| Caller → MCP server | Server mode reads the Authorization: Bearer header only and verifies the signature against CredMgr's JWKS. Local mode reads $FABRIC_TOKEN_LOCATION. |
| MCP server → Grafana | A Grafana service-account token (GRAFANA_TOKEN), or a replayed CILogon browser session for local development. |
Get a token: portal.fabric-testbed.net → Experiments → Manage Tokens, or fabric-cli tokens create. Identity tokens last about four hours.
Important: this server terminates authentication rather than proxying it — it queries Grafana with a credential broader than any caller's.
FABRIC_VERIFY_TOKENS=1is therefore mandatory in any reachable deployment; an unverified JWT payload is just base64.
"How busy is the CPU on cern-w5?"
│
▼ LLM picks tool + variant + params
fabric_metrics_cpu(variant="busy", node="cern-w5")
│
▼ HTTPS + Authorization: Bearer <FABRIC token>
┌─────────────────────────────────────────────────┐
│ nginx (TLS) → fabric-metrics-mcp │
│ ├─ verify token (CredMgr) │
│ ├─ resolve node → instance │
│ └─ substitute into the panel's│
│ FIXED PromQL │
└─────────────────────────────────────────────────┘
│
▼ Authorization: Bearer <Grafana service token>
Grafana → datasource proxy → Mimir / Prometheus
│
▼
→ 0.8 % (grounded, real data)
- The catalog of dashboards, panels, and their queries ships inside the package, so the running server has no dependency on the repo layout or on Grafana's dashboard API.
- Responses are capped at 60 series and 3000 total samples; beyond that the step widens to fit while keeping the full window.
fabric_metrics_mcp/
__main__.py # entry point: transport, middleware, prompt registration
config.py # all configuration, read once from the environment
registry.py # topic -> variant -> panel, loaded from the shipped catalog
query.py # time parsing, substitution, Grafana calls, response budget
tools.py # one tool per topic + discovery tools
session.py # the SERVER's Grafana credential (token or cookies)
system.md # served as the `fabric-metrics-system` MCP prompt
auth/resolver.py # the CALLER's FABRIC token
middleware/ # access log, rate limiting
data/annotated/ # per-dashboard panels: fixed PromQL, units, legends
data/groups/ # the topic -> variant taxonomy
deploy/
central.env.example # one config file for the served deployment
local.env.example # one config file for a local Docker run
deploy.sh # mode selector + preflight, wraps docker compose
DEPLOY.md # step-by-step deployment runbook
docker-compose.yml # central: mcp-server + optional nginx (tls profile)
nginx/default.conf # TLS termination, bearer-only /mcp, restricted /metrics
tests/ # offline unit tests (no network, no credentials)
step2_build_catalog.py # rebuild: scrape dashboards
step3_annotate.py # rebuild: authored descriptions
step3_5_group.py # rebuild: topic/variant taxonomy
test_server.py # end-to-end smoke test + LLM evaluation harness
Configuration is read once at startup. Deployment settings live in deploy/central.env (see Deploy with Docker Compose).
| Var | Default | Purpose |
|---|---|---|
FABRIC_LOCAL_MODE |
0 |
1 = stdio + token file; 0 = HTTP + bearer header |
FABRIC_MCP_TRANSPORT |
stdio if local, else http |
FastMCP transport |
FABRIC_VERIFY_TOKENS |
0 if local, else 1 |
Verify caller signatures against CredMgr's JWKS |
FABRIC_CREDMGR_HOST |
cm.fabric-testbed.net |
JWKS host |
FABRIC_TOKEN_AUDIENCE |
empty | Expected aud claim; unchecked when unset |
FABRIC_TOKEN_LOCATION |
empty | Local mode only: path to the caller's token file |
GRAFANA_TOKEN |
empty | Grafana service-account token. Required for a served deployment |
GRAFANA_TOKEN_FILE |
empty | Path to a file holding that token; preferred over inline |
GRAFANA_STATE_FILE |
./grafana_state.json |
Replayed CILogon session, used only when no token is set |
FABRIC_METRICS_BASE_URL |
https://infrastructure-metrics.fabric-testbed.net/grafana |
Grafana API base. Use Grafana's internal address when deployed alongside it |
FABRIC_METRICS_DS_UID |
P83FD23C85A64357C |
Mimir datasource UID |
FABRIC_METRICS_TOOL_PREFIX |
fabric_metrics_ |
Prefix on registered tool names; "" for bare names |
HOST / PORT |
0.0.0.0 / 8000 |
Listen address inside the container |
FORWARDED_ALLOW_IPS |
empty | Reverse proxy whose X-Forwarded-* headers to trust. Required behind nginx, or the app emits http:// redirects and clients lose their session. Never * |
RATE_LIMIT |
60/minute |
Fixed-window limit per caller |
RATE_LIMIT_ENABLED |
0 if local, else 1 |
Toggle the limiter |
RATE_LIMIT_TRUSTED_PROXIES |
empty | Proxies allowed to assert the real client via X-Real-IP, as /32. See Security notes |
LOG_LEVEL / LOG_FORMAT |
INFO / text |
LOG_FORMAT=json for structured logs |
METRICS_ENABLED |
0 if local, else 1 |
Expose /metrics for Prometheus |
METRICS_CLIENT_IP_LABELS |
0 |
Per-IP Prometheus series; unbounded on a public endpoint |
Deployment-only variables, consumed by docker-compose.yml rather than the app:
| Var | Default | Purpose |
|---|---|---|
MCP_BIND / MCP_PORT |
127.0.0.1 / 8000 |
Host-side publish address for the app |
NGINX_BIND |
0.0.0.0 |
Address nginx listens on. Set to the public IP on a multi-homed host |
HTTP_PORT / HTTPS_PORT |
80 / 443 |
Host-side nginx ports |
SSL_CERT / SSL_KEY |
./ssl/fullchain.pem, ./ssl/privkey.pem |
Certificate and key; any path, any filename |
PUBLIC_HOSTNAME |
empty | Checked against the certificate's SANs by the preflight |
Note: the server also publishes its operating instructions as an MCP prompt named
fabric-metrics-system.
The full runbook — including network prerequisites and troubleshooting — is in DEPLOY.md. Summary:
export GRAFANA_TOKEN='glsa_...'
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
https://infrastructure-metrics.fabric-testbed.net/grafana/api/user200 means the token reaches Grafana. A 302 to /login means an auth proxy intercepts it — point FABRIC_METRICS_BASE_URL at Grafana's internal address instead.
sudo mkdir -p /etc/ssl/fabric-metrics
# copy the certificate (FULL CHAIN, not the leaf alone) and key into it
sudo chmod 600 /etc/ssl/fabric-metrics/privkey.pemImportant: ask your CA for the full chain. Browsers fetch missing intermediates themselves, so a leaf-only certificate looks fine in a browser and fails every command-line client.
cp deploy/central.env.example deploy/central.env
chmod 600 deploy/central.envAt minimum set:
GRAFANA_TOKEN=glsa_...
FABRIC_METRICS_BASE_URL=<whatever Step 1 proved>
PUBLIC_HOSTNAME=<your hostname>
NGINX_BIND=<your public IP>
SSL_CERT=/etc/ssl/fabric-metrics/fullchain.pem
SSL_KEY=/etc/ssl/fabric-metrics/privkey.pem
FORWARDED_ALLOW_IPS=172.32.240.10
RATE_LIMIT_TRUSTED_PROXIES=172.32.240.10/32./deploy.sh central up -d --build
./deploy.sh central logs --tail 40Look for Grafana credential OK: authenticated to ... as '<service-account>'. Then from your workstation:
ssh -N -L 8000:127.0.0.1:8000 <user>@<host> # leave running
python test_server.py --http http://127.0.0.1:8000/mcpThis proves the app and the Grafana credential work with no certificate involved.
./deploy.sh central --profile tls up -dTwo containers run:
fabric-metrics-mcp— the MCP server, published on loopback onlyfabric-metrics-nginx— TLS termination, reverse proxy to:8000
The preflight prints the certificate's subject and expiry, refuses a mismatched certificate/key pair, and warns if PUBLIC_HOSTNAME is absent from the SANs.
./deploy.sh central --profile tls ps # both "running", app "(healthy)"
dig +short <your-hostname> # must resolve to this host
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://<your-host>/mcp
# 406 = up (the MCP endpoint rejects a plain GET). Never use -I here.
export FABRIC_TOKEN_LOCATION=$HOME/id_token.json
python test_server.py --http https://<your-host>/mcp| Service | Port | Exposure |
|---|---|---|
fabric-metrics-mcp |
8000 | loopback only |
fabric-metrics-nginx |
80, 443 | NGINX_BIND |
/metrics |
via nginx | restricted to the monitoring network |
deploy/central.env.example— every setting, one filedocker-compose.yml— services; nothing hardcodednginx/default.conf— TLS and proxy rulesdeploy.sh— mode selector, preflight, passes everything else todocker compose
NGINX requirements, if you front this with your own proxy:
proxy_set_header Authorization $http_authorization— the caller's token must arrive untouchedproxy_buffering offand a longproxy_read_timeout— MCP streams responsesproxy_set_header X-Real-IP $remote_addr—$remote_addroverwrites;X-Forwarded-Forappends and must never key a rate limit- No
auth_requeston/mcp— it is bearer-token-only, verified by the app
The shipped catalog is a snapshot of five Grafana dashboards. Rebuild it when FABRIC changes a dashboard:
pip install -e '.[build]'
python step2_build_catalog.py # scrape dashboards -> catalog/
python step3_annotate.py # authored descriptions -> data/annotated/
python step3_5_group.py # topic/variant taxonomy -> data/groups/step2extracts panels, queries, units, legends, and template variables for the five featured dashboards, keyed by UID.step3holds hand-authored descriptions; these are the source of truth for routing and are what the model reads.step3_5assigns every panel to exactly one(topic, variant)and fails loudly on duplicates or omissions.
Note: panel IDs are referenced by number, so a deleted panel breaks the rebuild with a
KeyErrorrather than silently vanishing.
Runs the server on your machine against your own Grafana session, so it sees exactly what your account sees. No service token.
Quick install:
curl -fsSL .../install.sh | bash -s -- --local
python3 -m venv .venv && source .venv/bin/activatepip install -e '.[local]'python get_grafana_session.pyOpens a browser, waits for you to complete CILogon, and saves grafana_state.json. It expires in hours to days.
Download id_token.json from the portal, or fabric-cli tokens create.
export FABRIC_TOKEN_LOCATION=$HOME/id_token.json| Var | Default | Purpose |
|---|---|---|
FABRIC_TOKEN_LOCATION |
./id_token.json |
Your FABRIC token |
GRAFANA_STATE_FILE |
./grafana_state.json |
Your saved Grafana session |
FABRIC_LOCAL_MODE |
set to 1 by run_stdio.sh |
stdio transport, token from file |
python test_server.pyPoint it at run_stdio.sh — see MCP client configuration.
Talks to the deployed server over HTTPS. Nothing runs locally except a stdio bridge.
Quick install:
curl -fsSL .../install.sh | bash -s -- --remote
# node (for npx mcp-remote) and jq
brew install node jq # or: apt install nodejs npm jqOnly needed to run test_server.py; MCP clients need just the bridge script.
python3 -m venv .venv && source .venv/bin/activate && pip install -e .Download id_token.json from the portal, or fabric-cli tokens create.
run_remote.sh from this repo. It reads your token, checks it has not expired, and bridges stdio to HTTPS.
export FABRIC_TOKEN_LOCATION=$HOME/id_token.json
export FABRIC_MCP_URL=https://<your-host>/mcp| Var | Default | Purpose |
|---|---|---|
FABRIC_TOKEN_LOCATION |
$PWD/id_token.json |
Your FABRIC token. Use an absolute path |
FABRIC_MCP_URL |
the FABRIC deployment | Server endpoint, no trailing slash |
Important: use an absolute path for
FABRIC_TOKEN_LOCATION. A relative./id_token.jsonresolves against whatever directory your MCP client happens to start in.
python test_server.py --http https://<your-host>/mcpPoint it at run_remote.sh — see MCP client configuration.
| Local mode | Remote mode | |
|---|---|---|
| Grafana credential | your own CILogon session | the server's service token |
| Expires | hours to days | not your problem |
| Needs a browser | yes, once per session | no |
| Transport | stdio | HTTPS + bearer |
| Sees | what your account sees | what the service account sees |
| Good for | developing the server | everyday use |
Recommendation: use remote mode unless you are changing the server itself. It needs only a FABRIC token, and the session never expires out from under you.
node accepts what a person would say; the server resolves it. Pass the user's target as-is.
| User says | Pass | Resolves to |
|---|---|---|
| "at amst" | amst |
all nodes at that site |
| "amst worker 2" | amst worker 2 |
that one worker |
| "the head node at uky" | uky head node |
that site's head node |
| a full instance | as given | that instance |
Head node is hn — one per site; there is no h1. Workers are w1…wN. Site codes are opaque: present them as-is rather than expanding them into institution names.
start / end take natural language, like Grafana's time picker: "past 5 minutes", "last 30 min", "now-1h", "2 hours ago", "today 6am", "2026-01-13 15:00".
- No time given → last 5 minutes. Gauge panels evaluate at
end, so they return the current value regardless. stepis auto-chosen for ~120 points, never finer than the 30 s scrape interval.mode(instant|range) overrides the panel's default query type.
Each result carries panel, unit, description, resolved_queries, params_used, and series. Each series has a name from the dashboard's legend.
- Honour
unit. Apercentpanel is already a percentage. - A range series carries
values, not timestamped pairs:values[i]is the sample atwindow.start + i * window.step, andnullis a gap — not zero. truncated: truemeans series were dropped (>60).downsampled: truemeans the step widened to fit the budget; the window is unchanged.- Zero series is a real answer, not a reason to invent one.
error: "invalid_param_value"means a fixed-option parameter got a value it can never match. The message names the valid values.
CPU utilization on one node
Compare memory across a whole site
{ "name": "fabric_metrics_memory",
"arguments": { "variant": "ram_used", "node": "cern" } }The three hottest nodes in the fleet
{ "name": "fabric_metrics_temperature",
"arguments": { "variant": "hottest", "params": { "toplimit": "3" } } }Routing hints for confusable requests
| Question | Tool and variant |
|---|---|
| OOM kills, page faults | memory — not memory_internals, despite the name |
| slab, vmalloc, hugepages, writeback | memory_internals |
| softnet / netdev budget | network, variants softnet_packets / softnet_quota |
| "how busy is the disk" | disk variant io_util; "how slow" → wait_time |
| "hottest node" | temperature variant hottest — the one real fleet-wide ranking |
The server publishes its operating instructions as an MCP prompt, so a client can load them without hardcoding anything:
@mcp.prompt(name="fabric-metrics-system")
def fabric_metrics_system_prompt():
return SYSTEM_TEXTTool names inside it track FABRIC_METRICS_TOOL_PREFIX, so the served prompt never goes stale.
- Every HTTP request is logged with method, path, status, duration, a request id, and the caller's identity.
- Every tool call logs start, completion with duration, sanitised parameters, and errors.
LOG_FORMAT=jsonemits one JSON object per line for ingestion.- Tokens are never logged — every helper is redacted by construction.
{"ts": "2026-09-16T15:45:19+0000", "level": "INFO", "logger": "fabric_metrics.access",
"msg": "POST /mcp -> 200 in 1.98ms", "request_id": "f4ea4c72f16f",
"user_email": "user@example.edu", "client_ip": "203.0.113.7", "duration_ms": 1.98}Important: with a shared Grafana service account, Grafana's own logs cannot tell you which person ran which query. These access logs are the only per-user record — ship them somewhere durable.
pip install -e '.[test]'
pytest # offline unit tests, no network or credentialsThe suite covers the pure layers where a wrong answer is easiest to hide: PromQL substitution (including the regex and $__all forms that must keep working), the parameter-injection guard, time-window parsing, the request timeout, the startup credential probe, and catalog loading.
End-to-end, against a real server:
python test_server.py # local stdio
python test_server.py --http https://<host>/mcp # a deployment
python test_server.py --eval --limit 20 # drive an LLM over test_questions.md--eval needs an OpenAI-compatible endpoint (FABRIC_LLM_BASE_URL, FABRIC_LLM_API_KEY, FABRIC_LLM_MODEL) and writes report.html grading which tool and variant the model chose.
MCP server ──/metrics──▶ Prometheus ──▶ Grafana
(:8000) (scrape) (dashboards)
- Enabled by default in server mode; needs the
[metrics]extra. /metricsis restricted to the monitoring network bynginx/default.conf.
| Metric | Type | Labels | Description |
|---|---|---|---|
mcp_requests_total |
counter | method, path, status |
HTTP requests |
mcp_request_duration_seconds |
histogram | method, path |
Request latency |
mcp_tool_calls_total |
counter | tool, status |
Tool invocations |
mcp_tool_duration_seconds |
histogram | tool |
Tool latency |
mcp_rate_limit_hits_total |
counter | key_type |
Rejected by the limiter |
METRICS_ENABLED=0- Per-IP labels are off by default — one Prometheus series per source address is unbounded on a public endpoint.
METRICS_CLIENT_IP_LABELS=1opts in deliberately. - Rate-limit state is per-process and in memory — fine for one container; a multi-replica deployment needs a shared store, and two replicas double the effective limit.
- The healthcheck is a TCP probe — it cannot detect an expired Grafana credential. Watch for
Grafana credential NOT workingin the logs.
- The caller's token is never forwarded. This server queries Grafana with its own credential, so it terminates authentication rather than proxying it.
FABRIC_VERIFY_TOKENS=1is mandatory when reachable. Without CredMgr verification the gate is decorative — a JWT payload is base64 anyone can write.- Server mode never falls back to a local token file. A served process must not answer someone else's request with the operator's credential.
- Parameters cannot escape their label matcher. Values carrying a quote or backslash are rejected before substitution; regex metacharacters stay legal because dashboards legitimately use them.
- Every Grafana request has a finite timeout. Without one a hung upstream pins a worker thread until the process restarts.
- There is no per-user authorization. Every authenticated FABRIC user sees all sites' metrics — the same access they already have by logging into the dashboards.
The limiter's key is its bucket, so it may only come from inputs a caller cannot forge — anything a caller controls is something they can rotate for a fresh bucket per request. But it must still distinguish callers: behind a reverse proxy the socket peer is the proxy for every request, so keying on it alone puts everyone in one bucket and turns RATE_LIMIT into a service-wide cap.
RATE_LIMIT_TRUSTED_PROXIES resolves this by naming the proxies allowed to assert the real client address:
- List only your reverse proxy, as a
/32. Any host inside a wider range could forge the header. - Use
X-Real-IP, which nginx sets from$remote_addrand therefore overwrites. Never key onX-Forwarded-For— nginx appends to whatever the client sent, so its left-most entry stays caller-controlled even on a trusted hop. - Leave it empty when clients reach the server directly.
Keying on a verified JWT subject would give true per-user limits regardless of address. It is not enabled because:
- The Starlette helper that reads claims at the edge has no verifier hook, so claims there are unverified.
- An unverified claim is attacker input, not identity — a payload can be hand-written with no signing key.
- Verification happens in the tool layer, after the limiter has already run.
- Address keying at the edge is the safe default; the cost is that callers behind one NAT share a bucket.
{ "name": "fabric_metrics_cpu", "arguments": { "variant": "busy", "node": "amst-w1" } }