Skip to content

Repository files navigation

mcp-aggregator

Tests

Single endpoint that aggregates multiple backend MCP servers into one unified tool namespace.

AI Client (Claude Desktop / Chat UI)
         │ SSE :8100
         ▼
    mcp-aggregator          ← this server
    ├── startup: discover tools from all backends
    ├── runtime: route + proxy tool calls
    └── cross-cutting: logging, error isolation
         │                    │
    SSE :8002            SSE :8003
    opcua-mcp            mock-backend (or any FastMCP server)

Tool namespacing

Tools are prefixed {backend_name}__{tool_name}. With backends named opcua and plant, the aggregated namespace looks like:

opcua__connect_server
opcua__browse_nodes
opcua__read_node
...
plant__get_plant_status
plant__list_sensors
plant__acknowledge_alarm
...

The prefix is stripped before forwarding to the backend, so backend servers receive the original tool name unchanged.

In environments with multiple data sources — OPC-UA, MQTT, SCADA configuration — namespacing prevents tool name collisions and makes the audit log immediately readable. Every tool call identifies both the domain and the operation.

How it works

Startup: for each backend in backends.json, connects to the backend and calls tools/list. Every discovered tool is registered with a {backend}__{tool} prefix so the routing is explicit and collision-free. Streamable HTTP backends also start a persistent pooled session at this point.

Runtime: each tool call is routed to the originating backend. Streamable HTTP backends use the persistent pooled session; SSE backends open a fresh connection per call. The aggregator adds no parsing or transformation — it is a transparent proxy.

Setup

Windows (PowerShell)

python -m venv .venv
.venv\Scripts\pip install -r requirements.txt

macOS / Linux

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt

Running tests

.venv/bin/pip install -r requirements-dev.txt
.venv/bin/python3 -m pytest tests/ -v

Running

Start all backends first, then the aggregator.

Windows (PowerShell)

# Terminal 1 — mock plant backend (demo, no AVEVA required)
.venv\Scripts\python mock_backend.py

# Terminal 2 — opcua-mcp (optional, needs OPC-UA simulator or server)
cd ..\opcua-mcp && .venv\Scripts\python server.py

# Terminal 3 — aggregator
.venv\Scripts\python server.py

macOS / Linux

# Terminal 1 — mock plant backend (demo, no AVEVA required)
.venv/bin/python mock_backend.py

# Terminal 2 — opcua-mcp (optional, needs OPC-UA simulator or server)
cd ../opcua-mcp && .venv/bin/python server.py

# Terminal 3 — aggregator
.venv/bin/python server.py

Or use the launcher scripts:

# Windows
.\start_demo.ps1
# macOS / Linux (make executable once, then run)
chmod +x start_demo.sh
./start_demo.sh

The aggregator runs on port 8100 by default. Set AGGREGATOR_PORT in .env to change.

backends.json

Edit to add or remove backends. The aggregator reads this at startup. Set the BACKENDS_FILE environment variable to point at a different file (relative to the script directory, or absolute path) — useful for keeping separate demo and production configs without modifying backends.json.

[
  { "name": "opcua", "url": "http://localhost:8002/sse" },
  { "name": "plant", "url": "http://localhost:8003/sse" }
]

If a backend is unreachable at startup, its tools are silently skipped and a warning is logged. The aggregator still starts with whatever tools it could discover.

Tool filtering

Use include_tools or exclude_tools to control which tools are exposed from a backend. Filtering happens at discovery time — filtered tools never appear in the aggregated namespace. The two fields are mutually exclusive; using both on the same backend is an error.

[
  {
    "name": "influxdb",
    "url": "http://localhost:8003/sse",
    "include_tools": ["query", "list_measurements"]
  },
  {
    "name": "opcua",
    "url": "http://localhost:8002/sse",
    "exclude_tools": ["dangerous_write_tool"]
  }
]

Default arguments

Use default_args to inject argument defaults into every tool call forwarded to a backend. Caller-supplied arguments always take precedence over defaults. This is useful for scoping a backend to a specific context — for example, pointing two MQTT backends at the same server but restricting each to a different topic namespace:

