Skip to content

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.

PropertyValue
ProtocolMCP streamable-HTTP (transport revision 2025-03-26)
Default bind127.0.0.1:8767
Path/mcp
Wire formatJSON-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.

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.

The MCP listener is enabled by default. Three config keys cover everything:

KeyDefaultDescription
MCP_ENABLEDtrueToggle the listener. When false, the daemon does not bind :8767 and the listener health check is skipped.
MCP_PORT8767TCP port for the streamable-HTTP listener.
MCP_PATH/mcpURL path the FastMCP app mounts at.

Set them in config.env like any other key:

Terminal window
galois-edge configure set MCP_PORT 8770
sudo systemctl restart galois-edge

The 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.

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.

13 tools are always present, regardless of what’s connected:

CategoryToolReturnsNotes
Discoverylist_instruments(filter="")[{id, manufacturer, model, address, profile_name, instrument_class, is_connected}, ...]Reads cached state.
Discoveryget_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.
Discoveryscan_instruments()List of newly discovered instrumentsTriggers a hardware bus scan.
Discoverylist_profiles()List of profile keys + matched instrument counts
Discoveryget_status(){edge_id, edge_name, version, instrument_count, ...}
Executeexecute_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.
Executeexecute_sequence(instrument_id, sequence_name, parameters?){result, steps_executed, execution_time_ms}Multi-step measurement (e.g. an IV sweep defined in the profile).
Executesend_scpi(instrument_id, scpi_command){response, error, execution_time_ms}Bypasses profile validation; useful for unmatched instruments and debugging.
Sweepstart_sweep(instrument_id, command_name, target_value, sweep_rate, parameters?){sweep_id, status: "running"}Daemon-resident; safe across client drops.
Sweepget_sweep_status(sweep_id){status, current_value, target_value, error}
Sweepstop_sweep(sweep_id, hold?){status: "holding" | "stopped"}hold=true parks at current value; hold=false runs the profile’s abort SCPI.
Streamstart_stream(instrument_id, command_name, interval_ms?, duration_ms?, parameters?){stream_id, count, last}Each measurement point is forwarded to the caller’s progressToken.
Streamstop_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.

When CapabilityManager registers an instrument, the dynamic tool registry emits one MCP tool per profile-defined command and one per sequence. Naming convention:

CaseTool 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 fieldJSON 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": ...}
unitappended to description as (unit: V)
mapenum 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.

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.

CapabilityManager exposes a register/unregister listener hook. The dynamic tool registry subscribes on startup. When an instrument hot-plugs:

  1. The discovery loop opens the device and queries *IDN?.
  2. Profile matching runs; if a profile matches, CapabilityManager.register_instrument fires.
  3. The listener emits one MCP tool per profile command; the registry broadcasts notifications/tools/list_changed to 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.

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: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"stream-17","progress":1,"message":"{\"value\":1.234,\"unit\":\"V\",\"timestamp_ms\":1746556800000}"}}
event: message
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"stream-17","progress":2,"message":"{\"value\":1.235,\"unit\":\"V\",\"timestamp_ms\":1746556800200}"}}
...
event: message
data: {"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:

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.

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.

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.

  • macOS: not supported. Linux + Windows + Raspberry Pi today.
  • Audit log: MCP calls go through the same CommandHandler path as gRPC, so audit / capability tracking is unified across transports.
  • Rate limiting: not yet enforced at the MCP layer. A misbehaving agent can tools/call in a tight loop. Edge-side rate limiting is a planned follow-up before any external customer hits the relay path.
  • tools/list_changed debounce: not currently debounced. If a hot-plug-heavy environment shows up where this matters, it’s a one-line change in DynamicToolRegistry.
  • Waveform payload size: progress notifications carry metadata only. Use fetch_waveform(stream_id, point_index) for the raw bytes.