mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user