Skip to content

feat(mcp): reach external MCP servers over HTTP, and resolve ${VAR} secrets from the environment - #91

Merged
antiv merged 2 commits into
mainfrom
claude/mcp-env-interpolation
Sep 2, 2026
Merged

feat(mcp): reach external MCP servers over HTTP, and resolve ${VAR} secrets from the environment#91
antiv merged 2 commits into
mainfrom
claude/mcp-env-interpolation

Conversation

@antiv

@antiv antiv commented Sep 2, 2026

Copy link
Copy Markdown
Owner

MATE serves its own agents as HTTP MCP servers, but the client could only speak stdio. A remote server therefore had to be reached by spawning npx mcp-remote as a subprocess — Node on the host, a process per server, and a translation hop to talk a protocol MATE already implements on the other side.

Two commits, reviewable separately.

1. HTTP/SSE transport for the MCP client

A server entry is now HTTP when it carries a url, and stdio when it carries command/args. Streamable HTTP is the default transport, with sse selectable for older servers; headers carries auth.

ADK 2.3 already ships StreamableHTTPConnectionParams and SseConnectionParams, so this is wiring rather than protocol work.

Decisions worth a look:

  • timeout keeps the meaning it already has for stdio — how long a slow tool may take — so it maps to the read timeout, not ADK's 5-second connect timeout. Otherwise every config that raised timeout to 300 for a slow tool would silently have started meaning something else. connect_timeout is separate.
  • A url that is not http or https is refused rather than handed to the transport.
  • If an entry sets both forms, the url wins and the command is ignored, with a warning — rather than silently spawning a subprocess nobody asked for.

The three config fields that may arrive parsed or as a JSON string were handling that inline; headers would have been a fourth copy, so the parsing moved into one helper.

2. ${VAR} interpolation, and the deprecated class name

The MCP config modal has been telling users to write secrets as ${AUTH_HEADER} while no such substitution existed anywhere in the tree. Anyone who followed the hint sent the literal thirteen-character string as their credential and got an authentication failure with nothing to explain it.

Any string value in a server entry — headers, url, command, args, env — may now reference the server environment as ${VAR}, so credentials stay out of the agents_config row and out of config exports.

Judgement calls, flagged because they are choices rather than obvious:

  • An unset variable skips that server and logs which names were missing. An empty substitution sends an unauthenticated request that may quietly succeed with reduced scope; leaving the placeholder sends ${VAR} itself as the credential, which is the bug being fixed. Skipping is the only failure that is loud.
  • Set-but-empty is a real value, not a miss — something deliberately set it.
  • Interpolation runs after JSON parsing, not over raw config text, so a secret containing a quote cannot corrupt a field that arrived as a JSON string.

Only ${NAME} is a placeholder; a bare $HOME is left alone.

Note recorded in the docs: interpolation reads the server's environment, so anyone who can edit an agent's MCP config can route an environment value into a URL they choose. That is the same trust level agent configuration already carries — an admin can enable code_executor — so it is not a new exposure, but it is now written down rather than implicit.

MCPToolset is also renamed to McpToolset. ADK 2.3 keeps the old name as a subclass that only warns and delegates, so this is behaviour-identical and silences the DeprecationWarning. Four remaining mentions are prose in comments in unrelated modules and are left alone.

UI and docs

The dashboard's MCP config is a free JSON textarea, so it needed no new form — only its placeholder, which taught the mcp-remote workaround as the default example. documents/MCP_SERVERS.md gains a transport table, the HTTP field reference, and an interpolation section.

Verification

799 tests, OK.

Each change was checked by reverting it and confirming the new tests fail:

Reverted Result
HTTP transport 9 of 13 fail; the 4 that pass are the stdio regression guards
Interpolation 8 of 10 fail; the 2 that pass assert literal config and bare $NAME are untouched

The stdio path is covered by explicit regression tests — it is what every existing installation is configured with, and it is unchanged.

Not covered: no test talks to a real MCP server over the network. The self-referential end-to-end check — pointing MATE at its own /agents/{name}/mcp endpoint — is worth doing once against a running instance before this is relied on in production.


🤖 Generated with Claude Code

https://claude.ai/code/session_01ToCUNy2SqwvfTq4a6xfSk1


Generated by Claude Code

MATE serves its own agents as HTTP MCP servers, but the client could only
speak stdio. A remote server therefore had to be reached by spawning
`npx mcp-remote` as a subprocess: Node on the host, a process per server,
and a translation hop to talk a protocol MATE already implements on the
other side.

A server entry is now HTTP when it carries a `url` and stdio when it
carries `command`/`args`. Streamable HTTP is the default transport, with
`sse` selectable for older servers; `headers` carries auth. If an entry
sets both forms, the url wins and the command is ignored rather than
silently spawning a subprocess nobody asked for.

`timeout` keeps the meaning it already has for stdio — how long a slow
tool may take — so it maps to the read timeout, not the connect timeout,
which is separately settable as `connect_timeout`. A url that is not http
or https is refused rather than handed to the transport.

The three config fields that may arrive parsed or as a JSON string were
handling that inline; headers would have been a fourth copy, so the
parsing moved into one helper.

The dashboard's MCP config is a free JSON textarea, so it needed no new
form — only its placeholder, which taught the mcp-remote workaround as
the default. Its help text also promised `${AUTH_HEADER}` interpolation
for secrets; no such substitution exists anywhere in the codebase, so
that sentence is replaced with what actually happens to a pasted token.

789 tests, OK. Reverting the change fails 9 of the 13 new tests; the 4
that still pass are the stdio regression guards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToCUNy2SqwvfTq4a6xfSk1
The MCP config modal has been telling users to write secrets as
${AUTH_HEADER} while no such substitution existed anywhere in the tree.
Anyone who followed the hint sent the literal thirteen-character string
as their credential and got an authentication failure with nothing to
explain it.

Any string value in a server entry — in headers, url, command, args or
env — may now reference the server environment as ${VAR}, so credentials
stay out of the agents_config row and out of config exports.

An unset variable skips that server and logs which names were missing.
Both alternatives are worse: an empty substitution sends an
unauthenticated request that may quietly succeed with reduced scope, and
leaving the placeholder sends `${VAR}` itself as the credential, which is
the bug being fixed. A variable that is set but empty is a real value,
not a miss, since something deliberately set it.

Interpolation runs after JSON parsing rather than over the raw config
text, so a secret containing a quote cannot corrupt a field that arrived
as a JSON string.

Also renames MCPToolset to McpToolset. ADK 2.3 keeps the old name as a
subclass that warns and delegates, so this is behaviour-identical and
only silences the DeprecationWarning. Remaining mentions elsewhere are
prose in unrelated comments and are left alone.

799 tests, OK. Reverting the interpolation fails 8 of the 10 new tests;
the 2 that still pass assert it leaves literal config and bare $NAME
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToCUNy2SqwvfTq4a6xfSk1
@antiv
antiv merged commit c908d80 into main Sep 2, 2026
1 check passed
@antiv
antiv deleted the claude/mcp-env-interpolation branch September 2, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants