1use crate::json::{self, Incoming, Request, Response};
20use crate::wire::mcp::{PROTOCOL_VERSION, method};
21use serde_json::json;
22use std::io::{BufRead, BufReader, Read, Write};
23use std::net::{TcpListener, TcpStream};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::time::Duration;
27
28struct State {
37 uri: String,
38 emit: bool,
39 pending_emit: AtomicBool,
40 store: std::sync::Mutex<
42 std::collections::BTreeMap<String, std::collections::BTreeMap<u64, serde_json::Value>>,
43 >,
44 flaky_calls: std::sync::atomic::AtomicU64,
46 fail_next: std::sync::atomic::AtomicU64,
48 ops: std::sync::Mutex<Vec<String>>,
50}
51
52pub fn run(addr_file: &str, uri: &str, emit: bool) -> i32 {
55 let listener = match TcpListener::bind("127.0.0.1:0") {
56 Ok(l) => l,
57 Err(e) => {
58 eprintln!("internal-mock-mcp-http: bind 127.0.0.1:0: {e}");
59 return 1;
60 }
61 };
62 if let Err(e) = crate::announce_addr(addr_file, &listener) {
63 eprintln!("internal-mock-mcp-http: write {addr_file}: {e}");
64 return 1;
65 }
66 let state = Arc::new(State {
67 uri: uri.to_string(),
68 emit,
69 pending_emit: AtomicBool::new(false),
70 store: std::sync::Mutex::new(std::collections::BTreeMap::new()),
71 flaky_calls: std::sync::atomic::AtomicU64::new(0),
72 fail_next: std::sync::atomic::AtomicU64::new(0),
73 ops: std::sync::Mutex::new(Vec::new()),
74 });
75 for conn in listener.incoming() {
76 let Ok(stream) = conn else { continue };
77 let state = Arc::clone(&state);
78 std::thread::spawn(move || handle_conn(stream, state));
79 }
80 0
81}
82
83fn handle_conn(mut stream: TcpStream, state: Arc<State>) {
86 let Some((method_line, body)) = read_http(&stream) else {
87 return;
88 };
89 let is_get = method_line.starts_with("GET ");
90 if is_get {
91 serve_notifications(&mut stream, &state);
92 return;
93 }
94 match serde_json::from_slice::<Incoming>(&body) {
96 Ok(Incoming::Request(req)) => {
97 let (resp, session) = handle_request(req, &state);
98 let payload = serde_json::to_value(resp).unwrap_or(serde_json::Value::Null);
99 write_json(&mut stream, payload, session);
100 }
101 Ok(Incoming::Notification(_)) | Ok(Incoming::Response(_)) | Err(_) => {
103 let _ = stream.write_all(
104 b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
105 );
106 }
107 }
108}
109
110fn handle_request(req: Request, state: &State) -> (Response, bool) {
113 let uri = &state.uri;
114 match req.method.as_str() {
115 "initialize" => (
116 Response::ok(
117 req.id,
118 json!({
119 "protocolVersion": PROTOCOL_VERSION,
120 "capabilities": {"resources": {"subscribe": true, "listChanged": true}, "tools": {}, "prompts": {"listChanged": true}},
121 "serverInfo": {"name": "agentd-mock-http", "version": crate::VERSION}
122 }),
123 ),
124 true,
125 ),
126 "ping" => (Response::ok(req.id, json!({})), false),
127 "tools/list" => (
128 Response::ok(
129 req.id,
130 json!({"tools": [
131 {"name": "state.put", "description": "checkpointer put", "inputSchema": {"type": "object"}},
132 {"name": "state.get", "description": "checkpointer get", "inputSchema": {"type": "object"}},
133 {"name": "state.list", "description": "checkpointer list", "inputSchema": {"type": "object"}},
134 {"name": "state.delete", "description": "checkpointer delete", "inputSchema": {"type": "object"}},
135 {"name": "flaky", "description": "fails once, then succeeds", "inputSchema": {"type": "object"}},
136 {"name": "mock.fault", "description": "fail the next N state.* calls", "inputSchema": {"type": "object"}},
137 {"name": "mock.ops", "description": "the state.* calls performed so far", "inputSchema": {"type": "object"}},
138 {"name": "knowledge.search", "description": "RAG search over the mock corpus", "inputSchema": {"type": "object"}},
139 {"name": "knowledge.get", "description": "fetch a mock document", "inputSchema": {"type": "object"}},
140 {"name": "knowledge.list", "description": "list mock documents", "inputSchema": {"type": "object"}},
141 {"name": "search.query", "description": "mock web search", "inputSchema": {"type": "object"}},
142 {"name": "search.fetch", "description": "mock page fetch", "inputSchema": {"type": "object"}},
143 ]}),
144 ),
145 false,
146 ),
147 "tools/call" => (handle_tool_call(req, state), false),
148 "resources/list" => (
149 Response::ok(
150 req.id,
151 json!({"resources": [
152 {"uri": uri, "name": "mock"},
153 {"uri": "skill://incident-runbook", "name": "incident-runbook", "description": "Handle a production incident. When to use: an alert or outage report", "mimeType": "text/x-skill+markdown"},
154 {"uri": "mock://instruction", "name": "instruction", "mimeType": "text/plain"}
155 ]}),
156 ),
157 false,
158 ),
159 "resources/read" => {
160 let asked = req
161 .params
162 .as_ref()
163 .and_then(|p| p.get("uri"))
164 .and_then(serde_json::Value::as_str)
165 .unwrap_or("")
166 .to_string();
167 let (mime, text) = match asked.as_str() {
168 "skill://incident-runbook" => ("text/x-skill+markdown", "# Incident runbook\n1. Acknowledge the alert. 2. Find the blast radius. 3. Mitigate first, root-cause later. 4. Write the timeline.".to_string()),
169 "mock://instruction" => ("text/plain", "You are the mock-served agent. Follow the served instruction.".to_string()),
170 _ => ("text/plain", "the watched resource changed".to_string()),
171 };
172 let uri_out = if asked.is_empty() { uri.clone() } else { asked };
173 (
174 Response::ok(
175 req.id,
176 json!({"contents": [{"uri": uri_out, "mimeType": mime, "text": text}]}),
177 ),
178 false,
179 )
180 }
181 "prompts/list" => (
183 Response::ok(
184 req.id,
185 json!({"prompts": [
186 {"name": "review-pr", "description": "Review a pull request thoroughly. When to use: any code review request", "arguments": [{"name": "target", "description": "What to review", "required": false}]},
187 {"name": "deploy-safely", "description": "Deploy with a rollback plan"}
188 ]}),
189 ),
190 false,
191 ),
192 "prompts/get" => {
193 let params = req.params.clone().unwrap_or(json!({}));
194 let name = params
195 .get("name")
196 .and_then(serde_json::Value::as_str)
197 .unwrap_or("");
198 let target = params
199 .get("arguments")
200 .and_then(|a| a.get("target"))
201 .and_then(serde_json::Value::as_str)
202 .unwrap_or("the change");
203 let body = match name {
204 "review-pr" => format!(
205 "# Skill: review-pr\nReview {target}: read the diff, check tests, look for security issues, summarize findings as bullets."
206 ),
207 "deploy-safely" => {
208 "# Skill: deploy-safely\nAlways deploy behind a flag with a rollback plan."
209 .to_string()
210 }
211 _ => {
212 return (
213 Response::err(
214 req.id,
215 json::INVALID_PARAMS,
216 format!("no such prompt: {name}"),
217 ),
218 false,
219 );
220 }
221 };
222 (
223 Response::ok(
224 req.id,
225 json!({"description": "skill body", "messages": [{"role": "user", "content": {"type": "text", "text": body}}]}),
226 ),
227 false,
228 )
229 }
230 "resources/unsubscribe" => (Response::ok(req.id, json!({})), false),
231 "resources/subscribe" => {
232 if state.emit {
234 state.pending_emit.store(true, Ordering::SeqCst);
235 }
236 (Response::ok(req.id, json!({})), false)
237 }
238 other => (
239 Response::err(
240 req.id,
241 json::METHOD_NOT_FOUND,
242 format!("unsupported: {other}"),
243 ),
244 false,
245 ),
246 }
247}
248
249fn handle_tool_call(req: Request, state: &State) -> Response {
255 fn tool_ok(id: json::Id, v: serde_json::Value) -> Response {
256 Response::ok(
257 id,
258 json!({"content": [{"type": "text", "text": v.to_string()}], "structuredContent": v, "isError": false}),
259 )
260 }
261 fn tool_err(id: json::Id, msg: &str) -> Response {
262 Response::ok(
263 id,
264 json!({"content": [{"type": "text", "text": msg}], "isError": true}),
265 )
266 }
267 let params = req.params.clone().unwrap_or(json!({}));
268 let name = params.get("name").and_then(serde_json::Value::as_str);
269 let args = params.get("arguments").cloned().unwrap_or(json!({}));
270 let key = || {
271 args.get("key")
272 .and_then(serde_json::Value::as_str)
273 .unwrap_or("")
274 .to_string()
275 };
276 if let Some(n) = name
277 && n.starts_with("state.")
278 {
279 state
280 .ops
281 .lock()
282 .unwrap_or_else(|e| e.into_inner())
283 .push(n.to_string());
284 let remaining = state.fail_next.load(Ordering::SeqCst);
286 if remaining > 0 {
287 state.fail_next.store(remaining - 1, Ordering::SeqCst);
288 return tool_err(req.id, &format!("injected fault on {n}"));
289 }
290 }
291 match name {
292 Some("mock.fault") => {
293 let n = args
294 .get("count")
295 .and_then(serde_json::Value::as_u64)
296 .unwrap_or(1);
297 state.fail_next.store(n, Ordering::SeqCst);
298 tool_ok(req.id, json!({"ok": true, "count": n}))
299 }
300 Some("mock.ops") => {
301 let ops = state.ops.lock().unwrap_or_else(|e| e.into_inner()).clone();
302 tool_ok(req.id, json!({"ops": ops}))
303 }
304 Some("state.put") => {
305 let seq = args
306 .get("seq")
307 .and_then(serde_json::Value::as_u64)
308 .unwrap_or(0);
309 let env = args.get("state").cloned().unwrap_or(json!(null));
310 let mut store = state.store.lock().unwrap_or_else(|e| e.into_inner());
311 let hist = store.entry(key()).or_default();
312 let latest = hist.keys().next_back().copied().unwrap_or(0);
313 if seq <= latest {
314 return tool_ok(req.id, json!({"ok": false, "latest": latest}));
317 }
318 hist.insert(seq, env);
319 tool_ok(req.id, json!({"ok": true, "seq": seq}))
320 }
321 Some("state.get") => {
322 let store = state.store.lock().unwrap_or_else(|e| e.into_inner());
323 match store.get(&key()) {
324 None => tool_err(req.id, "no such key"),
325 Some(hist) => {
326 let picked = match args.get("seq").and_then(serde_json::Value::as_u64) {
327 Some(seq) => hist.get(&seq),
328 None => hist.values().next_back(),
329 };
330 match picked {
331 Some(env) => tool_ok(req.id, json!({"state": env})),
332 None => tool_err(req.id, "no such seq"),
333 }
334 }
335 }
336 }
337 Some("state.list") => {
338 let store = state.store.lock().unwrap_or_else(|e| e.into_inner());
339 if let Some(prefix) = args.get("prefix").and_then(serde_json::Value::as_str) {
340 let keys: Vec<serde_json::Value> = store
343 .iter()
344 .filter(|(k, h)| {
345 k.starts_with(prefix)
346 && h.values().next_back().is_some_and(|v| {
347 !v.get("state").is_some_and(serde_json::Value::is_null)
348 })
349 })
350 .map(|(k, h)| json!({"key": k, "seq": h.keys().next_back().copied()}))
351 .collect();
352 return tool_ok(req.id, json!({"keys": keys}));
353 }
354 let seqs: Vec<u64> = store
356 .get(&key())
357 .map(|h| h.keys().copied().collect())
358 .unwrap_or_default();
359 tool_ok(req.id, json!({"seqs": seqs}))
360 }
361 Some("state.delete") => {
362 let mut store = state.store.lock().unwrap_or_else(|e| e.into_inner());
363 let existed = store.remove(&key()).is_some();
364 tool_ok(req.id, json!({"ok": true, "existed": existed}))
365 }
366 Some("flaky") => {
367 let n = state.flaky_calls.fetch_add(1, Ordering::SeqCst);
373 if n == 0 {
374 std::thread::sleep(Duration::from_secs(60));
375 tool_err(req.id, "flaky: the first call never completes in time")
376 } else {
377 tool_ok(req.id, json!({"ok": true, "attempt": n + 1}))
378 }
379 }
380 Some("knowledge.search") => {
383 let q = args
384 .get("query")
385 .and_then(serde_json::Value::as_str)
386 .unwrap_or("")
387 .to_ascii_lowercase();
388 let top_k = args
389 .get("top_k")
390 .and_then(serde_json::Value::as_u64)
391 .unwrap_or(5) as usize;
392 let hits: Vec<serde_json::Value> = corpus()
393 .iter()
394 .filter(|(_, title, body)| q.is_empty() || q.split_whitespace().any(|w| title.to_ascii_lowercase().contains(w) || body.to_ascii_lowercase().contains(w)))
395 .take(top_k)
396 .enumerate()
397 .map(|(i, (id, title, body))| json!({"id": id, "uri": format!("kb://{id}"), "title": title, "score": 1.0 - i as f64 * 0.1, "snippet": body.chars().take(120).collect::<String>(), "metadata": {"source": "mock"}}))
398 .collect();
399 tool_ok(req.id, json!({"hits": hits}))
400 }
401 Some("knowledge.get") => {
402 let want = args
403 .get("id")
404 .or_else(|| args.get("uri"))
405 .and_then(serde_json::Value::as_str)
406 .unwrap_or("")
407 .trim_start_matches("kb://")
408 .to_string();
409 match corpus().iter().find(|(id, _, _)| *id == want) {
410 Some((id, title, body)) => tool_ok(
411 req.id,
412 json!({"content": body, "mime": "text/markdown", "metadata": {"id": id, "title": title}}),
413 ),
414 None => tool_err(req.id, "no such document"),
415 }
416 }
417 Some("knowledge.list") => tool_ok(
418 req.id,
419 json!({"docs": corpus().iter().map(|(id, title, _)| json!({"id": id, "uri": format!("kb://{id}"), "title": title})).collect::<Vec<_>>()}),
420 ),
421 Some("search.query") => {
422 let q = args
423 .get("query")
424 .and_then(serde_json::Value::as_str)
425 .unwrap_or("");
426 tool_ok(
427 req.id,
428 json!({"results": [
429 {"title": format!("Result for {q}"), "url": format!("https://example.test/{}", q.replace(' ', "-")), "snippet": format!("A mock search result about {q}."), "source": "mock"},
430 ]}),
431 )
432 }
433 Some("search.fetch") => {
434 let url = args
435 .get("url")
436 .and_then(serde_json::Value::as_str)
437 .unwrap_or("");
438 tool_ok(
439 req.id,
440 json!({"content": format!("<html><body>fetched {url}</body></html>"), "mime": "text/html", "final_url": url}),
441 )
442 }
443 other => tool_err(req.id, &format!("no such tool: {other:?}")),
444 }
445}
446
447fn corpus() -> Vec<(&'static str, &'static str, &'static str)> {
449 vec![
450 (
451 "doc-1",
452 "Deployment policy",
453 "Deployments go through staging first; production deploys need a rollback plan and a canary of 5% for ten minutes.",
454 ),
455 (
456 "doc-2",
457 "Incident handbook",
458 "During an incident, mitigate before root-causing; page the on-call; write a timeline within 24 hours.",
459 ),
460 (
461 "doc-3",
462 "Vacation policy",
463 "Employees accrue 2 days of vacation per month; requests go to the manager two weeks ahead.",
464 ),
465 ]
466}
467
468fn serve_notifications(stream: &mut TcpStream, state: &State) {
474 let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n";
475 if stream.write_all(head.as_bytes()).is_err() {
476 return;
477 }
478 let _ = stream.flush();
479 loop {
480 if state.pending_emit.swap(false, Ordering::SeqCst) {
481 let note = json::Notification::new(
482 method::NOTIFY_RESOURCES_UPDATED,
483 Some(json!({"uri": state.uri})),
484 );
485 let data = serde_json::to_string(¬e).unwrap_or_default();
486 if stream
487 .write_all(format!("data: {data}\n\n").as_bytes())
488 .is_err()
489 {
490 return;
491 }
492 let _ = stream.flush();
493 }
494 std::thread::sleep(Duration::from_millis(25));
495 }
496}
497
498fn read_http(stream: &TcpStream) -> Option<(String, Vec<u8>)> {
502 let mut reader = BufReader::new(stream.try_clone().ok()?);
503 let mut request_line = String::new();
504 if reader.read_line(&mut request_line).ok()? == 0 {
505 return None;
506 }
507 let mut content_length = 0usize;
508 loop {
509 let mut line = String::new();
510 if reader.read_line(&mut line).ok()? == 0 {
511 break;
512 }
513 let line = line.trim_end();
514 if line.is_empty() {
515 break;
516 }
517 if let Some((k, v)) = line.split_once(':')
518 && k.trim().eq_ignore_ascii_case("content-length")
519 {
520 content_length = v.trim().parse().unwrap_or(0);
521 }
522 }
523 let mut body = vec![0u8; content_length];
524 reader.read_exact(&mut body).ok()?;
525 Some((request_line, body))
526}
527
528fn write_json(stream: &mut TcpStream, payload: serde_json::Value, session: bool) {
531 let body = serde_json::to_vec(&payload).unwrap_or_default();
532 let session_hdr = if session {
533 "Mcp-Session-Id: mock\r\n"
534 } else {
535 ""
536 };
537 let head = format!(
538 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{session_hdr}Content-Length: {}\r\nConnection: close\r\n\r\n",
539 body.len()
540 );
541 let _ = stream.write_all(head.as_bytes());
542 let _ = stream.write_all(&body);
543 let _ = stream.flush();
544}