An MCP (Model Context Protocol) server for the FDSN Web Service Station API (fdsnws-station 1.1). It exposes network, station, channel and instrument-response metadata of any FDSN-compliant datacenter (INGV, EarthScope, GFZ, ORFEUS, or a private intranet service) to MCP clients such as Claude Desktop and Open WebUI, with a bounded and predictable context cost.
- 4 MCP tools, one per level:
fdsnws_station_query_networks,fdsnws_station_query_stations,fdsnws_station_query_channels(from FDSNformat=text) andfdsnws_station_get_response(from StationXML through ObsPy) - Typed output: one Epoch object per line of text output, with the unit in the
field name (
elevation_m,azimuth_deg,sample_rate_hz, ...) and a realoutputSchemaadvertised to the client - Server-side ordering and pagination with an exact
total_count, because the FDSN service has neither - Multi-datacenter by name: ObsPy's registry plus your own
FDSN_DATACENTERS, so an intranet StationXML server is one environment variable away - Datacenter-agnostic: only specification parameters are sent; kilometre radii are converted to degrees so radial search works everywhere
mcp2.x: server identity and instructions, cache hints, stdio by default, Streamable HTTP without a proxy- Containerized: multi-arch image, non-root user
- Docker
- Python 3.11+ and
uv(for local development only)
Prebuilt multi-arch images (linux/amd64, linux/arm64) are published on Docker Hub:
# Latest release
docker pull ingv/mcp-fdsnws-station:latest
# A specific version (replace X.Y.Z with a published tag)
docker pull ingv/mcp-fdsnws-station:X.Y.Zgit clone https://github.com/INGV/mcp-fdsnws-station.git
cd mcp-fdsnws-station
docker build -t ingv/mcp-fdsnws-station .uv sync # creates .venv from uv.lock
uv run python -m fdsnws_station_server.server # stdio MCP server# stdio (default): the client spawns the container and talks over stdin/stdout
docker run -i --rm ingv/mcp-fdsnws-station
# Streamable HTTP on port 8000, endpoint http://localhost:8000/mcp
docker run -d --rm -p 8000:8000 -e MCP_TRANSPORT=streamable-http ingv/mcp-fdsnws-stationAll configuration is by environment variable.
| Variable | Default | Meaning |
|---|---|---|
MCP_TRANSPORT |
stdio |
stdio or streamable-http |
MCP_HOST |
0.0.0.0 |
Bind address (streamable-http only) |
MCP_PORT |
8000 |
Bind port (streamable-http only) |
FDSN_DEFAULT_DATACENTER |
INGV |
Datacenter used when a call omits datacenter |
FDSN_DATACENTERS |
(empty) | Extra datacenters, NAME=https://base.url,NAME2=http://host:port (see Datacenters) |
FDSN_TIMEOUT |
45 |
Upstream request timeout, seconds |
REQUESTS_CA_BUNDLE |
(system) | CA bundle for a private TLS authority, text path (requests) |
SSL_CERT_FILE |
(system) | The same bundle for the response path (ObsPy uses urllib) |
LOG_LEVEL |
INFO |
Python log level; logs go to stderr only |
{
"mcpServers": {
"fdsnws-station": {
"command": "docker",
"args": ["run", "-i", "--rm", "ingv/mcp-fdsnws-station"]
}
}
}To point it at an intranet datacenter, add the variables to the same command:
"args": ["run", "-i", "--rm",
"-e", "FDSN_DATACENTERS=RSNI=http://stationxml.intranet:8080",
"-e", "FDSN_DEFAULT_DATACENTER=RSNI",
"ingv/mcp-fdsnws-station"]Open WebUI (0.6.31 and later) speaks MCP natively over Streamable HTTP; no mcpo
proxy is involved and none is shipped.
- Run the server in HTTP mode:
docker run -d --name mcp-fdsnws-station -p 8000:8000 \ -e MCP_TRANSPORT=streamable-http ingv/mcp-fdsnws-station
- In Open WebUI go to Settings → Tools (or, for all users, Admin Panel →
Settings → Tools), add a tool server of type MCP (Streamable HTTP) with the
URL
http://<host>:8000/mcp. - If Open WebUI itself runs in Docker,
localhostfrom inside its container will not reach the host: usehttp://host.docker.internal:8000/mcp(Docker Desktop), the host's LAN address, or put both containers on the same Docker network and use the container name.
The server is stateless in HTTP mode (every request is self-contained, JSON
responses). It has no authentication and no rate limiting of its own, and in
the container it binds 0.0.0.0. That is fine on a workstation or inside a trusted
network; exposing it further means a reverse proxy in front that terminates TLS
and enforces authentication and rate limits. These are properties of the
deployment, not of the server, which is read-only, stateless and holds no
credentials. In stdio mode it opens no port at all.
All tools accept an optional datacenter name (default: FDSN_DEFAULT_DATACENTER,
normally INGV). The typical flow is networks → stations → channels → response.
Code parameters (network, station, location, channel) accept comma-separated
lists and the wildcards * and ?, and -- for a blank location code, exactly as
the FDSN specification defines them; they are forwarded verbatim. Time parameters are
ISO 8601 UTC dates or datetimes (2024-01-01 or 2024-01-01T00:00:00), forwarded
verbatim; there is no default time window, so a query without one returns every
Epoch ever operated.
level=network. Parameters: network, starttime, endtime, startbefore,
startafter, endbefore, endafter, updatedafter, includerestricted
(default true), limit, offset, datacenter.
level=station. Parameters: those of query_networks plus station, location,
channel, a bounding box (minlatitude, maxlatitude, minlongitude,
maxlongitude) or a radial search (latitude, longitude, minradiuskm,
maxradiuskm). Bounding box and radial search are mutually exclusive; latitude
and longitude must be given together, and a radius needs them as its centre.
level=channel. Same parameters as query_stations. A whole network at channel
level is thousands of Epochs (INGV IV: about 7500), which is what pagination is
for; narrow by station or channel when you can.
level=response through StationXML. Parameters: network, station, channel
(required, exact codes: no list, no wildcard), location (default *, may be a
wildcard or --), starttime, endtime, datacenter. Returns the full ObsPy
Inventory as a JSON tree: networks → stations → channels → response with
instrument_sensitivity and every response_stages entry (poles and zeros as
{real, imag} pairs, coefficients, FIR, decimation, gains). Values and units are
StationXML's own; nothing is derived. Use a time window to select a single Epoch
when the channel changed instrument over time.
A result whose text block would exceed 54000 bytes (about 19k tokens on qwen3.8:27b,
59% of a 32k context window) is returned with found: true, inventory: null and a
message listing every channel Epoch as NET.STA.LOC.CHA start to end, so the caller
can ask again with starttime and endtime inside one of them. One Epoch fits at each
of the four advertised datacenters; a channel with several instrument changes, such as
INGV IV.ACER..HHZ with three (66 kB), does not fit whole.
Every query tool returns one object:
datacenter resolved name (e.g. "INGV")
level "network" | "station" | "channel"
api_url the upstream request actually sent
query parameters sent upstream, with upstream names (radii in degrees)
pagination {total_count, returned_count, limit, offset, has_more, next_offset}; null on error
epochs list of Epoch objects for the level (below)
error {status, message} on upstream failure, else null
message hint when no Epoch matched, else null
One Epoch is one line of FDSN text output. Codes and times are verbatim strings,
numbers are floats, an empty cell is null, and an open Epoch has end_time: null.
| Level | Fields |
|---|---|
| network | network, description, start_time, end_time, total_stations |
| station | network, station, latitude (deg), longitude (deg), elevation_m, site_name, start_time, end_time |
| channel | network, station, location, channel, latitude (deg), longitude (deg), elevation_m, depth_m, azimuth_deg, dip_deg, sensor_description, scale, scale_frequency_hz, scale_units, sample_rate_hz, start_time, end_time |
At channel level, scale is the total sensitivity (StationXML
InstrumentSensitivity), valid at scale_frequency_hz; scale_units is the unit of
the data after the scale is applied (e.g. m/s, m/s**2). depth_m is the sensor
depth below the local surface; azimuth_deg is clockwise from north and dip_deg
is down from horizontal (-90 = vertical, pointing up).
fdsnws_station_get_response returns datacenter, api_url, found, the four
codes requested, channel_epochs_count, inventory (the JSON tree, null when not
found), error and message.
An upstream failure (HTTP 4xx/5xx, a network error, an unreachable host, or a 200
whose body is not the FDSN text format or not well-formed StationXML, such as an
HTML page from a misconfigured private service) is not a protocol error: the tool result carries error: {status, message} with
the datacenter's response body verbatim, and pagination is null. A query that matches nothing is not an
error either: total_count is 0 and message says so. Invalid input (a wildcard
in get_response, a bounding box together with a radial search, a malformed time,
an unknown datacenter name) is rejected before any request leaves, with the reason
in the tool error text; an unknown datacenter name lists every available name.
The FDSN station service has no limit, offset or orderby. Pagination and
ordering are features of this server, not of the datacenter, so do not look for
them in a datacenter's WADL.
For every query the server downloads the complete format=text response, sorts the
Epochs by network, station, location, channel, start_time (whichever the level
has), and returns the slice [offset, offset + limit). limit defaults to 50 and is
capped at 70; offset is 0-based. The cap is sized for the model that reads the page:
70 channel Epochs, the widest Level, cost about 18k tokens on qwen3.8:27b, 55% of a
32k context window (measured with tests/evals/calibrate_density.py). pagination.total_count is exact and
has_more/next_offset are derived from it, not guessed.
Consequences: page boundaries do not depend on the datacenter's own order (INGV, GFZ and EarthScope do not order the same way), and every page re-downloads the full result. The cap exists for the client's context budget, not for bandwidth: the second page of a 7500-Epoch query costs the datacenter as much as the first.
The specification defines minradius/maxradius in degrees. The tools take
minradiuskm/maxradiuskm in kilometres, because that is what people reason
in, and convert them before sending: degrees = km / 111.19 (great-circle arc on a
spherical Earth of mean radius 6371 km). The request that reaches the datacenter
uses the specification's minradius/maxradius, so it works on GFZ, ORFEUS,
EarthScope and any private service, not only on INGV (whose maxradiuskm is a
local extension and is never sent).
The conversion is spherical, not ellipsoidal: at the scale of a station search the
error is well under a kilometre. The degree values actually sent are visible in
api_url and query.
The tools expose only parameters defined in fdsnws-station 1.1 Table 1, and among those only the ones the advertised datacenters honour. Not exposed:
includeavailability,matchtimeseries: INGV documents them as not implemented and silently ignored; a filter that no-ops would mislead the model.format,nodata,level: internal to the implementation.minradiuskm/maxradiuskmas upstream parameters: an INGV extension, see above for how kilometres are handled.- INGV's
format=json/format=geojson: extensions, not portable.
Validation is structural only (types, ranges, ISO 8601 syntax, the bounding-box
versus radial guard). Whether starttime precedes endtime, whether a code exists,
how a wildcard is matched: those are the datacenter's decisions, and its error body
is returned verbatim in error.message. includerestricted and updatedafter are
exposed because both are in the specification and work on INGV.
A datacenter is always addressed by a name, never by a URL chosen by the caller (in HTTP mode a raw URL would turn the server into an open proxy).
Names resolve first against FDSN_DATACENTERS, then against ObsPy's registry
(obspy.clients.fdsn.header.URL_MAPPINGS); a configured name shadows an ObsPy one,
so an operator can redirect even INGV to a mirror. Names are case-insensitive.
FDSN_DEFAULT_DATACENTER (default INGV) is used when a call omits datacenter.
Advertised public datacenters, all verified live:
| Name | Base URL |
|---|---|
INGV (default) |
https://webservices.ingv.it |
EARTHSCOPE |
https://service.earthscope.org |
GFZ |
https://geofon.gfz.de |
ORFEUS |
https://www.orfeus-eu.org |
USGS and EMSC have no station service (HTTP 404) and are not advertised.
Any other ObsPy name (ETH, RESIF, NOA, ...) is accepted and its response
passed through.
An intranet StationXML server that implements fdsnws-station 1.1 (including the
WADL, which ObsPy reads for the response level) is added with one variable. Base
URLs are the part before /fdsnws/station/1/query.
docker run -i --rm \
-e FDSN_DATACENTERS="RSNI=http://stationxml.intranet:8080,LAB=https://lab.example.org" \
-e FDSN_DEFAULT_DATACENTER=RSNI \
ingv/mcp-fdsnws-stationWith this, a call without datacenter goes to RSNI; datacenter: "LAB" and
datacenter: "GFZ" still work; the server fails at startup on a malformed entry or
on a default name it cannot resolve.
If lab.example.org uses a certificate from a private CA, mount the bundle and
point both HTTP stacks at it, no code involved: REQUESTS_CA_BUNDLE=/certs/ca.pem
for the text path (requests) and SSL_CERT_FILE=/certs/ca.pem for the response
path (ObsPy uses urllib, whose default SSL context reads SSL_CERT_FILE). The api_url echoed in every result shows the configured host,
which is intended: it is the operator's own deployment.
"Which networks does INGV serve? List the ones still active."
"Find the stations of network IV within 30 km of L'Aquila."
"List the HH? channels of station IV.ACER with their sensitivities."
"Give me the instrument response of IV.ACER..HHZ in 2022."
"Same query on GFZ: channels of GE.APE."
mcp-fdsnws-station/
├── src/fdsnws_station_server/
│ ├── __init__.py # __version__ from package metadata
│ ├── server.py # MCPServer, the four tools, transport selection
│ ├── models.py # input constraints, Epoch and result models
│ └── client.py # datacenter registry, text path, response path, serialisation
├── tests/
│ ├── fixtures/ # format=text captured from INGV, GFZ, ORFEUS, EarthScope; StationXML
│ ├── unit/ # offline: parser, sort/slice, models, registry, error mapping
│ ├── protocol/ # spawns the server over stdio: initialize, server/discover, tools/*
│ ├── integration/ # live, opt-in (@pytest.mark.integration)
│ └── README.md
├── pyproject.toml, uv.lock
├── Dockerfile, run_tests.sh
└── .github/workflows/ # tests.yml (offline, 3.11-3.13), docker-build-push.yml
./run_tests.sh # build image, ruff, unit + protocol (offline)
./run_tests.sh --integration # ... plus live tests on INGV, EARTHSCOPE, GFZ, ORFEUS
# Without Docker
uv run ruff check src tests
uv run pytest # offline (default)
uv run pytest -m integration # liveThe live suite is never run in CI. Test details and fixture provenance in
tests/README.md.
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
| docker run -i --rm ingv/mcp-fdsnws-stationThe legacy initialize handshake (protocol 2024-11-05 and 2025-x clients such as
Claude Desktop) is still answered.
tests.yml runs ruff and the offline suite on Python 3.11, 3.12 and 3.13 from
uv.lock. docker-build-push.yml builds the multi-arch image and pushes it to
ingv/mcp-fdsnws-station: main → main, develop → develop, tag vX.Y.Z →
X.Y.Z and latest; pull requests build only. On a tag push a retention step keeps
the five latest semver versions.
- Specification: fdsnws-station 1.1
- INGV service: https://webservices.ingv.it/fdsnws/station/1/
- Query tools use
format=text;get_responseuses StationXML via ObsPy
A CITATION.cff file is provided, so GitHub can generate the
citation in APA or BibTeX from the Cite this repository button.
This project is released under the GNU Affero General Public License v3.0 or
later (AGPL-3.0-or-later). See the LICENSE file for the full text.
See AUTHORS.md.
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch off
develop - Commit your changes (see Conventional Commits)
- Open a Pull Request against
develop
By contributing you agree that your contributions are licensed under the AGPL-3.0-or-later license of this project.
For problems or questions, open an issue in the GitHub repository.