1#![allow(deprecated)]
10
11use std::sync::Arc;
12
13use anyhow::Result;
14use perspt_core::tools_driver::CoreGenerationConfig;
15use perspt_core::tools_driver::{
16 CoreMessage, CoreToolCall, CoreToolChoice, CoreToolSpec, CoreTurnStreamEvent,
17};
18use perspt_core::{Config, ExternalToolMode, GenAIProvider};
19use perspt_sdk::{ActorId, Capability, EffectKind};
20
21use super::{
22 ExternalToolRuntime, McpClientServices, McpElicitationAction, McpElicitationBroker,
23 McpPendingElicitation, McpSamplingProvider,
24};
25
26const MAX_TOOL_ROUNDS: usize = 4;
28
29pub const MCP_ACTIVITY_PREFIX: &str = "__PERSPT_MCP_ACTIVITY__:";
31
32fn tool_label(name: &str) -> String {
33 name.rsplit('.')
34 .next()
35 .unwrap_or(name)
36 .trim_start_matches("_perspt_")
37 .replace('_', " ")
38}
39
40#[derive(Clone)]
43pub struct ChatToolSession {
44 runtime: Arc<tokio::sync::Mutex<ExternalToolRuntime>>,
45 specs: Arc<std::sync::RwLock<Vec<CoreToolSpec>>>,
46 elicitation: Option<McpElicitationBroker>,
47}
48
49struct ChatSamplingProvider {
50 provider: Arc<GenAIProvider>,
51 model: String,
52}
53
54#[async_trait::async_trait]
55impl McpSamplingProvider for ChatSamplingProvider {
56 async fn create_message(
57 &self,
58 _server_id: &str,
59 request: rmcp::model::CreateMessageRequestParams,
60 ) -> Result<rmcp::model::CreateMessageResult> {
61 let sample = prepare_sample(request)?;
62 let output = self
63 .provider
64 .chat_turn_configured(
65 &self.model,
66 &sample.messages,
67 &sample.tools,
68 sample.choice,
69 sample.config,
70 )
71 .await?;
72 let result = sample_result(output, &self.model)?;
73 result.validate().map_err(anyhow::Error::msg)?;
74 Ok(result)
75 }
76}
77
78struct ChatSample {
79 messages: Vec<CoreMessage>,
80 tools: Vec<CoreToolSpec>,
81 choice: CoreToolChoice,
82 config: CoreGenerationConfig,
83}
84
85fn prepare_sample(request: rmcp::model::CreateMessageRequestParams) -> Result<ChatSample> {
86 request.validate().map_err(anyhow::Error::msg)?;
87 anyhow::ensure!(
88 request.include_context.is_none()
89 || request.include_context == Some(rmcp::model::ContextInclusion::None),
90 "MCP sampling includeContext is not advertised by Perspt"
91 );
92 let mut messages = request
93 .system_prompt
94 .map(|content| vec![CoreMessage::System { content }])
95 .unwrap_or_default();
96 for message in request.messages {
97 append_sampling(&mut messages, message)?;
98 }
99 let tools = request
100 .tools
101 .unwrap_or_default()
102 .into_iter()
103 .map(core_tool)
104 .collect();
105 let choice = match request.tool_choice.and_then(|choice| choice.mode) {
106 Some(rmcp::model::ToolChoiceMode::Required) => CoreToolChoice::Required,
107 Some(rmcp::model::ToolChoiceMode::None) => CoreToolChoice::None,
108 _ => CoreToolChoice::Auto,
109 };
110 Ok(ChatSample {
111 messages,
112 tools,
113 choice,
114 config: CoreGenerationConfig {
115 max_tokens: Some(request.max_tokens),
116 temperature: request.temperature,
117 stop_sequences: request.stop_sequences.unwrap_or_default(),
118 },
119 })
120}
121
122fn core_tool(tool: rmcp::model::Tool) -> CoreToolSpec {
123 CoreToolSpec {
124 name: tool.name.into_owned(),
125 description: tool
126 .description
127 .map(|value| value.into_owned())
128 .unwrap_or_default(),
129 schema: serde_json::Value::Object(tool.input_schema.as_ref().clone()),
130 strict: false,
131 }
132}
133
134fn sample_result(
135 output: perspt_core::CoreTurnOutput,
136 model: &str,
137) -> Result<rmcp::model::CreateMessageResult> {
138 match output {
139 perspt_core::CoreTurnOutput::Text(text) => Ok(rmcp::model::CreateMessageResult::new(
140 rmcp::model::SamplingMessage::assistant_text(text),
141 model.to_string(),
142 )
143 .with_stop_reason(rmcp::model::CreateMessageResult::STOP_REASON_END_TURN)),
144 perspt_core::CoreTurnOutput::ToolCalls(calls) => {
145 let blocks = calls
146 .into_iter()
147 .map(|call| {
148 let input = call.arguments.as_object().cloned().ok_or_else(|| {
149 anyhow::anyhow!("MCP sampling tool arguments must be an object")
150 })?;
151 Ok(rmcp::model::SamplingMessageContentBlock::tool_use(
152 call.call_id,
153 call.name,
154 input,
155 ))
156 })
157 .collect::<Result<Vec<_>>>()?;
158 Ok(rmcp::model::CreateMessageResult::new(
159 rmcp::model::SamplingMessage::new_multiple(rmcp::model::Role::Assistant, blocks),
160 model.to_string(),
161 )
162 .with_stop_reason(rmcp::model::CreateMessageResult::STOP_REASON_TOOL_USE))
163 }
164 }
165}
166
167fn append_sampling(
168 messages: &mut Vec<CoreMessage>,
169 message: rmcp::model::SamplingMessage,
170) -> Result<()> {
171 let mut text = Vec::new();
172 let mut calls = Vec::new();
173 let mut results = Vec::new();
174 for content in message.content.into_vec() {
175 match content {
176 rmcp::model::SamplingMessageContentBlock::Text(value) => text.push(value.text),
177 rmcp::model::SamplingMessageContentBlock::ToolUse(value) => {
178 calls.push(CoreToolCall {
179 call_id: value.id,
180 name: value.name,
181 arguments: serde_json::Value::Object(value.input),
182 });
183 }
184 rmcp::model::SamplingMessageContentBlock::ToolResult(value) => {
185 results.push((value.tool_use_id, serde_json::to_string(&value.content)?));
186 }
187 rmcp::model::SamplingMessageContentBlock::Image(_)
188 | rmcp::model::SamplingMessageContentBlock::Audio(_) => {
189 anyhow::bail!("the Perspt chat provider does not accept MCP media sampling content")
190 }
191 _ => anyhow::bail!("unknown MCP sampling content block"),
192 }
193 }
194 if !text.is_empty() {
195 messages.push(match message.role {
196 rmcp::model::Role::User => CoreMessage::User {
197 content: text.join("\n"),
198 },
199 rmcp::model::Role::Assistant => CoreMessage::Assistant {
200 content: text.join("\n"),
201 },
202 });
203 }
204 if !calls.is_empty() {
205 messages.push(CoreMessage::AssistantToolCalls { calls });
206 }
207 messages.extend(
208 results
209 .into_iter()
210 .map(|(call_id, content)| CoreMessage::ToolResponse { call_id, content }),
211 );
212 Ok(())
213}
214
215fn read_only_effects() -> Vec<EffectKind> {
217 [
218 EffectKind::ReadFile,
219 EffectKind::SystemProbe,
220 EffectKind::DataRead,
221 EffectKind::ToolSearch,
222 EffectKind::ToolProgram,
223 EffectKind::Search,
224 EffectKind::List,
225 EffectKind::LspQuery,
226 EffectKind::GitRead,
227 ]
228 .into()
229}
230
231fn chat_services(
232 config: &Config,
233 provider: Arc<GenAIProvider>,
234 model: &str,
235) -> (McpClientServices, Option<McpElicitationBroker>) {
236 let enabled = |select: fn(&perspt_core::ExternalToolConfig) -> bool| {
237 config
238 .external_tools
239 .iter()
240 .any(|server| server.supports(ExternalToolMode::Chat) && select(server))
241 };
242 let sampling = enabled(|server| server.sampling).then(|| {
243 Arc::new(ChatSamplingProvider {
244 provider,
245 model: model.to_string(),
246 }) as Arc<dyn McpSamplingProvider>
247 });
248 let elicitation = enabled(|server| server.elicitation).then(McpElicitationBroker::new);
249 let services = McpClientServices {
250 sampling,
251 elicitation: elicitation
252 .clone()
253 .map(|broker| Arc::new(broker) as Arc<dyn super::McpElicitationProvider>),
254 };
255 (services, elicitation)
256}
257
258async fn discover_all(runtime: &mut ExternalToolRuntime) -> Vec<String> {
259 let servers: Vec<String> = runtime
260 .server_ids()
261 .into_iter()
262 .map(str::to_string)
263 .collect();
264 let mut notices = Vec::new();
265 for server_id in servers {
266 match runtime.discover_server(&server_id).await {
267 Ok(admitted) => add_discovery_notice(runtime, &server_id, admitted.len(), &mut notices),
268 Err(error) => notices.push(format!("MCP server {server_id} failed: {error:#}")),
269 }
270 }
271 notices
272}
273
274fn add_discovery_notice(
275 runtime: &ExternalToolRuntime,
276 server_id: &str,
277 admitted: usize,
278 notices: &mut Vec<String>,
279) {
280 let rejected = runtime.admission_rejections(server_id);
281 notices.push(format!(
282 "MCP server {server_id}: {admitted} tool(s) admitted, {} rejected by local policy",
283 rejected.len()
284 ));
285 notices.extend(rejected.iter().take(8).map(|item| {
286 format!(
287 "MCP tool {}.{} rejected: {}",
288 item.server_id, item.remote_tool, item.reason
289 )
290 }));
291 if rejected.len() > 8 {
292 notices.push(format!(
293 "MCP server {server_id}: {} additional rejection(s) omitted",
294 rejected.len() - 8
295 ));
296 }
297}
298
299fn core_specs(runtime: &ExternalToolRuntime) -> Vec<CoreToolSpec> {
300 runtime
301 .admitted_entries()
302 .into_iter()
303 .map(|entry| CoreToolSpec {
304 name: entry.name,
305 description: entry.description,
306 schema: entry.schema,
307 strict: false,
308 })
309 .collect()
310}
311
312impl ChatToolSession {
313 pub async fn from_config(
316 config: &Config,
317 provider: Arc<GenAIProvider>,
318 model: &str,
319 ) -> Result<Option<(Self, Vec<String>)>> {
320 let has_chat_servers = config
321 .external_tools
322 .iter()
323 .any(|server| server.supports(ExternalToolMode::Chat));
324 if !has_chat_servers {
325 return Ok(None);
326 }
327 let capability = Capability::new(ActorId::new("chat"), read_only_effects());
328 let mut runtime =
329 ExternalToolRuntime::from_config(config, ExternalToolMode::Chat, vec![capability])?;
330 let (services, elicitation) = chat_services(config, provider, model);
331 runtime.set_client_services(services);
332 let notices = discover_all(&mut runtime).await;
333 let specs = core_specs(&runtime);
334 Ok(Some((
335 Self {
336 runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
337 specs: Arc::new(std::sync::RwLock::new(specs)),
338 elicitation,
339 },
340 notices,
341 )))
342 }
343
344 pub fn has_tools(&self) -> bool {
345 !self
346 .specs
347 .read()
348 .expect("MCP tool specs poisoned")
349 .is_empty()
350 }
351
352 pub fn tool_names(&self) -> Vec<String> {
358 self.specs
359 .read()
360 .expect("MCP tool specs poisoned")
361 .iter()
362 .map(|spec| spec.name.clone())
363 .collect()
364 }
365
366 pub fn try_next_elicitation(&self) -> Option<McpPendingElicitation> {
367 self.elicitation
368 .as_ref()
369 .and_then(McpElicitationBroker::try_next)
370 }
371
372 pub fn respond_elicitation(
373 &self,
374 id: u64,
375 action: McpElicitationAction,
376 content: Option<serde_json::Value>,
377 ) -> Result<()> {
378 self.elicitation
379 .as_ref()
380 .ok_or_else(|| anyhow::anyhow!("MCP elicitation is not enabled"))?
381 .respond(id, action, content)
382 }
383
384 pub async fn run_turn(
390 &self,
391 provider: &GenAIProvider,
392 model: &str,
393 mut messages: Vec<CoreMessage>,
394 updates: &tokio::sync::mpsc::UnboundedSender<String>,
395 ) -> Result<()> {
396 for _round in 0..MAX_TOOL_ROUNDS {
397 self.sync_notifications().await?;
398 let specs = self.specs.read().expect("MCP tool specs poisoned").clone();
399 let output = provider
400 .stream_tool_turn(model, &messages, &specs, CoreToolChoice::Auto, |event| {
401 match event {
402 CoreTurnStreamEvent::Text(text) => {
403 let _ = updates.send(text);
404 }
405 CoreTurnStreamEvent::Reasoning(text) => {
406 let _ = updates.send(format!("__PERSPT_REASONING__:{text}"));
407 }
408 }
409 })
410 .await?;
411 let calls = match output {
412 perspt_core::tools_driver::CoreTurnOutput::Text(_) => return Ok(()),
413 perspt_core::tools_driver::CoreTurnOutput::ToolCalls(calls) => calls,
414 };
415 messages.push(CoreMessage::AssistantToolCalls {
416 calls: calls.clone(),
417 });
418 for call in calls {
419 let _ = updates.send(format!(
420 "{MCP_ACTIVITY_PREFIX}Using MCP: {}…",
421 tool_label(&call.name)
422 ));
423 let content = self.execute(&call).await;
424 messages.push(CoreMessage::ToolResponse {
425 call_id: call.call_id,
426 content,
427 });
428 }
429 }
430 let _ = updates.send("The MCP tool round limit was reached without a final answer.".into());
431 Ok(())
432 }
433
434 async fn sync_notifications(&self) -> Result<()> {
435 let mut runtime = self.runtime.lock().await;
436 let events = runtime.sync_server_events().await?;
437 for (server, event) in events {
438 log::debug!("MCP {server}: {event:?}");
439 }
440 let specs = runtime
441 .admitted_entries()
442 .into_iter()
443 .map(|entry| CoreToolSpec {
444 name: entry.name,
445 description: entry.description,
446 schema: entry.schema,
447 strict: false,
448 })
449 .collect();
450 *self.specs.write().expect("MCP tool specs poisoned") = specs;
451 Ok(())
452 }
453
454 async fn execute(&self, call: &CoreToolCall) -> String {
455 let mut runtime = self.runtime.lock().await;
456 match runtime.call(&call.name, call.arguments.clone()).await {
457 Ok(result) => format!(
458 "[untrusted MCP result{}] {}",
459 if result.is_error { ", tool error" } else { "" },
460 result.content
461 ),
462 Err(error) => format!("tool failed: {error:#}"),
463 }
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn activity_labels_hide_protocol_namespaces() {
473 assert_eq!(tool_label("mcp.todos.list_tasks"), "list tasks");
474 assert_eq!(
475 tool_label("mcp.docs._perspt_resource_read"),
476 "resource read"
477 );
478 }
479}