Skip to main content

agentd/mcp/
mock_http.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! A minimal built-in **Streamable HTTP** MCP server, for tests and for operators
3//! kicking the tyres on reactive setups. Hidden mode:
4//! `agentd --internal-mock-mcp-http <addr-file> <uri> [--no-emit]`.
5//!
6//! Binds a **loopback TCP** listener on `127.0.0.1:0` and writes the bound
7//! `host:port` into `<addr-file>` (atomically: tmp + rename;
8//! [`crate::announce_addr`]) so the launching harness discovers the endpoint by
9//! waiting for the file, then hands agentd `--mcp name=http://<addr>`.
10//!
11//! It serves one resource at `<uri>` — `initialize` (advertising
12//! `resources.subscribe`), `resources/list`, `resources/read`,
13//! `resources/subscribe` — over the Streamable HTTP transport
14//! (thread-per-connection, blocking, no dep). After a subscribe it pushes one
15//! `notifications/resources/updated` on the long-lived `GET` SSE stream (unless
16//! `emit` is off), so a reactive agent reached over HTTP has something to react
17//! to.
18
19use 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
28/// Cross-connection server state: a subscribe (on a POST) arms a one-shot push
29/// that the open `GET` SSE stream delivers. The mock also implements the
30/// **checkpointer tool profile** (`state.put` / `state.get` / `state.list` /
31/// `state.delete` over an in-memory per-key history with the monotonic-seq
32/// guard) plus a `flaky` tool (fails on its first call, succeeds after) and a
33/// `mock.fault` control tool (fail the next N state calls) — together they let
34/// the e2e + chaos suites prove crash → restore → complete with no external
35/// infrastructure.
36struct State {
37    uri: String,
38    emit: bool,
39    pending_emit: AtomicBool,
40    /// The checkpointer store: key → (seq → envelope). Monotonic per key.
41    store: std::sync::Mutex<
42        std::collections::BTreeMap<String, std::collections::BTreeMap<u64, serde_json::Value>>,
43    >,
44    /// `flaky` call counter (first call errors, later ones succeed).
45    flaky_calls: std::sync::atomic::AtomicU64,
46    /// Fault injection: remaining `state.*` calls to fail with a tool error.
47    fail_next: std::sync::atomic::AtomicU64,
48    /// Every `state.*` call performed (tool name) — `mock.ops` reports it.
49    ops: std::sync::Mutex<Vec<String>>,
50}
51
52/// Serve the mock on loopback TCP until the process is killed, announcing the
53/// bound address through `addr_file`. Returns the process exit code.
54pub 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
83/// One HTTP request per connection (the client sends `Connection: close`). A
84/// `GET` is the notification SSE stream; a `POST` is one JSON-RPC frame.
85fn 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    // POST: parse the JSON-RPC frame.
95    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        // A notification POST (e.g. notifications/initialized) → 202, no body.
102        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
110/// Build the JSON-RPC response for one request. Returns the response and whether
111/// to stamp the `Mcp-Session-Id` header (on `initialize`).
112fn 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        // Skills as prompts: the catalogue, then a body per skill.
182        "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            // Arm the one-shot push the GET SSE stream will deliver.
233            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
249/// One MCP `tools/call`: the checkpointer profile plus `flaky` and the `mock.*`
250/// controls. A tool result is standard MCP content: one text part carrying the
251/// JSON **and** the same JSON as `structuredContent`. Both are emitted because
252/// the store adapter's default mapping reads `result.structuredContent.*` and
253/// falls back to the text part, so serving both exercises either path.
254fn 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        // Fault injection armed by `mock.fault`: the next N state calls fail.
285        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                // The monotonic-seq guard: a stale/duplicate writer is REFUSED
315                // (`ok:false` + the latest seq) — the split-brain signal.
316                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                // A prefix listing returns every LIVE key under `prefix` with
341                // its latest seq; a tombstone (latest state null) is omitted,
342                // because a deleted key must read as absent to the restorer.
343                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            // Without a `prefix`, a list reports the seqs held for ONE key.
356            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            // The crash-recovery shape: the FIRST call hangs
369            // (long enough for the harness to SIGKILL the agent mid-node — the
370            // checkpoint cursor then sits AT this node); every later call
371            // returns instantly. A resumed run re-enters the in-flight node
372            // (at-least-once) and succeeds.
373            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        // A canned corpus standing in for the knowledge and search tool
382        // contracts, so auto-context and tool e2e need no external service.
383        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
448/// The knowledge profile's canned corpus: `(id, title, body)`.
449fn 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
469/// The long-lived `GET` SSE stream: hold it open and deliver the one-shot
470/// `resources/updated` armed by a subscribe. Deliberately sends NO keep-alive
471/// comments — the client polls its stop flag via a read timeout between events,
472/// and a stream of comments would keep its SSE reader busy and defeat that. The
473/// thread loops until the process exits (a test mock; the harness reaps it).
474fn 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(&note).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
499/// Read one HTTP request (request line, headers, Content-Length body) off a
500/// clone of `stream`. Returns `(request_line, body)` — headers beyond
501/// Content-Length are unused by the mock.
502fn 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
529/// Write an `application/json` HTTP response carrying `payload`, optionally
530/// stamping the `Mcp-Session-Id` header.
531fn 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}