Skip to main content

origin_mcp_core/
server.rs

1use crate::protocol::{InitializeParams, PROTOCOL_VERSION, Request, Response, ServerInfo, codes};
2use crate::{AiPermissions, Tool};
3use origin_platform::{ConfirmationDecision, ConfirmationRequest, ConfirmationService};
4use serde_json::{Value, json};
5use std::collections::BTreeMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8
9/// Process-wide counter so every session gets a distinct, greppable id.
10static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
11
12fn next_session_id() -> String {
13    let number = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
14    format!("mcp-session-{number}")
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum Lifecycle {
19    Uninitialized,
20    AwaitingInitialized,
21    Ready,
22}
23
24/// Serves an application's tools to an external AI client.
25///
26/// Transport-agnostic: it turns a request into a response and nothing more, so the same
27/// server works over stdio, over a local HTTP endpoint, or over an in-memory pipe in a
28/// test.
29#[derive(Debug)]
30pub struct McpServer {
31    info: ServerInfo,
32    /// Identifies one session in logs. A clone starts a new session, so a stdio
33    /// connection and an HTTP client are distinguishable in the trace.
34    session_id: String,
35    tools: BTreeMap<String, Arc<dyn Tool>>,
36    permissions: AiPermissions,
37    /// Required before a mutating tool runs. `None` means block (fail-closed),
38    /// so a product that never wires a confirmer cannot grant mutation through
39    /// the AI boundary.
40    confirmation: Option<Arc<dyn ConfirmationService>>,
41    lifecycle: Mutex<Lifecycle>,
42}
43
44impl Clone for McpServer {
45    fn clone(&self) -> Self {
46        Self {
47            info: self.info.clone(),
48            session_id: next_session_id(),
49            tools: self.tools.clone(),
50            permissions: self.permissions.clone(),
51            confirmation: self.confirmation.clone(),
52            // A clone represents another transport session. Tools are shared, but MCP
53            // lifecycle state is connection-local.
54            lifecycle: Mutex::new(Lifecycle::Uninitialized),
55        }
56    }
57}
58
59impl McpServer {
60    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
61        Self {
62            info: ServerInfo {
63                name: name.into(),
64                version: version.into(),
65            },
66            session_id: next_session_id(),
67            tools: BTreeMap::new(),
68            permissions: AiPermissions::none(),
69            confirmation: None,
70            lifecycle: Mutex::new(Lifecycle::Uninitialized),
71        }
72    }
73
74    /// What an external AI is allowed to do. Nothing, unless the product says so.
75    pub fn with_permissions(mut self, permissions: AiPermissions) -> Self {
76        self.permissions = permissions;
77        self
78    }
79
80    /// Ask a human before a mutating tool takes effect.
81    ///
82    /// Without this, `Commit` and `Delete` tools are refused at the boundary — the
83    /// safest default. A product that grants mutation must wire a confirmer (a native
84    /// dialog, a policy file) at composition time.
85    pub fn with_confirmation(mut self, confirmation: Arc<dyn ConfirmationService>) -> Self {
86        self.confirmation = Some(confirmation);
87        self
88    }
89
90    /// The id this session is logged under.
91    pub fn session_id(&self) -> &str {
92        &self.session_id
93    }
94
95    pub fn with_tool(mut self, tool: Arc<dyn Tool>) -> Self {
96        self.tools.insert(tool.descriptor().name, tool);
97        self
98    }
99
100    /// Tools the current grant actually permits.
101    ///
102    /// Tools beyond the grant are not merely refused on call — they are never listed.
103    /// Advertising a tool that always fails wastes the model's attempts and teaches it
104    /// to retry.
105    fn available(&self) -> impl Iterator<Item = &Arc<dyn Tool>> {
106        self.tools
107            .values()
108            .filter(|tool| self.permissions.allows(tool.descriptor().permission))
109    }
110
111    /// Handle one request. Returns `None` for a notification.
112    pub async fn handle(&self, request: Request) -> Option<Response> {
113        if request.jsonrpc != "2.0" {
114            return Some(Response::error(
115                request.id.unwrap_or(Value::Null),
116                codes::INVALID_REQUEST,
117                "jsonrpc must be `2.0`",
118            ));
119        }
120
121        let Some(id) = request.id.clone() else {
122            if request.method == "notifications/initialized" {
123                let mut lifecycle = self.lifecycle();
124                if *lifecycle == Lifecycle::AwaitingInitialized {
125                    *lifecycle = Lifecycle::Ready;
126                } else {
127                    tracing::warn!("unexpected MCP initialized notification");
128                }
129            }
130            tracing::debug!(method = %request.method, "mcp notification");
131            return None;
132        };
133
134        let response = match request.method.as_str() {
135            "initialize" => self.initialize(id, request.params),
136            "ping" => Response::result(id, json!({})),
137            _ if *self.lifecycle() != Lifecycle::Ready => {
138                Response::error(id, codes::INVALID_REQUEST, "MCP session is not initialized")
139            }
140            "tools/list" => Response::result(id, self.list_tools()),
141            "tools/call" => self.call_tool(id, request.params).await,
142            other => Response::error(
143                id,
144                codes::METHOD_NOT_FOUND,
145                format!("unsupported method `{other}`"),
146            ),
147        };
148
149        Some(response)
150    }
151
152    fn initialize(&self, id: Value, params: Value) -> Response {
153        let params: InitializeParams = match serde_json::from_value(params) {
154            Ok(params) => params,
155            Err(error) => {
156                return Response::error(
157                    id,
158                    codes::INVALID_PARAMS,
159                    format!("invalid initialize parameters: {error}"),
160                );
161            }
162        };
163        if !params.capabilities.is_object() {
164            return Response::error(
165                id,
166                codes::INVALID_PARAMS,
167                "initialize capabilities must be an object",
168            );
169        }
170
171        let mut lifecycle = self.lifecycle();
172        if *lifecycle != Lifecycle::Uninitialized {
173            return Response::error(
174                id,
175                codes::INVALID_REQUEST,
176                "MCP session is already initialized",
177            );
178        }
179        *lifecycle = Lifecycle::AwaitingInitialized;
180        drop(lifecycle);
181
182        tracing::debug!(
183            client = %params.client_info.name,
184            client_version = %params.client_info.version,
185            requested_protocol = %params.protocol_version,
186            "MCP session initialized"
187        );
188
189        Response::result(
190            id,
191            json!({
192                "protocolVersion": PROTOCOL_VERSION,
193                "serverInfo": self.info,
194                "capabilities": { "tools": {} }
195            }),
196        )
197    }
198
199    fn list_tools(&self) -> Value {
200        let tools: Vec<Value> = self
201            .available()
202            .map(|tool| {
203                let descriptor = tool.descriptor();
204                json!({
205                    "name": descriptor.name,
206                    "title": descriptor.title,
207                    "description": descriptor.description,
208                    "inputSchema": descriptor.input_schema,
209                })
210            })
211            .collect();
212
213        json!({ "tools": tools })
214    }
215
216    async fn call_tool(&self, id: Value, params: Value) -> Response {
217        let Some(name) = params.get("name").and_then(Value::as_str) else {
218            return Response::error(id, codes::INVALID_PARAMS, "missing tool name");
219        };
220
221        let Some(tool) = self.tools.get(name) else {
222            return Response::error(id, codes::INVALID_PARAMS, format!("unknown tool `{name}`"));
223        };
224
225        let descriptor = tool.descriptor();
226        if !self.permissions.allows(descriptor.permission) {
227            // Refused at the boundary, and recorded: an external agent repeatedly
228            // reaching for a permission it does not have is worth seeing in a log.
229            tracing::warn!(
230                tool = name,
231                permission = descriptor.permission.as_str(),
232                "mcp tool call refused: permission not granted"
233            );
234            return Response::error(
235                id,
236                codes::INVALID_REQUEST,
237                format!(
238                    "`{name}` needs the `{}` permission, which this application does not grant",
239                    descriptor.permission.as_str()
240                ),
241            );
242        }
243
244        let arguments = params
245            .get("arguments")
246            .cloned()
247            .unwrap_or_else(|| json!({}));
248
249        // A mutating tool is where an external AI could change data. The caller is a
250        // language model acting on content it read elsewhere, so a human decides — and
251        // a missing confirmer means denial, because no human is available.
252        if descriptor.permission.is_mutating() {
253            match &self.confirmation {
254                Some(service) => {
255                    let request = ConfirmationRequest::new(
256                        format!("Allow `{name}`?"),
257                        format!(
258                            "An external AI wants to run `{name}`, which {}data.\n\n{}\n\n\
259                             Approve only if you asked for this.",
260                            if descriptor.permission == crate::AiPermission::Delete {
261                                "deletes "
262                            } else {
263                                "changes "
264                            },
265                            descriptor.description,
266                        ),
267                    );
268
269                    match service.confirm(request).await {
270                        Ok(ConfirmationDecision::Approved) => {}
271                        Ok(ConfirmationDecision::Denied) => {
272                            tracing::warn!(tool = name, "mcp mutating tool denied by the human");
273                            return Response::result(
274                                id,
275                                json!({
276                                    "content": [{
277                                        "type": "text",
278                                        "text": format!(
279                                            "The user did not approve running \"{name}\". \
280                                             Nothing was changed."
281                                        ),
282                                    }],
283                                    "isError": true,
284                                }),
285                            );
286                        }
287                        Err(error) => {
288                            // Fail-closed: a broken prompt must not let a tool through.
289                            tracing::warn!(tool = name, %error, "confirmation service failed; denying tool call");
290                            return Response::result(
291                                id,
292                                json!({
293                                    "content": [{
294                                        "type": "text",
295                                        "text": format!(
296                                            "Confirmation failed for \"{name}\". Nothing \
297                                             was changed. ({error})"
298                                        ),
299                                    }],
300                                    "isError": true,
301                                }),
302                            );
303                        }
304                    }
305                }
306
307                // No confirmer wired: a product that grants mutation but never asks a
308                // human. Safe denial as designed.
309                None => {
310                    tracing::warn!(
311                        tool = name,
312                        permission = descriptor.permission.as_str(),
313                        "mcp mutating tool denied: no confirmation service configured"
314                    );
315                    return Response::result(
316                        id,
317                        json!({
318                            "content": [{
319                                "type": "text",
320                                "text": format!(
321                                    "Running \"{name}\" requires a human, and the application \
322                                     has no confirmation service configured. Grant \
323                                     `AiPermission::Commit` only when a confirmer is wired."
324                                ),
325                            }],
326                            "isError": true,
327                        }),
328                    );
329                }
330            }
331        }
332
333        tracing::info!(
334            mcp_session_id = %self.session_id,
335            tool = name,
336            permission = descriptor.permission.as_str(),
337            "mcp tool call"
338        );
339
340        match tool.call(arguments).await {
341            Ok(output) => {
342                let mut result = json!({
343                    "content": [{ "type": "text", "text": output.text }],
344                    "isError": false,
345                });
346                if let Some(structured) = output.structured {
347                    result["structuredContent"] = structured;
348                }
349                Response::result(id, result)
350            }
351
352            // A failed tool is reported to the model as a tool error, not as a
353            // protocol error: the model can read it and choose differently, which a
354            // transport-level failure does not allow.
355            Err(error) => {
356                tracing::warn!(tool = name, %error, "mcp tool call failed");
357                Response::result(
358                    id,
359                    json!({
360                        "content": [{
361                            "type": "text",
362                            "text": error.to_contract().message,
363                        }],
364                        "isError": true,
365                    }),
366                )
367            }
368        }
369    }
370
371    /// Parse and handle one line of JSON. Convenience for line-based transports.
372    pub async fn handle_line(&self, line: &str) -> Option<Response> {
373        match serde_json::from_str::<Request>(line) {
374            Ok(request) => self.handle(request).await,
375            Err(error) => {
376                let code = if error.is_syntax() || error.is_eof() {
377                    codes::PARSE_ERROR
378                } else {
379                    codes::INVALID_REQUEST
380                };
381                Some(Response::error(
382                    Value::Null,
383                    code,
384                    format!("malformed request: {error}"),
385                ))
386            }
387        }
388    }
389
390    fn lifecycle(&self) -> std::sync::MutexGuard<'_, Lifecycle> {
391        self.lifecycle
392            .lock()
393            .unwrap_or_else(|error| error.into_inner())
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::{AiPermission, AiPermissions, Tool, ToolDescriptor, ToolOutput};
401    use async_trait::async_trait;
402    use origin_domain::Result;
403    use origin_platform::{ConfirmationDecision, ConfirmationRequest, ConfirmationService};
404    use std::sync::Arc;
405
406    /// A tool that records whether it was actually called.
407    #[derive(Debug)]
408    struct TestMutatingTool {
409        descriptor: ToolDescriptor,
410        called: std::sync::Mutex<bool>,
411    }
412
413    impl TestMutatingTool {
414        fn new() -> Self {
415            Self {
416                descriptor: ToolDescriptor::new(
417                    "test.mutate",
418                    "Test mutation",
419                    "A mutating test tool.",
420                    AiPermission::Commit,
421                ),
422                called: std::sync::Mutex::new(false),
423            }
424        }
425    }
426
427    #[async_trait]
428    impl Tool for TestMutatingTool {
429        fn descriptor(&self) -> ToolDescriptor {
430            self.descriptor.clone()
431        }
432
433        async fn call(&self, _arguments: serde_json::Value) -> Result<ToolOutput> {
434            *self.called.lock().unwrap() = true;
435            Ok(ToolOutput::text("done"))
436        }
437    }
438
439    /// A confirmer that always approves and records requests.
440    #[derive(Debug, Default)]
441    struct AlwaysApproving {
442        requests: std::sync::Mutex<Vec<ConfirmationRequest>>,
443    }
444
445    #[async_trait]
446    impl ConfirmationService for AlwaysApproving {
447        async fn confirm(&self, request: ConfirmationRequest) -> Result<ConfirmationDecision> {
448            self.requests.lock().unwrap().push(request.clone());
449            Ok(ConfirmationDecision::Approved)
450        }
451    }
452
453    async fn initialized_server() -> McpServer {
454        let server = McpServer::new("test", "0.1.0")
455            .with_permissions(AiPermissions::from([
456                AiPermission::Read,
457                AiPermission::Commit,
458            ]))
459            .with_tool(Arc::new(TestMutatingTool::new()));
460
461        // Fake an MCP session so tool calls don't fail on lifecycle.
462        let request = Request {
463            jsonrpc: "2.0".to_owned(),
464            id: Some(serde_json::Value::String("1".to_owned())),
465            method: "initialize".to_owned(),
466            params: serde_json::json!({
467                "protocolVersion": "2024-11-05",
468                "capabilities": {},
469                "clientInfo": { "name": "test", "version": "0.1" }
470            }),
471        };
472        let _ = server.handle(request).await;
473        let initialized = Request {
474            jsonrpc: "2.0".to_owned(),
475            id: None,
476            method: "notifications/initialized".to_owned(),
477            params: serde_json::Value::Null,
478        };
479        let _ = server.handle(initialized).await;
480
481        server
482    }
483
484    #[tokio::test]
485    async fn a_mutating_tool_is_denied_when_no_confirmer_is_wired() {
486        let server = initialized_server().await;
487
488        let response = server
489            .handle_line(r#"{"jsonrpc":"2.0","id":"t1","method":"tools/call","params":{"name":"test.mutate","arguments":{}}}"#)
490            .await
491            .expect("must produce a response");
492
493        assert!(response.error.is_none());
494        assert!(
495            response.result.is_some(),
496            "a tool-level error still returns a result with isError:true"
497        );
498    }
499
500    #[tokio::test]
501    async fn a_mutating_tool_is_allowed_when_the_confirmer_approves() {
502        let confirmer = Arc::new(AlwaysApproving::default());
503        let server = initialized_server()
504            .await
505            .with_confirmation(confirmer.clone());
506
507        let response = server
508            .handle_line(
509                r#"{"jsonrpc":"2.0","id":"t2","method":"tools/call","params":{"name":"test.mutate","arguments":{}}}"#,
510            )
511            .await
512            .expect("must produce a response");
513
514        assert!(response.error.is_none());
515        assert_eq!(confirmer.requests.lock().unwrap().len(), 1);
516    }
517}