1use serde_json::{json, Value};
32
33use crate::error::Result;
34use crate::MockServerClient;
35
36pub fn escape_json(value: &str) -> String {
45 let quoted = serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string());
46 quoted[1..quoted.len() - 1].to_string()
48}
49
50pub fn escape_velocity(value: &str) -> String {
54 value.replace('$', "${esc.d}").replace('#', "${esc.h}")
55}
56
57pub fn escape_json_path(value: &str) -> String {
60 value.replace('\'', "\\'")
61}
62
63pub 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
73fn ev_ej(value: &str) -> String {
76 escape_velocity(&escape_json(value))
77}
78
79fn 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
89fn 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#[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#[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 pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
199 self.server_name = name.into();
200 self
201 }
202
203 pub fn with_server_version(mut self, version: impl Into<String>) -> Self {
205 self.server_version = version.into();
206 self
207 }
208
209 pub fn with_protocol_version(mut self, version: impl Into<String>) -> Self {
211 self.protocol_version = version.into();
212 self
213 }
214
215 pub fn with_tools_capability(mut self) -> Self {
217 self.tools_capability = true;
218 self
219 }
220
221 pub fn with_resources_capability(mut self) -> Self {
223 self.resources_capability = true;
224 self
225 }
226
227 pub fn with_prompts_capability(mut self) -> Self {
229 self.prompts_capability = true;
230 self
231 }
232
233 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 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 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 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 pub fn apply_to(&self, client: &MockServerClient) -> Result<Value> {
320 client.upsert_raw(Value::Array(self.build()))
321 }
322
323 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
509pub struct McpToolBuilder {
516 parent: McpMockBuilder,
517 tool: ToolDef,
518}
519
520impl McpToolBuilder {
521 pub fn with_description(mut self, description: impl Into<String>) -> Self {
523 self.tool.description = Some(description.into());
524 self
525 }
526
527 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 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 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
548pub struct McpResourceBuilder {
551 parent: McpMockBuilder,
552 resource: ResourceDef,
553}
554
555impl McpResourceBuilder {
556 pub fn with_name(mut self, name: impl Into<String>) -> Self {
558 self.resource.name = Some(name.into());
559 self
560 }
561
562 pub fn with_description(mut self, description: impl Into<String>) -> Self {
564 self.resource.description = Some(description.into());
565 self
566 }
567
568 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 pub fn with_content(mut self, content: impl Into<String>) -> Self {
576 self.resource.content = Some(content.into());
577 self
578 }
579
580 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
588pub struct McpPromptBuilder {
591 parent: McpMockBuilder,
592 prompt: PromptDef,
593}
594
595impl McpPromptBuilder {
596 pub fn with_description(mut self, description: impl Into<String>) -> Self {
598 self.prompt.description = Some(description.into());
599 self
600 }
601
602 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 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 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
634pub fn mcp_mock(path: impl Into<String>) -> McpMockBuilder {
637 McpMockBuilder::new(path)
638}
639
640pub fn mcp_mock_default() -> McpMockBuilder {
642 McpMockBuilder::new("/mcp")
643}