Skip to main content

sigil_protocol/
mcp_server.rs

1//! Reference SIGIL MCP Server.
2//!
3//! An embeddable MCP (Model Context Protocol) server that any Rust
4//! application can use to expose SIGIL-protected tools over JSON-RPC 2.0.
5//!
6//! ```rust,no_run
7//! use sigil_protocol::mcp_server::{SigilMcpServer, ToolDef};
8//! use sigil_protocol::{SensitivityScanner, AuditLogger, AuditEvent};
9//! use std::sync::Arc;
10//!
11//! struct MyScanner;
12//! impl SensitivityScanner for MyScanner {
13//!     fn scan(&self, _text: &str) -> Option<String> { None }
14//! }
15//! struct MyAudit;
16//! impl AuditLogger for MyAudit {
17//!     fn log(&self, _e: &AuditEvent) -> anyhow::Result<()> { Ok(()) }
18//! }
19//!
20//! let scanner = Arc::new(MyScanner);
21//! let audit = Arc::new(MyAudit);
22//! let mut server = SigilMcpServer::new("my-server", "1.0.0", scanner, audit);
23//! server.register_tool(ToolDef {
24//!     name: "get_weather".into(),
25//!     description: "Get current weather".into(),
26//!     parameters_schema: serde_json::json!({"type": "object"}),
27//!     handler: Box::new(|args| Box::pin(async move {
28//!         Ok(serde_json::json!({"temp": 22}))
29//!     })),
30//! });
31//! ```
32
33use crate::{
34    sigil_envelope::{SigilEnvelope, SigilKeypair, Verdict},
35    AuditEvent, AuditEventType, AuditLogger, SensitivityScanner, TrustLevel,
36};
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39use std::future::Future;
40use std::pin::Pin;
41use std::sync::Arc;
42
43// ── Inbound _sigil parser ──────────────────────────────────────
44
45/// Inbound SIGIL envelope parsed from the request `params._sigil` field.
46#[derive(Debug, Deserialize, Default)]
47pub struct InboundSigil {
48    pub identity: Option<String>,
49    pub verdict: Option<String>,
50    pub signature: Option<String>,
51    pub nonce: Option<String>,
52    pub timestamp: Option<String>,
53}
54
55// ── JSON-RPC 2.0 types ─────────────────────────────────────────
56
57#[derive(Debug, Deserialize)]
58pub struct JsonRpcRequest {
59    pub jsonrpc: String,
60    pub id: Option<serde_json::Value>,
61    pub method: String,
62    #[serde(default)]
63    pub params: serde_json::Value,
64}
65
66#[derive(Debug, Serialize)]
67pub struct JsonRpcResponse {
68    pub jsonrpc: String,
69    pub id: serde_json::Value,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub result: Option<serde_json::Value>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub error: Option<JsonRpcError>,
74}
75
76#[derive(Debug, Serialize)]
77pub struct JsonRpcError {
78    pub code: i32,
79    pub message: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub data: Option<serde_json::Value>,
82}
83
84// ── MCP Tool definition ─────────────────────────────────────────
85
86/// Async handler for a tool call.
87pub type ToolHandler = Box<
88    dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = anyhow::Result<serde_json::Value>> + Send>>
89        + Send
90        + Sync,
91>;
92
93/// A tool definition to register with the SIGIL MCP server.
94pub struct ToolDef {
95    pub name: String,
96    pub description: String,
97    pub parameters_schema: serde_json::Value,
98    pub handler: ToolHandler,
99}
100
101// ── SIGIL MCP Server ────────────────────────────────────────────
102
103/// A reference SIGIL-secured MCP server.
104///
105/// Wraps any set of tools with:
106/// - **Input scanning** — tool arguments are scanned for secrets before execution
107/// - **Output scanning** — tool results are scanned for secrets before returning
108/// - **Audit logging** — every tool invocation is logged
109/// - **Trust gating** — tools can require a minimum trust level
110/// - **SIGIL signing** — every response carries a signed `_sigil` envelope
111pub struct SigilMcpServer<S: SensitivityScanner, A: AuditLogger> {
112    name: String,
113    version: String,
114    tools: HashMap<String, ToolEntry>,
115    scanner: Arc<S>,
116    audit: Arc<A>,
117    /// Minimum trust level required for this server (default: Low).
118    required_trust: TrustLevel,
119    /// Ed25519 keypair for signing outbound `_sigil` envelopes.
120    /// If `None`, responses are not signed (development mode only).
121    keypair: Option<Arc<SigilKeypair>>,
122    /// The server's DID identifier (e.g. `did:sigil:my-server`).
123    did: String,
124}
125
126struct ToolEntry {
127    description: String,
128    schema: serde_json::Value,
129    handler: ToolHandler,
130    /// Per-tool trust level override (None = use server default).
131    required_trust: Option<TrustLevel>,
132}
133
134impl<S: SensitivityScanner, A: AuditLogger> SigilMcpServer<S, A> {
135    /// Create a new SIGIL MCP server (no signing keypair — dev mode).
136    pub fn new(name: &str, version: &str, scanner: Arc<S>, audit: Arc<A>) -> Self {
137        Self {
138            name: name.to_string(),
139            version: version.to_string(),
140            tools: HashMap::new(),
141            scanner,
142            audit,
143            required_trust: TrustLevel::Low,
144            keypair: None,
145            did: format!("did:sigil:{}", name.to_lowercase().replace(' ', "_")),
146        }
147    }
148
149    /// Create a server with a signing keypair (production mode).
150    pub fn new_with_keypair(
151        name: &str,
152        version: &str,
153        scanner: Arc<S>,
154        audit: Arc<A>,
155        keypair: SigilKeypair,
156        did: &str,
157    ) -> Self {
158        Self {
159            name: name.to_string(),
160            version: version.to_string(),
161            tools: HashMap::new(),
162            scanner,
163            audit,
164            required_trust: TrustLevel::Low,
165            keypair: Some(Arc::new(keypair)),
166            did: did.to_string(),
167        }
168    }
169
170    /// Returns the server's public verifying key (base64url), if a keypair is set.
171    pub fn verifying_key(&self) -> Option<String> {
172        self.keypair.as_ref().map(|kp| kp.verifying_key_base64())
173    }
174
175    /// Set the minimum trust level for the entire server.
176    pub fn set_required_trust(&mut self, level: TrustLevel) {
177        self.required_trust = level;
178    }
179
180    /// Register a tool.
181    pub fn register_tool(&mut self, tool: ToolDef) {
182        self.tools.insert(
183            tool.name.clone(),
184            ToolEntry {
185                description: tool.description,
186                schema: tool.parameters_schema,
187                handler: tool.handler,
188                required_trust: None,
189            },
190        );
191    }
192
193    /// Register a tool with a specific trust requirement.
194    pub fn register_tool_with_trust(&mut self, tool: ToolDef, trust: TrustLevel) {
195        self.tools.insert(
196            tool.name.clone(),
197            ToolEntry {
198                description: tool.description,
199                schema: tool.parameters_schema,
200                handler: tool.handler,
201                required_trust: Some(trust),
202            },
203        );
204    }
205
206    /// Handle an incoming JSON-RPC 2.0 request string.
207    ///
208    /// Returns the JSON-RPC response string. All tool arguments and results
209    /// are scanned by the SIGIL `SensitivityScanner`, and every invocation
210    /// is logged via the `AuditLogger`.
211    pub async fn handle_request(
212        &self,
213        request: &str,
214        caller_trust: TrustLevel,
215    ) -> String {
216        let req: JsonRpcRequest = match serde_json::from_str(request) {
217            Ok(r) => r,
218            Err(e) => {
219                return serde_json::to_string(&JsonRpcResponse {
220                    jsonrpc: "2.0".into(),
221                    id: serde_json::Value::Null,
222                    result: None,
223                    error: Some(JsonRpcError {
224                        code: -32700,
225                        message: format!("Parse error: {e}"),
226                        data: None,
227                    }),
228                })
229                .unwrap_or_default();
230            }
231        };
232
233        let id = req.id.clone().unwrap_or(serde_json::Value::Null);
234
235        let response = match req.method.as_str() {
236            "initialize" => self.handle_initialize(&id),
237            "tools/list" => self.handle_tools_list(&id),
238            "tools/call" => self.handle_tools_call(&id, req.params, caller_trust).await,
239            _ => JsonRpcResponse {
240                jsonrpc: "2.0".into(),
241                id,
242                result: None,
243                error: Some(JsonRpcError {
244                    code: -32601,
245                    message: format!("Method not found: {}", req.method),
246                    data: None,
247                }),
248            },
249        };
250
251        serde_json::to_string(&response).unwrap_or_default()
252    }
253
254    fn handle_initialize(&self, id: &serde_json::Value) -> JsonRpcResponse {
255        JsonRpcResponse {
256            jsonrpc: "2.0".into(),
257            id: id.clone(),
258            result: Some(serde_json::json!({
259                "protocolVersion": "2024-11-05",
260                "serverInfo": {
261                    "name": self.name,
262                    "version": self.version,
263                },
264                "capabilities": {
265                    "tools": { "listChanged": false },
266                },
267                "sigil": {
268                    "version": "0.1.0",
269                    "requiredTrust": format!("{:?}", self.required_trust),
270                }
271            })),
272            error: None,
273        }
274    }
275
276    fn handle_tools_list(&self, id: &serde_json::Value) -> JsonRpcResponse {
277        let tools: Vec<serde_json::Value> = self
278            .tools
279            .iter()
280            .map(|(name, entry)| {
281                serde_json::json!({
282                    "name": name,
283                    "description": entry.description,
284                    "inputSchema": entry.schema,
285                })
286            })
287            .collect();
288
289        JsonRpcResponse {
290            jsonrpc: "2.0".into(),
291            id: id.clone(),
292            result: Some(serde_json::json!({ "tools": tools })),
293            error: None,
294        }
295    }
296
297    async fn handle_tools_call(
298        &self,
299        id: &serde_json::Value,
300        params: serde_json::Value,
301        caller_trust: TrustLevel,
302    ) -> JsonRpcResponse {
303        let tool_name = params
304            .get("name")
305            .and_then(|v| v.as_str())
306            .unwrap_or("")
307            .to_string();
308
309        let arguments = params
310            .get("arguments")
311            .cloned()
312            .unwrap_or(serde_json::json!({}));
313
314        // ── SIGIL: Parse + verify inbound _sigil envelope ──
315        let inbound_sigil = params
316            .get("_sigil")
317            .and_then(|v| serde_json::from_value::<InboundSigil>(v.clone()).ok());
318
319        if let Some(ref sig) = inbound_sigil {
320            if let (Some(identity), Some(nonce)) = (&sig.identity, &sig.nonce) {
321                let _ = self.audit.log(
322                    &AuditEvent::new(AuditEventType::McpToolGated).with_action(
323                        format!("Inbound _sigil: identity={identity} nonce={nonce}"),
324                        "low".into(),
325                        true,
326                        true,
327                    ),
328                );
329            }
330        }
331
332        // ── Lookup tool ──
333        let entry = match self.tools.get(&tool_name) {
334            Some(e) => e,
335            None => {
336                return JsonRpcResponse {
337                    jsonrpc: "2.0".into(),
338                    id: id.clone(),
339                    result: None,
340                    error: Some(JsonRpcError {
341                        code: -32602,
342                        message: format!("Unknown tool: {tool_name}"),
343                        data: None,
344                    }),
345                };
346            }
347        };
348
349        // ── SIGIL: Trust gate ──
350        let required = entry.required_trust.unwrap_or(self.required_trust);
351        if (caller_trust as u8) < (required as u8) {
352            let _ = self.audit.log(&AuditEvent::new(AuditEventType::PolicyViolation).with_action(
353                format!("Trust gate: {tool_name} requires {required:?}, caller has {caller_trust:?}"),
354                "high".into(),
355                false,
356                false,
357            ));
358            return JsonRpcResponse {
359                jsonrpc: "2.0".into(),
360                id: id.clone(),
361                result: None,
362                error: Some(JsonRpcError {
363                    code: -32001,
364                    message: format!(
365                        "SIGIL trust gate: tool '{tool_name}' requires {required:?} trust"
366                    ),
367                    data: None,
368                }),
369            };
370        }
371
372        // ── SIGIL: Scan input arguments ──
373        let args_str = serde_json::to_string(&arguments).unwrap_or_default();
374        let input_scan = self.scanner.scan(&args_str);
375        if input_scan.is_some() {
376            let _ = self.audit.log(&AuditEvent::new(AuditEventType::SigilInterception).with_action(
377                format!("Input scan: secrets detected in {tool_name} arguments"),
378                "high".into(),
379                true,
380                false,
381            ));
382        }
383
384        // ── Execute tool ──
385        let result = (entry.handler)(arguments).await;
386
387        match result {
388            Ok(output) => {
389                // ── SIGIL: Scan output ──
390                let output_str = serde_json::to_string(&output).unwrap_or_default();
391                let output_scan = self.scanner.scan(&output_str);
392
393                let _ = self.audit.log(&AuditEvent::new(AuditEventType::McpToolGated).with_action(
394                    format!(
395                        "MCP tool {tool_name}: input_secrets={}, output_secrets={}",
396                        input_scan.is_some(),
397                        output_scan.is_some()
398                    ),
399                    "low".into(),
400                    true,
401                    true,
402                ));
403
404                // ── SIGIL: Sign outbound envelope ──
405                let verdict = if output_scan.is_some() {
406                    Verdict::Scanned
407                } else {
408                    Verdict::Allowed
409                };
410                let reason = output_scan.clone().map(|cat| {
411                    format!("Outbound sensitivity scan detected: {cat}")
412                });
413                let sigil_envelope = self.keypair.as_ref().and_then(|kp| {
414                    SigilEnvelope::sign(&self.did, verdict, reason, kp).ok()
415                });
416
417                let mut result_obj = serde_json::json!({
418                    "content": [{
419                        "type": "text",
420                        "text": output_str,
421                    }],
422                    "isError": false,
423                    "sigil": {
424                        "inputSecrets": input_scan.is_some(),
425                        "outputSecrets": output_scan.is_some(),
426                    }
427                });
428
429                // Embed signed _sigil if keypair is available
430                if let Some(envelope) = sigil_envelope {
431                    result_obj["_sigil"] = serde_json::to_value(&envelope).unwrap_or_default();
432                }
433
434                JsonRpcResponse {
435                    jsonrpc: "2.0".into(),
436                    id: id.clone(),
437                    result: Some(result_obj),
438                    error: None,
439                }
440            }
441            Err(e) => JsonRpcResponse {
442                jsonrpc: "2.0".into(),
443                id: id.clone(),
444                result: Some(serde_json::json!({
445                    "content": [{
446                        "type": "text",
447                        "text": format!("Error: {e}"),
448                    }],
449                    "isError": true,
450                })),
451                error: None,
452            },
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    // Minimal test scanner
462    struct TestScanner;
463    impl SensitivityScanner for TestScanner {
464        fn scan(&self, text: &str) -> Option<String> {
465            if text.contains("sk-") {
466                Some("OpenAI Key".into())
467            } else {
468                None
469            }
470        }
471    }
472
473    // Minimal test audit logger
474    struct TestAudit {
475        log_count: std::sync::atomic::AtomicU32,
476    }
477    impl TestAudit {
478        fn new() -> Self {
479            Self {
480                log_count: std::sync::atomic::AtomicU32::new(0),
481            }
482        }
483        fn count(&self) -> u32 {
484            self.log_count.load(std::sync::atomic::Ordering::SeqCst)
485        }
486    }
487    impl AuditLogger for TestAudit {
488        fn log(&self, _event: &AuditEvent) -> anyhow::Result<()> {
489            self.log_count
490                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
491            Ok(())
492        }
493    }
494
495    fn make_server() -> SigilMcpServer<TestScanner, TestAudit> {
496        let scanner = Arc::new(TestScanner);
497        let audit = Arc::new(TestAudit::new());
498        let mut server = SigilMcpServer::new("test-server", "0.1.0", scanner, audit);
499
500        server.register_tool(ToolDef {
501            name: "echo".into(),
502            description: "Echo input back".into(),
503            parameters_schema: serde_json::json!({"type": "object"}),
504            handler: Box::new(|args| {
505                Box::pin(async move { Ok(args) })
506            }),
507        });
508
509        server.register_tool_with_trust(
510            ToolDef {
511                name: "admin_reset".into(),
512                description: "Dangerous admin operation".into(),
513                parameters_schema: serde_json::json!({"type": "object"}),
514                handler: Box::new(|_| {
515                    Box::pin(async move { Ok(serde_json::json!({"status": "reset"})) })
516                }),
517            },
518            TrustLevel::High,
519        );
520
521        server
522    }
523
524    #[tokio::test]
525    async fn initialize_returns_server_info() {
526        let server = make_server();
527        let req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
528        let resp = server.handle_request(req, TrustLevel::Low).await;
529        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
530        assert_eq!(parsed["result"]["serverInfo"]["name"], "test-server");
531        assert!(parsed["result"]["sigil"].is_object());
532    }
533
534    #[tokio::test]
535    async fn tools_list_returns_registered_tools() {
536        let server = make_server();
537        let req = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
538        let resp = server.handle_request(req, TrustLevel::Low).await;
539        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
540        let tools = parsed["result"]["tools"].as_array().unwrap();
541        assert_eq!(tools.len(), 2);
542        let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
543        assert!(names.contains(&"echo"));
544        assert!(names.contains(&"admin_reset"));
545    }
546
547    #[tokio::test]
548    async fn tools_call_echo_succeeds() {
549        let server = make_server();
550        let req = r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}"#;
551        let resp = server.handle_request(req, TrustLevel::Low).await;
552        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
553        assert!(parsed["result"]["content"][0]["text"]
554            .as_str()
555            .unwrap()
556            .contains("hello"));
557        assert_eq!(parsed["result"]["isError"], false);
558    }
559
560    #[tokio::test]
561    async fn tools_call_unknown_tool_returns_error() {
562        let server = make_server();
563        let req = r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nonexistent","arguments":{}}}"#;
564        let resp = server.handle_request(req, TrustLevel::Low).await;
565        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
566        assert!(parsed["error"]["message"]
567            .as_str()
568            .unwrap()
569            .contains("Unknown tool"));
570    }
571
572    #[tokio::test]
573    async fn trust_gate_blocks_low_trust_from_high_trust_tool() {
574        let server = make_server();
575        let req = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"admin_reset","arguments":{}}}"#;
576        let resp = server.handle_request(req, TrustLevel::Low).await;
577        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
578        assert!(parsed["error"]["message"]
579            .as_str()
580            .unwrap()
581            .contains("trust gate"));
582    }
583
584    #[tokio::test]
585    async fn trust_gate_allows_high_trust_for_high_trust_tool() {
586        let server = make_server();
587        let req = r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"admin_reset","arguments":{}}}"#;
588        let resp = server.handle_request(req, TrustLevel::High).await;
589        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
590        assert!(parsed["error"].is_null());
591        assert!(parsed["result"]["content"][0]["text"]
592            .as_str()
593            .unwrap()
594            .contains("reset"));
595    }
596
597    #[tokio::test]
598    async fn sigil_scan_detects_secrets_in_arguments() {
599        let server = make_server();
600        let req = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"echo","arguments":{"key":"sk-abc123def456"}}}"#;
601        let resp = server.handle_request(req, TrustLevel::Low).await;
602        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
603        // Tool still executes, but SIGIL metadata flags secrets
604        assert_eq!(parsed["result"]["sigil"]["inputSecrets"], true);
605        // Audit log was called (at least 2: interception + tool gated)
606        assert!(server.audit.count() >= 2);
607    }
608
609    #[tokio::test]
610    async fn sigil_scan_no_secrets_in_clean_input() {
611        let server = make_server();
612        let req = r#"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"echo","arguments":{"message":"safe text"}}}"#;
613        let resp = server.handle_request(req, TrustLevel::Low).await;
614        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
615        assert_eq!(parsed["result"]["sigil"]["inputSecrets"], false);
616        assert_eq!(parsed["result"]["sigil"]["outputSecrets"], false);
617    }
618
619    #[tokio::test]
620    async fn invalid_json_returns_parse_error() {
621        let server = make_server();
622        let resp = server.handle_request("not json", TrustLevel::Low).await;
623        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
624        assert_eq!(parsed["error"]["code"], -32700);
625    }
626
627    #[tokio::test]
628    async fn unknown_method_returns_method_not_found() {
629        let server = make_server();
630        let req = r#"{"jsonrpc":"2.0","id":10,"method":"resources/list","params":{}}"#;
631        let resp = server.handle_request(req, TrustLevel::Low).await;
632        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
633        assert_eq!(parsed["error"]["code"], -32601);
634    }
635
636    #[tokio::test]
637    async fn audit_logged_for_every_tool_call() {
638        let server = make_server();
639        let req = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"echo","arguments":{"msg":"hi"}}}"#;
640        let before = server.audit.count();
641        server.handle_request(req, TrustLevel::Low).await;
642        let after = server.audit.count();
643        assert!(after > before, "Audit log should record tool invocation");
644    }
645
646    #[tokio::test]
647    async fn signed_server_embeds_sigil_envelope_in_response() {
648        use crate::sigil_envelope::{SigilEnvelope, SigilKeypair};
649
650        let keypair = SigilKeypair::generate();
651        let verifying_key = keypair.verifying_key_base64();
652        let scanner = Arc::new(TestScanner);
653        let audit = Arc::new(TestAudit::new());
654
655        let mut server = SigilMcpServer::new_with_keypair(
656            "signed-server",
657            "0.1.0",
658            scanner,
659            audit,
660            keypair,
661            "did:sigil:signed_server",
662        );
663        server.register_tool(ToolDef {
664            name: "ping".into(),
665            description: "Returns pong".into(),
666            parameters_schema: serde_json::json!({"type": "object"}),
667            handler: Box::new(|_| {
668                Box::pin(async move { Ok(serde_json::json!({"pong": true})) })
669            }),
670        });
671
672        let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}"#;
673        let resp = server.handle_request(req, TrustLevel::Low).await;
674        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
675
676        // Response must contain a _sigil field
677        let sigil = &parsed["result"]["_sigil"];
678        assert!(sigil.is_object(), "_sigil must be present in signed response");
679        assert_eq!(sigil["identity"], "did:sigil:signed_server");
680        assert_eq!(sigil["verdict"], "allowed");
681        assert!(sigil["signature"].is_string(), "Signature must be present");
682        assert!(sigil["nonce"].is_string(), "Nonce must be present");
683
684        // Signature must be cryptographically valid
685        let envelope: SigilEnvelope = serde_json::from_value(sigil.clone()).unwrap();
686        assert!(
687            envelope.verify(&verifying_key).unwrap(),
688            "Outbound _sigil signature must verify against server public key"
689        );
690    }
691
692    #[tokio::test]
693    async fn unsigned_server_works_without_sigil_envelope() {
694        // Default server (no keypair) must still function correctly
695        let server = make_server();
696        let req = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"x":1}}}"#;
697        let resp = server.handle_request(req, TrustLevel::Low).await;
698        let parsed: serde_json::Value = serde_json::from_str(&resp).unwrap();
699        // No error, result present, but no _sigil (no keypair)
700        assert!(parsed["error"].is_null());
701        assert!(parsed["result"]["_sigil"].is_null());
702    }
703}