embacle-tool-host 0.21.1

Host your own tools to an ACP agent over a loopback MCP endpoint
Documentation

embacle-tool-host

Host your own tools to an ACP agent over a loopback MCP endpoint.

Why

An ACP agent such as copilot --acp runs its own tool loop inside its own subprocess. It never asks its caller to execute a tool — it executes them itself and reports afterwards, and that report carries no tool name: ACP's session/update notification has toolCallId, title, kind and status, and nothing identifying which tool ran.

So a caller that wants the agent to use its tools has one channel: declare an MCP server in session/new. The agent then speaks MCP to it, and tools/call carries the name and arguments in full fidelity.

That channel cannot be an in-process callback. With stdio the agent forks the server itself, so it is a grandchild process in another address space, and the ACP frame carries only command/args/env — no socket, no file descriptor, no back-channel. Reaching a caller's McpToolExecutor needs a real listener. Loopback HTTP is the smallest one that works.

This crate is separate from embacle because the root crate holds the line "No HTTP dependencies in core", and its ffi feature ships a staticlib compiled panic = "abort". Consumers that enable copilot-headless without hosting tools pay nothing for this.

Use

let host = ToolHost::bind(ToolHostConfig {
    server_name: "dravr".to_owned(),
    ..ToolHostConfig::default()
}).await?;

// Per turn: publish a surface, get a revocable credential.
// StaticSurface for a fixed tool list; implement ToolSurface yourself when
// what is visible depends on state that can change mid-turn.
let surface = Arc::new(StaticSurface::new(my_tool_definitions(), my_executor));
let session = host.open_session(surface);
let request = ChatRequest::new(messages).with_mcp_servers(session.mcp_servers());
let response = runner.converse(&request).await?;

// `session` drops here — the bearer is revoked at the same instant.

The session guard

ToolSession is a guard, not an id. Dropping it revokes the bearer immediately, so a turn that ends — normally, by error, or because the caller went away — leaves no live credential an orphaned agent subprocess can still spend on an irreversible action.

session.calls_served() reports how many tool calls the turn actually made. Zero, on a turn whose reply claimed to have consulted data, is the signal that it did not.

The surface is asked every time

ToolSurface has two halves and both are consulted per request:

async fn list_tools(&self) -> Vec<McpToolDefinition>;
async fn call(&self, tool_name: &str, arguments: &Value) -> ToolOutcome;

Fixing the tool list when the session opens would be cheaper and is wrong. A caller that withholds a tool while an interview is running, or gates on a role or a quota, needs the answer for the moment the agent asks — a gate that cannot be re-asked is a gate that silently stops applying. A tool withdrawn mid-session disappears from the listing and stops being callable, with no session reopen.

ToolOutcome has three states rather than two. A tool that ran and declined — a quota refusal, a guard saying no — is neither success nor transport failure: the model should read the reason and adapt. Result cannot say that without throwing away either the flag or the text.