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
344 .iter()
345 .filter(|(k, h)| {
346 k.starts_with(prefix)
347 && h.values().next_back().is_some_and(|v| {
348 !v.get("state").is_some_and(serde_json::Value::is_null)
349 })
350 })
351 .map(|(k, h)| json!({"key": k, "seq": h.keys().next_back().copied()}))
352 .collect();
353 return tool_ok(req.id, json!({"keys": keys}));
354 }
355 let seqs: Vec<u64> = store
357 .get(&key())
358 .map(|h| h.keys().copied().collect())
359 .unwrap_or_default();
360 tool_ok(req.id, json!({"seqs": seqs}))
361 }
362 Some("state.delete") => {
363 let mut store = state.store.lock().unwrap_or_else(|e| e.into_inner());
364 let existed = store.remove(&key()).is_some();
365 tool_ok(req.id, json!({"ok": true, "existed": existed}))
366 }
367 Some("flaky") => {
368 let n = state.flaky_calls.fetch_add(1, Ordering::SeqCst);
374 if n == 0 {
375 std::thread::sleep(Duration::from_secs(60));
376 tool_err(req.id, "flaky: the first call never completes in time")
377 } else {
378 tool_ok(req.id, json!({"ok": true, "attempt": n + 1}))
379 }
380 }
381 Some("knowledge.search") => {
384 let q = args
385 .get("query")
386 .and_then(serde_json::Value::as_str)
387 .unwrap_or("")
388 .to_ascii_lowercase();
389 let top_k = args
390 .get("top_k")
391 .and_then(serde_json::Value::as_u64)
392 .unwrap_or(5) as usize;
393 let hits: Vec<serde_json::Value> = corpus()
394 .iter()
395 .filter(|(_, title, body)| q.is_empty() || q.split_whitespace().any(|w| title.to_ascii_lowercase().contains(w) || body.to_ascii_lowercase().contains(w)))
396 .take(top_k)
397 .enumerate()
398 .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"}}))
399 .collect();
400 tool_ok(req.id, json!({"hits": hits}))
401 }
402 Some("knowledge.get") => {
403 let want = args
404 .get("id")
405 .or_else(|| args.get("uri"))
406 .and_then(serde_json::Value::as_str)
407 .unwrap_or("")
408 .trim_start_matches("kb://")
409 .to_string();
410 match corpus().iter().find(|(id, _, _)| *id == want) {
411 Some((id, title, body)) => tool_ok(
412 req.id,
413 json!({"content": body, "mime": "text/markdown", "metadata": {"id": id, "title": title}}),
414 ),
415 None => tool_err(req.id, "no such document"),
416 }
417 }
418 Some("knowledge.list") => tool_ok(
419 req.id,
420 json!({"docs": corpus().iter().map(|(id, title, _)| json!({"id": id, "uri": format!("kb://{id}"), "title": title})).collect::<Vec<_>>()}),
421 ),
422 Some("search.query") => {
423 let q = args
424 .get("query")
425 .and_then(serde_json::Value::as_str)
426 .unwrap_or("");
427 tool_ok(
428 req.id,
429 json!({"results": [
430 {"title": format!("Result for {q}"), "url": format!("https://example.test/{}", q.replace(' ', "-")), "snippet": format!("A mock search result about {q}."), "source": "mock"},
431 ]}),
432 )
433 }
434 Some("search.fetch") => {
435 let url = args
436 .get("url")
437 .and_then(serde_json::Value::as_str)
438 .unwrap_or("");
439 tool_ok(
440 req.id,
441 json!({"content": format!("<html><body>fetched {url}</body></html>"), "mime": "text/html", "final_url": url}),
442 )
443 }
444 other => tool_err(req.id, &format!("no such tool: {other:?}")),
445 }
446}
447
448fn corpus() -> Vec<(&'static str, &'static str, &'static str)> {
450 vec![
451 (
452 "doc-1",
453 "Deployment policy",
454 "Deployments go through staging first; production deploys need a rollback plan and a canary of 5% for ten minutes.",
455 ),
456 (
457 "doc-2",
458 "Incident handbook",
459 "During an incident, mitigate before root-causing; page the on-call; write a timeline within 24 hours.",
460 ),
461 (
462 "doc-3",
463 "Vacation policy",
464 "Employees accrue 2 days of vacation per month; requests go to the manager two weeks ahead.",
465 ),
466 ]
467}
468
469fn serve_notifications(stream: &mut TcpStream, state: &State) {
475 let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n";
476 if stream.write_all(head.as_bytes()).is_err() {
477 return;
478 }
479 let _ = stream.flush();
480 loop {
481 if state.pending_emit.swap(false, Ordering::SeqCst) {
482 let note = json::Notification::new(
483 method::NOTIFY_RESOURCES_UPDATED,
484 Some(json!({"uri": state.uri})),
485 );
486 let data = serde_json::to_string(¬e).unwrap_or_default();
487 if stream
488 .write_all(format!("data: {data}\n\n").as_bytes())
489 .is_err()
490 {
491 return;
492 }
493 let _ = stream.flush();
494 }
495 std::thread::sleep(Duration::from_millis(25));
496 }
497}
498
499fn read_http(stream: &TcpStream) -> Option<(String, Vec<u8>)> {
503 let mut reader = BufReader::new(stream.try_clone().ok()?);
504 let mut request_line = String::new();
505 if reader.read_line(&mut request_line).ok()? == 0 {
506 return None;
507 }
508 let mut content_length = 0usize;
509 loop {
510 let mut line = String::new();
511 if reader.read_line(&mut line).ok()? == 0 {
512 break;
513 }
514 let line = line.trim_end();
515 if line.is_empty() {
516 break;
517 }
518 if let Some((k, v)) = line.split_once(':')
519 && k.trim().eq_ignore_ascii_case("content-length")
520 {
521 content_length = v.trim().parse().unwrap_or(0);
522 }
523 }
524 let mut body = vec![0u8; content_length];
525 reader.read_exact(&mut body).ok()?;
526 Some((request_line, body))
527}
528
529fn write_json(stream: &mut TcpStream, payload: serde_json::Value, session: bool) {
532 let body = serde_json::to_vec(&payload).unwrap_or_default();
533 let session_hdr = if session {
534 "Mcp-Session-Id: mock\r\n"
535 } else {
536 ""
537 };
538 let head = format!(
539 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n{session_hdr}Content-Length: {}\r\nConnection: close\r\n\r\n",
540 body.len()
541 );
542 let _ = stream.write_all(head.as_bytes());
543 let _ = stream.write_all(&body);
544 let _ = stream.flush();
545}