Skip to main content

llm_agent/mcp/
toolkit.rs

1use super::{
2    content::convert_mcp_content, MCPInit, MCPParams, MCPStdioParams, MCPStreamableHTTPParams,
3};
4use crate::{
5    errors::BoxedError,
6    tool::{AgentTool, AgentToolResult},
7    toolkit::{Toolkit, ToolkitSession},
8    RunState,
9};
10use futures::future::BoxFuture;
11use llm_sdk;
12use rmcp::{
13    handler::client::ClientHandler,
14    model::{CallToolRequestParams, CallToolResult, Tool},
15    service::{serve_client, NotificationContext, RoleClient, RunningService},
16    transport::{
17        child_process::TokioChildProcess,
18        streamable_http_client::{
19            StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
20        },
21    },
22};
23use serde_json::Value;
24use std::{
25    io::{Error as IoError, ErrorKind},
26    sync::{Arc, OnceLock, RwLock, Weak},
27};
28use tokio::process::Command;
29
30type MCPRunningService<TCtx> = RunningService<RoleClient, MCPToolkitState<TCtx>>;
31
32/// Toolkit implementation backed by the Model Context Protocol.
33pub struct MCPToolkit<TCtx>
34where
35    TCtx: Send + Sync + 'static,
36{
37    init: MCPInit<TCtx>,
38}
39
40impl<TCtx> MCPToolkit<TCtx>
41where
42    TCtx: Send + Sync + 'static,
43{
44    pub fn new(init: impl Into<MCPInit<TCtx>>) -> Self {
45        Self { init: init.into() }
46    }
47}
48
49impl<TCtx> Toolkit<TCtx> for MCPToolkit<TCtx>
50where
51    TCtx: Send + Sync + 'static,
52{
53    fn create_session<'a>(
54        &'a self,
55        context: &'a TCtx,
56    ) -> BoxFuture<'a, Result<Box<dyn ToolkitSession<TCtx> + Send + Sync>, BoxedError>> {
57        Box::pin(async move {
58            let params = self.init.resolve(context).await?;
59            let session = MCPToolkitSession::new(params).await?;
60            let boxed: Box<dyn ToolkitSession<TCtx> + Send + Sync> = Box::new(session);
61            Ok(boxed)
62        })
63    }
64}
65
66/// `ToolkitSession` implementation that exposes MCP tools to the agent runtime.
67struct MCPToolkitSession<TCtx>
68where
69    TCtx: Send + Sync + 'static,
70{
71    service: Arc<MCPRunningService<TCtx>>,
72    state: MCPToolkitState<TCtx>,
73}
74
75impl<TCtx> MCPToolkitSession<TCtx>
76where
77    TCtx: Send + Sync + 'static,
78{
79    async fn new(params: MCPParams) -> Result<Self, BoxedError> {
80        let state = MCPToolkitState::new();
81        let handler = state.clone();
82
83        let service = match params {
84            MCPParams::Stdio(MCPStdioParams { command, args }) => {
85                let mut cmd = Command::new(command);
86                cmd.args(args);
87                let transport = TokioChildProcess::new(cmd)?;
88                serve_client(handler, transport).await?
89            }
90            MCPParams::StreamableHttp(MCPStreamableHTTPParams { url, authorization }) => {
91                let mut config = StreamableHttpClientTransportConfig::with_uri(url.clone());
92                if let Some(token) = authorization.as_deref() {
93                    config = config.auth_header(strip_bearer_prefix(token));
94                }
95                let transport = StreamableHttpClientTransport::from_config(config);
96                serve_client(handler, transport).await?
97            }
98        };
99
100        let service = Arc::new(service);
101        state.register_service(&service);
102        state.refresh_with(&service).await?;
103
104        Ok(Self { service, state })
105    }
106}
107
108impl<TCtx> ToolkitSession<TCtx> for MCPToolkitSession<TCtx>
109where
110    TCtx: Send + Sync + 'static,
111{
112    fn system_prompt(&self) -> Option<String> {
113        None
114    }
115
116    fn tools(&self) -> Vec<Arc<dyn AgentTool<TCtx>>> {
117        self.state.tools()
118    }
119
120    fn close(self: Box<Self>) -> BoxFuture<'static, Result<(), BoxedError>> {
121        Box::pin(async move {
122            match Arc::try_unwrap(self.service) {
123                Ok(service) => {
124                    let _ = service.cancel().await;
125                }
126                Err(arc) => {
127                    arc.cancellation_token().cancel();
128                }
129            }
130            Ok(())
131        })
132    }
133}
134
135struct MCPRemoteTool<TCtx>
136where
137    TCtx: Send + Sync + 'static,
138{
139    service: Weak<MCPRunningService<TCtx>>,
140    name: String,
141    description: String,
142    parameters: llm_sdk::JSONSchema,
143}
144
145impl<TCtx> MCPRemoteTool<TCtx>
146where
147    TCtx: Send + Sync + 'static,
148{
149    fn new(service: &Arc<MCPRunningService<TCtx>>, tool: Tool) -> Self {
150        let parameters =
151            Value::Object(Arc::try_unwrap(tool.input_schema).unwrap_or_else(|arc| (*arc).clone()));
152        let description = tool
153            .description
154            .map(std::borrow::Cow::into_owned)
155            .unwrap_or_default();
156        Self {
157            service: Arc::downgrade(service),
158            name: tool.name.into_owned(),
159            description,
160            parameters,
161        }
162    }
163}
164
165impl<TCtx> AgentTool<TCtx> for MCPRemoteTool<TCtx>
166where
167    TCtx: Send + Sync + 'static,
168{
169    fn name(&self) -> String {
170        self.name.clone()
171    }
172
173    fn description(&self) -> String {
174        self.description.clone()
175    }
176
177    fn parameters(&self) -> llm_sdk::JSONSchema {
178        self.parameters.clone()
179    }
180
181    fn execute(
182        &self,
183        args: Value,
184        _context: &TCtx,
185        _state: &RunState,
186    ) -> BoxFuture<'_, Result<AgentToolResult, BoxedError>> {
187        Box::pin(async move {
188            let arguments = match args {
189                Value::Null => None,
190                Value::Object(map) => Some(map),
191                other => {
192                    let message = format!("MCP tool arguments must be an object, received {other}");
193                    return Err(
194                        Box::new(IoError::new(ErrorKind::InvalidInput, message)) as BoxedError
195                    );
196                }
197            };
198
199            let request = match arguments {
200                Some(arguments) => {
201                    CallToolRequestParams::new(self.name.clone()).with_arguments(arguments)
202                }
203                None => CallToolRequestParams::new(self.name.clone()),
204            };
205
206            let Some(service) = self.service.upgrade() else {
207                return Err(Box::new(IoError::new(
208                    ErrorKind::NotConnected,
209                    "MCP service not initialised",
210                )) as BoxedError);
211            };
212            let result = service
213                .call_tool(request)
214                .await
215                .map_err(|err| Box::new(err) as BoxedError)?;
216
217            let CallToolResult {
218                content, is_error, ..
219            } = result;
220
221            let content = convert_mcp_content(content)?;
222            let is_error = is_error.unwrap_or(false);
223
224            Ok(AgentToolResult { content, is_error })
225        })
226    }
227}
228
229// Remove "Bearer " or "bearer " prefix if present because the rmcp library
230// already adds it.
231fn strip_bearer_prefix(token: &str) -> String {
232    let trimmed = token.trim();
233    if let Some(rest) = trimmed.strip_prefix("Bearer ") {
234        rest.to_string()
235    } else if let Some(rest) = trimmed.strip_prefix("bearer ") {
236        rest.to_string()
237    } else {
238        trimmed.to_string()
239    }
240}
241
242#[allow(clippy::type_complexity)]
243struct MCPToolkitState<TCtx>
244where
245    TCtx: Send + Sync + 'static,
246{
247    service: Arc<OnceLock<Weak<MCPRunningService<TCtx>>>>,
248    tools: Arc<RwLock<Result<Vec<Arc<dyn AgentTool<TCtx>>>, String>>>,
249}
250
251impl<TCtx> Clone for MCPToolkitState<TCtx>
252where
253    TCtx: Send + Sync + 'static,
254{
255    fn clone(&self) -> Self {
256        Self {
257            service: Arc::clone(&self.service),
258            tools: Arc::clone(&self.tools),
259        }
260    }
261}
262
263impl<TCtx> MCPToolkitState<TCtx>
264where
265    TCtx: Send + Sync + 'static,
266{
267    fn new() -> Self {
268        Self {
269            service: Arc::new(OnceLock::new()),
270            tools: Arc::new(RwLock::new(Ok(Vec::new()))),
271        }
272    }
273
274    fn register_service(&self, service: &Arc<MCPRunningService<TCtx>>) {
275        let _ = self.service.set(Arc::downgrade(service));
276    }
277
278    async fn refresh(&self) -> Result<(), BoxedError> {
279        let service = self.service()?;
280        self.refresh_with(&service).await
281    }
282
283    async fn refresh_with(&self, service: &Arc<MCPRunningService<TCtx>>) -> Result<(), BoxedError> {
284        let specs = service
285            .peer()
286            .list_all_tools()
287            .await
288            .map_err(|err| Box::new(err) as BoxedError)?;
289
290        let mut new_tools: Vec<Arc<dyn AgentTool<TCtx>>> = Vec::with_capacity(specs.len());
291        for spec in specs {
292            let remote = MCPRemoteTool::new(service, spec);
293            new_tools.push(Arc::new(remote));
294        }
295
296        let mut guard = self.tools.write().expect("tool registry lock poisoned");
297        *guard = Ok(new_tools);
298        Ok(())
299    }
300
301    fn tools(&self) -> Vec<Arc<dyn AgentTool<TCtx>>> {
302        let guard = self.tools.read().expect("tool registry lock poisoned");
303        match guard.as_ref() {
304            Ok(tools) => tools.clone(),
305            Err(message) => panic!("mcp tool discovery failed: {message}"),
306        }
307    }
308
309    fn record_error<E>(&self, err: E)
310    where
311        E: std::fmt::Display,
312    {
313        if let Ok(mut guard) = self.tools.write() {
314            *guard = Err(err.to_string());
315        }
316    }
317
318    fn service(&self) -> Result<Arc<MCPRunningService<TCtx>>, BoxedError> {
319        self.service
320            .get()
321            .and_then(Weak::upgrade)
322            .ok_or_else(|| -> BoxedError {
323                Box::new(IoError::new(
324                    ErrorKind::NotConnected,
325                    "MCP service not initialised",
326                ))
327            })
328    }
329}
330
331impl<TCtx> ClientHandler for MCPToolkitState<TCtx>
332where
333    TCtx: Send + Sync + 'static,
334{
335    fn on_tool_list_changed(
336        &self,
337        _context: NotificationContext<RoleClient>,
338    ) -> impl std::future::Future<Output = ()> + Send + '_ {
339        let state = self.clone();
340        async move {
341            if let Err(err) = state.refresh().await {
342                state.record_error(err);
343            }
344        }
345    }
346}