Skip to main content

mockserver_client/
mcp.rs

1//! Fluent builder for mocking an MCP (Model Context Protocol) server.
2//!
3//! This is an idiomatic Rust port of the Java/Node/Python client
4//! `McpMockBuilder` (`org.mockserver.client.McpMockBuilder`). It produces the
5//! same wire-level expectation JSON: a set of HTTP expectations that emulate a
6//! Streamable-HTTP MCP server speaking JSON-RPC 2.0.
7//!
8//! Each generated expectation matches a JSON-RPC method (`initialize`, `ping`,
9//! `notifications/initialized`, `tools/list`, `tools/call`, `resources/list`,
10//! `resources/read`, `prompts/list`, `prompts/get`) on `POST <path>` and
11//! responds with a Velocity template that echoes back the incoming JSON-RPC id
12//! via `$!{request.jsonRpcRawId}`.
13//!
14//! # Example
15//!
16//! ```
17//! use mockserver_client::mcp::mcp_mock;
18//!
19//! let expectations = mcp_mock("/mcp")
20//!     .with_tool("get_weather")
21//!         .with_description("Get the weather for a city")
22//!         .with_input_schema("{\"type\":\"object\"}")
23//!         .responding_with("sunny", false)
24//!         .and()
25//!     .build();
26//!
27//! // initialize, ping, notifications/initialized, tools/list, tools/call
28//! assert_eq!(expectations.len(), 5);
29//! ```
30
31use serde_json::{json, Value};
32
33use crate::error::Result;
34use crate::MockServerClient;
35
36// ---------------------------------------------------------------------------
37// Escaping helpers — ported 1:1 from the Java/Node/Python builders so the
38// produced template strings are byte-identical.
39// ---------------------------------------------------------------------------
40
41/// JSON-escape a string for inlining inside a JSON string literal, returning the
42/// contents WITHOUT the surrounding quotes. Mirrors Java's
43/// `OBJECT_MAPPER.writeValueAsString(value)` then stripping the outer quotes.
44pub fn escape_json(value: &str) -> String {
45    let quoted = serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string());
46    // Strip the surrounding quotes.
47    quoted[1..quoted.len() - 1].to_string()
48}
49
50/// Escape Velocity meta-characters so literal `$` / `#` in mock content are not
51/// interpreted as Velocity references/directives. Mirrors Java:
52/// `replace("$", "${esc.d}").replace("#", "${esc.h}")`.
53pub fn escape_velocity(value: &str) -> String {
54    value.replace('$', "${esc.d}").replace('#', "${esc.h}")
55}
56
57/// Escape single quotes for safe inclusion inside a JSONPath string literal.
58/// Mirrors Java's `escapeJsonPath`.
59pub fn escape_json_path(value: &str) -> String {
60    value.replace('\'', "\\'")
61}
62
63/// Validate that the supplied string is valid JSON and return it re-serialised
64/// in compact form. Mirrors Java's `validateAndSerializeJson`.
65///
66/// # Errors
67/// Returns [`crate::Error`] if `raw` is not valid JSON.
68pub fn validate_and_serialize_json(raw: &str) -> Result<String> {
69    let parsed: Value = serde_json::from_str(raw)?;
70    Ok(serde_json::to_string(&parsed)?)
71}
72
73/// Like [`escape_velocity`] composed with [`escape_json`] — applied to inlined
74/// string values exactly as in the reference clients.
75fn ev_ej(value: &str) -> String {
76    escape_velocity(&escape_json(value))
77}
78
79/// Build the Velocity template string that renders a JSON-RPC 2.0 success
80/// response wrapping the supplied result JSON. Mirrors `velocityJsonRpcResponse`.
81fn velocity_json_rpc_response(result_json: &str) -> String {
82    format!(
83        "{{\"statusCode\": 200, \
84\"headers\": [{{\"name\": \"Content-Type\", \"values\": [\"application/json\"]}}], \
85\"body\": {{\"jsonrpc\": \"2.0\", \"result\": {result_json}, \"id\": $!{{request.jsonRpcRawId}}}}}}"
86    )
87}
88
89// ---------------------------------------------------------------------------
90// Expectation fragment helpers
91// ---------------------------------------------------------------------------
92
93fn json_rpc_request(path: &str, method: &str) -> Value {
94    json!({
95        "method": "POST",
96        "path": path,
97        "body": { "type": "JSON_RPC", "method": method }
98    })
99}
100
101fn json_path_request(path: &str, json_path: &str) -> Value {
102    json!({
103        "method": "POST",
104        "path": path,
105        "body": { "type": "JSON_PATH", "jsonPath": json_path }
106    })
107}
108
109fn velocity_template_expectation(http_request: Value, result_json: &str) -> Value {
110    json!({
111        "httpRequest": http_request,
112        "httpResponseTemplate": {
113            "template": velocity_json_rpc_response(result_json),
114            "templateType": "VELOCITY"
115        }
116    })
117}
118
119// ---------------------------------------------------------------------------
120// Internal definitions
121// ---------------------------------------------------------------------------
122
123#[derive(Debug, Clone)]
124struct ToolDef {
125    name: String,
126    description: Option<String>,
127    input_schema: Option<String>,
128    response_content: Option<String>,
129    response_is_error: bool,
130}
131
132#[derive(Debug, Clone)]
133struct ResourceDef {
134    uri: String,
135    name: Option<String>,
136    description: Option<String>,
137    mime_type: String,
138    content: Option<String>,
139}
140
141#[derive(Debug, Clone)]
142struct PromptArgument {
143    name: String,
144    description: Option<String>,
145    required: bool,
146}
147
148#[derive(Debug, Clone)]
149struct PromptMessage {
150    role: String,
151    text: String,
152}
153
154#[derive(Debug, Clone)]
155struct PromptDef {
156    name: String,
157    description: Option<String>,
158    arguments: Vec<PromptArgument>,
159    messages: Vec<PromptMessage>,
160}
161
162// ---------------------------------------------------------------------------
163// McpMockBuilder
164// ---------------------------------------------------------------------------
165
166/// Fluent builder producing the expectations for a mocked MCP server.
167#[derive(Debug, Clone)]
168pub struct McpMockBuilder {
169    path: String,
170    server_name: String,
171    server_version: String,
172    protocol_version: String,
173    tools_capability: bool,
174    resources_capability: bool,
175    prompts_capability: bool,
176    tools: Vec<ToolDef>,
177    resources: Vec<ResourceDef>,
178    prompts: Vec<PromptDef>,
179}
180
181impl McpMockBuilder {
182    fn new(path: impl Into<String>) -> Self {
183        Self {
184            path: path.into(),
185            server_name: "MockMCPServer".to_string(),
186            server_version: "1.0.0".to_string(),
187            protocol_version: "2025-03-26".to_string(),
188            tools_capability: false,
189            resources_capability: false,
190            prompts_capability: false,
191            tools: Vec::new(),
192            resources: Vec::new(),
193            prompts: Vec::new(),
194        }
195    }
196
197    /// Set the advertised server name (default `"MockMCPServer"`).
198    pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
199        self.server_name = name.into();
200        self
201    }
202
203    /// Set the advertised server version (default `"1.0.0"`).
204    pub fn with_server_version(mut self, version: impl Into<String>) -> Self {
205        self.server_version = version.into();
206        self
207    }
208
209    /// Set the advertised protocol version (default `"2025-03-26"`).
210    pub fn with_protocol_version(mut self, version: impl Into<String>) -> Self {
211        self.protocol_version = version.into();
212        self
213    }
214
215    /// Advertise the tools capability even with no tools defined.
216    pub fn with_tools_capability(mut self) -> Self {
217        self.tools_capability = true;
218        self
219    }
220
221    /// Advertise the resources capability even with no resources defined.
222    pub fn with_resources_capability(mut self) -> Self {
223        self.resources_capability = true;
224        self
225    }
226
227    /// Advertise the prompts capability even with no prompts defined.
228    pub fn with_prompts_capability(mut self) -> Self {
229        self.prompts_capability = true;
230        self
231    }
232
233    /// Start defining a tool. Finish with [`McpToolBuilder::and`].
234    pub fn with_tool(self, name: impl Into<String>) -> McpToolBuilder {
235        McpToolBuilder {
236            parent: self,
237            tool: ToolDef {
238                name: name.into(),
239                description: None,
240                input_schema: None,
241                response_content: None,
242                response_is_error: false,
243            },
244        }
245    }
246
247    /// Start defining a resource. Finish with [`McpResourceBuilder::and`].
248    pub fn with_resource(self, uri: impl Into<String>) -> McpResourceBuilder {
249        McpResourceBuilder {
250            parent: self,
251            resource: ResourceDef {
252                uri: uri.into(),
253                name: None,
254                description: None,
255                mime_type: "application/json".to_string(),
256                content: None,
257            },
258        }
259    }
260
261    /// Start defining a prompt. Finish with [`McpPromptBuilder::and`].
262    pub fn with_prompt(self, name: impl Into<String>) -> McpPromptBuilder {
263        McpPromptBuilder {
264            parent: self,
265            prompt: PromptDef {
266                name: name.into(),
267                description: None,
268                arguments: Vec::new(),
269                messages: Vec::new(),
270            },
271        }
272    }
273
274    fn has_tools(&self) -> bool {
275        self.tools_capability || !self.tools.is_empty()
276    }
277
278    fn has_resources(&self) -> bool {
279        self.resources_capability || !self.resources.is_empty()
280    }
281
282    fn has_prompts(&self) -> bool {
283        self.prompts_capability || !self.prompts.is_empty()
284    }
285
286    /// Build the full ordered list of MCP expectations.
287    pub fn build(&self) -> Vec<Value> {
288        let mut expectations = vec![
289            self.build_initialize(),
290            self.build_ping(),
291            self.build_notifications_initialized(),
292        ];
293
294        if self.has_tools() {
295            expectations.push(self.build_tools_list());
296        }
297        for tool in &self.tools {
298            expectations.push(self.build_tools_call(tool));
299        }
300
301        if self.has_resources() {
302            expectations.push(self.build_resources_list());
303        }
304        for resource in &self.resources {
305            expectations.push(self.build_resources_read(resource));
306        }
307
308        if self.has_prompts() {
309            expectations.push(self.build_prompts_list());
310        }
311        for prompt in &self.prompts {
312            expectations.push(self.build_prompts_get(prompt));
313        }
314
315        expectations
316    }
317
318    /// Build and register the expectations on the given client.
319    pub fn apply_to(&self, client: &MockServerClient) -> Result<Value> {
320        client.upsert_raw(Value::Array(self.build()))
321    }
322
323    // --- individual expectation builders -------------------------------
324
325    fn build_initialize(&self) -> Value {
326        let mut caps_parts: Vec<&str> = Vec::new();
327        if self.has_tools() {
328            caps_parts.push("\"tools\": {\"listChanged\": false}");
329        }
330        if self.has_resources() {
331            caps_parts.push("\"resources\": {\"subscribe\": false, \"listChanged\": false}");
332        }
333        if self.has_prompts() {
334            caps_parts.push("\"prompts\": {\"listChanged\": false}");
335        }
336        let caps = format!("{{{}}}", caps_parts.join(", "));
337
338        let result_json = format!(
339            "{{\"protocolVersion\": \"{}\", \"capabilities\": {}, \"serverInfo\": {{\"name\": \"{}\", \"version\": \"{}\"}}}}",
340            ev_ej(&self.protocol_version),
341            caps,
342            ev_ej(&self.server_name),
343            ev_ej(&self.server_version),
344        );
345
346        velocity_template_expectation(json_rpc_request(&self.path, "initialize"), &result_json)
347    }
348
349    fn build_ping(&self) -> Value {
350        velocity_template_expectation(json_rpc_request(&self.path, "ping"), "{}")
351    }
352
353    fn build_notifications_initialized(&self) -> Value {
354        json!({
355            "httpRequest": json_rpc_request(&self.path, "notifications/initialized"),
356            "httpResponse": {
357                "statusCode": 200,
358                "headers": [{ "name": "Content-Type", "values": ["application/json"] }],
359                "body": "{}"
360            }
361        })
362    }
363
364    fn build_tools_list(&self) -> Value {
365        let items: Vec<String> = self
366            .tools
367            .iter()
368            .map(|tool| {
369                let mut s = format!("{{\"name\": \"{}\"", ev_ej(&tool.name));
370                if let Some(description) = &tool.description {
371                    s.push_str(&format!(", \"description\": \"{}\"", ev_ej(description)));
372                }
373                if let Some(input_schema) = &tool.input_schema {
374                    let compacted = validate_and_serialize_json(input_schema)
375                        .expect("inputSchema must be valid JSON");
376                    s.push_str(&format!(", \"inputSchema\": {}", escape_velocity(&compacted)));
377                }
378                s.push('}');
379                s
380            })
381            .collect();
382        let tools_json = format!("[{}]", items.join(", "));
383        velocity_template_expectation(
384            json_rpc_request(&self.path, "tools/list"),
385            &format!("{{\"tools\": {tools_json}}}"),
386        )
387    }
388
389    fn build_tools_call(&self, tool: &ToolDef) -> Value {
390        let json_path = format!(
391            "$[?(@.method == 'tools/call' && @.params.name == '{}')]",
392            escape_json_path(&tool.name)
393        );
394        let content = tool
395            .response_content
396            .as_deref()
397            .map(ev_ej)
398            .unwrap_or_default();
399        let is_error = if tool.response_is_error { "true" } else { "false" };
400        let result_json = format!(
401            "{{\"content\": [{{\"type\": \"text\", \"text\": \"{content}\"}}], \"isError\": {is_error}}}"
402        );
403        velocity_template_expectation(json_path_request(&self.path, &json_path), &result_json)
404    }
405
406    fn build_resources_list(&self) -> Value {
407        let items: Vec<String> = self
408            .resources
409            .iter()
410            .map(|resource| {
411                let mut s = format!("{{\"uri\": \"{}\"", ev_ej(&resource.uri));
412                if let Some(name) = &resource.name {
413                    s.push_str(&format!(", \"name\": \"{}\"", ev_ej(name)));
414                }
415                if let Some(description) = &resource.description {
416                    s.push_str(&format!(", \"description\": \"{}\"", ev_ej(description)));
417                }
418                s.push_str(&format!(", \"mimeType\": \"{}\"", ev_ej(&resource.mime_type)));
419                s.push('}');
420                s
421            })
422            .collect();
423        let resources_json = format!("[{}]", items.join(", "));
424        velocity_template_expectation(
425            json_rpc_request(&self.path, "resources/list"),
426            &format!("{{\"resources\": {resources_json}}}"),
427        )
428    }
429
430    fn build_resources_read(&self, resource: &ResourceDef) -> Value {
431        let json_path = format!(
432            "$[?(@.method == 'resources/read' && @.params.uri == '{}')]",
433            escape_json_path(&resource.uri)
434        );
435        let content = resource
436            .content
437            .as_deref()
438            .map(ev_ej)
439            .unwrap_or_default();
440        let result_json = format!(
441            "{{\"contents\": [{{\"uri\": \"{}\", \"mimeType\": \"{}\", \"text\": \"{content}\"}}]}}",
442            ev_ej(&resource.uri),
443            ev_ej(&resource.mime_type),
444        );
445        velocity_template_expectation(json_path_request(&self.path, &json_path), &result_json)
446    }
447
448    fn build_prompts_list(&self) -> Value {
449        let items: Vec<String> = self
450            .prompts
451            .iter()
452            .map(|prompt| {
453                let mut s = format!("{{\"name\": \"{}\"", ev_ej(&prompt.name));
454                if let Some(description) = &prompt.description {
455                    s.push_str(&format!(", \"description\": \"{}\"", ev_ej(description)));
456                }
457                if !prompt.arguments.is_empty() {
458                    let args: Vec<String> = prompt
459                        .arguments
460                        .iter()
461                        .map(|arg| {
462                            let mut a = format!("{{\"name\": \"{}\"", ev_ej(&arg.name));
463                            if let Some(description) = &arg.description {
464                                a.push_str(&format!(", \"description\": \"{}\"", ev_ej(description)));
465                            }
466                            a.push_str(&format!(
467                                ", \"required\": {}",
468                                if arg.required { "true" } else { "false" }
469                            ));
470                            a.push('}');
471                            a
472                        })
473                        .collect();
474                    s.push_str(&format!(", \"arguments\": [{}]", args.join(", ")));
475                }
476                s.push('}');
477                s
478            })
479            .collect();
480        let prompts_json = format!("[{}]", items.join(", "));
481        velocity_template_expectation(
482            json_rpc_request(&self.path, "prompts/list"),
483            &format!("{{\"prompts\": {prompts_json}}}"),
484        )
485    }
486
487    fn build_prompts_get(&self, prompt: &PromptDef) -> Value {
488        let json_path = format!(
489            "$[?(@.method == 'prompts/get' && @.params.name == '{}')]",
490            escape_json_path(&prompt.name)
491        );
492        let messages: Vec<String> = prompt
493            .messages
494            .iter()
495            .map(|msg| {
496                format!(
497                    "{{\"role\": \"{}\", \"content\": {{\"type\": \"text\", \"text\": \"{}\"}}}}",
498                    ev_ej(&msg.role),
499                    ev_ej(&msg.text),
500                )
501            })
502            .collect();
503        let messages_json = format!("[{}]", messages.join(", "));
504        let result_json = format!("{{\"messages\": {messages_json}}}");
505        velocity_template_expectation(json_path_request(&self.path, &json_path), &result_json)
506    }
507}
508
509// ---------------------------------------------------------------------------
510// Nested sub-builders
511// ---------------------------------------------------------------------------
512
513/// Sub-builder for a single MCP tool. Call [`and`](Self::and) to return to the
514/// parent [`McpMockBuilder`].
515pub struct McpToolBuilder {
516    parent: McpMockBuilder,
517    tool: ToolDef,
518}
519
520impl McpToolBuilder {
521    /// Set the tool description.
522    pub fn with_description(mut self, description: impl Into<String>) -> Self {
523        self.tool.description = Some(description.into());
524        self
525    }
526
527    /// Set the JSON-schema input descriptor (validated + compacted on build).
528    pub fn with_input_schema(mut self, json_schema: impl Into<String>) -> Self {
529        self.tool.input_schema = Some(json_schema.into());
530        self
531    }
532
533    /// Set the textual content returned by `tools/call` and whether it is an error.
534    pub fn responding_with(mut self, text_content: impl Into<String>, is_error: bool) -> Self {
535        self.tool.response_content = Some(text_content.into());
536        self.tool.response_is_error = is_error;
537        self
538    }
539
540    /// Finish this tool and return to the parent builder.
541    pub fn and(mut self) -> McpMockBuilder {
542        self.parent.tools.push(self.tool);
543        self.parent.tools_capability = true;
544        self.parent
545    }
546}
547
548/// Sub-builder for a single MCP resource. Call [`and`](Self::and) to return to
549/// the parent [`McpMockBuilder`].
550pub struct McpResourceBuilder {
551    parent: McpMockBuilder,
552    resource: ResourceDef,
553}
554
555impl McpResourceBuilder {
556    /// Set the resource name.
557    pub fn with_name(mut self, name: impl Into<String>) -> Self {
558        self.resource.name = Some(name.into());
559        self
560    }
561
562    /// Set the resource description.
563    pub fn with_description(mut self, description: impl Into<String>) -> Self {
564        self.resource.description = Some(description.into());
565        self
566    }
567
568    /// Set the MIME type (default `application/json`).
569    pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
570        self.resource.mime_type = mime_type.into();
571        self
572    }
573
574    /// Set the resource content returned by `resources/read`.
575    pub fn with_content(mut self, content: impl Into<String>) -> Self {
576        self.resource.content = Some(content.into());
577        self
578    }
579
580    /// Finish this resource and return to the parent builder.
581    pub fn and(mut self) -> McpMockBuilder {
582        self.parent.resources.push(self.resource);
583        self.parent.resources_capability = true;
584        self.parent
585    }
586}
587
588/// Sub-builder for a single MCP prompt. Call [`and`](Self::and) to return to the
589/// parent [`McpMockBuilder`].
590pub struct McpPromptBuilder {
591    parent: McpMockBuilder,
592    prompt: PromptDef,
593}
594
595impl McpPromptBuilder {
596    /// Set the prompt description.
597    pub fn with_description(mut self, description: impl Into<String>) -> Self {
598        self.prompt.description = Some(description.into());
599        self
600    }
601
602    /// Add a prompt argument descriptor.
603    pub fn with_argument(
604        mut self,
605        name: impl Into<String>,
606        description: Option<String>,
607        required: bool,
608    ) -> Self {
609        self.prompt.arguments.push(PromptArgument {
610            name: name.into(),
611            description,
612            required,
613        });
614        self
615    }
616
617    /// Add a message returned by `prompts/get` (use a `Role` value for `role`).
618    pub fn responding_with(mut self, role: impl Into<String>, text_content: impl Into<String>) -> Self {
619        self.prompt.messages.push(PromptMessage {
620            role: role.into(),
621            text: text_content.into(),
622        });
623        self
624    }
625
626    /// Finish this prompt and return to the parent builder.
627    pub fn and(mut self) -> McpMockBuilder {
628        self.parent.prompts.push(self.prompt);
629        self.parent.prompts_capability = true;
630        self.parent
631    }
632}
633
634/// Create a new MCP mock builder. `path` defaults to `/mcp` if you use
635/// [`mcp_mock_default`].
636pub fn mcp_mock(path: impl Into<String>) -> McpMockBuilder {
637    McpMockBuilder::new(path)
638}
639
640/// Create a new MCP mock builder targeting the default `/mcp` path.
641pub fn mcp_mock_default() -> McpMockBuilder {
642    McpMockBuilder::new("/mcp")
643}