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