Tooling, MCP, and tool-agents¶
milk gives any agent — primary or escalation, on any provider — three ways to act beyond generating text: built-in tools, other configured agents exposed as callable tools, and MCP servers.
Built-in tools¶
The primary agent (HTTP or Bedrock backends) has these tools available with no configuration:
| Tool | Parameters | Returns |
|---|---|---|
bash(command) |
command string |
stdout, stderr, exit_code |
find_files(path, pattern) |
root dir + glob (e.g. *_test.go) |
matching file paths — locate by filename; use grep for contents |
grep(pattern, path, recursive) |
pattern/path/bool | matches — searches file contents; use find_files for filenames |
read_file(path, offset, limit) |
path + optional range | content |
write_file(path, content) |
— | ok — creates parent directories; expands ~ |
edit_file(path, old_string, new_string, replace_all) |
— | ok — exact-string replacement; rejects ambiguous matches unless replace_all=true |
delete_file(path) |
— | ok — permission-gated |
move_file(source, destination) |
— | ok — permission-gated; creates destination parent directories |
list_dir(path) |
— | entries (names, types, sizes) |
http_get(url, max_bytes) |
— | body — bounded GET |
http_request(method, url, headers, body, max_bytes) |
— | body, status — permission-gated |
get_session_context() |
— | full shared session history (both agents), so the primary model can see prior escalation turns |
get_context_stats() |
— | current history turn counts and total character size, so the agent can self-regulate before hitting context limits |
open_file(path) |
— | ok — opens the file in the configured editor |
current_need(goal) |
one-sentence goal | ok — same effect as the user typing /need <goal> |
export_session(format, output_path) |
"text"|"json", optional file path |
transcript inline, or written to output_path |
milk_config_help(topic) |
e.g. "mcp add", "agent add" |
a section of milk's own embedded reference docs — lets the agent look up how to manage milk's own config instead of guessing the schema; omit topic to list what's available. Backed by internal/selfdocs, which indexes docs/spec.md/providers.md/workflows.md/tooling.md/operations.md by heading — same content this site is built from |
Self-escalation (escalate(reason)) is covered in docs/workflows.md. Memory tools (get_memory, list_memory, forget_memory) and task tools (create_task, update_task, list_tasks, complete_task) are covered in docs/operations.md.
Restrict or extend the set per agent with limits.included_tools / limits.excluded_tools — see docs/providers.md — Per-agent limit overrides.
Claude CLI and subprocess (aider, smolagents) agents use their own native tool sets instead of this list.
Agent-as-Tool¶
Any agent in the agents list can be exposed as a callable tool to any other agent via the global agent_tools list (or per-agent tools overrides). When enabled, milk synthesises an OpenAI function-schema for each peer agent and injects it alongside the built-in tools; the calling agent invokes a peer by name as it would any other tool call. The peer agent receives the caller's prompt and returns a text result fed back as a tool result — no session state is shared between peer calls; each call is a stateless, fresh first-turn for the peer.
agent_tools field¶
"agent_tools": [
{ "agent": "haiku-aws", "description": "Fast summarization and classification agent." },
{ "agent": "claude", "description": "Full-capability Claude Code escalation agent.", "enabled": false }
]
| Field | Type | Description |
|---|---|---|
agent |
string | Name of the agent to expose as a tool (must match a name in agents) |
description |
string | Description shown to the calling agent as the tool's purpose |
enabled |
bool | Whether the tool is active (default true when omitted) |
An agent entry's own tools field shadows or extends this global list for that agent specifically (same agent name = replace; new name = append). A cycle guard prevents an agent from calling itself as a tool. Unknown agent names are silently dropped.
Manage at runtime with /agent tool list|enable|disable|add|remove — see docs/spec.md — CLI Interface.
Worked example: real config using aider as a tool-agent¶
From a live setup where a Bearer-auth enterprise Copilot proxy is the primary agent, with aider available as a tool for direct file edits:
{
"agents": [
{
"name": "copilot-enterprise",
"provider": "bearer",
"url": "https://copilot-api.your-enterprise-ghe.example.com",
"model": "claude-sonnet-4.6",
"token_cmd": "gh auth token --hostname your-enterprise-ghe.example.com",
"tools": [
{
"agent": "aider",
"description": "aider is a coding agent that directly reads source code files and applies the requested changes"
}
]
},
{
"name": "aider",
"provider": "aider-cli",
"model": "openai/claude-sonnet-4.6",
"url": "https://copilot-api.your-enterprise-ghe.example.com",
"token_cmd": "gh auth token --hostname your-enterprise-ghe.example.com"
}
]
}
claude-cli as a tool-agent¶
A claude-cli agent can be a tool-agent too — called inline during another agent's tool loop, running the Claude CLI subprocess headlessly for each invocation.
Requirement: dangerously_skip_permissions: true is mandatory. Tool-agent calls have no interactive permission back-channel — Claude Code's --permission-prompt-tool stdio mode cannot be used headlessly. Without this flag, milk returns an error rather than risk a silent hang.
{
"agents": [
{
"name": "local",
"url": "http://localhost:8080",
"model": "qwen2.5-coder",
"tools": [
{ "agent": "claude-tool", "description": "Call Claude Code for complex reasoning or code generation tasks" }
]
},
{
"name": "claude-tool",
"provider": "claude-cli",
"bin": "claude",
"dangerously_skip_permissions": true
}
]
}
Limitations: no permission prompts (all tool uses auto-approved); each call is stateless (no session history); the tool-agent does not see the calling agent's session history. For testing without a live claude binary, point bin at a milk-mock claude wrapper — see docs/mock-setup.md.
MCP servers¶
milk connects to Model Context Protocol servers and makes their tools available to any agent assigned to them.
mcp_servers field¶
Global list of servers; reference them from an agent entry via "mcp_servers": ["my-server"].
"mcp_servers": [
{ "name": "internal-docs", "url": "https://mcp.your-internal-host.example.com/api/v1/mcp", "auth": "none", "timeout": "30s", "connect_timeout": "5s" },
{ "name": "cloudflare", "url": "https://mcp.cloudflare.com/mcp", "auth": "oauth" },
{ "name": "atlassian", "url": "https://your-atlassian-mcp.example.com/mcp", "auth": "token_cmd", "token_cmd": "your-token-fetch-command" },
{ "name": "local-tool", "url": "http://localhost:3333/mcp", "enabled": true }
]
| Field | Type | Description |
|---|---|---|
name |
string | Unique identifier referenced from AgentConfig.mcp_servers |
url |
string | MCP endpoint. Required for http transport (Streamable HTTP with SSE fallback) |
transport |
string | "http" (default) or "stdio" (launches a subprocess, communicates over stdin/stdout) |
command |
string | Executable path. Required when transport is "stdio" |
args |
string[] | Command-line arguments for the stdio subprocess |
auth |
string | "none" (default), "oauth", or "token_cmd" |
token_cmd |
string | Shell command whose stdout is the Bearer token (auth: "token_cmd") |
enabled |
bool | Whether the server is active (default true when omitted) |
mcp_connect_timeout_secs (default 5) bounds the per-server startup connect timeout. If a server doesn't respond in time, milk logs a warning and continues — the server's tools are still registered, and the client reconnects lazily on the first tool call targeting it (recorded as an mcp.lazy_reconnect span).
Manage at runtime with /mcp, or milk config mcp add|remove|assign|unassign.
MCP servers with OAuth¶
milk has native OAuth 2.0 support — it discovers the server's OAuth metadata, registers itself as a client, and runs the Authorization Code + PKCE flow itself. No manual app registration for spec-compliant servers.
The resolved token is shared across every agent, not just the primary agent: if a server is assigned to the escalation (claude-cli) agent's mcp_servers too, the same token is forwarded into the --mcp-config file generated for that turn. One /mcp auth <server> covers every agent using it.
{ "name": "my-server", "url": "https://mcp.example.com", "auth": "oauth" }
For servers that require a pre-registered app instead of dynamic client registration (RFC 7591):
{
"name": "my-server",
"url": "https://mcp.example.com",
"auth": "oauth",
"oauth_client_id": "...",
"oauth_client_secret": "...",
"oauth_scopes": ["read", "write"]
}
oauth_auth_timeout (default "5m") bounds how long /mcp auth waits for you to complete the browser flow.
Authorizing: the first time an agent tries to use the server, milk detects the OAuth error and prints a notice pointing at /mcp auth <server-name>. Run it — the TUI stays interactive while milk discovers the server's OAuth endpoints, registers a client if needed, starts a local callback listener, and prints the authorization URL:
[milk] starting OAuth flow for MCP server "my-server"
[milk] authorization URL: https://auth.example.com/authorize?...
[milk] opening your browser — if it doesn't open, paste the URL above into one
milk tries to open your browser automatically; over SSH, open the printed URL yourself. On success, reconnect with /mcp reconnect my-server.
Notes:
- Tokens are stored by milk at ~/.milk/mcp_oauth/<server-name>.json — not by the Claude CLI or any external tool.
- Refresh is automatic (proactive before expiry, reactive on 401) using the stored refresh token. The escalation-agent path resolves the token fresh on every turn instead, since it has no persistent connection to retry.
- Locking is per-process — two milk instances against the same OAuth-protected server concurrently aren't coordinated beyond what the disk-backed token file naturally serializes.
- "auth": "token_cmd" servers follow the same cross-agent sharing: resolved once, forwarded to both the primary and escalation agents.
How each agent type connects¶
claude-cliagents: milk translates applicablemcp_serversentries into a JSON file passed via--mcp-config— HTTP servers become{"type":"http","url":"...","headers":{...}}entries, stdio servers become{"type":"stdio","command":"...","args":[...]}. Theclaudesubprocess connects to each server directly; milk does not proxy the connection.aider-cliandsubprocessagents (aider, smolagents): these do not receive a generated MCP config file. Instead, MCP tool schemas are serialised into a text block and injected into the agent's context alongside built-in tool descriptions — informational only, not a wired function-calling path.aiderhas no native MCP client upstream.smolagentsdoes have one (MCPClient/ToolCollection.from_mcp) that milk's adapter script does not yet use — a possible follow-up, not yet scheduled.
Observability¶
The MCP client emits to ~/.milk/otel/:
| Signal | Type | Description |
|---|---|---|
mcp.connect |
span | One per server per connect attempt; status is ok or error |
mcp.tool_call |
span | One per tool invocation; server, tool, status attributes |
mcp.lazy_reconnect |
span | Emitted on a deferred reconnect triggered by first use |
mcp.connect_failures |
counter | Failed connects or lazy-reconnect failures |
mcp.tool_calls |
counter | Total tool calls dispatched through the MCP client |
Concurrent tool dispatch¶
When an agent emits a batch of tool calls in a single turn, all tools in the batch run concurrently, each in its own goroutine:
- Cancellation propagates immediately — Ctrl-C or
/stopcancels every in-flight tool goroutine; none wait for the turn timeout. - Per-tool timeout — each tool gets its own timeout; a hung tool is cancelled without affecting the rest of the batch.
- Result order preserved — results are appended to history in original call order, regardless of finish order.
- Permission checks are synchronous — run before dispatch so the TUI presents prompts in order without interleaving.
{
"agents": [
{
"name": "local",
"url": "http://localhost:8080",
"model": "qwen2.5-coder",
"limits": { "tool_timeout_secs": 30 }
}
]
}
| Field | Location | Default | Description |
|---|---|---|---|
tool_timeout_secs |
agents[*].limits |
120 | Per-tool timeout in seconds. -1 for no limit. |
turn_timeout_secs |
agents[*].limits |
600 | Per-turn timeout (unaffected by concurrent dispatch). |
File and image attachments¶
Attach files and images to an agent turn via /attach or by pasting a file path.
/attach <path>¶
/attach /path/to/notes.txt
/attach ~/screenshots/error.png
- Text files — contents injected as a fenced code block prepended to your prompt; both local and CLI agents receive the full contents.
- Images — base64-encoded and sent as a multipart vision payload to vision-capable local models (OpenAI
image_urlformat); for the Claude CLI path, a data URI is injected as a context block. - PDF and other binary files — noted as binary with a byte count; text extraction is not performed.
The status bar shows [N attached] while pending. Attachments clear after the turn completes.
File path detection in paste¶
Pasting text that looks like an existing absolute path (starting with / or ~/) prompts:
[milk] pasted path "/etc/hostname" — attach as file? [y/N]
y/Enter attaches it; n/Escape/anything else inserts it as plain text. Non-existent paths are inserted as plain text without prompting.
Session history¶
Attachment data is never stored verbatim. The session records a compact placeholder like [attached: filename.png] next to the prompt; file contents are sent on the turn they're attached, not persisted beyond that.
Supported types¶
| Extension | MIME type | Handling |
|---|---|---|
.png, .jpg, .jpeg, .gif, .webp |
image/* |
Multipart vision payload |
.txt, .md, .log |
text/plain |
Fenced block in prompt |
.go, .py, .js, .ts, .sh, … |
text/x-* |
Fenced block in prompt |
.json, .yaml, .toml, .html, .css |
Various text | Fenced block in prompt |
.pdf |
application/pdf |
Binary notice only |
| Other | text/plain (default) |
Fenced block in prompt |