AI Agent / MCP Integration
IoT DC3 turns the platform's entire HTTP surface into a single MCP (Model Context Protocol) tool catalog. An external AI Agent authenticates through OAuth 2.1, then discovers and calls tools over the gateway's /mcp endpoint: read devices, query point values, issue commands. This page covers how to get a token, how to call /mcp, why some tools don't show up, and why HIGH-risk operations need a second confirmation.
You're wiring IoT DC3 to an AI Agent. If you only want a chat box where people ask about data, see the Agentic Center. If you're driving the platform from scripts, see the CLI Guide.
Why MCP, instead of calling HTTP directly
The naive approach is to hand-write every REST endpoint as a tool and feed it to the LLM. That breaks down fast: 300+ endpoints spread across four centers, permission and tenant checks scattered everywhere, and destructive operations mixed in with read-only queries with no risk grading. MCP standardizes the layer. The platform automatically exports its endpoints as a risk-annotated tool catalog. The Agent discovers and calls them through one JSON-RPC protocol, and authentication, tenancy, permissions, and risk confirmation all flow through the gateway and auth center.
Three roles make up the pipeline. The Auth Center (dc3-center-auth) is the OAuth 2.1 authorization server — it issues tokens, performs introspection, and aggregates the tool catalog. The Gateway (dc3-gateway) is the MCP Resource Server: it hosts POST /mcp, re-checks permissions on every call, and forwards signed requests to the backend. The backend's Manager / Data / Agentic centers are where business logic actually runs. The Agent only ever talks to the first two.
Where the tool catalog comes from: registry × build-time snapshot, stable tool_id
The tool catalog is generated, not hand-written. Two inputs feed it:
- The tool set comes from service registration: the resource-registrar scans HTTP endpoints as each center boots and records them in
dc3_api(api_code/api_name) anddc3_resource(resource_code/permission_code). - Descriptions and schemas come from build-time static snapshots: the auth center ships four OpenAPI snapshots (
classpath*:openapi/openapi-*.json— auth / manager / data / agentic, 347 operations in total; regenerate withmake openapi).McpOpenApiAggregatorparses the snapshots, enriches every endpoint with its description,x-dc3-airisk annotations, and mergedinputSchema, joins that with the registry, creates one tool record per endpoint, and stores them indc3_mcp_tool_catalog.
Choosing a build-time snapshot over runtime fetching keeps the auth center free of any dependency on the four centers being reachable and removes any need to expose /v3/api-docs in production (see the McpOpenApiAggregator javadoc). The scale is roughly 340+ tools.
Each tool has a stable tool_id (equal to dc3_api.api_code), formatted as {service_name}:{HTTP_METHOD}:{api_path}, where service_name is the full service name dc3-center-<x> (from spring.application.name):
dc3-center-manager:POST:/device/add
dc3-center-data:POST:/point_command/write
dc3-center-data:POST:/point_value/latestA tool's risk level is set by hand, one endpoint at a time — never inferred from the verb. Every endpoint declares riskLevel (LOW / MEDIUM / HIGH) in its @Extension(name = "x-dc3-ai") annotation. The resource registrar enforces during scanning that the annotation exists and riskLevel is valid, and reports a defect if it's missing. The aggregator reads riskLevel from the annotation verbatim and only falls back to a conservative HIGH when the annotation is absent. So POST /point_command/write is marked HIGH (destructive=true) by hand, and never downgraded to MEDIUM just because it's a write.
read_only_hint is derived from the HTTP method (GET → 1, POST → 0). The aggregator writes these annotations — destructive_hint, idempotent_hint, open_world_hint — into dc3_mcp_tool_catalog, sourced from the x-dc3-ai OpenAPI extension on each endpoint (details at the end of this page).
How the catalog refreshes: manual endpoint + snapshot rebuild
The only way to refresh the tool catalog is an HTTP endpoint an administrator calls by hand: POST /api/v3/auth/mcp/tool/catalog/refresh (McpManagementController.refreshToolCatalog → ReactiveOAuthMcpRuntimeServiceImpl.refreshToolCatalog, permission point mcp:update). There is no scheduled refresh and no event-driven refresh — no @Scheduled task and no post-commit trigger such as McpToolCatalogChangedEvent. A refresh rebuilds the tool set from the current dc3_api / dc3_resource rows, but descriptions and schemas can only come from the OpenAPI snapshots packaged inside the auth service — after adding or changing an endpoint, a catalog refresh alone is not enough: a tool with no matching snapshot entry is conservatively marked riskLevel=HIGH with destructive=true, and lacks its description and inputSchema. To make a new endpoint fully effective, rerun make openapi, rebuild the auth image, then trigger one more refresh.
Access control: four gates decide whether a tool is visible and callable
Being in the catalog doesn't mean the Agent can use it. Whether a tool is visible to a given connection, and whether it's callable, depends on OAuth verification first (to get the principal and scope), then on the intersection of RBAC, the connection allowlist, and the risk policy.
tools/list returns the intersection of three sets: the principal's permission codes ∩ this MCP connection's allowlist ∩ the risk policy. tools/call adds the OAuth scope and a per-call risk confirmation. So even if dc3-center-data:POST:/point_value/latest is in the catalog, the Agent can't reach it when the connection's allowlist ( dc3_mcp_connection_tool) hasn't permitted it, the token lacks the mcp:tools:call scope, or the principal is missing the matching query permission.
HIGH risk is invisible by default
HIGH-risk tools (the various delete operations) are hidden by default in tools/list and only appear when explicitly enabled. Calling them requires the mcp:tools:call:high scope on top of that, plus the two-phase confirmation below. This conservative default keeps the Agent from deleting things by accident.
OAuth 2.1 authorization server: how to obtain a token
The auth center runs a hand-written OAuth 2.1 authorization server (RS256 JWT). These are its HTTP endpoints — all hosted by the auth center and exposed through the gateway:
| Endpoint | Method | Purpose |
|---|---|---|
/.well-known/oauth-authorization-server | GET | Authorization server metadata discovery |
/.well-known/oauth-protected-resource | GET | Protected resource metadata (RFC 9728, gateway side) |
/oauth2/authorize | GET | Authorization code + PKCE (user login + consent + MCP connection selection) |
/oauth2/token | POST (form) | Exchange for access_token / refresh_token |
/oauth2/jwks | GET | Public key set (RS256, for signature verification) |
/oauth2/revoke | POST (form) | Revoke a token (with replay detection) |
/oauth2/register | POST (JSON) | Dynamic client registration (admin-restricted) |
Token introspection (introspect) is not exposed as an HTTP endpoint. It's an internal gRPC interface the gateway uses, as the Resource Server, to validate the Bearer token.
Security baseline (all implemented): public clients are forced to use PKCE S256; redirect_uri is matched exactly, no wildcards; refresh tokens rotate (RFC 9700 §6.3, with replay detection via previous_refresh_token_hash); client secrets are stored as hashes only, never in plaintext.
Token types and lifetimes: the access_token is a short-lived JWT (default 15 minutes) carrying iss/aud/exp/nbf/sub=principal_id/principal_type/scope/tenant_id/mcp_connection_id; the refresh_token rotates, default 30 days; the authorization_code is single-use within 5 minutes and PKCE-bound; client_credentials runs as a SERVICE_ACCOUNT with no refresh.
OAuth 2.1 only, no long-lived tokens
The platform's MCP access supports OAuth 2.1 only. There is no PAT (Personal Access Token) and no long-lived static token such as dc3mcp_*. Every call uses a short-lived access_token plus a rotating refresh_token. Don't try to hard-code a "permanent MCP key" in a script — it doesn't exist.
A complete call: from obtaining a token to getting a result
The sequence below shows the whole path: the Agent gets a token, calls /mcp, and the gateway introspects and forwards the signed request.
Note the gateway-to-backend hop. The gateway uses McpGatewayClient.invokeBackend() to go straight through an internal WebClient (bypassing the gateway's own routing), building X-Auth-Principal and applying an HMAC signature. The backend's GatewayJwtConverter verifies the signature, restores the principal, and hands off to @PreAuthorize for the permission decision. The HMAC key AUTH_HMAC_SECRET fails fast in pre/pro if it's empty or equal to the default — see Auth · Tenancy · RBAC.
JSON-RPC methods of /mcp
POST /mcp is JSON-RPC 2.0 over Streamable HTTP, handled by the gateway's McpGatewayController. Supported methods: initialize, notifications/initialized, ping, tools/list, tools/call.
A tool's name (the one you reference in tools/call) is generated by a SQL rule: lower(service_name || '_' || api_name). Here service_name is the full spring.application.name (e.g. dc3-center-data, hyphens kept as-is), and api_name comes from the endpoint's @PreAuthorize permission point domain:action (e.g. point_value:list, colon also kept). So dc3-center-data:POST:/point_value/latest (@perm.can('point_value', 'list')) maps to the name dc3-center-data_point_value:list — not a made-up short name like data_point_value_latest. Agent authors should never guess tool names; trust what tools/list actually returns.
A tools/list request:
curl -X POST http://localhost:8000/mcp \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}'{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "dc3-center-data_point_value:list",
"title": "List Latest Point Values",
"description": "Return the most recent reading for each point under a device for the current tenant, paged by the request query. Use to read near-real-time snapshots; results are ordered by collection time.",
"inputSchema": {
"type": "object",
"properties": {
"deviceId": { "type": "integer", "format": "int64" },
"pointId": { "type": "integer", "format": "int64" },
"offset": { "type": "integer", "format": "int64", "minimum": 0 },
"limit": { "type": "integer", "format": "int32", "minimum": 1, "maximum": 200 }
}
}
}
]
}
}A tools/call (a low-risk query tool, with example argument values):
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "dc3-center-data_point_value:list",
"arguments": { "deviceId": 1839, "pointId": 1840, "offset": 0, "limit": 10 }
}
}On the way back, the gateway wraps the backend's R<T> into an MCP CallToolResult. For the real endpoints and fields behind a tool, see the Agentic Center and each center's OpenAPI.
HIGH risk: two-phase confirmation
For HIGH-risk tools (deletions and the like), the platform requires two-phase confirmation, so the Agent can't fire an irreversible operation in a single reasoning step.
Phase one: the Agent calls tools/call without a valid confirmation. The server doesn't execute — it returns CONFIRM_REQUIRED with a confirmId (UUID), default TTL PT5M.
Phase two: the Agent calls again with the confirmId + idempotency_key. The server checks that it isn't expired, the argument_digest matches the first call, the principal / connection / tool are unchanged, and it's consumed exactly once. status=PENDING is the SQL-layer concurrency guard, so a replayed confirmId loses the race.
The confirmation ticket is stored in dc3_mcp_tool_confirmation (confirm_id, tool_id, argument_digest, idempotency_key, status PENDING/CONSUMED/EXPIRED, ttl_expires), with the TTL set by dc3.mcp.confirm-ttl (default PT5M). Every HIGH-risk call is written to dc3_mcp_audit_log (confirm_id, idempotency_key, argument_digest, risk_level, duration_ms, remote_ip, and so on).
Constraints and boundaries (honestly labeled)
MCP resources / prompts not implemented
/mcp implements the tool methods only (tools/list / tools/call plus initialize / ping / notifications/initialized). The MCP protocol's resources/* and prompts/* capabilities are **not yet implemented ** (planned). The matching mcp:resources:read scope is reserved but not enabled.
tools/list_changed event push not implemented
When the tool catalog changes, the MCP tools/list_changed notification is not pushed (the design is planned; the RabbitMQ push isn't built). Catalog refresh runs through the manual endpoint described above. Clients should re-pull tools/list on their own schedule and not assume the server pushes changes.
Tool invocation is HTTP end to end (internal WebClient) — there is no gRPC tool-invocation channel, by design. The x-dc3-ai metadata comes from real annotations on each endpoint:
@Extension(name = "x-dc3-ai", properties = {
@ExtensionProperty(name = "riskLevel", value = "MEDIUM"), // LOW/MEDIUM/HIGH
@ExtensionProperty(name = "destructive", value = "false"), // whether it damages data/config
@ExtensionProperty(name = "idempotent", value = "false"), // whether it is safe to retry
@ExtensionProperty(name = "openWorld", value = "true") // whether it reaches the external/physical world
})Further reading
- Console · MCP Connections — managing clients, connections, and audit in the UI
- Agentic Center — the platform's built-in conversation and tool invocation; see what actually executes behind an MCP tool
- Auth · Tenancy · RBAC — the principal model, HMAC signing, and permission resolution, end to end
- CLI Guide — drive the platform directly with the
dc3CLI, no AI involved