566 lines
23 KiB
Rust
566 lines
23 KiB
Rust
pub mod config;
|
|
pub mod http_server;
|
|
pub mod log;
|
|
pub mod server;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::{Value, json};
|
|
|
|
pub use config::{McpServerConfig, McpTransport};
|
|
pub use log::{McpLogLine, McpLogTx};
|
|
pub use server::{
|
|
ElicitationAction, ElicitationHandler, ElicitationReply, ElicitationRequest, McpNotification,
|
|
};
|
|
|
|
/// MCP protocol version this client advertises in `initialize` and (over HTTP)
|
|
/// echoes in the `MCP-Protocol-Version` header on every post-initialize request.
|
|
/// Shared by both transports so they can never drift apart. The host tolerates a
|
|
/// server negotiating a different (older) version — see the HTTP transport, which
|
|
/// captures the server's reply and warns rather than disconnecting.
|
|
pub const PROTOCOL_VERSION: &str = "2025-11-25";
|
|
|
|
/// Safety cap on `tools/list` pagination: stop following `nextCursor` after this
|
|
/// many pages so a buggy or hostile server that never clears the cursor can't
|
|
/// loop the client forever.
|
|
pub(crate) const MAX_TOOL_PAGES: usize = 50;
|
|
|
|
/// Builds a `notifications/cancelled` message for an in-flight request. Shared by
|
|
/// both transports (like [`PROTOCOL_VERSION`]) so the wire shape can't drift. Per
|
|
/// the MCP spec the client MUST NOT cancel the `initialize` request; callers only
|
|
/// arm this for cancellable operations (`tools/call`).
|
|
pub(crate) fn cancelled_notification(request_id: u64, reason: &str) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "notifications/cancelled",
|
|
"params": { "requestId": request_id, "reason": reason },
|
|
})
|
|
}
|
|
|
|
/// Builds a `tasks/cancel` request for a task the client is abandoning
|
|
/// (experimental Tasks). Sent fire-and-forget from the poll cancel-guard — the
|
|
/// response is ignored — so it carries a pre-allocated `request_id`. Shared by both
|
|
/// transports.
|
|
pub(crate) fn tasks_cancel_request(request_id: u64, task_id: &str) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": "tasks/cancel",
|
|
"params": { "taskId": task_id },
|
|
})
|
|
}
|
|
|
|
// ── Public types ──────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct McpTool {
|
|
pub server_name: String,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub input_schema: Value,
|
|
/// Optional human-readable title (MCP 2025-06-18+).
|
|
pub title: Option<String>,
|
|
/// Optional JSON Schema describing the tool's structured output
|
|
/// (`outputSchema`, MCP 2025-06-18+). Captured for future validation and to
|
|
/// know a tool can return `structuredContent`.
|
|
pub output_schema: Option<Value>,
|
|
/// Optional tool annotations (`readOnlyHint`, `destructiveHint`, …), treated
|
|
/// as untrusted hints per the spec.
|
|
pub annotations: Option<Value>,
|
|
/// Optional per-tool Tasks negotiation (`execution.taskSupport`:
|
|
/// `required`/`optional`/`forbidden`, MCP 2025-11-25 experimental). Captured
|
|
/// as an untrusted hint so a future polling implementation knows which tools
|
|
/// may return a [`CreateTaskResult`]. See [`McpCallResult::Task`].
|
|
pub task_support: Option<String>,
|
|
}
|
|
|
|
impl McpTool {
|
|
/// Builds an [`McpTool`] from one entry of a `tools/list` `tools[]` array.
|
|
/// Shared by both transports so the field mapping (incl. the 2025-06-18+
|
|
/// `title`/`outputSchema`/`annotations`) stays in one place.
|
|
pub fn from_json(server_name: &str, t: &Value) -> McpTool {
|
|
McpTool {
|
|
server_name: server_name.to_string(),
|
|
name: t["name"].as_str().unwrap_or("").to_string(),
|
|
description: t["description"].as_str().unwrap_or("").to_string(),
|
|
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| json!({
|
|
"type": "object", "properties": {},
|
|
})),
|
|
title: t.get("title").and_then(Value::as_str).map(str::to_string),
|
|
output_schema: t.get("outputSchema").cloned(),
|
|
annotations: t.get("annotations").cloned(),
|
|
task_support: t.get("execution")
|
|
.and_then(|e| e.get("taskSupport"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
pub fn tool_id(&self) -> String {
|
|
format!("mcp__{}__{}", self.server_name, self.name)
|
|
}
|
|
|
|
pub fn to_openai_definition(&self) -> Value {
|
|
let params = if self.input_schema.is_object() {
|
|
self.input_schema.clone()
|
|
} else {
|
|
json!({ "type": "object", "properties": {} })
|
|
};
|
|
json!({
|
|
"type": "function",
|
|
"function": {
|
|
"name": self.tool_id(),
|
|
"description": format!("[{}] {}", self.server_name, self.description),
|
|
"parameters": params,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Status of a configured MCP server.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
#[serde(tag = "status", rename_all = "snake_case")]
|
|
pub enum McpServerStatus {
|
|
Running { tools: Vec<String> },
|
|
Error { message: String },
|
|
Disabled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct McpServerInfo {
|
|
pub name: String,
|
|
pub transport: String,
|
|
pub description: Option<String>,
|
|
pub friendly_name: Option<String>,
|
|
#[serde(flatten)]
|
|
pub status: McpServerStatus,
|
|
}
|
|
|
|
// ── Tool-result media ──────────────────────────────────────────────────────────
|
|
|
|
/// Kind of a non-text content block carried in an MCP `tools/call` result.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum McpMediaKind {
|
|
Image,
|
|
Audio,
|
|
/// Embedded or linked resource (any other binary/file payload).
|
|
Resource,
|
|
}
|
|
|
|
/// Payload of one media content block: either decoded inline bytes (from an
|
|
/// `image`/`audio`/embedded-`resource` base64 field) or a link to a remote
|
|
/// resource (`resource_link`), which the crate does not download.
|
|
#[derive(Debug, Clone)]
|
|
pub enum McpMediaData {
|
|
/// Decoded bytes plus the server-declared MIME type.
|
|
Inline { bytes: Vec<u8>, mime: String },
|
|
/// A `resource_link` URI, passed through untouched (no fetch here).
|
|
Link { uri: String, mime: Option<String> },
|
|
}
|
|
|
|
/// One non-text content block extracted from a `tools/call` result. The crate
|
|
/// stays a generic transport: it decodes base64 to bytes but never touches the
|
|
/// disk — persistence is the host's job (`McpManager`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct McpMedia {
|
|
pub kind: McpMediaKind,
|
|
pub data: McpMediaData,
|
|
}
|
|
|
|
// ── Tasks (MCP 2025-11-25, experimental) ────────────────────────────────────────
|
|
|
|
/// Lifecycle state of a Task (`CreateTaskResult.status`). `Working` transitions to
|
|
/// `Completed`/`Failed`/`Cancelled`; `InputRequired` means the receiver needs more
|
|
/// input (e.g. an elicitation) before it can continue.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TaskStatus {
|
|
Working,
|
|
Completed,
|
|
Failed,
|
|
Cancelled,
|
|
InputRequired,
|
|
}
|
|
|
|
impl TaskStatus {
|
|
/// A terminal status: the task will not change further, so polling stops.
|
|
/// `InputRequired` is **not** terminal (the task resumes once input is given),
|
|
/// but the current block-and-poll client can't fulfil input mid-task, so it
|
|
/// treats it as an error rather than looping forever — see `poll_task`.
|
|
pub fn is_terminal(self) -> bool {
|
|
matches!(self, TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Cancelled)
|
|
}
|
|
}
|
|
|
|
/// Clamps a server-suggested `pollInterval` (ms) into a sane range: default 1s when
|
|
/// absent, floored at 500ms (don't hammer the server) and capped at 30s (stay
|
|
/// responsive). Shared by both transports so their poll cadence can't drift.
|
|
pub(crate) fn clamp_poll_interval(ms: Option<u64>) -> std::time::Duration {
|
|
std::time::Duration::from_millis(ms.unwrap_or(1000).clamp(500, 30_000))
|
|
}
|
|
|
|
/// Deadline after which the block-and-poll loop gives up on a task and cancels it:
|
|
/// the server's `ttl` when given, otherwise a generous 1-hour cap so a stuck task
|
|
/// can't pin a session forever.
|
|
pub(crate) fn poll_deadline(ttl_ms: Option<u64>) -> std::time::Instant {
|
|
let ttl = std::time::Duration::from_millis(ttl_ms.unwrap_or(3_600_000));
|
|
std::time::Instant::now() + ttl
|
|
}
|
|
|
|
/// A durable task handle returned by a receiver when a request is executed as a
|
|
/// deferred, pollable operation instead of blocking (MCP 2025-11-25 *experimental*
|
|
/// Tasks). The client polls `tasks/get` until a terminal status, then fetches the
|
|
/// real result via `tasks/result` (see `poll_task` on each transport). Also used to
|
|
/// parse each `tasks/get` response (same shape).
|
|
#[derive(Debug, Clone)]
|
|
pub struct CreateTaskResult {
|
|
pub task_id: String,
|
|
pub status: TaskStatus,
|
|
/// Suggested delay between `tasks/get` polls, in milliseconds.
|
|
pub poll_interval_ms: Option<u64>,
|
|
/// Time-to-live of the task handle, in milliseconds.
|
|
pub ttl_ms: Option<u64>,
|
|
}
|
|
|
|
impl CreateTaskResult {
|
|
/// Parses a `tools/call` result that carries a task handle. Returns `None` if
|
|
/// `v` is not a task-shaped object (no `taskId`). The handle may live at the
|
|
/// top level or under a `task` field, depending on the server.
|
|
pub(crate) fn parse(v: &Value) -> Option<CreateTaskResult> {
|
|
let obj = v.get("task").filter(|t| t.is_object()).unwrap_or(v);
|
|
let task_id = obj.get("taskId").and_then(Value::as_str)?.to_string();
|
|
let status = obj.get("status")
|
|
.and_then(|s| serde_json::from_value::<TaskStatus>(s.clone()).ok())
|
|
.unwrap_or(TaskStatus::Working);
|
|
Some(CreateTaskResult {
|
|
task_id,
|
|
status,
|
|
poll_interval_ms: obj.get("pollInterval").and_then(Value::as_u64),
|
|
ttl_ms: obj.get("ttl").and_then(Value::as_u64),
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Transport trait ───────────────────────────────────────────────────────────
|
|
|
|
/// Typed result of an MCP `tools/call`. Mirrors the host's tool-result shape
|
|
/// without coupling this crate to it; the host (`McpManager`) maps it. Per the
|
|
/// MCP spec, `structuredContent` is canonical when present (servers SHOULD also
|
|
/// mirror it in a `TextContent` block for backwards compatibility); we prefer it
|
|
/// and fall back to the joined `text` items, which fixes the silent empty-result
|
|
/// case for servers that omit the text mirror. Non-text content blocks
|
|
/// (`image`/`audio`/`resource`/`resource_link`) are surfaced via [`McpCallResult::Media`]
|
|
/// so the host can persist them instead of dropping them.
|
|
#[derive(Debug, Clone)]
|
|
pub enum McpCallResult {
|
|
/// Joined `text` content items.
|
|
Text(String),
|
|
/// Canonical structured payload from `structuredContent`.
|
|
Json(Value),
|
|
/// At least one non-text media block was present. `text`/`structured` carry
|
|
/// any accompanying textual/structured payload from the same result.
|
|
Media {
|
|
text: Option<String>,
|
|
structured: Option<Value>,
|
|
items: Vec<McpMedia>,
|
|
},
|
|
/// The server deferred the call as a Task (experimental Tasks, 2025-11-25) and
|
|
/// returned a durable handle instead of a result. Skald surfaces the handle;
|
|
/// polling for the real result is a follow-up.
|
|
Task(CreateTaskResult),
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait McpServerClient: Send + Sync {
|
|
fn tools(&self) -> &[McpTool];
|
|
async fn call_tool(&self, name: &str, args: Value) -> anyhow::Result<McpCallResult>;
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Parses `mcp__<server>__<tool>` → `(server, tool)`.
|
|
pub fn parse_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
|
|
let rest = name.strip_prefix("mcp__")?;
|
|
let sep = rest.find("__")?;
|
|
Some((&rest[..sep], &rest[sep + 2..]))
|
|
}
|
|
|
|
/// Extracts text content from an MCP tool result value (the `text` items of
|
|
/// `content[]`, plus the inline `text` of embedded text resources). Used for the
|
|
/// `isError` path and as the text component of a successful result.
|
|
pub(crate) fn extract_text(result: &Value) -> String {
|
|
result["content"]
|
|
.as_array()
|
|
.map(|arr| classify_content(arr).0)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Decodes a standard-base64 string to bytes, returning `None` on malformed input.
|
|
fn decode_b64(s: &str) -> Option<Vec<u8>> {
|
|
use base64::Engine;
|
|
base64::engine::general_purpose::STANDARD.decode(s).ok()
|
|
}
|
|
|
|
/// Walks `content[]`, returning the joined text and any non-text media blocks
|
|
/// (`image`/`audio`/embedded-`resource`/`resource_link`). Base64 payloads are
|
|
/// decoded to bytes here; persistence is left to the host.
|
|
fn classify_content(content: &[Value]) -> (String, Vec<McpMedia>) {
|
|
let mut texts: Vec<String> = Vec::new();
|
|
let mut media: Vec<McpMedia> = Vec::new();
|
|
|
|
for item in content {
|
|
match item.get("type").and_then(Value::as_str) {
|
|
Some("text") => {
|
|
if let Some(t) = item.get("text").and_then(Value::as_str) {
|
|
texts.push(t.to_string());
|
|
}
|
|
}
|
|
Some(kind @ ("image" | "audio")) => {
|
|
let mime = item.get("mimeType").and_then(Value::as_str)
|
|
.unwrap_or("application/octet-stream").to_string();
|
|
if let Some(bytes) = item.get("data").and_then(Value::as_str).and_then(decode_b64) {
|
|
let kind = if kind == "image" { McpMediaKind::Image } else { McpMediaKind::Audio };
|
|
media.push(McpMedia { kind, data: McpMediaData::Inline { bytes, mime } });
|
|
}
|
|
}
|
|
Some("resource") => {
|
|
// Embedded resource: a base64 `blob` (binary) or inline `text`.
|
|
let res = item.get("resource");
|
|
let mime = res.and_then(|r| r.get("mimeType")).and_then(Value::as_str)
|
|
.unwrap_or("application/octet-stream").to_string();
|
|
if let Some(bytes) = res.and_then(|r| r.get("blob")).and_then(Value::as_str).and_then(decode_b64) {
|
|
media.push(McpMedia { kind: McpMediaKind::Resource, data: McpMediaData::Inline { bytes, mime } });
|
|
} else if let Some(t) = res.and_then(|r| r.get("text")).and_then(Value::as_str) {
|
|
texts.push(t.to_string());
|
|
}
|
|
}
|
|
Some("resource_link") => {
|
|
if let Some(uri) = item.get("uri").and_then(Value::as_str) {
|
|
let mime = item.get("mimeType").and_then(Value::as_str).map(str::to_string);
|
|
media.push(McpMedia {
|
|
kind: McpMediaKind::Resource,
|
|
data: McpMediaData::Link { uri: uri.to_string(), mime },
|
|
});
|
|
}
|
|
}
|
|
// Unknown/typeless block: capture a bare `text` field if present.
|
|
_ => {
|
|
if let Some(t) = item.get("text").and_then(Value::as_str) {
|
|
texts.push(t.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(texts.join("\n"), media)
|
|
}
|
|
|
|
/// Builds the typed result of an MCP `tools/call`. When any non-text media block
|
|
/// is present it returns [`McpCallResult::Media`] (so the host can persist the
|
|
/// bytes instead of dropping them). Otherwise it preserves the original
|
|
/// precedence: `structuredContent` is canonical when present, else the joined
|
|
/// `text` items — which also fixes the silent empty-result case for servers that
|
|
/// return only `structuredContent` without the recommended text mirror.
|
|
pub(crate) fn extract_call_result(result: &Value) -> McpCallResult {
|
|
// A Task handle (deferred execution) has no `content[]`; recognise it first so
|
|
// it isn't mistaken for an empty result.
|
|
if result.get("content").is_none() {
|
|
if let Some(task) = CreateTaskResult::parse(result) {
|
|
return McpCallResult::Task(task);
|
|
}
|
|
}
|
|
|
|
let content = result["content"].as_array().cloned().unwrap_or_default();
|
|
let (text, media) = classify_content(&content);
|
|
let structured = result.get("structuredContent").filter(|v| !v.is_null()).cloned();
|
|
|
|
if !media.is_empty() {
|
|
return McpCallResult::Media {
|
|
text: (!text.is_empty()).then_some(text),
|
|
structured,
|
|
items: media,
|
|
};
|
|
}
|
|
if let Some(sc) = structured {
|
|
return McpCallResult::Json(sc);
|
|
}
|
|
McpCallResult::Text(text)
|
|
}
|
|
|
|
/// Interpolates `${VAR}` references in a string from the process environment.
|
|
pub(crate) fn interpolate_env(s: &str) -> String {
|
|
let mut result = s.to_string();
|
|
loop {
|
|
let Some(start) = result.find("${") else { break };
|
|
let Some(rel_end) = result[start..].find('}') else { break };
|
|
let var_name = result[start + 2..start + rel_end].to_string();
|
|
let value = std::env::var(&var_name).unwrap_or_else(|_| {
|
|
tracing::warn!("MCP env var ${{{var_name}}} not set");
|
|
String::new()
|
|
});
|
|
result = format!("{}{}{}", &result[..start], value, &result[start + rel_end + 1..]);
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn b64(bytes: &[u8]) -> String {
|
|
use base64::Engine;
|
|
base64::engine::general_purpose::STANDARD.encode(bytes)
|
|
}
|
|
|
|
#[test]
|
|
fn image_block_becomes_media_with_decoded_bytes() {
|
|
let png = [0x89u8, b'P', b'N', b'G'];
|
|
let result = json!({
|
|
"content": [
|
|
{ "type": "text", "text": "here is your image" },
|
|
{ "type": "image", "data": b64(&png), "mimeType": "image/png" }
|
|
]
|
|
});
|
|
match extract_call_result(&result) {
|
|
McpCallResult::Media { text, items, structured } => {
|
|
assert_eq!(text.as_deref(), Some("here is your image"));
|
|
assert!(structured.is_none());
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0].kind, McpMediaKind::Image);
|
|
match &items[0].data {
|
|
McpMediaData::Inline { bytes, mime } => {
|
|
assert_eq!(bytes.as_slice(), &png);
|
|
assert_eq!(mime, "image/png");
|
|
}
|
|
_ => panic!("expected inline media"),
|
|
}
|
|
}
|
|
other => panic!("expected Media, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn text_only_stays_text() {
|
|
let result = json!({ "content": [ { "type": "text", "text": "plain" } ] });
|
|
match extract_call_result(&result) {
|
|
McpCallResult::Text(t) => assert_eq!(t, "plain"),
|
|
other => panic!("expected Text, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn structured_without_media_stays_json() {
|
|
let result = json!({
|
|
"content": [ { "type": "text", "text": "mirror" } ],
|
|
"structuredContent": { "ok": true }
|
|
});
|
|
match extract_call_result(&result) {
|
|
McpCallResult::Json(v) => assert_eq!(v, json!({ "ok": true })),
|
|
other => panic!("expected Json, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_notification_has_request_id_and_reason() {
|
|
let msg = cancelled_notification(7, "timeout");
|
|
assert_eq!(msg["jsonrpc"], "2.0");
|
|
assert_eq!(msg["method"], "notifications/cancelled");
|
|
assert!(msg.get("id").is_none(), "notifications MUST NOT carry an id");
|
|
assert_eq!(msg["params"]["requestId"], 7);
|
|
assert_eq!(msg["params"]["reason"], "timeout");
|
|
}
|
|
|
|
#[test]
|
|
fn from_json_captures_task_support() {
|
|
let t = json!({
|
|
"name": "gen_video",
|
|
"execution": { "taskSupport": "required" }
|
|
});
|
|
let tool = McpTool::from_json("srv", &t);
|
|
assert_eq!(tool.task_support.as_deref(), Some("required"));
|
|
|
|
// Absent execution → None.
|
|
let plain = McpTool::from_json("srv", &json!({ "name": "echo" }));
|
|
assert!(plain.task_support.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn create_task_result_parses_top_level_and_nested() {
|
|
// Top-level handle.
|
|
let top = json!({
|
|
"taskId": "abc-123", "status": "working",
|
|
"pollInterval": 500, "ttl": 60000
|
|
});
|
|
let r = CreateTaskResult::parse(&top).expect("top-level task");
|
|
assert_eq!(r.task_id, "abc-123");
|
|
assert_eq!(r.status, TaskStatus::Working);
|
|
assert_eq!(r.poll_interval_ms, Some(500));
|
|
assert_eq!(r.ttl_ms, Some(60000));
|
|
|
|
// Nested under `task`, unknown status → defaults to Working.
|
|
let nested = json!({ "task": { "taskId": "x", "status": "input_required" } });
|
|
let r = CreateTaskResult::parse(&nested).expect("nested task");
|
|
assert_eq!(r.task_id, "x");
|
|
assert_eq!(r.status, TaskStatus::InputRequired);
|
|
|
|
// Not task-shaped → None.
|
|
assert!(CreateTaskResult::parse(&json!({ "content": [] })).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn task_status_is_terminal() {
|
|
assert!(TaskStatus::Completed.is_terminal());
|
|
assert!(TaskStatus::Failed.is_terminal());
|
|
assert!(TaskStatus::Cancelled.is_terminal());
|
|
assert!(!TaskStatus::Working.is_terminal());
|
|
assert!(!TaskStatus::InputRequired.is_terminal());
|
|
}
|
|
|
|
#[test]
|
|
fn clamp_poll_interval_defaults_and_bounds() {
|
|
use std::time::Duration;
|
|
assert_eq!(clamp_poll_interval(None), Duration::from_millis(1000)); // default
|
|
assert_eq!(clamp_poll_interval(Some(10)), Duration::from_millis(500)); // floor
|
|
assert_eq!(clamp_poll_interval(Some(99_999)), Duration::from_millis(30_000)); // cap
|
|
assert_eq!(clamp_poll_interval(Some(2000)), Duration::from_millis(2000)); // passthrough
|
|
}
|
|
|
|
#[test]
|
|
fn tasks_cancel_request_shape() {
|
|
let msg = tasks_cancel_request(42, "job-1");
|
|
assert_eq!(msg["jsonrpc"], "2.0");
|
|
assert_eq!(msg["id"], 42);
|
|
assert_eq!(msg["method"], "tasks/cancel");
|
|
assert_eq!(msg["params"]["taskId"], "job-1");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_call_result_recognises_task_handle() {
|
|
let result = json!({ "taskId": "job-9", "status": "working", "ttl": 120000 });
|
|
match extract_call_result(&result) {
|
|
McpCallResult::Task(t) => {
|
|
assert_eq!(t.task_id, "job-9");
|
|
assert_eq!(t.status, TaskStatus::Working);
|
|
assert_eq!(t.ttl_ms, Some(120000));
|
|
}
|
|
other => panic!("expected Task, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resource_link_passes_through_without_fetch() {
|
|
let result = json!({
|
|
"content": [ { "type": "resource_link", "uri": "https://x/y.mp4", "mimeType": "video/mp4" } ]
|
|
});
|
|
match extract_call_result(&result) {
|
|
McpCallResult::Media { items, .. } => match &items[0].data {
|
|
McpMediaData::Link { uri, mime } => {
|
|
assert_eq!(uri, "https://x/y.mp4");
|
|
assert_eq!(mime.as_deref(), Some("video/mp4"));
|
|
}
|
|
_ => panic!("expected link"),
|
|
},
|
|
other => panic!("expected Media, got {other:?}"),
|
|
}
|
|
}
|
|
}
|