Skip to main content

netget/server/mcp/
mod.rs

1//! MCP (Model Context Protocol) server implementation
2//!
3//! This module implements an MCP server that allows LLM to control all server capabilities.
4//! MCP is built on JSON-RPC 2.0 and provides a standardized way for LLM applications
5//! to access external resources, tools, and prompts.
6//!
7//! Key features:
8//! - JSON-RPC 2.0 over HTTP/SSE transport
9//! - Full LLM control over resources, tools, and prompts
10//! - Session-based state management
11//! - Three-phase initialization (initialize → response → initialized)
12//! - Support for resource subscriptions, tool execution, and prompt templates
13
14pub mod actions;
15pub mod jsonrpc;
16
17use anyhow::Result;
18use axum::{
19    extract::{rejection::JsonRejection, DefaultBodyLimit, Json, State as AxumState},
20    http::StatusCode,
21    response::{IntoResponse, Response},
22    routing::post,
23    Router,
24};
25use jsonrpc::{ErrorCode, JsonRpcError, JsonRpcMessage, JsonRpcResponse};
26use serde_json::Value;
27use std::net::SocketAddr;
28use std::sync::Arc;
29use tokio::sync::mpsc;
30use tracing::{debug, error, info, trace, warn};
31
32use crate::console_error;
33#[cfg(not(target_arch = "wasm32"))]
34use crate::llm::action_helper::call_llm;
35#[cfg(not(target_arch = "wasm32"))]
36use crate::llm::ollama_client::OllamaClient;
37#[cfg(not(target_arch = "wasm32"))]
38use crate::logging::emit::Log;
39#[cfg(not(target_arch = "wasm32"))]
40use crate::protocol::Event;
41#[cfg(not(target_arch = "wasm32"))]
42use crate::server::connection::ConnectionId;
43#[cfg(not(target_arch = "wasm32"))]
44use crate::server::McpProtocol;
45#[cfg(not(target_arch = "wasm32"))]
46use crate::state::app_state::AppState;
47#[cfg(not(target_arch = "wasm32"))]
48use crate::state::server::{ConnectionStatus, ProtocolConnectionInfo, ServerId};
49#[cfg(not(target_arch = "wasm32"))]
50use actions::{
51    MCP_INITIALIZE_EVENT, MCP_PROMPTS_GET_EVENT, MCP_PROMPTS_LIST_EVENT, MCP_RESOURCES_LIST_EVENT,
52    MCP_RESOURCES_READ_EVENT, MCP_TOOLS_CALL_EVENT, MCP_TOOLS_LIST_EVENT,
53};
54#[cfg(not(target_arch = "wasm32"))]
55use jsonrpc::RequestId;
56
57/// MCP server shared state
58#[derive(Clone)]
59pub struct McpServerState {
60    /// LLM client for generating responses
61    pub llm_client: OllamaClient,
62    /// Application state
63    pub app_state: Arc<AppState>,
64    /// Status message sender
65    pub status_tx: mpsc::UnboundedSender<String>,
66    /// Server ID for tracking connections
67    pub server_id: ServerId,
68    /// Protocol implementation
69    pub protocol: Arc<McpProtocol>,
70    /// Local address the server is bound to
71    pub local_addr: SocketAddr,
72}
73
74/// The largest JSON-RPC request body this server buffers, and its declared
75/// `max_inbound_bytes`.
76///
77/// Set explicitly on the router as `DefaultBodyLimit`, rather than inherited: axum's own
78/// default happens to be the same 2 MiB, but a framework default is a number nobody chose and
79/// nothing would notice changing. Enforced while the body is buffered — up front from a
80/// `Content-Length`, as it streams for a chunked body — so an over-limit request never reaches
81/// the JSON parser, the event or the model. The refusal is a 413 carrying a JSON-RPC error with
82/// a fixed message ([`BODY_TOO_LARGE_MESSAGE`]).
83pub const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
84
85/// Message of the JSON-RPC error in the 413 for an over-limit body. Fixed: the peer's length
86/// goes to the log.
87#[cfg(not(target_arch = "wasm32"))]
88const BODY_TOO_LARGE_MESSAGE: &str = "request body too large";
89
90/// Largest slice of a request echoed onto the status channel.
91///
92/// The whole request used to be serialized onto `status_tx` on every call. That channel is
93/// unbounded with no backpressure (see the root CLAUDE.md), so a client posting bodies at
94/// axum's 2 MiB default limit could enqueue faster than the TUI drains.
95#[cfg(not(target_arch = "wasm32"))]
96const MAX_TRACE_BYTES: usize = 4096;
97
98/// MCP revisions this server will echo back in an `initialize` reply.
99#[cfg(not(target_arch = "wasm32"))]
100const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2024-11-05", "2025-03-26", "2025-06-18"];
101
102/// Implementation-defined JSON-RPC error code for "the backend is at capacity, retry".
103///
104/// JSON-RPC 2.0 reserves -32000..=-32099 for server-defined errors. Reporting overload as
105/// -32603 (InternalError) would tell the caller the server is broken when it is only busy.
106#[cfg(not(target_arch = "wasm32"))]
107const MCP_SERVER_BUSY_CODE: i32 = -32000;
108
109/// Offered when the client asks for a revision not in the list above.
110#[cfg(not(target_arch = "wasm32"))]
111const DEFAULT_PROTOCOL_VERSION: &str = "2024-11-05";
112
113/// How long to wait for the peer's first byte after it connects.
114///
115/// MCP rides on HTTP POST and is client-speaks-first: the request line is the first thing on
116/// the wire and the server says nothing before it. A peer that has connected and sent nothing
117/// has made no request, which is the state an unauthenticated flood lives in — and the one this
118/// server had no bound on at all.
119#[cfg(not(target_arch = "wasm32"))]
120const FIRST_REQUEST_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
121
122/// How long to wait for a *further* request on a keep-alive connection.
123///
124/// MCP is strictly request/response over HTTP, and a client holds its connection open between
125/// calls — an editor with an MCP server attached may go minutes between tool calls while a
126/// human thinks. Five minutes covers that; a client whose connection is reaped reconnects on
127/// its next call and loses nothing, because MCP session state lives in this server's own map
128/// and is keyed by session id, not by socket.
129///
130/// Crucially this bound applies **only while no request is outstanding** — see
131/// `relay_mcp_connection`. A request waiting on the model, or parked for a human at the
132/// dashboard (`src/state/intercepts.rs`, 300s by default), is not idle and is never timed out.
133#[cfg(not(target_arch = "wasm32"))]
134const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
135
136/// Concurrent connections this server admits.
137#[cfg(not(target_arch = "wasm32"))]
138const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
139
140/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
141///
142/// HTTP `503` carrying a JSON-RPC error object with [`MCP_SERVER_BUSY_CODE`] — both layers of
143/// this protocol's own vocabulary at once, so an HTTP client sees a 503 with `Retry-After` and
144/// a JSON-RPC client that reads the body sees the same "server is at capacity, retry" code this
145/// server already returns when the backend is overloaded.
146///
147/// `Content-Length` is 82, which is the length of the JSON object on the last line.
148#[cfg(not(target_arch = "wasm32"))]
149const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
150    Content-Type: application/json\r\nContent-Length: 82\r\nRetry-After: 5\r\n\
151    Connection: close\r\n\r\n\
152    {\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32000,\"message\":\"server at capacity\"}}";
153
154/// Turn an `mcp_error_response` action result into the JSON-RPC error it describes.
155///
156/// `mcp_error_response` is offered to the handler on every MCP event. Nothing used to consume
157/// its result, so the chosen `code` and `message` were dropped and the caller received either
158/// a generic `-32603` or - worse - a *success* reply such as `{"tools": []}`.
159#[cfg(not(target_arch = "wasm32"))]
160fn mcp_error_from_action(data: &Value) -> JsonRpcError {
161    // i64 rather than i32: JSON-RPC codes are small, but `as i32` would wrap a large number
162    // into a valid-looking code. Out-of-range values become InternalError.
163    let code = data
164        .get("code")
165        .and_then(|v| v.as_i64())
166        .and_then(|n| i32::try_from(n).ok())
167        .unwrap_or(ErrorCode::InternalError as i32);
168
169    JsonRpcError {
170        code,
171        message: data
172            .get("message")
173            .and_then(|v| v.as_str())
174            .unwrap_or("Internal error")
175            .to_string(),
176        data: data.get("data").cloned(),
177    }
178}
179
180/// Turn a failed LLM call into the JSON-RPC error the caller gets back.
181///
182/// Every MCP method routes its failure through here so the shape is identical
183/// across all seven, and so the caller always receives a response - `handle_jsonrpc`
184/// re-attaches the request `id` to whatever this returns, which is what lets the
185/// client match the failure to its request instead of waiting on a reply that
186/// never comes.
187///
188/// Overload is reported separately because it is transient: `-32603` says the
189/// server is broken, while a server-defined `-32000` with a retry hint says it is
190/// merely full. The JSON-RPC 2.0 spec reserves -32000..=-32099 for exactly this.
191///
192/// The peer-visible `message` comes from [`WireFailure`], which returns `&'static str`
193/// precisely so nothing derived from the error can reach the wire: the backend URL, the
194/// model name and the `anyhow` context chain go to the log and the status stream only.
195///
196/// The log line carries a `decision=` tag so the three outcomes an operator has to tell
197/// apart stay greppable: `decision=fail_closed_llm_error_overloaded`,
198/// `decision=fail_closed_llm_error_unavailable` (here), `decision=model_reject`
199/// (the handler chose `mcp_error`) and `decision=model_no_answer` (the handler ran but
200/// produced no usable action).
201#[cfg(not(target_arch = "wasm32"))]
202fn llm_failure_error(state: &McpServerState, method: &str, e: anyhow::Error) -> JsonRpcError {
203    let failure = crate::utils::WireFailure::classify(&e);
204    let decision = if failure.is_overloaded() {
205        "fail_closed_llm_error_overloaded"
206    } else {
207        "fail_closed_llm_error_unavailable"
208    };
209    error!("MCP {} decision={}: {}", method, decision, e);
210    Log::new(Some(&state.status_tx)).error(format!("MCP {} decision={}: {}", method, decision, e));
211
212    // `data` reaches the client, so it carries the retry hint and nothing else - the error
213    // itself was logged on both channels above. See `crate::utils::wire_failure`.
214    if failure.is_overloaded() {
215        return JsonRpcError {
216            code: MCP_SERVER_BUSY_CODE,
217            message: failure.text().to_string(),
218            data: Some(serde_json::json!({"retryable": true})),
219        };
220    }
221
222    JsonRpcError {
223        code: ErrorCode::InternalError as i32,
224        message: failure.text().to_string(),
225        data: Some(serde_json::json!({"retryable": false})),
226    }
227}
228
229/// Log that the handler explicitly chose to fail this request (`mcp_error`).
230///
231/// Distinct from a no-answer and from a backend failure: this one the model meant.
232#[cfg(not(target_arch = "wasm32"))]
233fn log_model_reject(state: &McpServerState, method: &str) {
234    warn!(
235        "MCP {} decision=model_reject: handler returned mcp_error",
236        method
237    );
238    Log::new(Some(&state.status_tx)).warn(format!(
239        "MCP {} decision=model_reject (handler returned mcp_error)",
240        method
241    ));
242}
243
244/// Log that the handler ran without erroring but produced no usable action, so the
245/// hardcoded default below is what the caller receives.
246#[cfg(not(target_arch = "wasm32"))]
247fn log_model_no_answer(state: &McpServerState, method: &str, fallback: &str) {
248    warn!(
249        "MCP {} decision=model_no_answer: replying with {}",
250        method, fallback
251    );
252    Log::new(Some(&state.status_tx)).warn(format!(
253        "MCP {} decision=model_no_answer (replying with {})",
254        method, fallback
255    ));
256}
257
258/// Recover a request id from a payload that failed to parse as a JSON-RPC request.
259///
260/// JSON-RPC 2.0 requires the id to be echoed whenever it can be determined. The parse-failure
261/// path used to pass `None` unconditionally, so a request whose `jsonrpc` field was missing or
262/// whose `method` was not a string came back with `"id": null` even though the id was sitting
263/// in the payload, leaving the client unable to match the error to its request.
264#[cfg(not(target_arch = "wasm32"))]
265fn recover_request_id(payload: &Value) -> Option<RequestId> {
266    match payload.get("id")? {
267        Value::String(s) => Some(RequestId::String(s.clone())),
268        Value::Number(n) => n.as_i64().map(RequestId::Number),
269        _ => None,
270    }
271}
272
273/// MCP server that handles Model Context Protocol over HTTP
274pub struct McpServer;
275
276#[cfg(not(target_arch = "wasm32"))]
277impl McpServer {
278    /// Spawn MCP server with Axum on HTTP (default port 8000)
279    pub async fn spawn_with_llm_actions(
280        listen_addr: SocketAddr,
281        llm_client: OllamaClient,
282        app_state: Arc<AppState>,
283        status_tx: mpsc::UnboundedSender<String>,
284        server_id: ServerId,
285    ) -> Result<SocketAddr> {
286        let listener = tokio::net::TcpListener::bind(listen_addr).await?;
287        let local_addr = listener.local_addr()?;
288
289        info!("MCP server (JSON-RPC 2.0) listening on {}", local_addr);
290        Log::new(Some(&status_tx)).info(format!("MCP server listening on {}", local_addr));
291
292        let protocol = Arc::new(McpProtocol::new());
293
294        let task_registrar = app_state.clone();
295        let server_state = McpServerState {
296            llm_client,
297            app_state,
298            status_tx: status_tx.clone(),
299            server_id,
300            protocol,
301            local_addr,
302        };
303
304        // Build Axum router
305        let app = Router::new()
306            .route("/", post(handle_jsonrpc))
307            .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
308            .with_state(server_state);
309
310        // `axum::serve` owns its accept loop and takes a concrete `TcpListener`, so there is no
311        // seam inside it for a connection cap or a read deadline — the same wall
312        // `src/server/nfs/guard.rs` hit with `NFSTcpListener`, and the answer is the same one:
313        // NetGet keeps the public listener and runs the crate behind it on a loopback-only
314        // ephemeral port, screening every connection on its own side of the socket.
315        //
316        // The cost is honest and worth stating: the axum handler sees the loopback relay as its
317        // peer, so `handle_jsonrpc` cannot report the real client address, and the loopback
318        // backend is reachable by other processes on this machine (again as with NFS).
319        let backend = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
320        let backend_addr = backend.local_addr()?;
321
322        let backend_status = status_tx.clone();
323        let backend_handle = tokio::spawn(async move {
324            if let Err(e) = axum::serve(backend, app).await {
325                console_error!(backend_status, "MCP server error: {}", e);
326            }
327        });
328
329        let relay_state = task_registrar.clone();
330        let accept_handle = tokio::spawn(async move {
331            serve_screened_mcp(listener, backend_addr, status_tx, relay_state, server_id).await;
332        });
333
334        // Both tasks are registered: aborting only the front one would leave axum holding the
335        // loopback port, so `stop_server` would not actually stop the server.
336        task_registrar
337            .register_server_task(server_id, backend_handle)
338            .await;
339        task_registrar
340            .register_server_task(server_id, accept_handle)
341            .await;
342
343        Ok(local_addr)
344    }
345}
346
347/// Handle incoming JSON-RPC 2.0 requests
348#[cfg(not(target_arch = "wasm32"))]
349async fn handle_jsonrpc(
350    AxumState(state): AxumState<McpServerState>,
351    payload: Result<Json<Value>, JsonRejection>,
352) -> Response {
353    let payload = match payload {
354        Ok(Json(payload)) => payload,
355        Err(rejection) if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => {
356            warn!(
357                "MCP request body over {} bytes decision=fail_closed_body_too_large",
358                MAX_REQUEST_BODY_BYTES
359            );
360            Log::new(Some(&state.status_tx)).warn(format!(
361                "MCP refused a request body over {} bytes (decision=fail_closed_body_too_large)",
362                MAX_REQUEST_BODY_BYTES
363            ));
364            let body = serde_json::json!({
365                "jsonrpc": "2.0",
366                "id": null,
367                "error": {
368                    "code": ErrorCode::InvalidRequest.as_i32(),
369                    "message": BODY_TOO_LARGE_MESSAGE,
370                },
371            });
372            return (StatusCode::PAYLOAD_TOO_LARGE, Json(body)).into_response();
373        }
374        // Wrong content type, unparseable JSON: axum's own rejection, as before.
375        Err(rejection) => return rejection.into_response(),
376    };
377    trace!(
378        "MCP received JSON-RPC request: {}",
379        serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string())
380    );
381
382    let mut trace_body = serde_json::to_string(&payload).unwrap_or_default();
383    if trace_body.len() > MAX_TRACE_BYTES {
384        // Truncate on a char boundary: byte-slicing LLM- or client-supplied JSON panics on
385        // multi-byte UTF-8 at the cut point.
386        let cut = trace_body
387            .char_indices()
388            .take_while(|(i, _)| *i <= MAX_TRACE_BYTES)
389            .last()
390            .map(|(i, _)| i)
391            .unwrap_or(0);
392        trace_body.truncate(cut);
393        trace_body.push_str("… (truncated)");
394    }
395    Log::new(Some(&state.status_tx)).trace(format!("MCP received: {}", trace_body));
396
397    // Parse JSON-RPC message
398    let message = match JsonRpcMessage::from_value(payload.clone()) {
399        Ok(msg) => msg,
400        Err(e) => {
401            error!("Failed to parse JSON-RPC message: {:?}", e);
402            let response = JsonRpcResponse::error(recover_request_id(&payload), e);
403            return Json(response).into_response();
404        }
405    };
406
407    // Handle based on message type
408    match message {
409        JsonRpcMessage::Request(req) => {
410            let request_id = req.id.clone();
411            let method = req.method.clone();
412
413            debug!("MCP request: method={}, id={:?}", method, request_id);
414
415            // Route to appropriate handler
416            let result = match method.as_str() {
417                "initialize" => handle_initialize(&state, req.params).await,
418                "ping" => handle_ping(),
419                "resources/list" => handle_resources_list(&state).await,
420                "resources/read" => handle_resources_read(&state, req.params).await,
421                "resources/subscribe" => handle_resources_subscribe(&state, req.params).await,
422                "resources/unsubscribe" => handle_resources_unsubscribe(&state, req.params).await,
423                "resources/templates/list" => handle_resources_templates_list(&state).await,
424                "tools/list" => handle_tools_list(&state).await,
425                "tools/call" => handle_tools_call(&state, req.params).await,
426                "prompts/list" => handle_prompts_list(&state).await,
427                "prompts/get" => handle_prompts_get(&state, req.params).await,
428                "logging/setLevel" => handle_logging_set_level(&state, req.params).await,
429                "completion/complete" => handle_completion(&state, req.params).await,
430                _ => Err(JsonRpcError::new(ErrorCode::MethodNotFound)),
431            };
432
433            let response = match result {
434                Ok(value) => {
435                    trace!(
436                        "MCP response success: {}",
437                        serde_json::to_string_pretty(&value).unwrap_or_default()
438                    );
439                    JsonRpcResponse::success(request_id, value)
440                }
441                Err(e) => {
442                    error!("MCP error: code={}, message={}", e.code, e.message);
443                    Log::new(Some(&state.status_tx)).error(format!("MCP error: {}", e.message));
444                    JsonRpcResponse::error(request_id, e)
445                }
446            };
447
448            Json(response).into_response()
449        }
450        JsonRpcMessage::Notification(notif) => {
451            let method = notif.method.clone();
452            debug!("MCP notification: method={}", method);
453
454            // Handle notifications (no response)
455            match method.as_str() {
456                "notifications/initialized" => {
457                    handle_initialized(&state).await;
458                }
459                "notifications/cancelled" => {
460                    handle_cancelled(&state, notif.params).await;
461                }
462                "notifications/progress" => {
463                    handle_progress(&state, notif.params).await;
464                }
465                _ => {
466                    debug!("Unknown MCP notification: {}", method);
467                }
468            }
469
470            // Notifications don't return responses
471            StatusCode::NO_CONTENT.into_response()
472        }
473    }
474}
475
476/// Handle initialize request - LLM declares capabilities
477#[cfg(not(target_arch = "wasm32"))]
478async fn handle_initialize(
479    state: &McpServerState,
480    params: Option<Value>,
481) -> Result<Value, JsonRpcError> {
482    let connection_id = ConnectionId::new(state.app_state.get_next_unified_id().await);
483    let result = handle_initialize_inner(state, params, connection_id).await;
484    // Close on every exit path, including the error ones.
485    state
486        .app_state
487        .close_connection_on_server(state.server_id, connection_id)
488        .await;
489    result
490}
491
492#[cfg(not(target_arch = "wasm32"))]
493async fn handle_initialize_inner(
494    state: &McpServerState,
495    params: Option<Value>,
496    connection_id: ConnectionId,
497) -> Result<Value, JsonRpcError> {
498    info!("MCP initialize request");
499
500    // Extract client info from params
501    let client_info = params
502        .as_ref()
503        .and_then(|p| p.get("clientInfo"))
504        .map(|c| c.to_string())
505        .unwrap_or_else(|| "unknown".to_string());
506
507    debug!("MCP client: {}", client_info);
508    let _ = state
509        .status_tx
510        .send(format!("→ MCP client initializing: {}", client_info));
511
512    // Record the initialize exchange as a connection so it shows up in the TUI and MCP
513    // connection lists. It is marked closed at the end of this function: MCP here is
514    // request-scoped HTTP POST with no persistent connection, and leaving every entry Active
515    // meant an unauthenticated client could grow AppState without bound by repeating
516    // `initialize`.
517    //
518    // No session record is created. `McpSession` held initialized/capabilities/subscriptions
519    // plus tools/resources/prompts maps; the map it lived in was write-only - inserted here
520    // and read nowhere in the tree - and every mutator on it (`mark_initialized`,
521    // `subscribe`, `register_tool`, …) had zero call sites. It was both an unbounded leak and
522    // a protocol-level store of tools/resources/prompts, which the no-storage rule forbids.
523    // It is gone rather than left half-built: nothing consumed it, so nothing regresses.
524    state
525        .app_state
526        .add_connection_to_server(
527            state.server_id,
528            crate::state::ConnectionState {
529                id: connection_id,
530                remote_addr: state.local_addr, // HTTP POST carries no peer addr here
531                local_addr: state.local_addr,
532                bytes_sent: 0,
533                bytes_received: 0,
534                packets_sent: 0,
535                packets_received: 0,
536                last_activity: crate::utils::clock::Instant::now(),
537                status: ConnectionStatus::Active,
538                status_changed_at: crate::utils::clock::Instant::now(),
539                protocol_info: ProtocolConnectionInfo::empty(),
540            },
541        )
542        .await;
543
544    let requested_version = params
545        .as_ref()
546        .and_then(|p| p.get("protocolVersion"))
547        .and_then(|v| v.as_str())
548        .unwrap_or("unknown")
549        .to_string();
550
551    // Create event for LLM
552    let event = Event::new(
553        &MCP_INITIALIZE_EVENT,
554        serde_json::json!({
555            "method": "initialize",
556            "client_info": client_info,
557            "protocol_version": requested_version,
558            "capabilities": params.as_ref().and_then(|p| p.get("capabilities")),
559        }),
560    );
561
562    // Reuse the server's protocol instance rather than allocating a throwaway per request.
563    let protocol = state.protocol.clone();
564
565    Log::new(Some(&state.status_tx)).debug("MCP calling LLM for initialize request");
566
567    // Call LLM with action system
568    let execution_result = match call_llm(
569        &state.llm_client,
570        &state.app_state,
571        state.server_id,
572        Some(connection_id),
573        &event,
574        protocol.as_ref(),
575    )
576    .await
577    {
578        Ok(result) => result,
579        Err(e) => {
580            return Err(llm_failure_error(state, "initialize", e));
581        }
582    };
583
584    // Display messages from LLM
585    let log = Log::new(Some(&state.status_tx));
586    for message in &execution_result.messages {
587        log.info(format!("{}", message));
588    }
589
590    log.debug(format!(
591        "MCP got {} protocol results",
592        execution_result.protocol_results.len()
593    ));
594
595    // Process action results
596    for protocol_result in &execution_result.protocol_results {
597        use crate::llm::actions::protocol_trait::ActionResult;
598        if let ActionResult::Custom { name, data } = protocol_result {
599            // A handler that returned mcp_error_response used to be ignored entirely: the
600            // loop matched one name and fell through to the hardcoded default, so a chosen
601            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
602            if name == "mcp_error" {
603                log_model_reject(state, "initialize");
604                return Err(mcp_error_from_action(data));
605            }
606            if name == "mcp_initialize" {
607                if let Some(response) = data.get("response") {
608                    return Ok(response.clone());
609                }
610            }
611        }
612    }
613
614    // Default response if the handler does not provide one.
615    //
616    // The version is negotiated rather than hardcoded: MCP says the server echoes the client's
617    // requested version if it can speak it, and otherwise offers its own. This used to answer
618    // "2024-11-05" unconditionally, which tells a client on a newer revision that its request
619    // was honored when it was not.
620    log_model_no_answer(state, "initialize", "the negotiated protocol version");
621    let agreed_version = if SUPPORTED_PROTOCOL_VERSIONS.contains(&requested_version.as_str()) {
622        requested_version.as_str()
623    } else {
624        DEFAULT_PROTOCOL_VERSION
625    };
626
627    Ok(serde_json::json!({
628        "protocolVersion": agreed_version,
629        "capabilities": {
630            "resources": {},
631            "tools": {},
632            "prompts": {}
633        },
634        "serverInfo": {
635            "name": "netget-mcp",
636            "version": "0.1.0"
637        }
638    }))
639}
640
641/// Handle ping request - simple health check
642#[cfg(not(target_arch = "wasm32"))]
643fn handle_ping() -> Result<Value, JsonRpcError> {
644    Ok(serde_json::json!({}))
645}
646
647/// Handle resources/list request - LLM returns available resources
648#[cfg(not(target_arch = "wasm32"))]
649async fn handle_resources_list(state: &McpServerState) -> Result<Value, JsonRpcError> {
650    Log::new(Some(&state.status_tx)).debug("MCP resources/list request");
651
652    // Create event for LLM
653    let event = Event::new(
654        &MCP_RESOURCES_LIST_EVENT,
655        serde_json::json!({
656            "method": "resources/list",
657        }),
658    );
659
660    // Reuse the server's protocol instance rather than allocating a throwaway per request.
661    let protocol = state.protocol.clone();
662
663    // Call LLM with action system
664    let execution_result = match call_llm(
665        &state.llm_client,
666        &state.app_state,
667        state.server_id,
668        None,
669        &event,
670        protocol.as_ref(),
671    )
672    .await
673    {
674        Ok(result) => result,
675        Err(e) => {
676            return Err(llm_failure_error(state, "resources/list", e));
677        }
678    };
679
680    // Process action results
681    for protocol_result in &execution_result.protocol_results {
682        use crate::llm::actions::protocol_trait::ActionResult;
683        if let ActionResult::Custom { name, data } = protocol_result {
684            // A handler that returned mcp_error_response used to be ignored entirely: the
685            // loop matched one name and fell through to the hardcoded default, so a chosen
686            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
687            if name == "mcp_error" {
688                log_model_reject(state, "resources/list");
689                return Err(mcp_error_from_action(data));
690            }
691            if name == "mcp_resources_list" {
692                if let Some(response) = data.get("response") {
693                    return Ok(response.clone());
694                }
695            }
696        }
697    }
698
699    // Default: empty resources list
700    log_model_no_answer(state, "resources/list", "an empty resources list");
701    Ok(serde_json::json!({"resources": []}))
702}
703
704/// Handle resources/read request - LLM returns resource content
705#[cfg(not(target_arch = "wasm32"))]
706async fn handle_resources_read(
707    state: &McpServerState,
708    params: Option<Value>,
709) -> Result<Value, JsonRpcError> {
710    let uri = params
711        .as_ref()
712        .and_then(|p| p.get("uri"))
713        .and_then(|u| u.as_str())
714        .ok_or_else(|| JsonRpcError::new(ErrorCode::InvalidParams))?;
715
716    Log::new(Some(&state.status_tx)).debug(format!("MCP resources/read: {}", uri));
717
718    // Create event for LLM
719    let event = Event::new(
720        &MCP_RESOURCES_READ_EVENT,
721        serde_json::json!({
722            "method": "resources/read",
723            "uri": uri,
724        }),
725    );
726
727    // Reuse the server's protocol instance rather than allocating a throwaway per request.
728    let protocol = state.protocol.clone();
729
730    // Call LLM with action system
731    let execution_result = match call_llm(
732        &state.llm_client,
733        &state.app_state,
734        state.server_id,
735        None,
736        &event,
737        protocol.as_ref(),
738    )
739    .await
740    {
741        Ok(result) => result,
742        Err(e) => {
743            return Err(llm_failure_error(state, "resources/read", e));
744        }
745    };
746
747    // Process action results
748    for protocol_result in &execution_result.protocol_results {
749        use crate::llm::actions::protocol_trait::ActionResult;
750        if let ActionResult::Custom { name, data } = protocol_result {
751            // A handler that returned mcp_error_response used to be ignored entirely: the
752            // loop matched one name and fell through to the hardcoded default, so a chosen
753            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
754            if name == "mcp_error" {
755                log_model_reject(state, "resources/read");
756                return Err(mcp_error_from_action(data));
757            }
758            if name == "mcp_resources_read" {
759                if let Some(response) = data.get("response") {
760                    return Ok(response.clone());
761                }
762            }
763        }
764    }
765
766    // Default: resource not found
767    log_model_no_answer(state, "resources/read", "a JSON-RPC error");
768    Err(JsonRpcError::custom(
769        ErrorCode::InternalError,
770        format!("Resource not found: {}", uri),
771    ))
772}
773
774/// Handle resources/subscribe request
775#[cfg(not(target_arch = "wasm32"))]
776async fn handle_resources_subscribe(
777    _state: &McpServerState,
778    params: Option<Value>,
779) -> Result<Value, JsonRpcError> {
780    let uri = params
781        .as_ref()
782        .and_then(|p| p.get("uri"))
783        .and_then(|u| u.as_str())
784        .ok_or_else(|| JsonRpcError::new(ErrorCode::InvalidParams))?;
785
786    debug!("MCP resources/subscribe: uri={}", uri);
787
788    // TODO: Add LLM integration for subscription management
789    Ok(serde_json::json!({}))
790}
791
792/// Handle resources/unsubscribe request
793#[cfg(not(target_arch = "wasm32"))]
794async fn handle_resources_unsubscribe(
795    _state: &McpServerState,
796    params: Option<Value>,
797) -> Result<Value, JsonRpcError> {
798    let uri = params
799        .as_ref()
800        .and_then(|p| p.get("uri"))
801        .and_then(|u| u.as_str())
802        .ok_or_else(|| JsonRpcError::new(ErrorCode::InvalidParams))?;
803
804    debug!("MCP resources/unsubscribe: uri={}", uri);
805    Ok(serde_json::json!({}))
806}
807
808/// Handle resources/templates/list request
809#[cfg(not(target_arch = "wasm32"))]
810async fn handle_resources_templates_list(_state: &McpServerState) -> Result<Value, JsonRpcError> {
811    debug!("MCP resources/templates/list");
812
813    // TODO: Add LLM integration
814    Ok(serde_json::json!({
815        "resourceTemplates": []
816    }))
817}
818
819/// Handle tools/list request - LLM returns available tools
820#[cfg(not(target_arch = "wasm32"))]
821async fn handle_tools_list(state: &McpServerState) -> Result<Value, JsonRpcError> {
822    Log::new(Some(&state.status_tx)).debug("MCP tools/list request");
823
824    // Create event for LLM
825    let event = Event::new(
826        &MCP_TOOLS_LIST_EVENT,
827        serde_json::json!({
828            "method": "tools/list",
829        }),
830    );
831
832    // Reuse the server's protocol instance rather than allocating a throwaway per request.
833    let protocol = state.protocol.clone();
834
835    // Call LLM with action system
836    let execution_result = match call_llm(
837        &state.llm_client,
838        &state.app_state,
839        state.server_id,
840        None,
841        &event,
842        protocol.as_ref(),
843    )
844    .await
845    {
846        Ok(result) => result,
847        Err(e) => {
848            return Err(llm_failure_error(state, "tools/list", e));
849        }
850    };
851
852    // Process action results
853    for protocol_result in &execution_result.protocol_results {
854        use crate::llm::actions::protocol_trait::ActionResult;
855        if let ActionResult::Custom { name, data } = protocol_result {
856            // A handler that returned mcp_error_response used to be ignored entirely: the
857            // loop matched one name and fell through to the hardcoded default, so a chosen
858            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
859            if name == "mcp_error" {
860                log_model_reject(state, "tools/list");
861                return Err(mcp_error_from_action(data));
862            }
863            if name == "mcp_tools_list" {
864                if let Some(response) = data.get("response") {
865                    return Ok(response.clone());
866                }
867            }
868        }
869    }
870
871    // Default: empty tools list
872    log_model_no_answer(state, "tools/list", "an empty tools list");
873    Ok(serde_json::json!({"tools": []}))
874}
875
876/// Handle tools/call request - LLM executes tool
877#[cfg(not(target_arch = "wasm32"))]
878async fn handle_tools_call(
879    state: &McpServerState,
880    params: Option<Value>,
881) -> Result<Value, JsonRpcError> {
882    let tool_name = params
883        .as_ref()
884        .and_then(|p| p.get("name"))
885        .and_then(|n| n.as_str())
886        .ok_or_else(|| JsonRpcError::new(ErrorCode::InvalidParams))?;
887
888    let tool_arguments = params.as_ref().and_then(|p| p.get("arguments"));
889
890    Log::new(Some(&state.status_tx)).debug(format!("MCP tools/call: {}", tool_name));
891
892    // Create event for LLM
893    let event = Event::new(
894        &MCP_TOOLS_CALL_EVENT,
895        serde_json::json!({
896            "method": "tools/call",
897            "name": tool_name,
898            "arguments": tool_arguments,
899        }),
900    );
901
902    // Reuse the server's protocol instance rather than allocating a throwaway per request.
903    let protocol = state.protocol.clone();
904
905    // Call LLM with action system
906    let execution_result = match call_llm(
907        &state.llm_client,
908        &state.app_state,
909        state.server_id,
910        None,
911        &event,
912        protocol.as_ref(),
913    )
914    .await
915    {
916        Ok(result) => result,
917        Err(e) => {
918            return Err(llm_failure_error(state, "tools/call", e));
919        }
920    };
921
922    // Process action results
923    for protocol_result in &execution_result.protocol_results {
924        use crate::llm::actions::protocol_trait::ActionResult;
925        if let ActionResult::Custom { name, data } = protocol_result {
926            // A handler that returned mcp_error_response used to be ignored entirely: the
927            // loop matched one name and fell through to the hardcoded default, so a chosen
928            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
929            if name == "mcp_error" {
930                log_model_reject(state, "tools/call");
931                return Err(mcp_error_from_action(data));
932            }
933            if name == "mcp_tools_call" {
934                if let Some(response) = data.get("response") {
935                    return Ok(response.clone());
936                }
937            }
938        }
939    }
940
941    // Default: tool execution failed
942    log_model_no_answer(state, "tools/call", "a JSON-RPC error");
943    Err(JsonRpcError::custom(
944        ErrorCode::InternalError,
945        format!("Tool execution failed: {}", tool_name),
946    ))
947}
948
949/// Handle prompts/list request - LLM returns available prompts
950#[cfg(not(target_arch = "wasm32"))]
951async fn handle_prompts_list(state: &McpServerState) -> Result<Value, JsonRpcError> {
952    Log::new(Some(&state.status_tx)).debug("MCP prompts/list request");
953
954    // Create event for LLM
955    let event = Event::new(
956        &MCP_PROMPTS_LIST_EVENT,
957        serde_json::json!({
958            "method": "prompts/list",
959        }),
960    );
961
962    // Reuse the server's protocol instance rather than allocating a throwaway per request.
963    let protocol = state.protocol.clone();
964
965    // Call LLM with action system
966    let execution_result = match call_llm(
967        &state.llm_client,
968        &state.app_state,
969        state.server_id,
970        None,
971        &event,
972        protocol.as_ref(),
973    )
974    .await
975    {
976        Ok(result) => result,
977        Err(e) => {
978            return Err(llm_failure_error(state, "prompts/list", e));
979        }
980    };
981
982    // Process action results
983    for protocol_result in &execution_result.protocol_results {
984        use crate::llm::actions::protocol_trait::ActionResult;
985        if let ActionResult::Custom { name, data } = protocol_result {
986            // A handler that returned mcp_error_response used to be ignored entirely: the
987            // loop matched one name and fell through to the hardcoded default, so a chosen
988            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
989            if name == "mcp_error" {
990                log_model_reject(state, "prompts/list");
991                return Err(mcp_error_from_action(data));
992            }
993            if name == "mcp_prompts_list" {
994                if let Some(response) = data.get("response") {
995                    return Ok(response.clone());
996                }
997            }
998        }
999    }
1000
1001    // Default: empty prompts list
1002    log_model_no_answer(state, "prompts/list", "an empty prompts list");
1003    Ok(serde_json::json!({"prompts": []}))
1004}
1005
1006/// Handle prompts/get request - LLM returns prompt template
1007#[cfg(not(target_arch = "wasm32"))]
1008async fn handle_prompts_get(
1009    state: &McpServerState,
1010    params: Option<Value>,
1011) -> Result<Value, JsonRpcError> {
1012    let prompt_name = params
1013        .as_ref()
1014        .and_then(|p| p.get("name"))
1015        .and_then(|n| n.as_str())
1016        .ok_or_else(|| JsonRpcError::new(ErrorCode::InvalidParams))?;
1017
1018    let prompt_arguments = params.as_ref().and_then(|p| p.get("arguments"));
1019
1020    Log::new(Some(&state.status_tx)).debug(format!("MCP prompts/get: {}", prompt_name));
1021
1022    // Create event for LLM
1023    let event = Event::new(
1024        &MCP_PROMPTS_GET_EVENT,
1025        serde_json::json!({
1026            "method": "prompts/get",
1027            "name": prompt_name,
1028            "arguments": prompt_arguments,
1029        }),
1030    );
1031
1032    // Reuse the server's protocol instance rather than allocating a throwaway per request.
1033    let protocol = state.protocol.clone();
1034
1035    // Call LLM with action system
1036    let execution_result = match call_llm(
1037        &state.llm_client,
1038        &state.app_state,
1039        state.server_id,
1040        None,
1041        &event,
1042        protocol.as_ref(),
1043    )
1044    .await
1045    {
1046        Ok(result) => result,
1047        Err(e) => {
1048            return Err(llm_failure_error(state, "prompts/get", e));
1049        }
1050    };
1051
1052    // Process action results
1053    for protocol_result in &execution_result.protocol_results {
1054        use crate::llm::actions::protocol_trait::ActionResult;
1055        if let ActionResult::Custom { name, data } = protocol_result {
1056            // A handler that returned mcp_error_response used to be ignored entirely: the
1057            // loop matched one name and fell through to the hardcoded default, so a chosen
1058            // JSON-RPC error was silently converted into a *success* reply. Honor it first.
1059            if name == "mcp_error" {
1060                log_model_reject(state, "prompts/get");
1061                return Err(mcp_error_from_action(data));
1062            }
1063            if name == "mcp_prompts_get" {
1064                if let Some(response) = data.get("response") {
1065                    return Ok(response.clone());
1066                }
1067            }
1068        }
1069    }
1070
1071    // Default: prompt not found
1072    log_model_no_answer(state, "prompts/get", "a JSON-RPC error");
1073    Err(JsonRpcError::custom(
1074        ErrorCode::InternalError,
1075        format!("Prompt not found: {}", prompt_name),
1076    ))
1077}
1078
1079/// Handle logging/setLevel request
1080#[cfg(not(target_arch = "wasm32"))]
1081async fn handle_logging_set_level(
1082    state: &McpServerState,
1083    params: Option<Value>,
1084) -> Result<Value, JsonRpcError> {
1085    let level = params
1086        .as_ref()
1087        .and_then(|p| p.get("level"))
1088        .and_then(|l| l.as_str())
1089        .unwrap_or("info");
1090
1091    // Was `debug!` on the file log but `[INFO]` on the TUI - level drift the Log facade
1092    // exists to prevent. Unified to INFO on both sinks: it was already user-visible.
1093    Log::new(Some(&state.status_tx)).info(format!("MCP log level set to: {}", level));
1094
1095    Ok(serde_json::json!({}))
1096}
1097
1098/// Handle completion/complete request - LLM provides completions
1099#[cfg(not(target_arch = "wasm32"))]
1100async fn handle_completion(
1101    _state: &McpServerState,
1102    _params: Option<Value>,
1103) -> Result<Value, JsonRpcError> {
1104    debug!("MCP completion/complete");
1105
1106    // Not wired to the handler. The mcp_completion event type and the
1107    // mcp_completion_response action are no longer advertised, so nobody is told to write a
1108    // handler for something that never fires.
1109    Ok(serde_json::json!({
1110        "completion": {
1111            "values": [],
1112            "total": 0,
1113            "hasMore": false
1114        }
1115    }))
1116}
1117
1118/// Handle initialized notification
1119#[cfg(not(target_arch = "wasm32"))]
1120async fn handle_initialized(state: &McpServerState) {
1121    Log::new(Some(&state.status_tx)).info("MCP client initialized");
1122}
1123
1124/// Handle cancelled notification
1125#[cfg(not(target_arch = "wasm32"))]
1126async fn handle_cancelled(state: &McpServerState, params: Option<Value>) {
1127    if let Some(req_id) = params.as_ref().and_then(|p| p.get("requestId")) {
1128        Log::new(Some(&state.status_tx)).debug(format!("MCP cancelled: {:?}", req_id));
1129    }
1130}
1131
1132/// Handle progress notification
1133#[cfg(not(target_arch = "wasm32"))]
1134async fn handle_progress(_state: &McpServerState, params: Option<Value>) {
1135    if let Some(progress) = params {
1136        trace!("MCP progress: {}", progress);
1137    }
1138}
1139
1140/// Accept on NetGet's own listener, apply the cap and the read deadlines, and relay each
1141/// admitted connection to the loopback axum backend.
1142///
1143/// Runs until the task is aborted, which `stop_server` does through
1144/// `AppState::register_server_task`.
1145#[cfg(not(target_arch = "wasm32"))]
1146async fn serve_screened_mcp(
1147    listener: tokio::net::TcpListener,
1148    backend_addr: SocketAddr,
1149    status_tx: mpsc::UnboundedSender<String>,
1150    app_state: std::sync::Arc<crate::state::app_state::AppState>,
1151    server_id: crate::state::ServerId,
1152) {
1153    let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
1154    loop {
1155        let (peer_stream, peer_addr, permit) = match crate::server::accept_bounded::accept_bounded(
1156            &listener,
1157            &limiter,
1158            CONNECTION_CAP_REFUSAL,
1159            "MCP",
1160            Some(&status_tx),
1161        )
1162        .await
1163        {
1164            Ok(triple) => triple,
1165            Err(e) => {
1166                console_error!(status_tx, "MCP accept failed, listener stopped: {}", e);
1167                return;
1168            }
1169        };
1170
1171        let status = status_tx.clone();
1172        // Tracked, not detached: stop_server must abort this task too.
1173        let task_owner = app_state.clone();
1174        task_owner
1175            .spawn_server_task(server_id, async move {
1176                let _permit = permit;
1177                relay_mcp_connection(peer_stream, peer_addr, backend_addr, status).await;
1178            })
1179            .await;
1180    }
1181}
1182
1183/// Relay one admitted connection, bounding only the peer's *silence*.
1184///
1185/// The rule that makes this safe is the one the project learned from TFTP: a connection waiting
1186/// on a slow answer is not idle. MCP is strictly request/response, so "waiting on us" is exactly
1187/// "bytes have gone peer → backend and none have come back yet", and `awaiting_response` tracks
1188/// that. While it is set, the read deadline re-arms instead of closing — so an LLM round-trip,
1189/// or a request parked for a human at the dashboard for as long as they need, can never be
1190/// timed out from under itself. Only a peer that has been answered and then says nothing, or
1191/// that never said anything at all, is closed.
1192#[cfg(not(target_arch = "wasm32"))]
1193async fn relay_mcp_connection(
1194    peer_stream: tokio::net::TcpStream,
1195    peer_addr: SocketAddr,
1196    backend_addr: SocketAddr,
1197    status_tx: mpsc::UnboundedSender<String>,
1198) {
1199    use std::sync::atomic::{AtomicBool, Ordering};
1200    use tokio::io::{AsyncReadExt, AsyncWriteExt};
1201
1202    let backend_stream = match tokio::net::TcpStream::connect(backend_addr).await {
1203        Ok(stream) => stream,
1204        Err(e) => {
1205            console_error!(status_tx, "MCP could not reach its own backend: {}", e);
1206            return;
1207        }
1208    };
1209
1210    let (mut peer_read, mut peer_write) = tokio::io::split(peer_stream);
1211    let (mut backend_read, mut backend_write) = tokio::io::split(backend_stream);
1212
1213    let awaiting_response = Arc::new(AtomicBool::new(false));
1214
1215    let to_backend_awaiting = Arc::clone(&awaiting_response);
1216    let to_backend_status = status_tx.clone();
1217    let to_backend = tokio::spawn(async move {
1218        let mut buf = vec![0u8; 16 * 1024];
1219        let mut seen_request = false;
1220        loop {
1221            let bound = if seen_request {
1222                IDLE_BETWEEN_REQUESTS_TIMEOUT
1223            } else {
1224                FIRST_REQUEST_READ_TIMEOUT
1225            };
1226            let read = loop {
1227                match tokio::time::timeout(bound, peer_read.read(&mut buf)).await {
1228                    Ok(read) => break Some(read),
1229                    Err(_) => {
1230                        if to_backend_awaiting.load(Ordering::SeqCst) {
1231                            // The peer is waiting for an answer we have not produced yet.
1232                            // That is not silence; re-arm.
1233                            continue;
1234                        }
1235                        Log::new(Some(&to_backend_status)).info(format!(
1236                            "MCP peer {} sent nothing for {}s; closing idle connection",
1237                            peer_addr,
1238                            bound.as_secs()
1239                        ));
1240                        break None;
1241                    }
1242                }
1243            };
1244            let n = match read {
1245                Some(Ok(0)) | None => break,
1246                Some(Ok(n)) => n,
1247                Some(Err(_)) => break,
1248            };
1249            seen_request = true;
1250            to_backend_awaiting.store(true, Ordering::SeqCst);
1251            if backend_write.write_all(&buf[..n]).await.is_err() {
1252                break;
1253            }
1254        }
1255        let _ = backend_write.shutdown().await;
1256    });
1257
1258    let to_peer_awaiting = Arc::clone(&awaiting_response);
1259    let to_peer = tokio::spawn(async move {
1260        let mut buf = vec![0u8; 16 * 1024];
1261        loop {
1262            match backend_read.read(&mut buf).await {
1263                Ok(0) | Err(_) => break,
1264                Ok(n) => {
1265                    if peer_write.write_all(&buf[..n]).await.is_err() {
1266                        break;
1267                    }
1268                    // Answered: from here the peer owes us the next request, so the idle
1269                    // deadline becomes meaningful again.
1270                    to_peer_awaiting.store(false, Ordering::SeqCst);
1271                }
1272            }
1273        }
1274        let _ = peer_write.shutdown().await;
1275    });
1276
1277    // Either direction ending ends the connection: a half-open relay would be one more way to
1278    // hold a socket for nothing.
1279    tokio::select! {
1280        _ = to_backend => {}
1281        _ = to_peer => {}
1282    }
1283}