Skip to content

Commit 3c34860

Browse files
agents cli: load remote Streamable HTTP MCP servers from mcp_servers (#1795)
1 parent ebeef4f commit 3c34860

36 files changed

Lines changed: 4777 additions & 43 deletions

‎agents/cli/README.md‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ claude: {}
6060
| `dex.tls.ca_file`, `dex.tls.server_name` | none | An `https` `dex.server` only. A PEM file of root CAs to trust as well, and the name the certificate must match. `DEX_TLS_CA_FILE` and `DEX_TLS_SERVER_NAME` override them. |
6161
| `connectors.mcp` | `auto` | The connectors MCP server. `auto` uses the one `dexcli dev` provides. Otherwise an `http` or `https` URL on `127.0.0.1`, `localhost`, or `[::1]`. See URLs below. |
6262
| `connectors.enabled` | every authorized connection | Which connections, and which of their operations, the agent may call. See below. |
63+
| `mcp_servers` | `{}` | Remote MCP servers whose tools the agent may call, by name, over Streamable HTTP. See Remote MCP servers below. |
6364
| `approval.always` | `[]` | Tool names, as the agent sees them, whose every call waits for approval. This adds to what the connectors and local tools already require. |
6465
| `workspace` | `~/dex-agent-workspace`; for `dex-agents-pi`, the directory the command starts in | The only directory the local file tools use, and the working directory of shell commands. |
6566
| `local_tools.files` | `true` | Read, write, list, and search files in `workspace`. Reads run at once; writes wait for approval. |
@@ -85,7 +86,8 @@ boundary. As in Pi, they run without approval unless `approval.always` names
8586
them; see [its README](typescript/pi/README.md#tools-and-approvals).
8687

8788
Do not put secrets in the file. Provider keys come from the environment.
88-
Connector credentials stay in the connections you authorize in Dex Web. The
89+
Connector credentials stay in the connections you authorize in Dex Web.
90+
Remote MCP server headers take their secrets from environment variables. The
8991
schema rejects user info in URLs, but it cannot check the harness sections,
9092
which each harness command defines.
9193

@@ -173,6 +175,66 @@ letter or a digit, and an acronym stays one word, so `listStoryIDs` is
173175
Two entries must not cover the same connection, so `slack` and `slack/work`
174176
cannot appear together. An empty list gives the agent no connector tools.
175177

178+
### Remote MCP servers
179+
180+
`mcp_servers` adds the tools of MCP servers that are not Dex connectors, such
181+
as a team's own tool server. Each key is the server's name, and each server
182+
speaks the Streamable HTTP transport. Stateless servers work: every request
183+
opens its own connection and protocol session.
184+
185+
```yaml
186+
mcp_servers:
187+
studio:
188+
url: https://mcp.example.com/mcp
189+
headers:
190+
Authorization: "Bearer ${STUDIO_API_KEY}"
191+
local-notes:
192+
url: http://127.0.0.1:9000/mcp
193+
trust_tool_annotations: true
194+
```
195+
196+
| Field | Default | Meaning |
197+
| --- | --- | --- |
198+
| name (the key) | | 1 to 32 ASCII letters, digits, `_`, or `-`, starting with a letter. |
199+
| `url` | required | The server's endpoint: `https`, or `http` on `localhost`, `127.0.0.0/8`, or `[::1]` (`insecure_mcp_server_url`). No user info or fragment. It follows the URL rules of `dex.server` and is used as written. |
200+
| `headers` | `{}` | HTTP headers sent with every request to the server. |
201+
| `trust_tool_annotations` | `false` | Whether the server's `readOnlyHint` and `idempotentHint` annotations, and the effects it declares under the Dex MCP conventions, pick each tool's effect. |
202+
203+
The agent sees each tool as `<server name>__<tool name>`, such as
204+
`studio__search_assets`, and `approval.always` names it that way. With
205+
`trust_tool_annotations: false`, every call waits for approval and runs at
206+
most once. With `true`, a tool the server marks read-only or idempotent runs
207+
without approval, unless `approval.always` names it, and may be retried; set
208+
it only for a server whose hints you trust. `dex-agents-pi` and
209+
`dex-agents-deepagents` still make every call that is not a read wait for
210+
approval, as they do for connector tools.
211+
212+
Secrets belong in environment variables, never in the file. In a header
213+
value, each `${NAME}` is replaced by the value of the environment variable
214+
`NAME` when the command starts. `NAME` has ASCII letters, digits, and `_`, and
215+
does not start with a digit. Any other `$` stays as written. The value is used
216+
exactly as set, never trimmed or expanded again. `DEX_API_KEY`, `DEX_CREDENTIAL`, and `DEX_CREDENTIAL_FILE` count as not set,
217+
so a Dex credential never reaches another server. A variable that is not set
218+
or is empty stops the command with exit code 2 and a message that names it,
219+
such as
220+
`mcp_servers.studio.headers.Authorization needs the environment variable STUDIO_API_KEY, which is not set or is empty.`
221+
A value outside printable ASCII stops it the same way. A header value may
222+
also be literal text, but a literal secret then sits in the file: use a
223+
`${NAME}` reference for every secret. Only the Worker expands the values. The command never prints, logs, or stores them:
224+
the effective configuration, the session, and the log file keep the text as
225+
written. Error text that a server sends back, such as an HTTP 401 body or a
226+
tool error, is redacted on a best-effort basis: each header value, each of its
227+
parts between spaces, `=`, `:`, `,`, or `;`, and each variable value of at
228+
least 8 characters becomes `[redacted]` in the log file, the terminal, tool
229+
output, and errors. Other content the server returns, such as progress and
230+
task status messages and tool descriptions, is not covered, so give a token
231+
only to servers you trust with it. A Worker that runs a stored call checks it against its own
232+
configuration: for a server whose annotations it does not trust, the call must
233+
have had approval and a single-attempt effect, or it fails unsent.
234+
235+
A server that cannot be listed offers no tools until it answers again; the
236+
log file has the error. Session settings cannot change `mcp_servers`.
237+
176238
### Images
177239

178240
In the chat, `/image <path>` attaches an image file to your next message. A
@@ -242,7 +304,7 @@ session in Dex Web can edit its settings.
242304

243305
Other top-level keys are ignored, so the settings can also hold keys that
244306
other readers use. This includes `dex`, `workspace`, `system_prompt_file`,
245-
`media`, and the harness sections, which belong to the file only. The command reports each
307+
`media`, `mcp_servers`, and the harness sections, which belong to the file only. The command reports each
246308
ignored key as a warning, but the turn still runs. Inside a known field, an
247309
unknown key is an error, so `connectors.mcp` fails.
248310

@@ -257,7 +319,9 @@ cases every CLI core runs: YAML parsing, valid and invalid files with the
257319
expected effective configuration or error, session settings with the
258320
expected turn configuration and warnings, or error, and, in `dex_connection`,
259321
files and environments with the expected Dex connection settings, or the
260-
setting to fix and its exact message. Its `error_codes`,
322+
setting to fix and its exact message, and, in `mcp_servers`, files and
323+
environments with the expected remote MCP servers and their expanded headers,
324+
or the setting to fix and its exact message. Its `error_codes`,
261325
`warnings`, and `check_order` fields define how errors and warnings are
262326
reported.
263327

‎agents/cli/config.schema.json‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,17 @@
222222
}
223223
}
224224
},
225+
"mcp_servers": {
226+
"type": "object",
227+
"default": {},
228+
"description": "Remote MCP servers whose tools the agent may call over the Streamable HTTP transport, by server name: 1 to 32 ASCII letters, digits, underscores, or hyphens, starting with a letter. The agent sees each tool as <server name>__<tool name>, and approval.always names it that way. Every call waits for approval unless trust_tool_annotations lets the server's hints mark the tool a read or an idempotent write. Keep secrets out of this file: a header value can take them from environment variables with ${NAME} references, which the Worker expands when the command starts. Session settings cannot change it: they ignore an mcp_servers key with a warning.",
229+
"patternProperties": {
230+
"^[A-Za-z][A-Za-z0-9_-]{0,31}(?![\\s\\S])": {
231+
"$ref": "#/$defs/mcp_server"
232+
}
233+
},
234+
"additionalProperties": false
235+
},
225236
"approval": {
226237
"type": "object",
227238
"additionalProperties": false,
@@ -475,6 +486,39 @@
475486
"$ref": "#/$defs/connector_entry"
476487
}
477488
},
489+
"mcp_server": {
490+
"type": "object",
491+
"additionalProperties": false,
492+
"required": [
493+
"url"
494+
],
495+
"description": "One remote MCP server.",
496+
"properties": {
497+
"url": {
498+
"type": "string",
499+
"pattern": "^https?://(?:(?:[A-Za-z0-9-]+\\.)*[A-Za-z][A-Za-z0-9-]*|[0-9]{1,3}(?:\\.[0-9]{1,3}){3}|\\[[0-9A-Fa-f:.]+\\])(?::[0-9]{1,5})?(?:/[!-\"$-~]*)?(?![\\s\\S])",
500+
"description": "The server's Streamable HTTP endpoint: an https URL, or an http URL on localhost, 127.0.0.0/8, or [::1], without user info or a fragment. The host rules and the CLI core checks are those of dex.server. The URL is used as written, never expanded, so it must not hold a secret."
501+
},
502+
"headers": {
503+
"type": "object",
504+
"default": {},
505+
"description": "HTTP headers sent with every request to the server, such as Authorization, by header name: 1 to 256 HTTP token characters. A value is printable ASCII. Each ${NAME} in it is replaced by the value of the environment variable NAME when the command starts; NAME has ASCII letters, digits, and underscores and does not start with a digit. A variable that is not set, is empty, or holds anything but printable ASCII stops the command. Any other $ stays as written. A value may be literal; put each secret in a ${NAME} reference instead. The command never prints, logs, or stores the expanded values.",
506+
"patternProperties": {
507+
"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}(?![\\s\\S])": {
508+
"type": "string",
509+
"pattern": "^(?:[ -#%-~]|\\$(?!\\{)|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})*(?![\\s\\S])",
510+
"description": "A header value: printable ASCII, where ${ always starts a ${NAME} reference."
511+
}
512+
},
513+
"additionalProperties": false
514+
},
515+
"trust_tool_annotations": {
516+
"type": "boolean",
517+
"default": false,
518+
"description": "Whether the server's readOnlyHint and idempotentHint annotations, and the effects it declares by the Dex MCP conventions, pick each tool's effect. false: every call of the server's tools waits for approval and runs at most once. true: a tool the server marks read-only or idempotent runs without approval, unless approval.always names it, and may be retried. Set it only for a server whose hints you trust."
519+
}
520+
}
521+
},
478522
"shell_commands": {
479523
"type": "array",
480524
"uniqueItems": true,

‎agents/cli/python/core/README.md‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,35 @@ read, and caches them for the harness:
438438
Connectors page, `<Dex Web URL>/v1/connectors`, and opens it only in an
439439
interactive terminal.
440440

441+
## Remote MCP server tools
442+
443+
`McpServersToolSource` offers the tools of the configuration's
444+
`mcp_servers` through `McpToolSource`, over Streamable HTTP. The agent sees
445+
each tool as `<server name>__<tool name>`. Its `source_name` is
446+
`mcp_servers`, and the command registers it even without servers, so it
447+
refuses a stored call of a server this Worker does not configure.
448+
449+
- `resolve_mcp_servers(config, os.environ)` expands each `${NAME}` in a
450+
header value when the command starts. A variable that is not set, is
451+
empty, or is not printable ASCII raises `McpServerSettingsError`, whose
452+
message names the setting and the variable, never the value; the command
453+
prints it and exits with code 2. The Worker host does the same.
454+
- Each call adds the turn's `approval.always`. The tool Step checks it again
455+
on the Worker that runs it and refuses a call that had no approval but now
456+
needs one. For a server whose annotations that Worker does not trust, it
457+
also refuses, unsent, a call without approval or with a retried effect.
458+
- The source replaces each header value, each part of it between whitespace,
459+
`=`, `:`, `,`, or `;`, and each value of `mcp_server_secret_texts`, of at
460+
least 8 characters, with `[redacted]` in errors, tool output, and the log
461+
file. This is best-effort for error text only: progress and task status
462+
messages and tool descriptions are not redacted. The log filter covers the
463+
root logger's handlers that exist when the Worker connects the servers.
464+
- A listing that no server answers offers no remote tools and prints one
465+
line, `MCP server tools are unavailable for now; each turn tries again.
466+
See <log file> for the error.`, until a server answers again. The error
467+
goes to the log file. The command lists every server once at startup, so a
468+
harness whose tool set is fixed at build gets the tools that answered then.
469+
441470
## Local tools
442471

443472
The local tool source offers these tools in their own Dex tool Steps. Paths
@@ -646,7 +675,7 @@ harness may replace only `FILES` and `SHELL`.
646675
| `workspace: Path` | The absolute workspace, which exists. |
647676
| `instructions: str \| None` | `system_prompt`, else the text of `system_prompt_file`, else `None` for the harness default. |
648677
| `harness_options: Mapping[str, Any]` | The harness's own section, `{}` by default. |
649-
| `tool_sources: Sequence[ToolSource]` | The connector and local tool sources, which apply each turn's settings themselves. Offer all of them. |
678+
| `tool_sources: Sequence[ToolSource]` | The connector, remote MCP server, and local tool sources, which apply each turn's settings themselves. Offer all of them. |
650679
| `media_store: MediaStore \| None` | The media store of `media.store`, holding the images of `/image` and of connector tool results. Pass it to an adapter that takes one, such as `media_store=context.media_store`. The CLI core always sets it; it is `None` only in a context a test builds without one. |
651680
| `resolve_turn(session_settings) -> ResolvedTurn` | A turn's narrowed configuration with its `model`, `instructions`, and `reasoning_effort`. Raises `ConfigError` for rejected settings. |
652681
| `resolve_tool_step_permissions(call=None) -> ResolvedTurn` | The permissions a replacement tool's own Step applies: this Worker's file narrowed by the session settings recorded for the call's turn. Pass the `DeferredToolExecution` from a runner, or nothing from a built-in loop function tool. Raises `RuntimeError` without a call outside a tool Step and a model call. |

‎agents/cli/python/core/src/dex_agents_cli_core/__init__.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ConnectorEntry,
2828
EffectiveConfig,
2929
MediaConfig,
30+
RemoteMcpServerConfig,
3031
SessionsConfig,
3132
TurnConfigResolution,
3233
canonical_operation_name,
@@ -54,6 +55,13 @@
5455
session_flow_type,
5556
)
5657
from dex_agents_cli_core.local_tools import LOCAL_TOOL_SOURCE_NAME, LocalToolSource
58+
from dex_agents_cli_core.mcp_servers import (
59+
MCP_SERVERS_TOOL_SOURCE_NAME,
60+
McpServerSettingsError,
61+
McpServersToolSource,
62+
mcp_server_secret_texts,
63+
resolve_mcp_servers,
64+
)
5765
from dex_agents_cli_core.tool_policy import (
5866
is_local_tool_group_enabled,
5967
is_shell_command_approval_required,
@@ -71,6 +79,7 @@
7179
"CONNECTORS_TOOL_SOURCE_NAME",
7280
"CONNECTOR_TOOL_META_KEYS",
7381
"LOCAL_TOOL_SOURCE_NAME",
82+
"MCP_SERVERS_TOOL_SOURCE_NAME",
7483
"SESSION_ACTION_PERMISSION",
7584
"AgentHarness",
7685
"ConfigError",
@@ -81,7 +90,10 @@
8190
"HarnessContext",
8291
"LocalToolGroup",
8392
"LocalToolSource",
93+
"McpServerSettingsError",
94+
"McpServersToolSource",
8495
"MediaConfig",
96+
"RemoteMcpServerConfig",
8597
"ResolvedTurn",
8698
"SessionEnvironmentIdentity",
8799
"SessionsConfig",
@@ -96,9 +108,11 @@
96108
"is_session_summary_indexed",
97109
"is_shell_command_allowed",
98110
"is_shell_command_approval_required",
111+
"mcp_server_secret_texts",
99112
"namespace_session_flow_type",
100113
"parse_config_yaml",
101114
"resolve_config",
115+
"resolve_mcp_servers",
102116
"resolve_turn_config",
103117
"resolve_turn_tool_policy",
104118
"run_agent_cli",

‎agents/cli/python/core/src/dex_agents_cli_core/cli.py‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
sort_agent_sessions_by_last_activity,
5858
)
5959
from dex_agents.builtin_loop import ToolSource
60+
from dex_agents.mcp import McpStreamableHttpServerConfig
6061
from dex_agents_cli_core.chat import Terminal, TerminalChat
6162
from dex_agents_cli_core.config import (
6263
AUTO,
@@ -125,6 +126,12 @@
125126
)
126127
from dex_agents_cli_core.local_tools import LocalToolSource
127128
from dex_agents_cli_core.local_tools.shell import ShellProcessGroups
129+
from dex_agents_cli_core.mcp_servers import (
130+
McpServerSettingsError,
131+
McpServersToolSource,
132+
mcp_server_secret_texts,
133+
resolve_mcp_servers,
134+
)
128135
from dex_agents_cli_core.session_lease import (
129136
HeldSessionLease,
130137
SessionLeaseHolder,
@@ -865,6 +872,8 @@ class _RunInputs:
865872
workspace: Path
866873
resolver: TurnConfigResolver
867874
connection: DexConnectionSettings
875+
mcp_servers: tuple[McpStreamableHttpServerConfig, ...] = ()
876+
mcp_server_secret_texts: frozenset[str] = frozenset()
868877

869878

870879
def _resolve_connection(
@@ -920,7 +929,14 @@ def _prepare_run(
920929
report_ignored_settings,
921930
supported_reasoning_efforts=declared_reasoning_efforts(harness),
922931
)
923-
return _RunInputs(config, media_store, workspace, resolver, connection)
932+
try:
933+
mcp_servers = resolve_mcp_servers(config, os.environ)
934+
secret_texts = mcp_server_secret_texts(config, os.environ)
935+
except McpServerSettingsError as error:
936+
raise CommandError(error.message) from error
937+
return _RunInputs(
938+
config, media_store, workspace, resolver, connection, mcp_servers, secret_texts
939+
)
924940

925941

926942
async def _run_plain_chat(
@@ -1091,8 +1107,9 @@ async def start(
10911107
local_tools = _local_tool_source(
10921108
config, inputs.workspace, harness, inputs.resolver, process_groups
10931109
)
1110+
mcp_servers = await _connect_mcp_servers(inputs, reports)
10941111
tool_sources: list[ToolSource] = [
1095-
source for source in (connectors, local_tools) if source is not None
1112+
source for source in (connectors, mcp_servers, local_tools) if source is not None
10961113
]
10971114
identity = current_session_environment_identity(loaded.path, inputs.workspace)
10981115
flow_type = namespace_session_flow_type(harness.flow_type, identity)
@@ -1343,6 +1360,35 @@ def describe_unavailable(error: ConnectorsMcpUnavailableError) -> str:
13431360
return source
13441361

13451362

1363+
async def _connect_mcp_servers(
1364+
inputs: _RunInputs, reports: _SessionWorkerReports
1365+
) -> McpServersToolSource:
1366+
"""Return the remote MCP server source, listed once so fixed tool sets get its tools.
1367+
1368+
It is returned even without servers, so it refuses stored calls of servers
1369+
another Worker has.
1370+
"""
1371+
log_path = reports.log_path
1372+
unavailable_line = "MCP server tools are unavailable for now; each turn tries again."
1373+
if log_path is not None:
1374+
unavailable_line = f"{unavailable_line} See {log_path} for the error."
1375+
source = McpServersToolSource(
1376+
inputs.mcp_servers,
1377+
inputs.resolver,
1378+
report=reports.report_runtime,
1379+
report_at_startup=reports.report,
1380+
unavailable_line=unavailable_line,
1381+
media_store=inputs.media_store,
1382+
secret_texts=inputs.mcp_server_secret_texts,
1383+
)
1384+
if inputs.mcp_servers:
1385+
# Every record of this process may quote a server's error, which may echo a header.
1386+
for handler in logging.getLogger().handlers:
1387+
handler.addFilter(source.secret_redacting_log_filter)
1388+
await source.list_file_enabled_tools()
1389+
return source
1390+
1391+
13461392
def _connectors_unavailable_hint(log_path: Path | None, web_url: str | None) -> str:
13471393
"""Say where the error and the connections are, for a line about an unavailable server."""
13481394
page = f"{web_url}{CONNECTORS_WEB_PATH}" if web_url else "the Dex Web Connectors page"

0 commit comments

Comments
 (0)