fix(cli): expand ${VAR} in mcp.json header values (#3523)

Closes #3508

---

Reviving #3506 (auto-closed pending issue assignment). Credit to
@chiplay for the original patch — preserved as the commit author.

The emitted `_load_mcp_tools()` forwarded `mcp.json` header values to
`MultiServerMCPClient` verbatim, so configs like `"Authorization":
"Bearer ${SUBTEXT_API_KEY}"` reached MCP servers as the literal `${VAR}`
string. `client.get_tools()` then errored and the loader's `except:
return []` returned silently, leaving the agent with no MCP tools.

This adds an `os.path.expandvars` pass over `url` and each header value
inside `MCP_TOOLS_TEMPLATE`, mirroring the `${VAR}` substitution already
documented for `deepagents-code`'s `.mcp.json` — so an mcp config shape
that works with `dcode` now also works with `deepagents deploy`. Unset
variables are left as the literal `${VAR}` so a recognizable token
surfaces in the failing request rather than a silent empty header.

Unit test asserts the rendered MCP block references `os.path.expandvars`
and wires it through both the `url` and `headers` paths.

---------

Co-authored-by: Chip Lay <chip.lay@gmail.com>
This commit is contained in:
Mason Daugherty
2026-05-20 19:01:26 -05:00
committed by GitHub
parent e7bd3d8109
commit 6cfc5f9004
2 changed files with 123 additions and 7 deletions
+38 -7
View File
@@ -409,10 +409,28 @@ AUTH_BLOCKS: dict[str, tuple[str, str | None]] = {
MCP_TOOLS_TEMPLATE = '''\
async def _load_mcp_tools():
"""Load MCP tools from bundled config (http/sse only)."""
"""Load MCP tools from bundled config (http/sse only).
The `url` and `headers` values support `${VAR}` references which are
expanded against `os.environ` when the deployed graph loads. This
mirrors the substitution behavior documented for `deepagents-code`'s
`.mcp.json` (https://docs.langchain.com/oss/python/deepagents/code/mcp-tools).
Unset variables are left as the literal `${VAR}` so the resulting auth
failure surfaces a recognizable token rather than an empty header.
"""
import json
import os
import re
from pathlib import Path
def _expand(value):
"""Expand `${VAR}` references in strings; pass other types through."""
if isinstance(value, str):
return os.path.expandvars(value)
return value
unresolved_re = re.compile(r"\\$\\{[^}]+\\}")
mcp_path = Path(__file__).parent / "_mcp.json"
if not mcp_path.exists():
return []
@@ -428,9 +446,23 @@ async def _load_mcp_tools():
for name, cfg in servers.items():
transport = cfg.get("type", cfg.get("transport", "stdio"))
if transport in ("http", "sse"):
conn = {"transport": transport, "url": cfg["url"]}
conn = {"transport": transport, "url": _expand(cfg["url"])}
if "headers" in cfg:
conn["headers"] = cfg["headers"]
conn["headers"] = {
k: _expand(v) for k, v in cfg["headers"].items()
}
unresolved = sorted({
match
for value in (conn["url"], *conn.get("headers", {}).values())
if isinstance(value, str)
for match in unresolved_re.findall(value)
})
if unresolved:
logger.warning(
"MCP server %r has unresolved environment reference(s): %s",
name,
", ".join(unresolved),
)
connections[name] = conn
if not connections:
@@ -441,11 +473,10 @@ async def _load_mcp_tools():
client = MultiServerMCPClient(connections)
return await client.get_tools()
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to load MCP tools from %d server(s): %s",
except Exception:
logger.exception(
"Failed to load MCP tools from %d server(s)",
len(connections),
exc,
)
return []
'''
@@ -2,7 +2,11 @@
from __future__ import annotations
import asyncio
import json
import logging
import sys
import types
from typing import TYPE_CHECKING
import pytest
@@ -207,6 +211,87 @@ class TestRenderDeployGraph:
assert "_load_mcp_tools" not in result
assert "pass # no MCP servers configured" in result
def test_mcp_loader_expands_env_vars(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Emitted loader expands `${VAR}` in url + headers before MCP connect.
Mirrors the `${VAR}` substitution behavior of `deepagents-code`'s
`.mcp.json`. Without this, headers like
`Authorization: Bearer ${TOKEN}` reach MCP servers as the literal
string and auth fails silently.
Also verifies the documented contracts: unset references are left as
literal `${VAR}` so a recognizable token surfaces, non-string header
values pass through unchanged, and unresolved tokens emit a warning.
"""
from deepagents_cli.deploy.templates import MCP_TOOLS_TEMPLATE
captured: dict[str, dict] = {}
class _FakeClient:
def __init__(self, connections: dict) -> None:
captured["connections"] = connections
async def get_tools(self) -> list:
return []
fake_root = types.ModuleType("langchain_mcp_adapters")
fake_client = types.ModuleType("langchain_mcp_adapters.client")
fake_client.MultiServerMCPClient = _FakeClient # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "langchain_mcp_adapters", fake_root)
monkeypatch.setitem(sys.modules, "langchain_mcp_adapters.client", fake_client)
(tmp_path / "_mcp.json").write_text(
json.dumps(
{
"mcpServers": {
"primary": {
"type": "http",
"url": "https://api.example.com/${ENDPOINT}",
"headers": {
"Authorization": "Bearer ${TOKEN}",
"X-Unset": "value-${MISSING_VAR}",
"X-Numeric": 42,
},
},
},
},
),
encoding="utf-8",
)
monkeypatch.setenv("ENDPOINT", "v1/chat")
monkeypatch.setenv("TOKEN", "secret-abc")
monkeypatch.delenv("MISSING_VAR", raising=False)
loader_logger = logging.getLogger("test_mcp_loader")
namespace: dict = {
"__file__": str(tmp_path / "graph.py"),
"logger": loader_logger,
}
exec(
compile(MCP_TOOLS_TEMPLATE, "<MCP_TOOLS_TEMPLATE>", "exec"),
namespace,
)
with caplog.at_level(logging.WARNING, logger="test_mcp_loader"):
asyncio.run(namespace["_load_mcp_tools"]())
conn = captured["connections"]["primary"]
assert conn["url"] == "https://api.example.com/v1/chat"
assert conn["headers"]["Authorization"] == "Bearer secret-abc"
assert conn["headers"]["X-Unset"] == "value-${MISSING_VAR}"
assert conn["headers"]["X-Numeric"] == 42
assert any(
"unresolved environment reference" in rec.getMessage()
and "${MISSING_VAR}" in rec.getMessage()
for rec in caplog.records
)
def test_no_system_prompt_in_output(self) -> None:
"""AGENTS.md should not be baked into the deploy graph as a system prompt."""
config = _minimal_config()