Skip to content

Commit 7c6676b

Browse files
committed
Enhance MCP server configuration by adding support for optional bearer tokens in README.md and example YAML files. Update schema to include bearer_token field and modify MCP client to utilize bearer tokens for authentication. Add tests for environment variable substitution of bearer tokens in configuration.
1 parent d02a084 commit 7c6676b

8 files changed

Lines changed: 189 additions & 21 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ Copy [config/agents.example.yaml](config/agents.example.yaml) as a starting poin
301301
| `mcp_servers` | List of remote MCP servers (`transport` must be `streamable_http`) |
302302
| `mcp_servers[].name` | Short name used in qualified tool names (`name.tool`) |
303303
| `mcp_servers[].url` | Streamable HTTP MCP endpoint (must end with `/mcp` for standard layouts) |
304-
| `mcp_servers[].headers` | Optional HTTP headers (for example auth tokens) |
304+
| `mcp_servers[].bearer_token` | Optional bearer token; sent as `Authorization: Bearer ...` (supports `${ENV_VAR}`) |
305+
| `mcp_servers[].headers` | Optional extra HTTP headers merged with bearer auth |
305306
| `tool_allowlist` | Optional list of `server.tool` names; omit to allow all tools from connected servers |
306307

307308
Environment variable substitution supports `${VAR}` and `${VAR:-default}`. If `VAR` is unset and no default is provided, startup fails with a clear error.
@@ -339,6 +340,12 @@ mcp_servers:
339340
- name: my_api
340341
transport: streamable_http
341342
url: https://mcp.example.com/mcp
343+
bearer_token: ${MY_MCP_TOKEN}
344+
```
345+
346+
You can also set auth manually via `headers`:
347+
348+
```yaml
342349
headers:
343350
Authorization: "Bearer ${MY_MCP_TOKEN}"
344351
```

config/agents.example.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ agents:
1515
- name: search
1616
transport: streamable_http
1717
url: http://search-mcp:8002/mcp
18+
bearer_token: ${SEARCH_MCP_TOKEN}
1819
tool_allowlist:
1920
- filesystem.read_file
2021
- search.web_search

src/sub_agent_mcp/config/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class MCPServerConfig(BaseModel):
3232
name: str = Field(min_length=1)
3333
transport: Literal["streamable_http"]
3434
url: HttpUrl
35+
bearer_token: SecretStr | None = None
3536
headers: dict[str, str] = Field(default_factory=dict)
3637

3738

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""HTTP header resolution for downstream MCP server connections."""
2+
3+
from __future__ import annotations
4+
5+
from sub_agent_mcp.config.schema import MCPServerConfig
6+
7+
8+
def resolve_mcp_server_headers(server: MCPServerConfig) -> dict[str, str] | None:
9+
"""Build request headers for a downstream MCP server, or None when no auth is configured."""
10+
headers = dict(server.headers)
11+
if server.bearer_token is not None:
12+
token = server.bearer_token.get_secret_value()
13+
if token:
14+
headers["Authorization"] = f"Bearer {token}"
15+
return headers or None

src/sub_agent_mcp/mcp_client/manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from sub_agent_mcp.agent.errors import MCPConnectionError
1010
from sub_agent_mcp.config.schema import AgentConfig
1111
from sub_agent_mcp.logging import get_logger
12+
from sub_agent_mcp.mcp_client.headers import resolve_mcp_server_headers
1213
from sub_agent_mcp.mcp_client.tool_registry import allows_all_tools, qualified_tool_name
1314

1415
if TYPE_CHECKING:
@@ -26,7 +27,11 @@ def build_client_config(agent: AgentConfig) -> dict[str, dict[str, Any]]:
2627
server.name: {
2728
"transport": "streamable_http",
2829
"url": str(server.url),
29-
"headers": server.headers,
30+
**(
31+
{"headers": headers}
32+
if (headers := resolve_mcp_server_headers(server)) is not None
33+
else {}
34+
),
3035
"timeout": timeout,
3136
}
3237
for server in agent.mcp_servers

src/sub_agent_mcp/mcp_client/tool_registry.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
from mcp import ClientSession
99
from mcp.client.streamable_http import streamable_http_client
10+
from mcp.shared._httpx_utils import create_mcp_http_client
1011

1112
from sub_agent_mcp.agent.errors import MCPConnectionError
1213
from sub_agent_mcp.config.schema import AgentConfig
1314
from sub_agent_mcp.logging import get_logger
15+
from sub_agent_mcp.mcp_client.headers import resolve_mcp_server_headers
1416

1517
logger = get_logger(__name__)
1618

@@ -86,26 +88,31 @@ async def discover_tools(agent: AgentConfig) -> list[ToolMetadata]:
8688

8789
for server in agent.mcp_servers:
8890
try:
89-
async with streamable_http_client(str(server.url)) as (read, write, _):
90-
async with ClientSession(read, write) as session:
91-
await session.initialize()
92-
result = await session.list_tools()
93-
for tool in result.tools:
94-
if not is_tool_allowed(server.name, tool.name, agent.tool_allowlist):
95-
continue
96-
97-
schema = tool.inputSchema if hasattr(tool, "inputSchema") else None
98-
if not validate_tool_schema(tool.name, tool.description, schema):
99-
continue
100-
101-
discovered.append(
102-
ToolMetadata(
103-
name=tool.name,
104-
server=server.name,
105-
description=tool.description or "",
106-
qualified_name=qualified_tool_name(server.name, tool.name),
91+
headers = resolve_mcp_server_headers(server)
92+
async with create_mcp_http_client(headers=headers) as http_client:
93+
async with streamable_http_client(
94+
str(server.url),
95+
http_client=http_client,
96+
) as (read, write, _):
97+
async with ClientSession(read, write) as session:
98+
await session.initialize()
99+
result = await session.list_tools()
100+
for tool in result.tools:
101+
if not is_tool_allowed(server.name, tool.name, agent.tool_allowlist):
102+
continue
103+
104+
schema = tool.inputSchema if hasattr(tool, "inputSchema") else None
105+
if not validate_tool_schema(tool.name, tool.description, schema):
106+
continue
107+
108+
discovered.append(
109+
ToolMetadata(
110+
name=tool.name,
111+
server=server.name,
112+
description=tool.description or "",
113+
qualified_name=qualified_tool_name(server.name, tool.name),
114+
)
107115
)
108-
)
109116
except Exception as exc:
110117
logger.error(
111118
"mcp_server_unreachable",

tests/test_config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,27 @@ def test_invalid_agent_id(tmp_path: Path) -> None:
132132
load_agents_config(config_file)
133133

134134

135+
def test_load_config_with_bearer_token_env_substitution(
136+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
137+
) -> None:
138+
config_file = tmp_path / "agents.yaml"
139+
config_file.write_text(
140+
VALID_YAML.replace(
141+
"url: http://filesystem-mcp:8001/mcp",
142+
"url: http://filesystem-mcp:8001/mcp\n bearer_token: ${MCP_TOKEN}",
143+
),
144+
encoding="utf-8",
145+
)
146+
monkeypatch.setenv("MCP_TOKEN", "downstream-secret")
147+
148+
config = load_agents_config(config_file)
149+
150+
assert (
151+
config.agents[0].mcp_servers[0].bearer_token.get_secret_value() # type: ignore[union-attr]
152+
== "downstream-secret"
153+
)
154+
155+
135156
def test_load_config_without_tool_allowlist(tmp_path: Path) -> None:
136157
"""Omitting tool_allowlist from YAML allows all tools."""
137158
yaml_without_allowlist = """

tests/test_mcp_headers.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Tests for downstream MCP server header resolution."""
2+
3+
from __future__ import annotations
4+
5+
from pydantic import SecretStr
6+
7+
from sub_agent_mcp.config.schema import AgentConfig, LLMConfig, MCPServerConfig
8+
from sub_agent_mcp.mcp_client.headers import resolve_mcp_server_headers
9+
from sub_agent_mcp.mcp_client.manager import build_client_config
10+
11+
12+
def test_resolve_mcp_server_headers_bearer_token() -> None:
13+
server = MCPServerConfig(
14+
name="api",
15+
transport="streamable_http",
16+
url="https://mcp.example.com/mcp",
17+
bearer_token=SecretStr("secret-token"),
18+
)
19+
20+
headers = resolve_mcp_server_headers(server)
21+
22+
assert headers == {"Authorization": "Bearer secret-token"}
23+
24+
25+
def test_resolve_mcp_server_headers_merges_custom_headers() -> None:
26+
server = MCPServerConfig(
27+
name="api",
28+
transport="streamable_http",
29+
url="https://mcp.example.com/mcp",
30+
bearer_token=SecretStr("secret-token"),
31+
headers={"X-Custom": "value"},
32+
)
33+
34+
headers = resolve_mcp_server_headers(server)
35+
36+
assert headers == {
37+
"Authorization": "Bearer secret-token",
38+
"X-Custom": "value",
39+
}
40+
41+
42+
def test_resolve_mcp_server_headers_without_bearer_token() -> None:
43+
server = MCPServerConfig(
44+
name="api",
45+
transport="streamable_http",
46+
url="https://mcp.example.com/mcp",
47+
)
48+
49+
assert resolve_mcp_server_headers(server) is None
50+
51+
52+
def test_resolve_mcp_server_headers_empty_bearer_token() -> None:
53+
server = MCPServerConfig(
54+
name="api",
55+
transport="streamable_http",
56+
url="https://mcp.example.com/mcp",
57+
bearer_token=SecretStr(""),
58+
)
59+
60+
assert resolve_mcp_server_headers(server) is None
61+
62+
63+
def test_build_client_config_omits_headers_without_bearer_token() -> None:
64+
agent = AgentConfig(
65+
id="researcher",
66+
title="Research Agent",
67+
description="Test agent",
68+
llm=LLMConfig(
69+
base_uri="https://api.openai.com/v1",
70+
api_key=SecretStr("llm-key"),
71+
model_id="gpt-4.1-mini",
72+
),
73+
system_prompt="You are helpful.",
74+
mcp_servers=[
75+
MCPServerConfig(
76+
name="api",
77+
transport="streamable_http",
78+
url="https://mcp.example.com/mcp",
79+
)
80+
],
81+
)
82+
83+
config = build_client_config(agent)
84+
85+
assert "headers" not in config["api"]
86+
87+
88+
def test_build_client_config_includes_bearer_token() -> None:
89+
agent = AgentConfig(
90+
id="researcher",
91+
title="Research Agent",
92+
description="Test agent",
93+
llm=LLMConfig(
94+
base_uri="https://api.openai.com/v1",
95+
api_key=SecretStr("llm-key"),
96+
model_id="gpt-4.1-mini",
97+
),
98+
system_prompt="You are helpful.",
99+
mcp_servers=[
100+
MCPServerConfig(
101+
name="api",
102+
transport="streamable_http",
103+
url="https://mcp.example.com/mcp",
104+
bearer_token=SecretStr("mcp-token"),
105+
)
106+
],
107+
)
108+
109+
config = build_client_config(agent)
110+
111+
assert config["api"]["headers"] == {"Authorization": "Bearer mcp-token"}

0 commit comments

Comments
 (0)