MCP Server Reference
The galois-edge daemon embeds a FastMCP server reachable over the streamable-HTTP transport at a single endpoint. Every connected instrument is projected as one or more typed MCP tools; sweeps and streams use daemon-resident state so an agent session can disconnect without stranding hardware.
This page is the reference: transport, the static + dynamic tool taxonomy, JWT auth, hot-plug behaviour, and streaming. For copy-pasteable configuration of specific MCP clients see Client Configuration.
Transport
Section titled “Transport”| Property | Value |
|---|---|
| Protocol | MCP streamable-HTTP (transport revision 2025-03-26) |
| Default bind | 127.0.0.1:8767 |
| Path | /mcp |
| Wire format | JSON-RPC 2.0 in HTTP request bodies; either application/json (unary) or text/event-stream (streaming + notifications) in responses |
| Auth (tailnet) | Network membership is the boundary (no per-call auth) |
| Auth (relay) | Galois-Caller-JWT header, validated against the cloud’s JWKS |
Streamable-HTTP supersedes the legacy two-endpoint SSE transport (GET /sse + POST /messages). The daemon does not implement the legacy transport.
Why streamable-HTTP
Section titled “Why streamable-HTTP”A single endpoint that can return either a unary JSON body or an SSE stream simplifies routing through the cloud relay (one HTTP frame to forward, one auth context to attach) and matches what Anthropic’s mcp_servers connector (mcp-client-2025-11-20) speaks. SSE remains the streaming wire format inside streamable-HTTP responses — only the envelope changed.
Configuration
Section titled “Configuration”The MCP listener is enabled by default. Three config keys cover everything:
| Key | Default | Description |
|---|---|---|
MCP_ENABLED | true | Toggle the listener. When false, the daemon does not bind :8767 and the listener health check is skipped. |
MCP_PORT | 8767 | TCP port for the streamable-HTTP listener. |
MCP_PATH | /mcp | URL path the FastMCP app mounts at. |
Set them in config.env like any other key:
galois-edge configure set MCP_PORT 8770sudo systemctl restart galois-edgeThe Go supervisor proxies the configured MCP_PORT from the tailnet IP and 0.0.0.0 to the loopback port the Python engine binds on.
Tool taxonomy
Section titled “Tool taxonomy”The server registers two layers of tools onto the same endpoint: a fixed set of generic static tools that work against any matched instrument, and per-instrument typed tools generated at runtime from profile YAML.
Static tools
Section titled “Static tools”13 tools are always present, regardless of what’s connected:
| Category | Tool | Returns | Notes |
|---|---|---|---|
| Discovery | list_instruments(filter="") | [{id, manufacturer, model, address, profile_name, instrument_class, is_connected}, ...] | Reads cached state. |
| Discovery | get_capabilities(instrument_id?, instrument_class?) | List of InstrumentCapabilities (commands, sequences, settings) | Lead with this when prompting the agent — it’s the cheapest way to learn what’s available. |
| Discovery | scan_instruments() | List of newly discovered instruments | Triggers a hardware bus scan. |
| Discovery | list_profiles() | List of profile keys + matched instrument counts | |
| Discovery | get_status() | {edge_id, edge_name, version, instrument_count, ...} | |
| Execute | execute_command(instrument_id, command_name, parameters?, is_query?) | {success, data, scpi_command, execution_time_ms} | Refused for commands marked requires_sweep. Use start_sweep instead. |
| Execute | execute_sequence(instrument_id, sequence_name, parameters?) | {result, steps_executed, execution_time_ms} | Multi-step measurement (e.g. an IV sweep defined in the profile). |
| Execute | send_scpi(instrument_id, scpi_command) | {response, error, execution_time_ms} | Bypasses profile validation; useful for unmatched instruments and debugging. |
| Sweep | start_sweep(instrument_id, command_name, target_value, sweep_rate, parameters?) | {sweep_id, status: "running"} | Daemon-resident; safe across client drops. |
| Sweep | get_sweep_status(sweep_id) | {status, current_value, target_value, error} | |
| Sweep | stop_sweep(sweep_id, hold?) | {status: "holding" | "stopped"} | hold=true parks at current value; hold=false runs the profile’s abort SCPI. |
| Stream | start_stream(instrument_id, command_name, interval_ms?, duration_ms?, parameters?) | {stream_id, count, last} | Each measurement point is forwarded to the caller’s progressToken. |
| Stream | stop_stream(stream_id) | {status} | Cancels a running stream early. |
A sibling fetch_waveform(stream_id, point_index) tool returns the raw bytes for a single waveform sample. It exists because waveform y_data (often hundreds of KB) is too large to embed in every progress notification — only the metadata is streamed; the bytes are pulled on demand.
Dynamic per-instrument tools
Section titled “Dynamic per-instrument tools”When CapabilityManager registers an instrument, the dynamic tool registry emits one MCP tool per profile-defined command and one per sequence. Naming convention:
| Case | Tool name |
|---|---|
| First instance of a profile | <profile_key>__<command_name> — e.g. keithley_2400__source_voltage |
| Multiple instances of the same profile | <profile_key>__<short_id>__<command_name> — short_id is the last 8 chars of instrument_id with MCP-illegal characters scrubbed (anything outside A-Za-z0-9_-. → _) |
| Sequences | <profile_key>__sequence__<sequence_name> |
When a second instance of a profile registers, the first instance’s already-emitted tools are removed and re-emitted with the disambiguating suffix — the agent never sees a stable bare name racing with a suffixed one for the same profile.
The input schema for each tool comes from ParameterConfig in the profile YAML:
ParameterConfig field | JSON Schema field |
|---|---|
type: float | {"type": "number"} |
type: int | {"type": "integer"} |
type: string | {"type": "string"} |
type: bool | {"type": "boolean"} |
type: enum + options: [...] | {"type": "string", "enum": [...]} |
min | {"minimum": ...} |
max | {"maximum": ...} |
default | {"default": ...} |
unit | appended to description as (unit: V) |
map | enum over map.keys() (forward-map labels are agent-facing; wire values stay internal) |
Out-of-range values are rejected at the FastMCP/Pydantic layer before any SCPI hits the wire. is_dangerous from CommandConfig becomes Tool.annotations.destructiveHint = true so MCP clients that surface confirmation prompts (Claude Desktop’s “Allow once / Always allow” UI) can treat them differently.
Dynamic per-SDK tools
Section titled “Dynamic per-SDK tools”Each module under src/galois_edge/sdk_wrappers/ that declares a module-level MCP_TOOL_SPECS constant produces one MCP tool per spec entry. Tool names follow <wrapper_stem>__<spec_name> — e.g. dps150_wrapper__set_voltage, digilent_dwf_wrapper__configure_oscilloscope. The handler routes through SDKExecutor.call_method internally, so the agent never sees the opaque ProxySDKCall primitive.
The first wrappers shipping with MCP_TOOL_SPECS are dps150_wrapper (FNIRSI DPS-150 power supply) and digilent_dwf_wrapper (Digilent Analog Discovery 3). Adding a new SDK wrapper to the agent surface is a one-file change — declare the constant and the registry picks it up at startup.
Hot-plug behaviour
Section titled “Hot-plug behaviour”CapabilityManager exposes a register/unregister listener hook. The dynamic tool registry subscribes on startup. When an instrument hot-plugs:
- The discovery loop opens the device and queries
*IDN?. - Profile matching runs; if a profile matches,
CapabilityManager.register_instrumentfires. - The listener emits one MCP tool per profile command; the registry broadcasts
notifications/tools/list_changedto every active streamable-HTTP session.
Steady-state latency from physical plug-in to tools/list_changed is around 2 seconds, dominated by the discovery loop’s bus rescan cadence (RESCAN_INTERVAL_SEC, default 60 s — the trickle scanner picks up USB hot-plug faster than that via the kernel hot-plug monitor). Unplug fires the inverse: tools are removed from the registry and tools/list_changed re-broadcasts.
Streaming and progress notifications
Section titled “Streaming and progress notifications”start_stream is the streaming entry point. The agent passes a progressToken (per the MCP spec); each measurement point becomes one notifications/progress event keyed by that token. The final tool response carries {stream_id, count, last}.
// Agent → server{ "jsonrpc": "2.0", "id": 17, "method": "tools/call", "params": { "name": "start_stream", "arguments": { "instrument_id": "USB0::0x2A8D::0x0101::MY54505555::INSTR", "command_name": "measure_voltage_dc", "interval_ms": 200, "duration_ms": 30000 }, "_meta": { "progressToken": "stream-17" } }}
// Server → agent (one event per measurement point, then the final result)event: messagedata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"stream-17","progress":1,"message":"{\"value\":1.234,\"unit\":\"V\",\"timestamp_ms\":1746556800000}"}}
event: messagedata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"stream-17","progress":2,"message":"{\"value\":1.235,\"unit\":\"V\",\"timestamp_ms\":1746556800200}"}}
...
event: messagedata: {"jsonrpc":"2.0","id":17,"result":{"content":[{"type":"text","text":"{\"stream_id\":\"mcp-a1b2c3d4\",\"count\":150,\"last\":{\"value\":1.241,\"unit\":\"V\",\"timestamp_ms\":1746556830000}}"}]}}For waveform-shaped points (oscilloscope traces) only the metadata (y_dtype, y_length, x_unit, y_unit, x_increment) lands in each progress message — the raw y_data bytes are too large for SSE event payloads and would blow out the agent’s context window at 4096-point traces. Call fetch_waveform(stream_id, point_index) to pull the bytes for a specific point.
Stream state lives on the daemon. If the agent disconnects, the stream is cancelled; if the agent calls stop_stream(stream_id) early, the run terminates and the partial result is returned.
The daemon supports two auth paths on the same endpoint:
Tailnet-direct (Phase 1)
Section titled “Tailnet-direct (Phase 1)”No per-call auth. Network membership (the tailnet, or loopback if the agent runs on the same host) is the boundary. This is the default and the path Claude Desktop uses when configured against http://<edge>.tail-1234.ts.net:8767/mcp. The optional INBOUND_AUTH_TOKEN (used by gRPC) does not gate MCP — the gRPC interceptor isn’t in this code path.
Relay-routed (Phase 2)
Section titled “Relay-routed (Phase 2)”When the cloud relay forwards an mcp_request, the Go supervisor sets a Galois-Caller-JWT header on the local FastMCP request. An ASGI middleware validates the JWT against the cloud’s JWKS (RS256, cached for 24 h, refreshed on signature failure) and stuffs the claims onto a contextvar. Each tool handler calls EdgeContext.authorize(tool_name, scope, is_dangerous) early in its body; the middleware-populated claims gate the call.
JWT shape:
{ "iss": "https://cloud.galoislabs.ai", "aud": "edge:<edge_id>", "sub": "user:<user_id>", "exp": <now + 5 min>, "iat": <now>, "edge_id": "<edge_id>", "tools_allow": [ "list_instruments", "get_capabilities", "execute_command:keithley_2400__source_voltage", "start_sweep", "get_sweep_status", "stop_sweep" ], "danger_allow": false}tools_allow accepts both bare tool names and qualified <tool>:<scope> — execute_command:keithley_2400__measure_current allows only that specific command through the generic execute tool. danger_allow=false blocks any tool whose underlying is_dangerous is true regardless of tools_allow.
Failure modes (signature failure, expiry, audience mismatch, missing tools_allow entry, dangerous-call without danger_allow=true) all surface as JSON-RPC errors at the daemon — never silent passes.
Health checks
Section titled “Health checks”galois-edge doctor includes an MCP listener check at the same level as the WebSocket check — PASS / FAIL with the bound port. If the listener fails to bind (port collision, FastMCP startup error), the daemon logs a warning and the rest of the daemon comes up regardless. The MCP server is non-essential for hardware control via gRPC; it’s an additional surface, not the only surface.
Limits and known quirks
Section titled “Limits and known quirks”- macOS: not supported. Linux + Windows + Raspberry Pi today.
- Audit log: MCP calls go through the same
CommandHandlerpath as gRPC, so audit / capability tracking is unified across transports. - Rate limiting: not yet enforced at the MCP layer. A misbehaving agent can
tools/callin a tight loop. Edge-side rate limiting is a planned follow-up before any external customer hits the relay path. tools/list_changeddebounce: not currently debounced. If a hot-plug-heavy environment shows up where this matters, it’s a one-line change inDynamicToolRegistry.- Waveform payload size: progress notifications carry metadata only. Use
fetch_waveform(stream_id, point_index)for the raw bytes.
Where to go next
Section titled “Where to go next”- Client Configuration — concrete config blocks per agent framework.
- Connecting Instruments — profile matching is what populates the dynamic tool surface.
- Daemon API Reference — the gRPC contract MCP wraps in-process.
- WebSocket Relay — the cloud relay envelope MCP-over-relay extends.