Transport is hardcoded to streamable HTTP, so stdio and SSE servers cannot be rostered #120

Closed
opened 2026-08-11 23:33:31 +00:00 by coilyco-ops · 1 comment
Member

MCPProvider.Open builds one transport and only one (internal/community/mcp.go):

client.Connect(ctx, &mcp.StreamableClientTransport{
    Endpoint: server.URL, HTTPClient: p.HTTPClient, DisableStandaloneSSE: true,
}, nil)

MCPServerDefinition matches that assumption: Name, URL, URLEnv, and validation demanding exactly one of the two URL forms. An MCP server that speaks stdio or the 2024-11-05 SSE transport cannot be expressed, let alone connected.

The SDK already provides all three client transports behind the same mcp.Transport interface, and client.Connect takes that interface:

  • mcp.CommandTransport - stdio over a subprocess
  • mcp.SSEClientTransport - the 2024-11-05 HTTP+SSE transport
  • mcp.StreamableClientTransport - current

Everything above the transport (ListTools, CallTool, session lifecycle, the proxy tool loop) is transport-agnostic already. This is a factory keyed off the definition, not a rewrite.

Why this is load-bearing, not just breadth

Server-initiated notification support is a property of the transport, which is what #116 got stuck on:

  • stdio holds a persistent bidirectional pipe. tools/list_changed arrives natively, no flag.
  • SSE holds a persistent GET stream by construction. Same.
  • Streamable HTTP delivers them only when the standalone SSE stream is enabled, which is exactly the DisableStandaloneSSE: true currently set.

So "can this roster be cached and invalidated" is not one global decision. It is per server, answered by its transport. A supervisor that asks the transport gets real invalidation wherever the transport supports it and falls back to a TTL refresh only where it does not.

Schema

MCPServerDefinition needs a transport discriminator, defaulting to streamable so existing entries keep working, plus the fields each transport needs. The exactly-one-of-url-or-url_env rule becomes transport-conditional rather than universal.

Authority boundary, needs deciding

A stdio entry names a command Echo executes. Combined with #117, which moves the roster to a deployment-owned source, that is process execution driven by config from another repo. That may be entirely acceptable, since deploy is a trusted layer and already chooses Echo's image and arguments, but it should be an explicit decision rather than a side effect of adding a transport. The conservative alternative is admitting stdio only from source-controlled definitions and not from the deployment-owned roster.

Honest scope note

stdio buys little for the current k3s lanes. Those run MCP servers as separate workloads holding their own credentials, reached over the tailnet, which is the arrangement docs/sirens-echo-tools.md describes and a good one. stdio matters for local development, the eval harness, and any future co-located server. Worth building for correctness and reach rather than for an immediate production need.

Blocks the clean form of #116. Relates to #117, which decides where the roster with these fields comes from.

