MCP servers
DataHub ships two MCP servers, so an agent can work against your data model directly instead of you hand-writing an integration layer for it:
| Server | Endpoint | Exposes |
|---|---|---|
| API | <api-url>/mcp (port 8081 locally) | 37 tools across datasets, resources, relationships, timeseries, events, labels and units |
| Analysis | the analysis service's /mcp (port 8082 locally) | one tool, analysis_related_series |
The important part is what they aren't: each MCP endpoint is an ordinary Spring endpoint on the same security chain as the REST API. There is no separate agent door, no service account with blanket access, and no MCP-specific bypass. An agent sees exactly what the identity in its token is allowed to see.
Endpoints
Both servers speak the same transport:
| Path | POST /mcp, the only route |
| Transport | Streamable HTTP, Spring AI WebMVC |
| Protocol mode | STATELESS: no initialize handshake, no session to hold |
| Server version | 1.0.0 |
Authentication
Identical for both servers, and identical to every other call in this documentation: an
OAuth2 Bearer JWT in the Authorization header, on each request.
POST /mcp HTTP/1.1
Host: api.intellistream.ai
Authorization: Bearer <your-jwt>
Content-Type: application/json
Three things are checked before any tool body runs:
- Signature and issuer, by the same
JwtDecoderthe REST API uses. - The
DATAHUB_ACCESSrole. Every non-public endpoint requiresROLE_DATAHUB_ACCESS, not merely a valid token. A token with a good organization claim but without this role authenticates and can then read and mutate nothing. - Tenant. The
organization.*.idclaim populates the tenant context through the sameOrganizationValidatorused for REST, so an agent cannot reach another tenant's data even by asking for an id it happens to know.
Past those gates, per-dataset access grants apply exactly as on REST: rows in data sets the identity cannot read are omitted, writes it lacks a grant for are refused.
The practical consequence, and the reason this design is worth the trouble: an agent inherits the permissions of whoever it is acting for. If you want an agent that can read but not write, issue it a token that can read but not write. There is no second permission model to keep in sync.
For an unattended agent, get the token from the OAuth2 client-credentials grant. Whether
the token request must also name a scope such as organization:* depends on how your realm
issues the organization claim that check 3 reads: see
provider-specific parameters in the client
reference.
Connecting a client
Any MCP client that speaks streamable HTTP and can attach a bearer token will work. The two servers are two entries; substitute the analysis URL for wherever your deployment exposes the analysis service:
{
"mcpServers": {
"datahub": {
"type": "http",
"url": "https://api.intellistream.ai/mcp",
"headers": {
"Authorization": "Bearer ${DATAHUB_TOKEN}"
}
},
"datahub-analysis": {
"type": "http",
"url": "https://<your-analysis-host>/mcp",
"headers": {
"Authorization": "Bearer ${DATAHUB_TOKEN}"
}
}
}
}
Tokens are short-lived. For anything long-running, refresh through your IdP and update the header rather than pinning a token into a config file.
By hand, with curl
Well-known MCP clients handle the transport for you. If you roll your own, two details
matter: the body is plain JSON-RPC 2.0, and the Accept header must be exactly
application/json, text/event-stream. Both media types, in that order, no q= parameters;
anything else earns an empty 400.
curl -s https://api.intellistream.ai/mcp \
-H "Authorization: Bearer $DATAHUB_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Calling a tool is the same request with a tools/call body:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"resource_fetch_nearest","arguments":{"externalId":"pump_12","endLabels":"TIMESERIES"}}}
The API server's tools
Named <domain>_<action> in snake_case, deliberately, so the listing reads the same to you
and to the model.
Datasets
| Tool | What it does |
|---|---|
dataset_list | List datasets visible to the caller. The catalogue is usually small |
dataset_search | Full-text search, returns ids needed by timeseries_create |
dataset_create | Create one dataset, returns the server-assigned id |
dataset_update | Update one dataset by id. Only provided fields change |
dataset_delete | Delete by id or externalId. Does not cascade to members |
Resources
A resource is any node in the graph that is not a dataset or a timeseries: equipment, work items, documents.
| Tool | What it does |
|---|---|
resource_search | Full-text search over name, description and metadata |
resource_get | Fetch one or more by id or externalId |
resource_create | Create one node. At least one label is required |
resource_update | Update common fields by id |
resource_delete | Delete by id or externalId. Connected edges go with it |
resource_fetch_related | Walk outward and return the connected neighbourhood |
resource_fetch_nearest | Breadth-first to the nearest nodes carrying a wanted label |
resource_fetch_related is the one to reach for when a question is really about context:
it returns the neighbourhood in a single call rather than making the agent traverse edge by
edge, which is both slower and worse for the context window.
resource_fetch_nearest asks the sharper question: not "what surrounds this node" but
"where is the nearest X". Breadth-first from a starting resource (externalId or id), it
returns the closest limit nodes (default 10) carrying one of the endLabels
(comma-separated, such as TIMESERIES), plus the sub-graph connecting them.
relationshipTypes restricts which edge types are followed; excludedLabels prunes
branches. The cap counts matching end nodes, not hops, so a match five hops out is still
found. Prefer it over resource_fetch_related whenever the agent hunts a specific kind of
neighbour ("the time series measuring this pump").
Relationships
| Tool | What it does |
|---|---|
edge_list_types | List relationship types for the tenant, e.g. PROCESSED_BY |
edge_create_type | Create a type. Coerced to SCREAMING_SNAKE_CASE server-side |
edge_create | Connect two existing nodes, by externalId or numeric id |
edge_get | Fetch a single edge by numeric id |
edge_delete | Delete edges by id. Nodes on either side are untouched |
Timeseries
| Tool | What it does |
|---|---|
timeseries_list | Browse when you do not yet have a search term |
timeseries_search | Full-text search over name, description and metadata |
timeseries_get | Look up by externalId or numeric id |
timeseries_create | Create one timeseries, returns the assigned id. Needs a unit or a unitExternalId (see unit_list) |
timeseries_update | Update common fields by id |
timeseries_delete | Delete the definition. Existing datapoints are not removed |
timeseries_get_latest | Most recent datapoint, for "what is it reading now?" |
timeseries_fetch_datapoints | History between two ISO-8601 timestamps |
timeseries_send_datapoint | Append a single datapoint |
Events
| Tool | What it does |
|---|---|
event_search | Full-text search, returns compact events |
event_filter | Structured query by time range or exact field values |
event_get | Full detail for specific events by externalId and/or UUID |
event_create | Record something that happened: an alarm, a state transition |
event_update | Update common fields by id or externalId |
event_delete | Delete by externalId and/or UUID, many in one call |
Use event_filter rather than event_search for anything time-bounded or exact. Search is
for when the agent only has words to go on.
Labels and units
| Tool | What it does |
|---|---|
label_list | List tenant labels, e.g. PRODUCTION |
label_create | Create one label, coerced to SCREAMING_SNAKE_CASE |
label_update | Update description, i18n code or colour by id |
unit_list | Units of measure known to DataHub, e.g. Celsius |
unit_get | Look up one unit by externalId, e.g. celsius |
The analysis server
The analysis service serves its own /mcp with a single tool, analysis_related_series:
given one series, which others move with it over a window, and can the relation be trusted?
It walks the knowledge graph outward from the focus series to collect physically related
candidates, then tests each pair statistically and returns the candidates ranked.
| Parameter | |
|---|---|
focusExternalId | The series to explain. Required |
start, end | ISO-8601 window. Required |
limit | Candidates to test. Default 10, max 200 |
relationshipTypes | Optional comma-separated list; restricts the graph walk to these edge types |
analyses | Optional comma-separated subset of the tests below; default all |
analyses value | Question it answers |
|---|---|
raw | Lagged cross-correlation on the aggregated series |
whitened | The same after ARIMA prewhitening, with Haugh-Box significance: does the relation survive removing trend and autocorrelation? |
cointegration | Engle-Granger: do the two series share a long-run equilibrium? |
stability | Does the correlation hold across subwindows, or ride one burst? |
coherence | Welch coherence: at what period is the shared periodicity strongest? |
Reading a result
One entry from a call with focus pump_flow:
{
"externalId": "discharge_pressure",
"name": "Discharge pressure",
"overlapCount": 1380,
"path": "pump_flow -FEEDS-> #55 -MEASURED_BY-> discharge_pressure",
"rawCorrelation": 0.84,
"rawLagSeconds": 120,
"whitenedCorrelation": 0.61,
"whitenedLagSeconds": 120,
"haughBoxPValue": 0.004,
"whitenedSignificant": true,
"cointegrated": true,
"stable": true,
"peakCoherence": 0.9,
"peakCoherencePeriodSeconds": 3600,
"rankScore": 0.87
}
Read it as:
path: how the candidate is physically connected to the focus, flattened to one string (#55is an intermediate node without an externalId).rawCorrelationatrawLagSeconds: correlation 0.84 with the candidate shifted by 120 s. A positive lag means the candidate lags the focus (the focus leads); here, pressure follows flow by two minutes.whitenedSignificant,haughBoxPValue0.004: the correlation survives prewhitening, so it is not an artifact of two series each trending or oscillating on their own. Trust this over the raw number.cointegrated: the pair shares a long-run equilibrium; after a shock they drift back together.stable: the correlation holds across subwindows rather than being carried by one event.peakCoherenceatpeakCoherencePeriodSeconds: the strongest shared cycle sits at one hour.overlapCount: how many aligned samples the tests ran on. Treat verdicts from a thin overlap with suspicion.rankScore: the combined score the list is sorted by.
Fields for analyses you did not request are absent from each entry. Around the list, the
response carries focusExternalId and focusName, bucketSeconds (the aggregation bucket
the series were resampled to), maxLagSeconds (how far the lag scan went), skipped
(candidates that could not be tested) and message.
How results are shaped
The API server is written for a reader with a finite context window, which is not something the REST wire contract has to care about.
- Nothing empty is sent. Every tool serializes with
NON_EMPTYinclusion, so nulls, empty collections and empty strings never reach the model. The REST DTOs keep their ownALWAYScontract, unchanged. - List and search return lean projections.
LeanResource,LeanTimeseriesand friends drop audit timestamps, empty metadata and embedded edge lists. Call the matching*_getwhen the agent actually needs the full record, orresource_fetch_relatedfor edges. - Timestamps are ISO-8601 strings, matching the REST API, so a value reads the same whichever way the agent reached it.
The pattern to encourage in your prompts: search or list to find candidates, then fetch detail for the few that matter. An agent that opens with a full listing burns its context on records it will not use.
Limits worth knowing
The servers do not ask permission. They run in STATELESS mode, which has no
elicitation: a tool cannot open a channel back to the user to confirm a mutating action.
resource_delete, event_delete and timeseries_send_datapoint execute the moment they are
called. If your agent should pause for a human before it writes, that gate belongs in the
agent you build, not in the server. Give the agent a read-only token if it has no business
writing at all.
Deletes do not cascade. Deleting a dataset leaves its resources and timeseries in place; deleting a timeseries leaves its datapoints. This is deliberate, but it means an agent tidying up will need more than one call, and a half-finished tidy leaves orphans.
Name coercion is server-side. Relationship types and labels are normalised to
SCREAMING_SNAKE_CASE, so derived from becomes DERIVED_FROM. Agents that construct a
name and then search for the string they constructed will miss.
Adding a tool
Annotate a public method on a Spring bean in the API server's mcp.tools package with @Tool, reference
McpResultConverter from it, and register the bean in the ToolCallbackProvider. If it is
not registered there, the server does not advertise it. Spring AI builds the schema from the
parameter types and the @ToolParam descriptions, so those descriptions are what the model
reads: write them for the model, not for a colleague.