//! MCP (Model Context Protocol) server: `POST /mcp` (JSON-RPC 2.0). //! //! Exposes the gateway's routing to MCP-native orchestrators as two tools: //! `cortiq_route` (classify a prompt → routing decision) and `cortiq_chat` //! (run a prompt through intelligent routing → answer). Single JSON endpoint; //! notifications are accepted with `202`. Gated by `protocols.mcp`. use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective}; use crate::state::SharedState; use axum::response::{IntoResponse, Response}; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use serde_json::{json, Value}; pub fn routes() -> Router { Router::new().route("/mcp", post(handler)) } fn parse_routing(model: &str) -> RoutingDirective { if let Some(rest) = model.strip_prefix("cortiq-auto") { RoutingDirective::Auto { profile: rest.strip_prefix(':').map(|p| p.to_string()), } } else { RoutingDirective::Pinned { model_id: model.to_string(), } } } fn tools_list() -> Value { json!([ { "name": "cortiq_route", "description": "Classify a prompt and return the routing decision (task type, complexity tier, score, and the model the gateway would select).", "inputSchema": { "type": "object", "properties": { "text": { "type": "string", "description": "The prompt to classify." } }, "required": ["text"] } }, { "name": "cortiq_chat", "description": "Run a prompt through the gateway's intelligent routing and return the selected model's answer.", "inputSchema": { "type": "object", "properties": { "prompt": { "type": "string", "description": "The user prompt." }, "model": { "type": "string", "description": "Optional: cortiq-auto[:profile] or a model id (default cortiq-auto)." } }, "required": ["prompt"] } } ]) } fn tool_text(text: String, is_error: bool) -> Value { json!({ "content": [{ "type": "text", "text": text }], "isError": is_error }) } async fn handle_tool_call( state: &SharedState, params: &Value, ) -> std::result::Result { let name = params["name"].as_str().unwrap_or(""); let args = ¶ms["arguments"]; let live = state.live(); match name { "cortiq_route" => { let text = args["text"].as_str().unwrap_or(""); let prof = live.cfg.route.profile.clone(); match live.router.route(text, &prof).await { Ok(Some(d)) => Ok(tool_text( format!( "task_label={}\ncomplexity_tier={}\ncomplexity_score={:.3}\ncandidates={:?}", d.task_label, d.complexity_tier, d.complexity_score, live.routing.candidates(&d.complexity_tier) ), false, )), _ => Ok(tool_text( "router unavailable — would use the default model".to_string(), false, )), } } "cortiq_chat" => { let prompt = args["prompt"].as_str().unwrap_or(""); let model = args["model"].as_str().unwrap_or("cortiq-auto"); let req = ChatRequest { routing: parse_routing(model), messages: vec![Message { role: "user".into(), content: prompt.to_string(), tool_calls: vec![], ..Default::default() }], tools: vec![], params: GenParams::default(), stream: false, meta: RequestMeta { account: "mcp".into(), protocol: "mcp".into(), ..Default::default() }, }; match state.pipeline.run(req, state).await { Ok(resp) => { let answer = resp .choices .first() .map(|c| c.message.content.clone()) .unwrap_or_default(); Ok(tool_text(answer, false)) } Err(e) => Ok(tool_text(e.to_string(), true)), } } _ => Err((-32602, format!("unknown tool '{name}'"))), } } async fn handler(State(state): State, Json(req): Json) -> Response { if !state.live().cfg.protocols.mcp { return (StatusCode::NOT_FOUND, "mcp protocol is disabled").into_response(); } let method = req["method"].as_str().unwrap_or("").to_string(); let id = req.get("id").cloned(); // JSON-RPC notifications carry no id and expect no response if id.is_none() || method.starts_with("notifications/") { return StatusCode::ACCEPTED.into_response(); } let id = id.unwrap_or(Value::Null); let result: std::result::Result = match method.as_str() { "initialize" => { let pv = req["params"]["protocolVersion"] .as_str() .unwrap_or("2024-11-05") .to_string(); Ok(json!({ "protocolVersion": pv, "capabilities": { "tools": {} }, "serverInfo": { "name": "cortiq-gateway", "version": env!("CARGO_PKG_VERSION") } })) } "ping" => Ok(json!({})), "tools/list" => Ok(json!({ "tools": tools_list() })), "tools/call" => handle_tool_call(&state, &req["params"]).await, other => Err((-32601, format!("method not found: {other}"))), }; let body = match result { Ok(r) => json!({ "jsonrpc": "2.0", "id": id, "result": r }), Err((code, message)) => { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) } }; Json(body).into_response() }