`MCPProvider.Open` builds one transport and only one (`internal/community/mcp.go`): ```go client.Connect(ctx, &mcp.StreamableClientTransport{ Endpoint: server.URL, HTTPClient: p.HTTPClient, DisableStandaloneSSE: true, }, nil) ``` `MCPServerDefinition` matches that assumption: `Name`, `URL`, `URLEnv`, and validation demanding exactly one of the two URL forms. An MCP server that speaks stdio or the 2024-11-05 SSE transport cannot be expressed, let alone connected. The SDK already provides all three client transports behind the same `mcp.Transport` interface, and `client.Connect` takes that interface: * `mcp.CommandTransport` - stdio over a subprocess * `mcp.SSEClientTransport` - the 2024-11-05 HTTP+SSE transport * `mcp.StreamableClientTransport` - current Everything above the transport (`ListTools`, `CallTool`, session lifecycle, the proxy tool loop) is transport-agnostic already. This is a factory keyed off the definition, not a rewrite. ## Why this is load-bearing, not just breadth Server-initiated notification support is a **property of the transport**, which is what #116 got stuck on: * **stdio** holds a persistent bidirectional pipe. `tools/list_changed` arrives natively, no flag. * **SSE** holds a persistent GET stream by construction. Same. * **Streamable HTTP** delivers them only when the standalone SSE stream is enabled, which is exactly the `DisableStandaloneSSE: true` currently set. So "can this roster be cached and invalidated" is not one global decision. It is per server, answered by its transport. A supervisor that asks the transport gets real invalidation wherever the transport supports it and falls back to a TTL refresh only where it does not. ## Schema `MCPServerDefinition` needs a transport discriminator, defaulting to streamable so existing entries keep working, plus the fields each transport needs. The exactly-one-of-url-or-url_env rule becomes transport-conditional rather than universal. ## Authority boundary, needs deciding A stdio entry names a **command Echo executes**. Combined with #117, which moves the roster to a deployment-owned source, that is process execution driven by config from another repo. That may be entirely acceptable, since deploy is a trusted layer and already chooses Echo's image and arguments, but it should be an explicit decision rather than a side effect of adding a transport. The conservative alternative is admitting stdio only from source-controlled definitions and not from the deployment-owned roster. ## Honest scope note stdio buys little for the current k3s lanes. Those run MCP servers as separate workloads holding their own credentials, reached over the tailnet, which is the arrangement `docs/sirens-echo-tools.md` describes and a good one. stdio matters for local development, the eval harness, and any future co-located server. Worth building for correctness and reach rather than for an immediate production need. Blocks the clean form of #116. Relates to #117, which decides where the roster with these fields comes from.
Author
Member

Decision on the authority boundary, from Kai: deployment owns every MCP, including stdio. The analogy is an operator owning their own Claude Code MCP config. The harness vendor does not get to decide which MCP servers the operator runs.

So the restriction I floated is dropped. The deployment-owned roster carries every transport uniformly, and there is no allowlist of permitted commands in Echo. Echo validates the shape of a roster entry, never the policy of which server is acceptable.

That makes one property explicit rather than accidental: whoever can write the roster source can run a process inside Echo's pod. This is the intended trust model, matching the fact that the same layer already chooses Echo's image, arguments, and mounts. It is worth writing into the docs on the deploy side so the roster source is understood to deserve the same write protection as the pod spec, and so nobody later sources it from a channel with weaker controls without realising what that grants.

One safety-by-default choice that is about shape rather than policy, so it stays: a stdio child does not inherit Echo's environment wholesale. Echo carries the Discord token, the Agent Proxy route, and the readiness endpoint, and handing all of that to every subprocess by default is a leak, not a capability. A roster entry names the variables to forward:

- name: local
  transport: stdio
  command: /usr/bin/some-mcp
  args: ["--flag"]
  env: ["SOME_TOKEN"]

That constrains nothing about which command runs. It only stops an unrelated secret riding along by accident, and the operator can forward anything they choose.

Decision on the authority boundary, from Kai: **deployment owns every MCP, including stdio.** The analogy is an operator owning their own Claude Code MCP config. The harness vendor does not get to decide which MCP servers the operator runs. So the restriction I floated is dropped. The deployment-owned roster carries every transport uniformly, and there is no allowlist of permitted commands in Echo. Echo validates the **shape** of a roster entry, never the **policy** of which server is acceptable. That makes one property explicit rather than accidental: whoever can write the roster source can run a process inside Echo's pod. This is the intended trust model, matching the fact that the same layer already chooses Echo's image, arguments, and mounts. It is worth writing into the docs on the deploy side so the roster source is understood to deserve the same write protection as the pod spec, and so nobody later sources it from a channel with weaker controls without realising what that grants. One safety-by-default choice that is about shape rather than policy, so it stays: a stdio child does **not** inherit Echo's environment wholesale. Echo carries the Discord token, the Agent Proxy route, and the readiness endpoint, and handing all of that to every subprocess by default is a leak, not a capability. A roster entry names the variables to forward: ```yaml - name: local transport: stdio command: /usr/bin/some-mcp args: ["--flag"] env: ["SOME_TOKEN"] ``` That constrains nothing about which command runs. It only stops an unrelated secret riding along by accident, and the operator can forward anything they choose.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#120
No description provided.