Skip to main content

atd_server_http/
mcp.rs

1//! MCP JSON-RPC 2.0 ↔ ATD translation.
2//!
3//! SP-streamable-http §4.2: ATD-native `Request::Hello` / `Request::ToolList`
4//! / `Request::RunTool` (`atd-protocol::messages.rs:34-52`) does **not**
5//! appear on the HTTP wire. Instead the listener translates the four MCP
6//! methods (`initialize`, `notifications/initialized`, `tools/list`,
7//! `tools/call`) into ATD operations, then dispatches via
8//! `atd_runtime::dispatch::run_tool` for tool calls. Bytes returned from
9//! `Tool::call` are byte-identical to the UDS path — the parity test in
10//! `tests/e2e_parity.rs` is the regression guard.
11
12use std::sync::Arc;
13
14use atd_protocol::{Response, ToolVisibility};
15use atd_runtime::capability::CapabilitySet;
16use atd_runtime::dispatch::ServerState;
17use atd_runtime::secrets::BearerIdentity;
18use atd_runtime::tracker::ReadTracker;
19use axum::Json;
20use axum::http::StatusCode;
21use axum::response::IntoResponse;
22use serde::{Deserialize, Serialize};
23use serde_json::{Value, json};
24
25/// Incoming JSON-RPC envelope (MCP `POST /mcp` body). `jsonrpc` is
26/// retained for downstream introspection but the listener does not
27/// branch on it — MCP rev `2025-06-18` pins it to `"2.0"`.
28#[derive(Debug, Deserialize)]
29pub struct JsonRpcRequest {
30    #[allow(dead_code)]
31    #[serde(default)]
32    pub jsonrpc: String,
33    /// `null` for notifications, integer/string for requests. Echoed
34    /// back on the response.
35    #[serde(default)]
36    pub id: Option<Value>,
37    pub method: String,
38    #[serde(default)]
39    pub params: Value,
40}
41
42/// Outgoing JSON-RPC envelope. `result` and `error` are mutually
43/// exclusive — exactly one is `Some` per MCP `2025-06-18`.
44#[derive(Debug, Serialize)]
45pub struct JsonRpcResponse {
46    pub jsonrpc: &'static str,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub id: Option<Value>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub result: Option<Value>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub error: Option<JsonRpcError>,
53}
54
55#[derive(Debug, Serialize)]
56pub struct JsonRpcError {
57    pub code: i32,
58    pub message: String,
59}
60
61/// Convenience builder for a JSON-RPC error response paired with an HTTP
62/// status. SP-streamable-http §5.6 codifies the (status, code) pairs:
63/// origin → 403/-32001; missing bearer → 401/-32002; unknown method →
64/// 200/-32601; capability denied / broker error / internal → 200/-32603.
65pub fn error_response(
66    status: StatusCode,
67    id: Option<Value>,
68    code: i32,
69    message: impl Into<String>,
70) -> axum::response::Response {
71    error_response_with_headers(status, id, code, message, &[])
72}
73
74/// `error_response` with optional response headers (e.g.
75/// `WWW-Authenticate`, `Retry-After`). SP-token-broker-phase2 §4.4
76/// requires distinct headers for each bearer-auth error class so
77/// adopter-side UIs can distinguish "expired" / "revoked" / "unknown"
78/// without re-parsing the message body.
79pub fn error_response_with_headers(
80    status: StatusCode,
81    id: Option<Value>,
82    code: i32,
83    message: impl Into<String>,
84    extra_headers: &[(&str, String)],
85) -> axum::response::Response {
86    let mut resp: axum::response::Response = (
87        status,
88        Json(JsonRpcResponse {
89            jsonrpc: "2.0",
90            id,
91            result: None,
92            error: Some(JsonRpcError {
93                code,
94                message: message.into(),
95            }),
96        }),
97    )
98        .into_response();
99
100    if !extra_headers.is_empty() {
101        let headers = resp.headers_mut();
102        for (name, value) in extra_headers {
103            if let (Ok(hn), Ok(hv)) = (
104                axum::http::HeaderName::from_bytes(name.as_bytes()),
105                axum::http::HeaderValue::from_str(value),
106            ) {
107                headers.insert(hn, hv);
108            }
109        }
110    }
111
112    resp
113}
114
115/// JSON-RPC success envelope.
116pub fn ok_response(id: Option<Value>, result: Value) -> axum::response::Response {
117    Json(JsonRpcResponse {
118        jsonrpc: "2.0",
119        id,
120        result: Some(result),
121        error: None,
122    })
123    .into_response()
124}
125
126/// Build the `initialize` reply. Echoes `serverInfo` (name + version),
127/// an empty `capabilities.tools` map, and — when a `TokenBroker` is
128/// wired — the broker's `accepted_token_formats()` as an `experimental`
129/// hint (SP-token-broker-phase2 §8.2 last entry).
130///
131/// SP-streamable-http §4.2: `initialize` does NOT enter the dispatch
132/// state machine — the listener synthesises this reply directly.
133/// Mirrors `celia-cli/src/http_server.rs:417-435`.
134pub fn handle_initialize(
135    id: Option<Value>,
136    server_version: &str,
137    token_broker: Option<&Arc<dyn atd_runtime::TokenBroker>>,
138) -> axum::response::Response {
139    let (name, version) = split_server_version(server_version);
140    let mut capabilities = json!({ "tools": {} });
141
142    // SP-token-broker-phase2 §4.2 / §8.2 — the listener does NOT route
143    // on this field; it is a diagnostic / discovery hint so clients +
144    // operators can see at a glance which bearer formats the deployed
145    // broker accepts. MCP's `capabilities.experimental` map is the
146    // designated extension slot for non-MCP-spec capabilities.
147    if let Some(broker) = token_broker {
148        let formats = broker.accepted_token_formats();
149        if !formats.is_empty() {
150            let formats_value = serde_json::Value::Array(
151                formats
152                    .iter()
153                    .map(|f| serde_json::Value::String((*f).to_string()))
154                    .collect(),
155            );
156            capabilities["experimental"] = json!({
157                "atd": {
158                    "acceptedTokenFormats": formats_value
159                }
160            });
161        }
162    }
163
164    let info = json!({
165        "protocolVersion": "2025-06-18",
166        "serverInfo": {
167            "name": name,
168            "version": version,
169        },
170        "capabilities": capabilities,
171    });
172    ok_response(id, info)
173}
174
175fn split_server_version(s: &str) -> (String, String) {
176    // Server-version strings look like `"atd-ref-server 0.3.0"` —
177    // last whitespace-separated token is the version. Defensive: if no
178    // space found, return the whole string as the name and "0.0.0" as
179    // version.
180    if let Some(idx) = s.rfind(' ') {
181        let (name, ver) = s.split_at(idx);
182        (name.to_string(), ver.trim().to_string())
183    } else {
184        (s.to_string(), "0.0.0".to_string())
185    }
186}
187
188/// `notifications/initialized` is one-way per MCP; we return an empty
189/// JSON-RPC result so the client's request future resolves cleanly.
190pub fn handle_initialized_notification(id: Option<Value>) -> axum::response::Response {
191    ok_response(id, json!({}))
192}
193
194/// `tools/list` — wraps `Registry::summaries` filtered by visibility
195/// into the MCP `{ tools: [...] }` shape. Each entry exposes
196/// `{name, description, inputSchema, ...atd_fields...}` so MCP clients
197/// see the standard fields and ATD-aware clients still see the full
198/// definition. Mirrors `celia-cli/src/http_server.rs:437-455` with the
199/// ATD `ToolSummary` payload preserved.
200pub fn handle_tools_list(id: Option<Value>, state: &ServerState) -> axum::response::Response {
201    let tools: Vec<Value> = state
202        .registry
203        .summaries()
204        .into_iter()
205        .filter(|s| !matches!(s.visibility, ToolVisibility::Hidden))
206        .map(|summary| {
207            // Compose the MCP-required shape (`name` / `description` /
208            // `inputSchema`) by reading the underlying `ToolDefinition`
209            // — the summary alone does not carry the output schema /
210            // tier-aware version. For any tool whose definition is
211            // missing (deleted between summaries() and get(), should not
212            // happen), fall back to the summary alone.
213            let def_extras = state
214                .registry
215                .get(&summary.id)
216                .map(|t| {
217                    let def = t.definition();
218                    json!({
219                        "version": def.version,
220                        "outputSchema": def.output_schema,
221                    })
222                })
223                .unwrap_or_else(|| json!({}));
224            let mut entry = json!({
225                "name": summary.id,
226                "description": summary.description,
227                "inputSchema": summary.input_schema.clone().unwrap_or_else(|| json!({})),
228            });
229            if let Some(obj) = entry.as_object_mut() {
230                if let Some(extra_obj) = def_extras.as_object() {
231                    for (k, v) in extra_obj {
232                        obj.insert(k.clone(), v.clone());
233                    }
234                }
235            }
236            entry
237        })
238        .collect();
239    ok_response(id, json!({ "tools": tools }))
240}
241
242/// `tools/call` — the load-bearing path.
243///
244/// 1. Extract `name` + `arguments` from `params`.
245/// 2. Build a per-request `CapabilitySet` from the bearer identity
246///    (intersected with the server allow-list — SP-streamable-http §4.3).
247/// 3. Call `atd_runtime::dispatch::run_tool` — the very same function
248///    `atd-server`'s connection loop uses.
249/// 4. Wrap the returned `Response` in MCP's
250///    `{ content: [{type:"text", text}], isError }` envelope.
251///
252/// The `text` field carries the JSON-serialised
253/// `Response::ToolResultResponse.result`. SP-streamable-http §5.3
254/// pins this contract: bytes are equal to UDS for the same `RunTool`.
255pub async fn handle_tools_call(
256    id: Option<Value>,
257    state: &Arc<ServerState>,
258    identity: Option<&BearerIdentity>,
259    params: Value,
260) -> axum::response::Response {
261    let tool_id = match params.get("name").and_then(|v| v.as_str()) {
262        Some(s) => s.to_string(),
263        None => {
264            return error_response(StatusCode::OK, id, -32602, "missing `name` parameter");
265        }
266    };
267    let mut args = params
268        .get("arguments")
269        .cloned()
270        .unwrap_or_else(|| Value::Object(Default::default()));
271
272    // SP-pagination-v1 §4.6 — detect `arguments.__cursor` and route through
273    // RunToolContinue. The cursor is extracted and the field stripped before
274    // forwarding so the tool's `args` schema is unaffected.
275    let cursor: Option<String> = match args.as_object_mut() {
276        Some(obj) => obj
277            .remove("__cursor")
278            .and_then(|v| v.as_str().map(String::from)),
279        None => None,
280    };
281
282    // SP-streamable-http §4.3: capability set is per-request, intersected
283    // with the server allow-list. Anonymous mode (no identity) yields
284    // empty caps — same as the UDS pre-Hello default.
285    let caps = Arc::new(build_caps(state, identity));
286    let caller_id = identity.map(|i| i.caller_id.clone());
287    // SP-streamable-http §4.3: fresh tracker per request. Read-budget
288    // enforcement still works but is scoped to one call rather than the
289    // connection lifetime.
290    let tracker = Arc::new(ReadTracker::new());
291
292    let resp = if let Some(c) = cursor {
293        atd_runtime::dispatch::run_tool_continue(
294            state,
295            &tracker,
296            &caps,
297            caller_id.as_deref(),
298            tool_id.clone(),
299            c,
300        )
301        .await
302    } else {
303        atd_runtime::dispatch::run_tool(
304            state,
305            &tracker,
306            &caps,
307            caller_id.as_deref(),
308            tool_id.clone(),
309            args,
310            false, // HTTP does not expose dry_run on tools/call
311        )
312        .await
313    };
314
315    wrap_tool_response(id, resp)
316}
317
318/// Intersect `identity.granted_capabilities` (when present) with the
319/// operator allow-list `state.config.granted_capabilities`. Anonymous
320/// callers (no identity) get an empty set — matching the UDS pre-Hello
321/// default (`atd-server::connection.rs:22`). This is the SP-12 Hello
322/// semantics specialised per-request rather than per-connection.
323fn build_caps(state: &ServerState, identity: Option<&BearerIdentity>) -> CapabilitySet {
324    let Some(id) = identity else {
325        return CapabilitySet::empty();
326    };
327    let allow = CapabilitySet::from_iter(state.config.granted_capabilities.iter().cloned());
328    let (granted, _denied) = allow.intersect(&id.granted_capabilities);
329    CapabilitySet::from_iter(granted)
330}
331
332/// Translate an ATD `Response` (from `run_tool`) into the MCP wire shape
333/// the HTTP listener returns. Mirrors `celia-cli/src/http_server.rs:497-525`
334/// for the success / execution_failed branches; non-result `Response`
335/// variants (capability denied, broker error, internal) map to the
336/// JSON-RPC `error` envelope per SP-streamable-http §5.6.
337pub fn wrap_tool_response(id: Option<Value>, resp: Response) -> axum::response::Response {
338    match resp {
339        Response::ToolResultResponse {
340            result,
341            success,
342            dry_run: _,
343            tool_id: _,
344            next_cursor,
345        } => {
346            let text = serde_json::to_string(&result).unwrap_or_else(|_| "{}".into());
347            // SP-pagination-v1 §4.6 — surface `nextCursor` in the MCP
348            // result envelope when present. Omitted on terminal pages
349            // and on responses from non-paginating tools. MCP 2025-11-25
350            // does not standardize the field; we ship it as a non-standard
351            // extension. Cursor-aware clients (Hermes patched, future MCP
352            // spec) consume it; non-aware clients ignore unknown fields.
353            let mut body = json!({
354                "content": [{ "type": "text", "text": text }],
355                "isError": !success,
356            });
357            if let Some(cur) = next_cursor {
358                if let Some(obj) = body.as_object_mut() {
359                    obj.insert("nextCursor".into(), Value::String(cur));
360                }
361            }
362            ok_response(id, body)
363        }
364        Response::Error {
365            message,
366            code,
367            retryable,
368            details,
369        } => {
370            // SP-streamable-http §5.6: ATD numeric codes (1001/1002/1003)
371            // ride inside the JSON-RPC error.data so MCP clients see
372            // `-32603` (generic server error) and ATD-aware clients can
373            // introspect the numeric. Capability-denied is the one
374            // exception — surfaced as 1001 in `data` but still `-32603`
375            // at JSON-RPC level (no MCP code for capability semantics).
376            let jsonrpc_code = -32603i32;
377            let mut data = serde_json::Map::new();
378            if let Some(c) = code {
379                data.insert("atd_code".into(), Value::from(c));
380            }
381            if let Some(r) = retryable {
382                data.insert("retryable".into(), Value::Bool(r));
383            }
384            if let Some(d) = details {
385                data.insert("details".into(), d);
386            }
387            let err = JsonRpcError {
388                code: jsonrpc_code,
389                message,
390            };
391            // Inline `data` into the JSON-RPC error object as MCP spec
392            // permits (extension field). axum::Json will serialise the
393            // whole envelope.
394            let envelope = JsonRpcResponse {
395                jsonrpc: "2.0",
396                id,
397                result: None,
398                error: Some(err),
399            };
400            let mut value = serde_json::to_value(&envelope).unwrap_or_else(|_| {
401                json!({"jsonrpc":"2.0", "error": {"code": jsonrpc_code, "message": "serialise"}})
402            });
403            if !data.is_empty() {
404                if let Some(err_obj) = value.get_mut("error").and_then(|e| e.as_object_mut()) {
405                    err_obj.insert("data".into(), Value::Object(data));
406                }
407            }
408            (StatusCode::OK, Json(value)).into_response()
409        }
410        // Hello / Pong / ToolList / ToolSchema cannot be produced by
411        // `run_tool` — covered for completeness.
412        other => error_response(
413            StatusCode::INTERNAL_SERVER_ERROR,
414            id,
415            -32603,
416            format!("unexpected dispatch variant: {other:?}"),
417        ),
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use atd_runtime::dispatch::SharedServerConfig;
425    use atd_runtime::registry::{CallFuture, Registry, Tool};
426    use atd_runtime::{CallContext, TierPolicy};
427    use http_body_util::BodyExt;
428    use std::sync::Arc;
429
430    fn stub_def(id: &str) -> atd_protocol::ToolDefinition {
431        use atd_protocol::{
432            BindingProtocol, SafetyLevel, ToolBinding, ToolCapability, ToolResources, ToolSafety,
433            ToolTier, ToolTrust, ToolVisibility, TrustLevel,
434        };
435        let _ = ToolTier::Warm; // ensure import path is correct
436        atd_protocol::ToolDefinition {
437            id: id.into(),
438            name: id.into(),
439            description: "stub".into(),
440            version: "0.0.0".into(),
441            capability: ToolCapability {
442                domain: "echo".into(),
443                actions: vec![],
444                tags: vec![],
445                intent_examples: vec![],
446            },
447            input_schema: json!({"type":"object"}),
448            output_schema: json!({"type":"object"}),
449            bindings: vec![ToolBinding {
450                protocol: BindingProtocol::Cli,
451                config: json!({}),
452            }],
453            safety: ToolSafety {
454                level: SafetyLevel::Read,
455                dry_run: false,
456                side_effects: vec![],
457                data_sensitivity: None,
458            },
459            resources: ToolResources {
460                timeout_ms: 1000,
461                max_concurrent: 1,
462                rate_limit_per_min: None,
463                estimated_tokens: None,
464            },
465            trust: ToolTrust {
466                publisher: "test".into(),
467                trust_level: TrustLevel::L0Unverified,
468                signature: None,
469            },
470            visibility: ToolVisibility::Read,
471            required_capabilities: vec![],
472            tier: None,
473            errors: vec![],
474        }
475    }
476
477    struct EchoStub {
478        def: atd_protocol::ToolDefinition,
479    }
480    impl EchoStub {
481        fn new() -> Self {
482            Self {
483                def: stub_def("ref:echo.say"),
484            }
485        }
486    }
487    impl Tool for EchoStub {
488        fn definition(&self) -> &atd_protocol::ToolDefinition {
489            &self.def
490        }
491        fn call<'a>(&'a self, args: serde_json::Value, _ctx: &'a CallContext) -> CallFuture<'a> {
492            Box::pin(async move { Ok(json!({"echoed": args})) })
493        }
494    }
495
496    fn state_with_echo() -> Arc<ServerState> {
497        let mut reg = Registry::new();
498        reg.register(Arc::new(EchoStub::new()));
499        Arc::new(ServerState {
500            registry: reg,
501            config: SharedServerConfig::for_test(),
502            tier_policy: TierPolicy::defaults(),
503            middleware: vec![],
504            metrics: Arc::new(atd_runtime::MetricsCounters::default()),
505            cursor_issuer: Arc::new(atd_runtime::cursor::CursorIssuer::new([0u8; 32])),
506        })
507    }
508
509    async fn body_to_json(resp: axum::response::Response) -> Value {
510        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
511        serde_json::from_slice(&bytes).unwrap()
512    }
513
514    #[tokio::test]
515    async fn initialize_returns_server_info_with_tools_capability() {
516        let resp = handle_initialize(Some(json!(1)), "atd-test 0.3.0", None);
517        let body = body_to_json(resp).await;
518        assert_eq!(body["jsonrpc"], "2.0");
519        assert_eq!(body["id"], 1);
520        assert_eq!(body["result"]["serverInfo"]["name"], "atd-test");
521        assert_eq!(body["result"]["serverInfo"]["version"], "0.3.0");
522        assert!(body["result"]["capabilities"]["tools"].is_object());
523        assert_eq!(body["result"]["protocolVersion"], "2025-06-18");
524        // No broker → no `experimental.atd.acceptedTokenFormats`.
525        assert!(body["result"]["capabilities"]["experimental"].is_null());
526    }
527
528    #[tokio::test]
529    async fn initialize_echoes_broker_accepted_token_formats_when_wired() {
530        use atd_runtime::secrets::{ResolveBearerFuture, ResolveFuture, TokenBroker};
531        #[derive(Debug)]
532        struct DeclaringBroker;
533        impl TokenBroker for DeclaringBroker {
534            fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
535                Box::pin(async { Ok(None) })
536            }
537            fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
538                Box::pin(async { Ok(None) })
539            }
540            fn accepted_token_formats(&self) -> &'static [&'static str] {
541                &["ucan-jwt", "opaque", "ce-pairing-code"]
542            }
543        }
544        let broker: Arc<dyn TokenBroker> = Arc::new(DeclaringBroker);
545        let resp = handle_initialize(Some(json!(1)), "atd-test 0.3.0", Some(&broker));
546        let body = body_to_json(resp).await;
547        let formats =
548            &body["result"]["capabilities"]["experimental"]["atd"]["acceptedTokenFormats"];
549        assert_eq!(
550            formats,
551            &json!(["ucan-jwt", "opaque", "ce-pairing-code"]),
552            "broker formats should appear in initialize.capabilities.experimental.atd"
553        );
554    }
555
556    #[tokio::test]
557    async fn initialize_omits_experimental_when_broker_declares_empty_formats() {
558        use atd_runtime::secrets::{ResolveBearerFuture, ResolveFuture, TokenBroker};
559        #[derive(Debug)]
560        struct SilentBroker;
561        impl TokenBroker for SilentBroker {
562            fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
563                Box::pin(async { Ok(None) })
564            }
565            fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
566                Box::pin(async { Ok(None) })
567            }
568            // returns &[] (default impl) — listener should NOT emit
569            // an empty acceptedTokenFormats array, just omit the field.
570        }
571        let broker: Arc<dyn TokenBroker> = Arc::new(SilentBroker);
572        let resp = handle_initialize(Some(json!(1)), "atd-test 0.3.0", Some(&broker));
573        let body = body_to_json(resp).await;
574        assert!(body["result"]["capabilities"]["experimental"].is_null());
575    }
576
577    #[tokio::test]
578    async fn tools_list_advertises_registered_tools() {
579        let state = state_with_echo();
580        let resp = handle_tools_list(Some(json!(2)), &state);
581        let body = body_to_json(resp).await;
582        let tools = body["result"]["tools"].as_array().unwrap();
583        assert_eq!(tools.len(), 1);
584        assert_eq!(tools[0]["name"], "ref:echo.say");
585        assert!(tools[0]["inputSchema"].is_object());
586    }
587
588    #[tokio::test]
589    async fn tools_call_echo_returns_is_error_false_with_serialised_result() {
590        let state = state_with_echo();
591        let params = json!({"name": "ref:echo.say", "arguments": {"hi": "x"}});
592        let resp = handle_tools_call(Some(json!(3)), &state, None, params).await;
593        let body = body_to_json(resp).await;
594        assert_eq!(body["result"]["isError"], false);
595        let text = body["result"]["content"][0]["text"].as_str().unwrap();
596        let parsed: Value = serde_json::from_str(text).unwrap();
597        assert_eq!(parsed["echoed"]["hi"], "x");
598    }
599
600    #[tokio::test]
601    async fn tools_call_missing_name_returns_invalid_params_error() {
602        let state = state_with_echo();
603        let params = json!({"arguments": {}});
604        let resp = handle_tools_call(Some(json!(4)), &state, None, params).await;
605        let body = body_to_json(resp).await;
606        assert_eq!(body["error"]["code"], -32602);
607        assert!(
608            body["error"]["message"]
609                .as_str()
610                .unwrap()
611                .contains("missing")
612        );
613    }
614
615    #[tokio::test]
616    async fn tools_call_unknown_tool_returns_error_envelope() {
617        let state = state_with_echo();
618        let params = json!({"name": "ref:does-not-exist", "arguments": {}});
619        let resp = handle_tools_call(Some(json!(5)), &state, None, params).await;
620        let body = body_to_json(resp).await;
621        // Wrapped in JSON-RPC error (Response::Error → -32603 / tool not found message).
622        assert_eq!(body["error"]["code"], -32603);
623        assert!(
624            body["error"]["message"]
625                .as_str()
626                .unwrap()
627                .contains("tool not found")
628        );
629    }
630
631    // ---- SP-pagination-v1 Phase F: HTTP transport cursor passthrough ----
632
633    /// Paginating stub: emits a cursor on every call, threads the page
634    /// index through `opaque_state`. Used to verify the HTTP layer
635    /// extracts `__cursor` from arguments and surfaces `nextCursor` in
636    /// the MCP envelope.
637    struct PageStubHttp {
638        def: atd_protocol::ToolDefinition,
639    }
640    impl PageStubHttp {
641        fn new() -> Self {
642            Self {
643                def: stub_def("ref:page_stub"),
644            }
645        }
646    }
647    impl Tool for PageStubHttp {
648        fn definition(&self) -> &atd_protocol::ToolDefinition {
649            &self.def
650        }
651        fn supports_pagination(&self) -> bool {
652            true
653        }
654        fn call<'a>(&'a self, _args: serde_json::Value, _ctx: &'a CallContext) -> CallFuture<'a> {
655            Box::pin(async { Ok(json!({"unused": true})) })
656        }
657        fn call_paginated<'a>(
658            &'a self,
659            _args: serde_json::Value,
660            ctx: &'a CallContext,
661            cursor: Option<&'a str>,
662        ) -> atd_runtime::PaginatedCallFuture<'a> {
663            let issuer = ctx.cursor_issuer().expect("issuer attached");
664            let page = match cursor {
665                None => 1u32,
666                Some(c) => {
667                    let p = issuer.verify(c, 300).expect("pre-verified");
668                    u32::from_be_bytes(p.opaque_state[..4].try_into().unwrap())
669                }
670            };
671            let payload = atd_runtime::cursor::CursorPayload {
672                tool_id: "ref:page_stub".into(),
673                caller_id: ctx.caller_id.clone(),
674                args_fingerprint: [0u8; 32],
675                page_index: page + 1,
676                issued_at_unix: std::time::SystemTime::now()
677                    .duration_since(std::time::UNIX_EPOCH)
678                    .unwrap()
679                    .as_secs(),
680                server_session: issuer.session_nonce(),
681                opaque_state: (page + 1).to_be_bytes().to_vec(),
682            };
683            let next = issuer.issue(payload).ok();
684            Box::pin(async move {
685                Ok(atd_runtime::PaginatedResult {
686                    value: json!({"page": page}),
687                    next_cursor: next,
688                })
689            })
690        }
691    }
692
693    fn state_with_page_stub() -> Arc<ServerState> {
694        let mut reg = Registry::new();
695        reg.register(Arc::new(PageStubHttp::new()));
696        Arc::new(ServerState {
697            registry: reg,
698            config: SharedServerConfig::for_test(),
699            tier_policy: TierPolicy::defaults(),
700            middleware: vec![],
701            metrics: Arc::new(atd_runtime::MetricsCounters::default()),
702            cursor_issuer: Arc::new(atd_runtime::cursor::CursorIssuer::new([0u8; 32])),
703        })
704    }
705
706    #[tokio::test]
707    async fn tools_call_initial_paginated_call_surfaces_next_cursor_in_envelope() {
708        let state = state_with_page_stub();
709        // After Phase H (RunTool routes through call_paginated when
710        // supports_pagination=true), initial calls against paginating
711        // tools surface nextCursor in the MCP envelope.
712        let params = json!({"name": "ref:page_stub", "arguments": {}});
713        let resp = handle_tools_call(Some(json!(7)), &state, None, params).await;
714        let body = body_to_json(resp).await;
715        assert_eq!(body["result"]["isError"], false);
716        assert!(
717            body["result"]["nextCursor"].is_string(),
718            "paginating tool on first call must emit nextCursor; got: {body}"
719        );
720    }
721
722    #[tokio::test]
723    async fn tools_call_with_dunder_cursor_routes_to_run_tool_continue() {
724        let state = state_with_page_stub();
725        // Mint a page-2 cursor against the state's issuer (the same one
726        // dispatch_request will verify against on RunToolContinue).
727        let payload = atd_runtime::cursor::CursorPayload {
728            tool_id: "ref:page_stub".into(),
729            caller_id: None,
730            args_fingerprint: [0u8; 32],
731            page_index: 2,
732            issued_at_unix: std::time::SystemTime::now()
733                .duration_since(std::time::UNIX_EPOCH)
734                .unwrap()
735                .as_secs(),
736            server_session: state.cursor_issuer.session_nonce(),
737            opaque_state: 2u32.to_be_bytes().to_vec(),
738        };
739        let cursor = state.cursor_issuer.issue(payload).unwrap();
740
741        // HTTP sends arguments with __cursor inside.
742        let params = json!({
743            "name": "ref:page_stub",
744            "arguments": {"__cursor": cursor},
745        });
746        let resp = handle_tools_call(Some(json!(8)), &state, None, params).await;
747        let body = body_to_json(resp).await;
748        assert_eq!(body["result"]["isError"], false);
749        // text content carries the page payload from PageStubHttp::call_paginated
750        let text = body["result"]["content"][0]["text"].as_str().unwrap();
751        let parsed: Value = serde_json::from_str(text).unwrap();
752        assert_eq!(parsed["page"], 2);
753        // nextCursor must be present in the envelope.
754        assert!(
755            body["result"]["nextCursor"].is_string(),
756            "continuation must emit nextCursor; got: {body}"
757        );
758    }
759
760    #[tokio::test]
761    async fn tools_call_expired_cursor_returns_jsonrpc_error() {
762        let state = state_with_page_stub();
763        // Cursor issued long ago (older than default 300s TTL).
764        let payload = atd_runtime::cursor::CursorPayload {
765            tool_id: "ref:page_stub".into(),
766            caller_id: None,
767            args_fingerprint: [0u8; 32],
768            page_index: 2,
769            issued_at_unix: 1, // ancient
770            server_session: state.cursor_issuer.session_nonce(),
771            opaque_state: 2u32.to_be_bytes().to_vec(),
772        };
773        let cursor = state.cursor_issuer.issue(payload).unwrap();
774        let params = json!({
775            "name": "ref:page_stub",
776            "arguments": {"__cursor": cursor},
777        });
778        let resp = handle_tools_call(Some(json!(9)), &state, None, params).await;
779        let body = body_to_json(resp).await;
780        // JSON-RPC -32603 wrapping the ATD code 1020.
781        assert_eq!(body["error"]["code"], -32603);
782        assert_eq!(body["error"]["data"]["atd_code"], 1020);
783    }
784}