Skip to main content

agentd/mcp/
mock_http.rs

1// SPDX-License-Identifier: Apache-2.0
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 RFC 0004 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 RFC
30/// 0021 §8.3 / RFC 0025 §4.1 **checkpointer tool profile** (`state.put` /
31/// `state.get` / `state.list` / `state.delete` over an in-memory per-key history
32/// with the monotonic-seq guard) plus a `flaky` tool (fails on its first call,
33/// succeeds after) and a `mock.fault` control tool (fail the next N state calls)
34/// — together they let the e2e + chaos suites prove crash → restore → complete
35/// with no external 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 (RFC 0028 §7): the catalogue + 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 RFC 0021 §8.3 / RFC 0025 §4.1 checkpointer profile
250/// plus `flaky` and the `mock.*` controls. A tool result is standard MCP content:
251/// one text part carrying the JSON **and** the same JSON as `structuredContent`
252/// (the modern shape — the store adapter's default mapping reads
253/// `result.structuredContent.*`, falling back to the text part).
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                // RFC 0025 §4.1: every live key under `prefix` with its latest
341                // seq (a tombstone — latest state null — is not listed).
342                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            // RFC 0021 §8.3 (v1 checkpointer): the seqs of ONE key.
355            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            // The crash-recovery shape (RFC 0021 §8.4 e2e): the FIRST call hangs
368            // (long enough for the harness to SIGKILL the agent mid-node — the
369            // checkpoint cursor then sits AT this node); every later call
370            // returns instantly. A resumed run re-enters the in-flight node
371            // (at-least-once) and succeeds.
372            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        // RFC 0028 §5/§6 profiles: a canned corpus for the knowledge and search
381        // contracts (auto-context + tool e2e).
382        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
447/// The knowledge profile's canned corpus: `(id, title, body)`.
448fn 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
468/// The long-lived `GET` SSE stream: hold it open and deliver the one-shot
469/// `resources/updated` armed by a subscribe. Deliberately sends NO keep-alive
470/// comments — the client polls its stop flag via a read timeout between events,
471/// and a stream of comments would keep its SSE reader busy and defeat that. The
472/// thread loops until the process exits (a test mock; the harness reaps it).
473fn 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(&note).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
498/// Read one HTTP request (request line, headers, Content-Length body) off a
499/// clone of `stream`. Returns `(request_line, body)` — headers beyond
500/// Content-Length are unused by the mock.
501fn 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
528/// Write an `application/json` HTTP response carrying `payload`, optionally
529/// stamping the `Mcp-Session-Id` header.
530fn 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}