Skip to main content

embacle_tool_host/
lib.rs

1// ABOUTME: Loopback MCP endpoint serving a CALLER-SUPPLIED tool surface to an ACP agent
2// ABOUTME: One listener per process, one revocable session per turn, bearer dies with the guard
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Host your own tools to an ACP agent.
8//!
9//! # Why this exists
10//!
11//! An ACP agent such as `copilot --acp` runs its own tool loop inside its own
12//! subprocess. It never asks its caller to execute a tool; it executes them
13//! itself and reports afterwards, and the report carries no tool name — ACP's
14//! `session/update` notification has `toolCallId`, `title`, `kind` and `status`,
15//! and nothing that identifies which tool ran.
16//!
17//! So a caller that wants the agent to use ITS tools has exactly one channel:
18//! declare an MCP server in `session/new`. The agent then speaks MCP to that
19//! server, and `tools/call` carries the name and the arguments in full fidelity.
20//!
21//! That channel cannot be an in-process callback. The agent forks the MCP
22//! server itself when the transport is stdio, so the server is a grandchild
23//! process in a different address space, and the ACP frame carries only
24//! `command`/`args`/`env` — no socket, no file descriptor, no back-channel.
25//! Reaching a caller's [`McpToolExecutor`] therefore requires a real listener,
26//! and loopback HTTP is the smallest one that works.
27//!
28//! # Why it is not in the root crate
29//!
30//! `AGENTS.md` states "No HTTP dependencies in core" as a design decision, and
31//! the root crate earns it: `ffi = ["copilot-headless"]` ships a `staticlib`
32//! compiled `panic = "abort"`, where a panic inside a tool handler would abort
33//! the host application. Consumers that enable `copilot-headless` without ever
34//! hosting tools should not pay for a web stack. This crate is opt-in by
35//! existing separately.
36//!
37//! # Lifetime
38//!
39//! One [`ToolHost`] per process binds one listener. Each turn opens a
40//! [`ToolSession`] carrying its own bearer token and its own tool surface.
41//! **Dropping the session revokes the bearer immediately** — an orphaned agent
42//! subprocess that retries after the turn ends gets `401` instead of executing
43//! an irreversible action for a user who has already gone.
44
45use std::collections::HashMap;
46use std::net::{IpAddr, Ipv4Addr, SocketAddr};
47use std::sync::atomic::{AtomicU64, Ordering};
48use std::sync::{Arc, PoisonError, RwLock, Weak};
49
50use async_trait::async_trait;
51use dravr_tronc::mcp::auth::{AuthError, AuthHook};
52use dravr_tronc::mcp::host::ToolDispatcher;
53use dravr_tronc::mcp::protocol::JsonRpcRequest;
54use dravr_tronc::mcp::schema::{Tool, ToolResponse};
55use dravr_tronc::mcp::server::McpServer;
56use dravr_tronc::mcp::tool::{ToolContext, ToolRegistry};
57use dravr_tronc::mcp::transport::http::mcp_router;
58use embacle::types::{McpHeader, McpServerConfig, McpTransport, RunnerError};
59use embacle::{McpToolDefinition, McpToolExecutor};
60use rand::RngCore;
61use serde_json::{json, Value};
62use subtle::ConstantTimeEq;
63use tokio::net::TcpListener;
64use tokio::sync::oneshot;
65use tracing::{debug, warn};
66
67/// Header the session bearer travels in, matching what ACP forwards verbatim.
68const AUTHORIZATION: &str = "authorization";
69
70/// How the endpoint binds.
71#[derive(Debug, Clone)]
72pub struct ToolHostConfig {
73    /// Emitted as `mcpServers[].name`, so it namespaces the tools the model
74    /// sees. A tool `get_activities` under server `dravr` is reported by the
75    /// agent as `dravr-get_activities`.
76    pub server_name: String,
77    /// Interface to bind. Loopback by default — widening it publishes the
78    /// caller's entire tool surface to the network.
79    pub bind_addr: IpAddr,
80    /// `0` lets the kernel assign, which is what you want: no port to
81    /// configure, and no collision between concurrent stacks on one host.
82    pub port: u16,
83    /// Natural-language instructions served at `initialize`.
84    ///
85    /// An agent that opts in — Copilot's CLI does so with
86    /// `--allow-all-mcp-server-instructions` — folds these into its SYSTEM
87    /// prompt. That matters when the caller has a persona to impose: a CLI
88    /// runner with no system-prompt flag can only put one in the prompt body,
89    /// where the model reads it as the user talking and answers as itself.
90    /// This is the one channel that reaches the system layer.
91    pub instructions: Option<String>,
92}
93
94impl Default for ToolHostConfig {
95    fn default() -> Self {
96        Self {
97            server_name: "tools".to_owned(),
98            bind_addr: IpAddr::V4(Ipv4Addr::LOCALHOST),
99            port: 0,
100            instructions: None,
101        }
102    }
103}
104
105/// One tool call's outcome, in MCP's own shape.
106///
107/// Three states, not two. `Result` can say "it worked" or "it failed", but a
108/// tool that RAN and declined — a quota refusal, a guard saying no, a provider
109/// that needs reconnecting — is neither: the model should read the reason and
110/// adapt, exactly as it reads a success. Collapsing that into `Err` throws away
111/// the text the model needed; collapsing it into `Ok` tells the model it
112/// succeeded.
113#[derive(Debug, Clone)]
114pub struct ToolOutcome {
115    /// Text handed to the model.
116    pub text: String,
117    /// Machine-readable payload mirrored into MCP `structuredContent`.
118    pub structured: Option<Value>,
119    /// MCP `isError`. True for a refusal the model should adapt to.
120    pub is_error: bool,
121}
122
123impl ToolOutcome {
124    /// A successful call carrying JSON. The text is the compact encoding, which
125    /// is what a model reads when a server sends no separate rendering.
126    #[must_use]
127    pub fn json(value: Value) -> Self {
128        Self {
129            text: value.to_string(),
130            structured: Some(value),
131            is_error: false,
132        }
133    }
134
135    /// A successful call carrying prose.
136    #[must_use]
137    pub fn text(text: impl Into<String>) -> Self {
138        Self {
139            text: text.into(),
140            structured: None,
141            is_error: false,
142        }
143    }
144
145    /// The tool ran and declined. `reason` is for the model, not the operator.
146    #[must_use]
147    pub fn refused(reason: impl Into<String>) -> Self {
148        Self {
149            text: reason.into(),
150            structured: None,
151            is_error: true,
152        }
153    }
154
155    /// Attach structured content to a refusal, so a caller can carry a machine
156    /// -readable code alongside the prose.
157    #[must_use]
158    pub fn with_structured(mut self, value: Value) -> Self {
159        self.structured = Some(value);
160        self
161    }
162}
163
164/// The caller's tool surface, consulted per request.
165///
166/// Both halves are asked every time, deliberately. A caller whose visible set
167/// is fixed for a turn can answer from a `Vec` and pay nothing; a caller whose
168/// set depends on state that can change — a role, a quota, an interview in
169/// progress that must withhold a tool until it ends — can answer from that
170/// state at the moment the agent asks. Fixing the list at session open would
171/// make the second kind unrepresentable, and a gate that cannot be re-asked is
172/// a gate that silently stops applying.
173#[async_trait]
174pub trait ToolSurface: Send + Sync {
175    /// Tools visible to this session right now.
176    async fn list_tools(&self) -> Vec<McpToolDefinition>;
177
178    /// Run one call. A tool absent from `list_tools` is already refused by the
179    /// host, so this is only reached for a tool the surface just advertised.
180    async fn call(&self, tool_name: &str, arguments: &Value) -> ToolOutcome;
181}
182
183/// A surface whose tool list never changes, backed by an [`McpToolExecutor`].
184///
185/// The simple case, kept simple: callers with nothing dynamic to say hand over
186/// a `Vec` and an executor and are done.
187///
188/// # Fidelity
189///
190/// [`McpToolExecutor`] returns `Result<Value, RunnerError>`, which has two
191/// states where a tool call has three. A tool that RAN and declined can only
192/// come back as `Err`, so this adapter reports it as a refusal — correct — but
193/// the only machine-readable thing an `Err` carries is its
194/// [`ErrorKind`](embacle::types::ErrorKind), which is preserved as
195/// `structuredContent.error_kind`. A caller that needs to hand the model a
196/// richer refusal — an error code, a pending id, a provider to reconnect —
197/// should implement [`ToolSurface`] directly and build its own
198/// [`ToolOutcome`]. That is not a limitation of the host; it is the shape of
199/// the narrower trait.
200pub struct StaticSurface {
201    tools: Vec<McpToolDefinition>,
202    executor: Arc<dyn McpToolExecutor>,
203}
204
205impl StaticSurface {
206    /// Wrap a fixed tool list and its executor.
207    #[must_use]
208    pub const fn new(tools: Vec<McpToolDefinition>, executor: Arc<dyn McpToolExecutor>) -> Self {
209        Self { tools, executor }
210    }
211}
212
213#[async_trait]
214impl ToolSurface for StaticSurface {
215    async fn list_tools(&self) -> Vec<McpToolDefinition> {
216        self.tools.clone()
217    }
218
219    async fn call(&self, tool_name: &str, arguments: &Value) -> ToolOutcome {
220        match self.executor.execute(tool_name, arguments).await {
221            Ok(value) => ToolOutcome::json(value),
222            // The kind is the only machine-readable thing a `RunnerError`
223            // carries; dropping it would leave the model nothing but prose to
224            // branch on.
225            Err(e) => ToolOutcome::refused(e.message.clone())
226                .with_structured(json!({ "error_kind": format!("{:?}", e.kind) })),
227        }
228    }
229}
230
231/// What one live session may see and run.
232struct SessionState {
233    /// Stable correlation key, also what the auth hook hands the dispatcher.
234    id: String,
235    /// Constant-time compared, so a wrong bearer cannot be recovered by timing.
236    bearer: String,
237    surface: Arc<dyn ToolSurface>,
238    calls_served: AtomicU64,
239}
240
241/// Everything shared between the listener task and the session guards.
242struct Inner {
243    server_name: String,
244    addr: SocketAddr,
245    sessions: RwLock<HashMap<String, Arc<SessionState>>>,
246    shutdown: RwLock<Option<oneshot::Sender<()>>>,
247}
248
249impl Inner {
250    // Every lock here recovers from poisoning rather than propagating it. The
251    // map's invariant is "these sessions are open", which a panic in unrelated
252    // code cannot break — and treating a poisoned lock as failure would take
253    // one caller's panic and turn it into every other session silently
254    // refusing, which is a worse outcome than the panic.
255
256    /// Resolve a bearer to its session, in constant time across candidates.
257    ///
258    /// A revoked session is simply absent, which is the whole revocation
259    /// mechanism: the guard's `Drop` removes the entry.
260    fn session_for(&self, bearer: &str) -> Option<Arc<SessionState>> {
261        let sessions = self.sessions.read().unwrap_or_else(PoisonError::into_inner);
262        sessions
263            .values()
264            .find(|s| s.bearer.as_bytes().ct_eq(bearer.as_bytes()).into())
265            .map(Arc::clone)
266    }
267}
268
269/// A bound loopback MCP endpoint. Cheap to clone.
270#[derive(Clone)]
271pub struct ToolHost {
272    inner: Arc<Inner>,
273}
274
275impl ToolHost {
276    /// Bind and start serving.
277    ///
278    /// Returns only once the listener is accepting, so [`Self::local_addr`] is
279    /// valid the instant this returns — there is no window where a session's
280    /// `mcpServers` entry names a port nothing answers on.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`RunnerError`] when the bind fails.
285    pub async fn bind(config: ToolHostConfig) -> Result<Self, RunnerError> {
286        let listener = TcpListener::bind(SocketAddr::new(config.bind_addr, config.port))
287            .await
288            .map_err(|e| RunnerError::config(format!("tool host could not bind: {e}")))?;
289        let addr = listener
290            .local_addr()
291            .map_err(|e| RunnerError::config(format!("tool host bound but has no address: {e}")))?;
292
293        let instructions = config.instructions;
294        let (tx, rx) = oneshot::channel();
295        let inner = Arc::new(Inner {
296            server_name: config.server_name,
297            addr,
298            sessions: RwLock::new(HashMap::new()),
299            shutdown: RwLock::new(Some(tx)),
300        });
301
302        let mut server = McpServer::new(
303            "embacle-tool-host",
304            env!("CARGO_PKG_VERSION"),
305            ToolRegistry::new(),
306            Arc::clone(&inner),
307        )
308        .with_tool_dispatcher(Arc::new(Forwarding))
309        .with_auth_hook(Arc::new(BearerSessions));
310        if let Some(text) = instructions {
311            server = server.with_instructions(text);
312        }
313        let server = Arc::new(server);
314
315        let router = mcp_router(server);
316        tokio::spawn(async move {
317            let outcome = axum::serve(listener, router)
318                .with_graceful_shutdown(async {
319                    let _ = rx.await;
320                })
321                .await;
322            if let Err(e) = outcome {
323                warn!(error = %e, "tool host listener stopped");
324            }
325        });
326
327        debug!(%addr, "tool host listening");
328        Ok(Self { inner })
329    }
330
331    /// The bound address, with the kernel-assigned port resolved.
332    #[must_use]
333    pub fn local_addr(&self) -> SocketAddr {
334        self.inner.addr
335    }
336
337    /// Open a turn-scoped session served by `surface`.
338    ///
339    /// The returned guard owns the session's lifetime. Hold it for exactly as
340    /// long as the turn may legitimately call tools.
341    #[must_use]
342    pub fn open_session(&self, surface: Arc<dyn ToolSurface>) -> ToolSession {
343        let session_id = uuid::Uuid::new_v4().to_string();
344        let bearer = mint_bearer();
345        let state = Arc::new(SessionState {
346            id: session_id.clone(),
347            bearer: bearer.clone(),
348            surface,
349            calls_served: AtomicU64::new(0),
350        });
351        self.inner
352            .sessions
353            .write()
354            .unwrap_or_else(PoisonError::into_inner)
355            .insert(session_id.clone(), Arc::clone(&state));
356        ToolSession {
357            session_id,
358            bearer,
359            server_name: self.inner.server_name.clone(),
360            addr: self.inner.addr,
361            state,
362            host: Arc::downgrade(&self.inner),
363        }
364    }
365
366    /// Live sessions. A floor that keeps rising is a leaked guard.
367    #[must_use]
368    pub fn open_sessions(&self) -> usize {
369        self.inner
370            .sessions
371            .read()
372            .unwrap_or_else(PoisonError::into_inner)
373            .len()
374    }
375
376    /// Stop the listener. Idempotent, and safe to call from a signal handler.
377    pub fn shutdown(&self) {
378        // Taken and released before sending: holding the lock across the send
379        // would let a concurrent shutdown block on a lock this one still owns.
380        let signal = self
381            .inner
382            .shutdown
383            .write()
384            .unwrap_or_else(PoisonError::into_inner)
385            .take();
386        if let Some(tx) = signal {
387            let _ = tx.send(());
388        }
389    }
390}
391
392/// 256 bits from the OS, hex-encoded.
393fn mint_bearer() -> String {
394    let mut raw = [0_u8; 32];
395    rand::thread_rng().fill_bytes(&mut raw);
396    raw.iter().fold(String::with_capacity(64), |mut acc, b| {
397        use std::fmt::Write;
398        let _ = write!(acc, "{b:02x}");
399        acc
400    })
401}
402
403/// A turn's credential and tool surface.
404///
405/// Dropping this revokes the bearer. That is deliberate and is the reason the
406/// guard exists rather than a plain id: a turn that ends — normally, by error,
407/// or because the caller went away — must not leave a live credential that an
408/// orphaned agent subprocess can still spend.
409pub struct ToolSession {
410    session_id: String,
411    bearer: String,
412    server_name: String,
413    addr: SocketAddr,
414    state: Arc<SessionState>,
415    host: Weak<Inner>,
416}
417
418impl ToolSession {
419    /// The `mcpServers` entry to hand the agent.
420    ///
421    /// A `Vec` because that is the shape `ChatRequest::with_mcp_servers` takes
422    /// and a caller may be composing several servers.
423    #[must_use]
424    pub fn mcp_servers(&self) -> Vec<McpServerConfig> {
425        vec![McpServerConfig {
426            name: self.server_name.clone(),
427            transport: McpTransport::Http {
428                url: format!("http://{}/mcp", self.addr),
429                headers: vec![McpHeader {
430                    name: "Authorization".to_owned(),
431                    value: format!("Bearer {}", self.bearer),
432                }],
433            },
434        }]
435    }
436
437    /// Non-secret id for log correlation. The bearer is never exposed.
438    #[must_use]
439    pub fn session_id(&self) -> &str {
440        &self.session_id
441    }
442
443    /// Tool calls served on this session.
444    ///
445    /// Zero on a turn whose reply claimed to have consulted data is the signal
446    /// that it did not.
447    #[must_use]
448    pub fn calls_served(&self) -> u64 {
449        self.state.calls_served.load(Ordering::SeqCst)
450    }
451}
452
453impl Drop for ToolSession {
454    fn drop(&mut self) {
455        if let Some(inner) = self.host.upgrade() {
456            inner
457                .sessions
458                .write()
459                .unwrap_or_else(PoisonError::into_inner)
460                .remove(&self.session_id);
461        }
462    }
463}
464
465/// Resolves the session bearer, and refuses everything else.
466struct BearerSessions;
467
468#[async_trait]
469impl AuthHook<Inner> for BearerSessions {
470    async fn authenticate(
471        &self,
472        request: &JsonRpcRequest,
473        state: &Arc<Inner>,
474    ) -> Result<ToolContext, AuthError> {
475        let bearer = request
476            .auth_token
477            .as_deref()
478            .or_else(|| {
479                request
480                    .headers
481                    .as_ref()
482                    .and_then(|h| h.get(AUTHORIZATION))
483                    .and_then(Value::as_str)
484            })
485            .map(|raw| raw.trim_start_matches("Bearer ").trim())
486            .unwrap_or_default();
487
488        // A revoked or unknown bearer is absent from the map, so both fail the
489        // same way and neither says which.
490        state.session_for(bearer).map_or_else(
491            || {
492                Err(AuthError::Unauthorized {
493                    www_authenticate: "Bearer".to_owned(),
494                })
495            },
496            |session| Ok(ToolContext::new().with_request_id(Value::from(session.id.clone()))),
497        )
498    }
499}
500
501/// Forwards `tools/list` and `tools/call` into the caller's executor.
502struct Forwarding;
503
504#[async_trait]
505impl ToolDispatcher<Inner> for Forwarding {
506    async fn list_tools(&self, state: &Arc<Inner>, ctx: &ToolContext) -> Vec<Tool> {
507        let Some(session) = resolve(state, ctx) else {
508            return Vec::new();
509        };
510        // Asked now, not at session open: a caller gating on state that moves —
511        // a role, a quota, an interview that must withhold a tool until it ends
512        // — gets to answer for the moment the agent is asking about.
513        session
514            .surface
515            .list_tools()
516            .await
517            .into_iter()
518            .map(|t| Tool {
519                name: t.name,
520                description: t.description,
521                input_schema: t.input_schema,
522                annotations: None,
523            })
524            .collect()
525    }
526
527    async fn call_tool(
528        &self,
529        name: &str,
530        state: &Arc<Inner>,
531        ctx: &ToolContext,
532        arguments: Value,
533    ) -> ToolResponse {
534        let Some(session) = resolve(state, ctx) else {
535            return ToolResponse::error("session is no longer open".to_owned());
536        };
537
538        // Re-check visibility at call time against the SAME live answer the
539        // listing came from. A tool withheld between the agent's list and its
540        // call must not run just because it was visible a moment ago.
541        if !session
542            .surface
543            .list_tools()
544            .await
545            .iter()
546            .any(|t| t.name == name)
547        {
548            return ToolResponse::error(format!("unknown tool: {name}"));
549        }
550
551        session.calls_served.fetch_add(1, Ordering::SeqCst);
552        let outcome = session.surface.call(name, &arguments).await;
553        let mut response = if outcome.is_error {
554            ToolResponse::error(outcome.text)
555        } else {
556            ToolResponse::text(outcome.text)
557        };
558        response.structured_content = outcome.structured;
559        response
560    }
561}
562
563/// Recover the session a request authenticated as.
564fn resolve(state: &Arc<Inner>, ctx: &ToolContext) -> Option<Arc<SessionState>> {
565    let id = ctx.request_id.as_ref()?.as_str()?;
566    let sessions = state
567        .sessions
568        .read()
569        .unwrap_or_else(PoisonError::into_inner);
570    sessions.get(id).map(Arc::clone)
571}