Skip to main content

netget/client/mcp/
mod.rs

1//! MCP (Model Context Protocol) client implementation
2//!
3//! Implements JSON-RPC 2.0 client over HTTP for the Model Context Protocol.
4
5pub mod actions;
6
7pub use actions::McpClientProtocol;
8
9use anyhow::{Context, Result};
10use std::net::SocketAddr;
11use std::sync::Arc;
12use tokio::sync::mpsc;
13use tracing::{debug, error, info};
14
15use crate::client::llm_budget::call_llm_for_client;
16use crate::client::mcp::actions::{MCP_CLIENT_CONNECTED_EVENT, MCP_CLIENT_RESPONSE_RECEIVED_EVENT};
17use crate::llm::actions::client_trait::{Client, ClientActionResult};
18use crate::llm::actions::protocol_trait::Protocol;
19use crate::llm::ollama_client::OllamaClient;
20use crate::logging::emit::Log;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25use serde_json::{json, Value};
26
27/// JSON-RPC 2.0 request message
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29struct JsonRpcRequest {
30    jsonrpc: String,
31    method: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    params: Option<Value>,
34    id: i64,
35}
36
37/// JSON-RPC 2.0 response message
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39struct JsonRpcResponse {
40    jsonrpc: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    result: Option<Value>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    error: Option<JsonRpcError>,
45    id: Option<i64>,
46}
47
48/// JSON-RPC 2.0 notification message
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50struct JsonRpcNotification {
51    jsonrpc: String,
52    method: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    params: Option<Value>,
55}
56
57/// JSON-RPC 2.0 error object
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
59struct JsonRpcError {
60    code: i32,
61    message: String,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    data: Option<Value>,
64}
65
66/// `clientInfo.name` when the `client_name` startup parameter is not set.
67const DEFAULT_CLIENT_NAME: &str = "netget-mcp-client";
68
69/// `clientInfo.version` when the `client_version` startup parameter is not set.
70const DEFAULT_CLIENT_VERSION: &str = "0.1.0";
71
72/// MCP client that connects to MCP servers
73pub struct McpClient;
74
75impl McpClient {
76    /// Connect to an MCP server with integrated LLM actions
77    pub async fn connect_with_llm_actions(
78        remote_addr: String,
79        llm_client: OllamaClient,
80        app_state: Arc<AppState>,
81        status_tx: mpsc::UnboundedSender<String>,
82        client_id: ClientId,
83        startup_params: Option<crate::protocol::StartupParams>,
84    ) -> Result<SocketAddr> {
85        info!("MCP client {} connecting to {}", client_id, remote_addr);
86
87        // `client_name` / `client_version` are declared as the identity of this MCP client
88        // and were never read: the `clientInfo` of the initialize request was the hardcoded
89        // `netget-mcp-client` / `0.1.0` whatever the operator set, so an MCP server logging
90        // or gating on who connected saw the wrong name.
91        let (client_name, client_version) = match &startup_params {
92            Some(params) => (
93                params.get_optional_string("client_name")?,
94                params.get_optional_string("client_version")?,
95            ),
96            None => (None, None),
97        };
98        let client_name = client_name.unwrap_or_else(|| DEFAULT_CLIENT_NAME.to_string());
99        let client_version = client_version.unwrap_or_else(|| DEFAULT_CLIENT_VERSION.to_string());
100
101        // Build HTTP client
102        let http_client = reqwest::Client::builder()
103            .timeout(std::time::Duration::from_secs(30))
104            .build()
105            .context("Failed to build HTTP client")?;
106
107        // Store client data
108        let base_url = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://")
109        {
110            remote_addr.clone()
111        } else {
112            format!("http://{}", remote_addr)
113        };
114
115        app_state
116            .with_client_mut(client_id, |client| {
117                client.set_protocol_field("base_url".to_string(), serde_json::json!(base_url));
118                client.set_protocol_field("request_id".to_string(), serde_json::json!(1));
119                client.set_protocol_field("initialized".to_string(), serde_json::json!(false));
120            })
121            .await;
122
123        // Phase 1: Send initialize request
124        let init_response = Self::send_initialize_request(
125            &http_client,
126            &base_url,
127            client_id,
128            &app_state,
129            &status_tx,
130            &client_name,
131            &client_version,
132        )
133        .await?;
134
135        // Parse server info from response
136        let server_info = init_response
137            .get("serverInfo")
138            .and_then(|v| v.as_object())
139            .context("Missing serverInfo in initialize response")?;
140
141        let server_name = server_info
142            .get("name")
143            .and_then(|v| v.as_str())
144            .unwrap_or("unknown");
145
146        let server_version = server_info
147            .get("version")
148            .and_then(|v| v.as_str())
149            .unwrap_or("unknown");
150
151        let capabilities = init_response
152            .get("capabilities")
153            .cloned()
154            .unwrap_or(json!({}));
155
156        // Store server capabilities
157        app_state
158            .with_client_mut(client_id, |client| {
159                client.set_protocol_field("server_info".to_string(), json!(server_info));
160                client.set_protocol_field("capabilities".to_string(), capabilities.clone());
161            })
162            .await;
163
164        // Phase 2: Send initialized notification
165        Self::send_initialized_notification(&http_client, &base_url, &status_tx).await?;
166
167        // Mark as initialized
168        app_state
169            .with_client_mut(client_id, |client| {
170                client.set_protocol_field("initialized".to_string(), serde_json::json!(true));
171            })
172            .await;
173
174        // Update status to connected
175        app_state
176            .update_client_status(client_id, ClientStatus::Connected)
177            .await;
178        Log::new(Some(&status_tx)).info(format!(
179            "MCP client {} initialized with server {}",
180            client_id, server_name
181        ));
182        let _ = status_tx.send("__UPDATE_UI__".to_string());
183
184        info!("MCP client {} initialization complete", client_id);
185
186        // Create connected event and call LLM
187        let event = Event::new(
188            &MCP_CLIENT_CONNECTED_EVENT,
189            json!({
190                "server_name": server_name,
191                "server_version": server_version,
192                "capabilities": capabilities,
193            }),
194        );
195
196        // Command channel for injected actions (the dashboard's [ send ] row).
197        // Registered - and already being drained by its own task - BEFORE the
198        // connected-event LLM call, which a manual `*` rule can park for minutes: the
199        // operator must be able to reach the client while it waits. This task also
200        // replaces the old 5s "is the client gone yet" poll: the channel closes when the
201        // client is removed, which ends the loop immediately.
202        let command_rx =
203            crate::client::command_support::register_command_channel(&app_state, client_id).await;
204        let cmd_task = tokio::spawn(Self::command_loop(
205            command_rx,
206            client_id,
207            http_client.clone(),
208            app_state.clone(),
209            llm_client.clone(),
210            status_tx.clone(),
211        ));
212        app_state.register_client_task(client_id, cmd_task).await;
213
214        // Spawn task to handle LLM interactions
215        let http_client_clone = http_client.clone();
216        // Registered with AppState so stop_client can abort this task —
217        // dropping a JoinHandle only detaches it in Tokio.
218        let task_registrar = app_state.clone();
219        let task_handle = tokio::spawn(async move {
220            let protocol = Arc::new(McpClientProtocol::new());
221
222            // Get instruction and memory
223            let instruction = app_state
224                .get_instruction_for_client(client_id)
225                .await
226                .unwrap_or_default();
227            let memory = app_state
228                .get_memory_for_client(client_id)
229                .await
230                .unwrap_or_default();
231
232            // Initial LLM call with connected event
233            match call_llm_for_client(
234                &llm_client,
235                &app_state,
236                client_id.to_string(),
237                &instruction,
238                &memory,
239                Some(&event),
240                protocol.as_ref(),
241                &status_tx,
242            )
243            .await
244            {
245                Ok(result) => {
246                    // Update memory
247                    if let Some(mem) = result.memory_updates {
248                        app_state.set_memory_for_client(client_id, mem).await;
249                    }
250
251                    // Execute actions from LLM
252                    if let Err(e) = Self::execute_llm_actions(
253                        client_id,
254                        result.actions,
255                        &http_client_clone,
256                        &llm_client,
257                        &app_state,
258                        &status_tx,
259                        protocol.clone(),
260                    )
261                    .await
262                    {
263                        Log::new(Some(&status_tx))
264                            .error(format!("Failed to execute LLM actions: {}", e));
265                    }
266                }
267                Err(e) => {
268                    Log::new(Some(&status_tx)).error(format!("Failed to call LLM: {}", e));
269                }
270            }
271        });
272        task_registrar
273            .register_client_task(client_id, task_handle)
274            .await;
275
276        // Return dummy local address (MCP is HTTP-based)
277        Ok("0.0.0.0:0".parse().unwrap())
278    }
279
280    /// Send initialize request to MCP server
281    ///
282    /// `client_name` / `client_version` come from the startup parameters of the same name
283    /// and become the `clientInfo` this client presents; they fall back to
284    /// [`DEFAULT_CLIENT_NAME`] / [`DEFAULT_CLIENT_VERSION`].
285    async fn send_initialize_request(
286        http_client: &reqwest::Client,
287        base_url: &str,
288        client_id: ClientId,
289        app_state: &Arc<AppState>,
290        status_tx: &mpsc::UnboundedSender<String>,
291        client_name: &str,
292        client_version: &str,
293    ) -> Result<Value> {
294        let request_id: i64 = app_state
295            .with_client_mut(client_id, |client| {
296                let id = client
297                    .get_protocol_field("request_id")
298                    .and_then(|v| v.as_i64())
299                    .unwrap_or(1);
300                client.set_protocol_field("request_id".to_string(), json!(id + 1));
301                id
302            })
303            .await
304            .context("Client not found")?;
305
306        let request = JsonRpcRequest {
307            jsonrpc: "2.0".to_string(),
308            method: "initialize".to_string(),
309            params: Some(json!({
310                "protocolVersion": "2024-11-05",
311                "capabilities": {
312                    "roots": {
313                        "listChanged": false
314                    }
315                },
316                "clientInfo": {
317                    "name": client_name,
318                    "version": client_version
319                }
320            })),
321            id: request_id,
322        };
323
324        debug!("Sending initialize request: {:?}", request);
325        Log::new(Some(status_tx)).info("Sending MCP initialize request".to_string());
326
327        let response = http_client
328            .post(base_url)
329            .json(&request)
330            .send()
331            .await
332            .context("Failed to send initialize request")?;
333
334        let status = response.status();
335        let response_text = crate::client::http_fetch::read_response_text(
336            response,
337            crate::client::http_fetch::transport::MAX_RESPONSE_BODY_BYTES,
338        )
339        .await?;
340
341        debug!(
342            "Initialize response status: {}, body: {}",
343            status, response_text
344        );
345
346        if !status.is_success() {
347            return Err(anyhow::anyhow!("HTTP error {}: {}", status, response_text));
348        }
349
350        let json_response: JsonRpcResponse =
351            serde_json::from_str(&response_text).context("Failed to parse initialize response")?;
352
353        if let Some(error) = json_response.error {
354            return Err(anyhow::anyhow!(
355                "JSON-RPC error {}: {}",
356                error.code,
357                error.message
358            ));
359        }
360
361        json_response
362            .result
363            .context("Missing result in initialize response")
364    }
365
366    /// Send the `notifications/initialized` notification to the MCP server.
367    ///
368    /// The method name is `notifications/initialized`, not `initialized`. MCP namespaces every
369    /// notification under `notifications/`, and this client sent the bare name — so phase 3 of
370    /// the handshake was an unrecognised notification to every server it ever talked to,
371    /// **including NetGet's own MCP server**, whose router matches `notifications/initialized`
372    /// and drops anything else into a `debug!("Unknown MCP notification")`. Nothing failed
373    /// visibly because a notification has no reply, which is exactly why it survived: the
374    /// client logged "Sending MCP initialized notification", got a 204, and declared the
375    /// handshake complete.
376    async fn send_initialized_notification(
377        http_client: &reqwest::Client,
378        base_url: &str,
379        status_tx: &mpsc::UnboundedSender<String>,
380    ) -> Result<()> {
381        let notification = JsonRpcNotification {
382            jsonrpc: "2.0".to_string(),
383            method: "notifications/initialized".to_string(),
384            params: Some(json!({})),
385        };
386
387        debug!("Sending initialized notification: {:?}", notification);
388        Log::new(Some(status_tx)).info("Sending MCP initialized notification".to_string());
389
390        http_client
391            .post(base_url)
392            .json(&notification)
393            .send()
394            .await
395            .context("Failed to send initialized notification")?;
396
397        Ok(())
398    }
399
400    /// Execute actions returned by LLM
401    fn execute_llm_actions<'a>(
402        client_id: ClientId,
403        actions: Vec<Value>,
404        http_client: &'a reqwest::Client,
405        llm_client: &'a OllamaClient,
406        app_state: &'a Arc<AppState>,
407        status_tx: &'a mpsc::UnboundedSender<String>,
408        protocol: Arc<McpClientProtocol>,
409    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
410        Box::pin(async move {
411            for action in actions {
412                debug!("Executing action: {:?}", action);
413
414                // Parse action
415                let action_result = protocol.as_ref().execute_action(action.clone())?;
416
417                match Self::apply_action(
418                    client_id,
419                    action_result,
420                    Notify::Inline,
421                    http_client,
422                    llm_client,
423                    app_state,
424                    status_tx,
425                    protocol.clone(),
426                )
427                .await
428                {
429                    Ok(Applied::Disconnect) => break,
430                    Ok(Applied::Executed(_)) => {}
431                    Err(e) => {
432                        Log::new(Some(status_tx))
433                            .error(format!("Failed to execute MCP action: {}", e));
434                    }
435                }
436            }
437
438            Ok(())
439        })
440    }
441
442    /// Apply one already-executed action result: the single place a JSON-RPC call is
443    /// issued and its `mcp_response_received` event fired, shared by the LLM path and by
444    /// injected commands so both behave identically.
445    ///
446    /// Boxed because it recurses through [`Self::execute_llm_actions`] for the follow-up
447    /// actions the response event produces.
448    #[allow(clippy::too_many_arguments)]
449    fn apply_action<'a>(
450        client_id: ClientId,
451        result: ClientActionResult,
452        notify: Notify,
453        http_client: &'a reqwest::Client,
454        llm_client: &'a OllamaClient,
455        app_state: &'a Arc<AppState>,
456        status_tx: &'a mpsc::UnboundedSender<String>,
457        protocol: Arc<McpClientProtocol>,
458    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Applied>> + Send + 'a>> {
459        Box::pin(async move {
460            match result {
461                ClientActionResult::Disconnect => {
462                    app_state
463                        .update_client_status(client_id, ClientStatus::Disconnected)
464                        .await;
465                    // Every exit path drops the handle so the dashboard stops offering
466                    // [ send ] into a dead client.
467                    app_state.remove_client_handle(client_id).await;
468                    Log::new(Some(status_tx))
469                        .info(format!("MCP client {} disconnected", client_id));
470                    Ok(Applied::Disconnect)
471                }
472                ClientActionResult::Custom { name, data } => {
473                    let response = Self::execute_mcp_action(
474                        client_id,
475                        &name,
476                        &data,
477                        http_client,
478                        app_state,
479                        status_tx,
480                    )
481                    .await?;
482
483                    let event_data = json!({
484                        "method": name,
485                        "result": response,
486                    });
487
488                    match notify {
489                        Notify::Inline => {
490                            Self::notify_response(
491                                client_id,
492                                event_data,
493                                http_client,
494                                llm_client,
495                                app_state,
496                                status_tx,
497                                protocol.clone(),
498                            )
499                            .await;
500                        }
501                        Notify::Deferred => {
502                            // The caller (the injected-command loop) already holds the
503                            // truthful JSON-RPC result and must reply to the operator
504                            // before this event's handler runs: a dashboard-created client
505                            // defaults to a `*` -> manual rule, so the handler can park for
506                            // a human's think time (300s by default), far longer than the
507                            // composer's 30s send timeout.
508                            let http_clone = http_client.clone();
509                            let llm_clone = llm_client.clone();
510                            let state_clone = app_state.clone();
511                            let status_clone = status_tx.clone();
512                            let protocol_clone = protocol.clone();
513                            let notify_handle = tokio::spawn(async move {
514                                McpClient::notify_response(
515                                    client_id,
516                                    event_data,
517                                    &http_clone,
518                                    &llm_clone,
519                                    &state_clone,
520                                    &status_clone,
521                                    protocol_clone,
522                                )
523                                .await;
524                            });
525                            // Registered so the notification - and the LLM call and any
526                            // follow-up JSON-RPC calls it makes - are aborted when the
527                            // client is stopped.
528                            app_state
529                                .register_client_task(client_id, notify_handle)
530                                .await;
531                        }
532                    }
533
534                    Ok(Applied::Executed(format!("{name} completed")))
535                }
536                other => {
537                    debug!("Ignoring non-custom action result: {:?}", other);
538                    Ok(Applied::Executed(
539                        "action produced no MCP request".to_string(),
540                    ))
541                }
542            }
543        })
544    }
545
546    /// Fire one `mcp_response_received` event at the LLM, apply any memory update, and
547    /// run the follow-up actions it asks for.
548    #[allow(clippy::too_many_arguments)]
549    async fn notify_response(
550        client_id: ClientId,
551        event_data: Value,
552        http_client: &reqwest::Client,
553        llm_client: &OllamaClient,
554        app_state: &Arc<AppState>,
555        status_tx: &mpsc::UnboundedSender<String>,
556        protocol: Arc<McpClientProtocol>,
557    ) {
558        let event = Event::new(&MCP_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
559
560        let instruction = app_state
561            .get_instruction_for_client(client_id)
562            .await
563            .unwrap_or_default();
564        let memory = app_state
565            .get_memory_for_client(client_id)
566            .await
567            .unwrap_or_default();
568
569        match call_llm_for_client(
570            llm_client,
571            app_state,
572            client_id.to_string(),
573            &instruction,
574            &memory,
575            Some(&event),
576            protocol.as_ref(),
577            status_tx,
578        )
579        .await
580        {
581            Ok(result) => {
582                if let Some(mem) = result.memory_updates {
583                    app_state.set_memory_for_client(client_id, mem).await;
584                }
585                if let Err(e) = Self::execute_llm_actions(
586                    client_id,
587                    result.actions,
588                    http_client,
589                    llm_client,
590                    app_state,
591                    status_tx,
592                    protocol.clone(),
593                )
594                .await
595                {
596                    error!("Failed to execute nested actions: {}", e);
597                }
598            }
599            Err(e) => {
600                error!("Failed to call LLM: {}", e);
601            }
602        }
603    }
604
605    /// Drain injected commands until the channel closes (client removed) or an injected
606    /// `disconnect` ends the session.
607    ///
608    /// `command_support::handle_stream_client_command` cannot serve this client: it writes
609    /// `SendData` to a socket, and every MCP verb yields `ClientActionResult::Custom` that
610    /// has to become a JSON-RPC POST. So the action goes through [`Self::apply_action`] -
611    /// the same function the LLM path uses - and the outcome is recorded and replied
612    /// exactly the way the generic arm does it.
613    async fn command_loop(
614        mut command_rx: tokio::sync::mpsc::Receiver<ClientCommand>,
615        client_id: ClientId,
616        http_client: reqwest::Client,
617        app_state: Arc<AppState>,
618        llm_client: OllamaClient,
619        status_tx: mpsc::UnboundedSender<String>,
620    ) {
621        let protocol = Arc::new(McpClientProtocol::new());
622
623        while let Some(command) = command_rx.recv().await {
624            let action = command.action.clone();
625            let outcome = match protocol.as_ref().execute_action(action.clone()) {
626                Err(e) => Ok(ClientSendOutcome::Rejected {
627                    error: e.to_string(),
628                }),
629                // The JSON-RPC call is awaited, so the reported outcome describes a
630                // request that has actually completed. Notify::Deferred delivers the
631                // mcp_response_received event from its own registered task, so a manual
632                // handler parked for a human's think time cannot wedge this loop or time
633                // out the dashboard's [ send ].
634                Ok(result) => Self::apply_action(
635                    client_id,
636                    result,
637                    Notify::Deferred,
638                    &http_client,
639                    &llm_client,
640                    &app_state,
641                    &status_tx,
642                    protocol.clone(),
643                )
644                .await
645                .map(|applied| match applied {
646                    Applied::Disconnect => ClientSendOutcome::Disconnected,
647                    Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
648                }),
649            };
650
651            let outcome_json = match &outcome {
652                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
653                Err(e) => serde_json::json!({"error": e.to_string()}),
654            };
655            app_state
656                .record_access_log(
657                    AccessLogOwner::Client(client_id.as_u32()),
658                    protocol.protocol_name(),
659                    None,
660                    "injected_action",
661                    action,
662                    vec![outcome_json],
663                )
664                .await;
665
666            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
667            if let Err(e) = &outcome {
668                error!("MCP client {} injected action failed: {}", client_id, e);
669                let _ = status_tx.send(format!(
670                    "[WARN] Client {} injected action failed: {}",
671                    client_id, e
672                ));
673            }
674            let _ = status_tx.send("__UPDATE_UI__".to_string());
675            crate::client::command_support::reply(command, outcome);
676
677            if disconnect {
678                break;
679            }
680        }
681
682        info!("MCP client {} command loop stopped", client_id);
683        app_state.remove_client_handle(client_id).await;
684        let _ = status_tx.send("__UPDATE_UI__".to_string());
685    }
686
687    /// Execute a specific MCP action
688    async fn execute_mcp_action(
689        client_id: ClientId,
690        action_name: &str,
691        data: &Value,
692        http_client: &reqwest::Client,
693        app_state: &Arc<AppState>,
694        status_tx: &mpsc::UnboundedSender<String>,
695    ) -> Result<Value> {
696        // Read the two fields and bump the counter under one guard, then decide.
697        //
698        // These were `.expect("Missing base_url")` / `.expect("Missing request_id")` *inside*
699        // the `with_client_mut` closure, so a client whose protocol fields had not been set —
700        // or had been reset — panicked while the `AppState` write guard was held. The panic
701        // landed inside the spawned command loop or notify task, which swallows it, so the
702        // symptom was a client that silently stopped answering: no error, no log line, and
703        // `[ send ]` timing out on a request that was never made.
704        let (base_url, request_id) = app_state
705            .with_client_mut(client_id, |client| {
706                let url = client
707                    .get_protocol_field("base_url")
708                    .and_then(|v| v.as_str())
709                    .map(|s| s.to_string());
710
711                let id = client
712                    .get_protocol_field("request_id")
713                    .and_then(|v| v.as_i64());
714
715                if let Some(id) = id {
716                    client.set_protocol_field("request_id".to_string(), json!(id + 1));
717                }
718
719                (url, id)
720            })
721            .await
722            .context("Client not found")?;
723
724        let base_url = base_url.context(
725            "MCP client has no base_url; it was never initialized or its state was reset",
726        )?;
727        let request_id = request_id.context(
728            "MCP client has no request_id; it was never initialized or its state was reset",
729        )?;
730
731        let (method, params) = match action_name {
732            "mcp_list_resources" => ("resources/list".to_string(), None),
733            "mcp_read_resource" => {
734                let uri = data
735                    .get("uri")
736                    .and_then(|v| v.as_str())
737                    .context("Missing uri in read_resource")?;
738                ("resources/read".to_string(), Some(json!({"uri": uri})))
739            }
740            "mcp_list_tools" => ("tools/list".to_string(), None),
741            "mcp_call_tool" => {
742                let name = data
743                    .get("name")
744                    .and_then(|v| v.as_str())
745                    .context("Missing name in call_tool")?;
746                let arguments = data.get("arguments").cloned().unwrap_or(json!({}));
747                (
748                    "tools/call".to_string(),
749                    Some(json!({
750                        "name": name,
751                        "arguments": arguments
752                    })),
753                )
754            }
755            "mcp_list_prompts" => ("prompts/list".to_string(), None),
756            "mcp_get_prompt" => {
757                let name = data
758                    .get("name")
759                    .and_then(|v| v.as_str())
760                    .context("Missing name in get_prompt")?;
761                // `arguments` is omitted when absent rather than sent as `null`: MCP declares
762                // it optional, and a server that validates the field against its declared
763                // object type rejects an explicit null. `Some(json!({... "arguments": None}))`
764                // serialised to exactly that.
765                let mut params = serde_json::Map::new();
766                params.insert("name".to_string(), json!(name));
767                if let Some(arguments) = data.get("arguments") {
768                    if !arguments.is_null() {
769                        params.insert("arguments".to_string(), arguments.clone());
770                    }
771                }
772                ("prompts/get".to_string(), Some(Value::Object(params)))
773            }
774            _ => return Err(anyhow::anyhow!("Unknown MCP action: {}", action_name)),
775        };
776
777        let request = JsonRpcRequest {
778            jsonrpc: "2.0".to_string(),
779            method: method.clone(),
780            params,
781            id: request_id,
782        };
783
784        debug!("Sending MCP request: {:?}", request);
785        Log::new(Some(status_tx)).info(format!("Sending MCP request: {}", method));
786
787        let response = http_client
788            .post(&base_url)
789            .json(&request)
790            .send()
791            .await
792            .context("Failed to send MCP request")?;
793
794        let status = response.status();
795        let response_text = crate::client::http_fetch::read_response_text(
796            response,
797            crate::client::http_fetch::transport::MAX_RESPONSE_BODY_BYTES,
798        )
799        .await?;
800
801        debug!("MCP response status: {}, body: {}", status, response_text);
802
803        if !status.is_success() {
804            return Err(anyhow::anyhow!("HTTP error {}: {}", status, response_text));
805        }
806
807        let json_response: JsonRpcResponse =
808            serde_json::from_str(&response_text).context("Failed to parse MCP response")?;
809
810        if let Some(error) = json_response.error {
811            return Err(anyhow::anyhow!(
812                "JSON-RPC error {}: {}",
813                error.code,
814                error.message
815            ));
816        }
817
818        json_response
819            .result
820            .context("Missing result in MCP response")
821    }
822}
823
824/// When an action's `mcp_response_received` event is delivered to the LLM.
825///
826/// MCP always awaits the JSON-RPC call itself; only the notification moves. The request is
827/// already inside a spawned task on the LLM-driven path, so unlike `http` there is no
828/// second "spawn the request" mode here.
829#[derive(Clone, Copy)]
830enum Notify {
831    /// Fire the event and run its follow-up actions before returning. The LLM-driven path.
832    Inline,
833    /// Fire the event from its own registered task and return at once. The
834    /// injected-command path, which must reply to the operator first.
835    Deferred,
836}
837
838/// What [`McpClient::apply_action`] did with one action. MCP rides on reqwest, so there
839/// is no honest byte count to report - only "the JSON-RPC call ran" or "the session
840/// should end".
841enum Applied {
842    /// The action ran; the string says what, for the injected action's outcome detail.
843    Executed(String),
844    /// The session should end.
845    Disconnect,
846}