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}