Skip to main content

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:

ServerEndpointExposes
API<api-url>/mcp (port 8081 locally)37 tools across datasets, resources, relationships, timeseries, events, labels and units
Analysisthe 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:

PathPOST /mcp, the only route
TransportStreamable HTTP, Spring AI WebMVC
Protocol modeSTATELESS: no initialize handshake, no session to hold
Server version1.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:

  1. Signature and issuer, by the same JwtDecoder the REST API uses.
  2. The DATAHUB_ACCESS role. Every non-public endpoint requires ROLE_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.
  3. Tenant. The organization.*.id claim populates the tenant context through the same OrganizationValidator used 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

ToolWhat it does
dataset_listList datasets visible to the caller. The catalogue is usually small
dataset_searchFull-text search, returns ids needed by timeseries_create
dataset_createCreate one dataset, returns the server-assigned id
dataset_updateUpdate one dataset by id. Only provided fields change
dataset_deleteDelete 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.

ToolWhat it does
resource_searchFull-text search over name, description and metadata
resource_getFetch one or more by id or externalId
resource_createCreate one node. At least one label is required
resource_updateUpdate common fields by id
resource_deleteDelete by id or externalId. Connected edges go with it
resource_fetch_relatedWalk outward and return the connected neighbourhood
resource_fetch_nearestBreadth-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

ToolWhat it does
edge_list_typesList relationship types for the tenant, e.g. PROCESSED_BY
edge_create_typeCreate a type. Coerced to SCREAMING_SNAKE_CASE server-side
edge_createConnect two existing nodes, by externalId or numeric id
edge_getFetch a single edge by numeric id
edge_deleteDelete edges by id. Nodes on either side are untouched

Timeseries

ToolWhat it does
timeseries_listBrowse when you do not yet have a search term
timeseries_searchFull-text search over name, description and metadata
timeseries_getLook up by externalId or numeric id
timeseries_createCreate one timeseries, returns the assigned id. Needs a unit or a unitExternalId (see unit_list)
timeseries_updateUpdate common fields by id
timeseries_deleteDelete the definition. Existing datapoints are not removed
timeseries_get_latestMost recent datapoint, for "what is it reading now?"
timeseries_fetch_datapointsHistory between two ISO-8601 timestamps
timeseries_send_datapointAppend a single datapoint

Events

ToolWhat it does
event_searchFull-text search, returns compact events
event_filterStructured query by time range or exact field values
event_getFull detail for specific events by externalId and/or UUID
event_createRecord something that happened: an alarm, a state transition
event_updateUpdate common fields by id or externalId
event_deleteDelete 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

ToolWhat it does
label_listList tenant labels, e.g. PRODUCTION
label_createCreate one label, coerced to SCREAMING_SNAKE_CASE
label_updateUpdate description, i18n code or colour by id
unit_listUnits of measure known to DataHub, e.g. Celsius
unit_getLook 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
focusExternalIdThe series to explain. Required
start, endISO-8601 window. Required
limitCandidates to test. Default 10, max 200
relationshipTypesOptional comma-separated list; restricts the graph walk to these edge types
analysesOptional comma-separated subset of the tests below; default all
analyses valueQuestion it answers
rawLagged cross-correlation on the aggregated series
whitenedThe same after ARIMA prewhitening, with Haugh-Box significance: does the relation survive removing trend and autocorrelation?
cointegrationEngle-Granger: do the two series share a long-run equilibrium?
stabilityDoes the correlation hold across subwindows, or ride one burst?
coherenceWelch 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 (#55 is an intermediate node without an externalId).
  • rawCorrelation at rawLagSeconds: 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, haughBoxPValue 0.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.
  • peakCoherence at peakCoherencePeriodSeconds: 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_EMPTY inclusion, so nulls, empty collections and empty strings never reach the model. The REST DTOs keep their own ALWAYS contract, unchanged.
  • List and search return lean projections. LeanResource, LeanTimeseries and friends drop audit timestamps, empty metadata and embedded edge lists. Call the matching *_get when the agent actually needs the full record, or resource_fetch_related for 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.