[
  {
    "name": "mqtt_rawwater",
    "url": "http://localhost:8001/sse",
    "include_tools": ["read_topic_value", "scan_topics"],
    "default_args": { "topic_filter": "Plant/WTP/Pump/RawWater_*" }
  },
  {
    "name": "mqtt_treated",
    "url": "http://localhost:8001/sse",
    "include_tools": ["read_topic_value", "scan_topics"],
    "default_args": { "topic_filter": "Plant/WTP/Pump/Treated_*" }
  }
]

Streamable HTTP backends

Backends that serve the newer Streamable HTTP transport can be declared with "transport": "streamable_http". The default is "sse" if the field is absent.

[
  { "name": "opcua", "url": "http://localhost:8002/sse" },
  { "name": "analytics", "url": "http://localhost:8004/mcp", "transport": "streamable_http" }
]

Streamable HTTP backends benefit from connection pooling — the aggregator keeps one persistent ClientSession open per backend and routes all calls through it. SSE backends still open a fresh connection per call.

Stdio backends

Backends that are local subprocesses speaking MCP over stdio (e.g. the Rust fieldworks-adapters binaries — mqtt-mcp, opcua-mcp) are declared with "transport": "stdio" and command/args (and optionally env) instead of a url:

[
  { "name": "mqtt", "transport": "stdio", "command": "mqtt-mcp", "args": ["--broker", "localhost:1883"] },
  { "name": "opcua", "transport": "stdio", "command": "/opt/fieldworks/opcua-mcp", "args": [] }
]

The aggregator spawns the subprocess and keeps it running for the aggregator's lifetime — like Streamable HTTP, stdio backends use the persistent connection pool rather than spawning a fresh process per call, since many adapters expose an explicit connect tool that establishes state later calls depend on. If the pool session isn't ready yet (or drops), a call falls back to briefly spawning its own subprocess for that one call, then the pool reconnects on its own.

Management API

The aggregator exposes a runtime management API for adding and removing backends without restarting. All endpoints are unauthenticated.

OT environment note: In a live operational technology environment, expose the management API only on a trusted network interface or behind a reverse proxy with access controls. Unauthenticated POST /backends allows any caller to register an arbitrary backend server.

Method Path Description
GET /backends List active backends, tool counts, pool status
POST /backends Add a backend (same JSON shape as a backends.json entry)
DELETE /backends/{name} Remove a backend and deregister its tools
POST /backends/reload Re-read backends.json, add new entries, remove gone ones

Note: MCP clients (including Claude Desktop) call tools/list once at connect time and cache the result. Adding or removing a backend takes effect for new client connections but won't be visible to already-connected clients until they reconnect.

Example: add a backend at runtime

curl -X POST http://localhost:8100/backends \
  -H "Content-Type: application/json" \
  -d '{"name": "historian", "url": "http://192.168.1.50:8005/sse"}'

Example: reload from backends.json

curl -X POST http://localhost:8100/backends/reload

Running as a Windows service

The aggregator can run as an auto-start Windows service via NSSM. Start the three backend MCP servers before starting the aggregator — tool discovery runs at startup and backends that are unreachable at that point will not have their tools registered.

# Install (run as Administrator)
.\install_service.ps1

# Remove
.\uninstall_service.ps1

Edit the $BackendsFile variable at the top of install_service.ps1 to point at your backends config. The service is installed as AVEVA Demo McpAggregator on port 8100.

Claude Desktop config

Claude Desktop requires HTTPS for URL-based remote connectors and rejects plain HTTP entries at config validation. The workaround is mcp-remote, a lightweight npm bridge that runs as a local stdio subprocess and connects to the aggregator over HTTP internally.

Prerequisite: Node.js on the client machine (winget install OpenJS.NodeJS or nodejs.org).

Add to %APPDATA%\Claude\claude_desktop_config.json:

{
  "mcpServers": {
    "scada": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://<aggregator-host>:8100/mcp", "--allow-http"]
    }
  }
}

Replace <aggregator-host> with the IP or hostname of the machine running the aggregator (e.g. 192.168.80.134). Use localhost if Claude Desktop and the aggregator are on the same machine.

The --allow-http flag is required because mcp-remote also enforces HTTPS by default for non-localhost URLs.

The aggregator serves two transports on the same port:

Path Transport Use for
/mcp Streamable HTTP Claude Desktop (via mcp-remote), modern MCP clients
/sse Legacy SSE Older clients, custom chat UIs

About

MCP aggregator — unified tool namespace across multiple backend MCP servers

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages