diff --git a/src/v1/agent.rs b/src/v1/agent.rs index bd081d1cc..2e31cfc4a 100644 --- a/src/v1/agent.rs +++ b/src/v1/agent.rs @@ -5646,6 +5646,109 @@ mod test_serialization { assert!(matches!(deserialized, AuthMethod::Agent(_))); } + #[test] + fn test_logout_method_name_is_stable() { + // `logout` was recently stabilized (PR #1273); pinning the wire name + // here ensures a future feature-flag or rename can't silently move it. + assert_eq!(AGENT_METHOD_NAMES.logout, "logout"); + assert_eq!( + ClientRequest::LogoutRequest(LogoutRequest::new()).method(), + "logout" + ); + } + + #[test] + fn test_logout_request_round_trip_empty_and_with_meta() { + // Empty request must encode as `{}` so peers that omit params still + // produce a valid wire payload. + let req = LogoutRequest::new(); + assert_eq!(serde_json::to_value(&req).unwrap(), json!({})); + + // Round-trip with `_meta` populated. The `_meta` extension key MUST + // be serialized with the leading underscore. + let mut meta = serde_json::Map::new(); + meta.insert("trace_id".into(), json!("abc")); + let req = LogoutRequest::new().meta(meta.clone()); + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value, json!({"_meta": {"trace_id": "abc"}})); + + let deserialized: LogoutRequest = serde_json::from_value(value).unwrap(); + assert_eq!(deserialized.meta.as_ref().unwrap(), &meta); + } + + #[test] + fn test_logout_response_default_round_trip() { + // Response is also conventionally `{}`; the `AgentResponse` enum + // wraps it with `#[serde(default)]` so peers may omit the body. + let resp = LogoutResponse::new(); + assert_eq!(serde_json::to_value(&resp).unwrap(), json!({})); + + let parsed: LogoutResponse = serde_json::from_value(json!({})).unwrap(); + assert_eq!(parsed, LogoutResponse::default()); + + // Round-trip through the AgentResponse routing enum (untagged), which + // is how the type is actually carried over the wire. + let envelope = AgentResponse::LogoutResponse(LogoutResponse::new()); + let wire = serde_json::to_value(&envelope).unwrap(); + // Untagged variants serialize as the inner value. + assert_eq!(wire, json!({})); + } + + #[test] + fn test_agent_auth_capabilities_default_and_round_trip() { + // Default capabilities advertise nothing: a wire payload of `{}` + // means "no auth-related capabilities supported". + let caps = AgentAuthCapabilities::new(); + assert_eq!(serde_json::to_value(&caps).unwrap(), json!({})); + + // Setting `logout` with the empty marker capability MUST serialize + // as `{"logout": {}}` per the "supplying `{}` means supported" + // convention documented on the field. + let caps = AgentAuthCapabilities::new().logout(LogoutCapabilities::new()); + let v = serde_json::to_value(&caps).unwrap(); + assert_eq!(v, json!({"logout": {}})); + + let parsed: AgentAuthCapabilities = serde_json::from_value(v).unwrap(); + assert!(parsed.logout.is_some()); + } + + #[test] + fn test_agent_auth_capabilities_tolerates_malformed_logout_field() { + // `logout` uses `DefaultOnError` so an old/misbehaving peer that + // sends garbage for the capability shouldn't crash deserialization + // of the whole `initialize` response — it should fall back to None. + let parsed: AgentAuthCapabilities = + serde_json::from_value(json!({"logout": "not an object"})).unwrap(); + assert!(parsed.logout.is_none()); + + let parsed: AgentAuthCapabilities = serde_json::from_value(json!({"logout": 42})).unwrap(); + assert!(parsed.logout.is_none()); + + // `null` is treated as "absent", giving `None`. + let parsed: AgentAuthCapabilities = + serde_json::from_value(json!({"logout": null})).unwrap(); + assert!(parsed.logout.is_none()); + } + + #[test] + fn test_agent_capabilities_default_omits_auth() { + // `auth` is a required field with a `Default` value; omitting it + // entirely on the wire must be tolerated by deserialization. + let parsed: AgentCapabilities = serde_json::from_value(json!({})).unwrap(); + assert!(parsed.auth.logout.is_none()); + } + + #[test] + fn test_agent_capabilities_threads_auth_through_round_trip() { + let caps = AgentCapabilities::new() + .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new())); + let v = serde_json::to_value(&caps).unwrap(); + assert_eq!(v["auth"], json!({"logout": {}})); + + let parsed: AgentCapabilities = serde_json::from_value(v).unwrap(); + assert!(parsed.auth.logout.is_some()); + } + #[cfg(feature = "unstable_session_delete")] #[test] fn test_session_delete_serialization() { diff --git a/src/v1/error.rs b/src/v1/error.rs index 212914738..ca861f468 100644 --- a/src/v1/error.rs +++ b/src/v1/error.rs @@ -370,4 +370,156 @@ mod tests { ); } } + + #[test] + fn error_code_round_trip_to_i32() { + // Standard JSON-RPC numeric codes are part of the protocol contract; + // regressing them would silently break clients that match on numbers. + for error in ErrorCode::iter() { + let n: i32 = error.into(); + let back: ErrorCode = n.into(); + assert_eq!(error, back); + } + // Unknown numeric codes must round-trip through `Other(_)`. + let other: ErrorCode = 12345i32.into(); + assert_eq!(other, ErrorCode::Other(12345)); + let back: i32 = other.into(); + assert_eq!(back, 12345); + } + + #[test] + fn error_constructors_set_expected_codes() { + assert_eq!(Error::parse_error().code, ErrorCode::ParseError); + assert_eq!(Error::invalid_request().code, ErrorCode::InvalidRequest); + assert_eq!(Error::method_not_found().code, ErrorCode::MethodNotFound); + assert_eq!(Error::invalid_params().code, ErrorCode::InvalidParams); + assert_eq!(Error::internal_error().code, ErrorCode::InternalError); + assert_eq!(Error::auth_required().code, ErrorCode::AuthRequired); + } + + #[test] + fn error_display_uses_message_when_present() { + let err = Error::new(-32000, "boom"); + assert_eq!(err.to_string(), "boom"); + } + + #[test] + fn error_display_falls_back_to_code_when_message_empty() { + // Empty message should not produce a blank string; the numeric code + // is the next-best identifier for log lines and panics. + let err = Error::new(-32600, ""); + assert_eq!(err.to_string(), "-32600"); + } + + #[test] + fn error_display_appends_data() { + let err = Error::new(-32000, "boom").data(serde_json::json!({"k": "v"})); + let rendered = err.to_string(); + assert!( + rendered.starts_with("boom: "), + "unexpected display: {rendered:?}" + ); + // Pretty-printed JSON preserves the key/value pair regardless of + // whitespace formatting choices. + assert!(rendered.contains("\"k\""), "missing key in {rendered:?}"); + assert!(rendered.contains("\"v\""), "missing value in {rendered:?}"); + } + + #[test] + fn resource_not_found_attaches_uri_when_given() { + let err = Error::resource_not_found(Some("file:///tmp/missing".into())); + assert_eq!(err.code, ErrorCode::ResourceNotFound); + assert_eq!( + err.data, + Some(serde_json::json!({"uri": "file:///tmp/missing"})) + ); + } + + #[test] + fn resource_not_found_omits_data_when_uri_missing() { + let err = Error::resource_not_found(None); + assert_eq!(err.code, ErrorCode::ResourceNotFound); + assert!(err.data.is_none()); + } + + #[test] + fn from_serde_json_error_is_invalid_params_with_message_in_data() { + // Triggering an actual serde error so behavior matches real usage. + let serde_err = serde_json::from_str::("not a number").unwrap_err(); + let original_message = serde_err.to_string(); + + let err: Error = serde_err.into(); + assert_eq!(err.code, ErrorCode::InvalidParams); + // The original parse error message is preserved in `data` so callers + // can debug malformed requests; regressing this would silently lose + // root-cause info on the wire. + let data = err.data.expect("data should carry the serde message"); + assert_eq!(data, serde_json::Value::String(original_message)); + } + + #[test] + fn into_internal_error_attaches_error_string() { + #[derive(Debug)] + struct MyErr; + impl std::fmt::Display for MyErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("kaboom") + } + } + impl std::error::Error for MyErr {} + + let err = Error::into_internal_error(MyErr); + assert_eq!(err.code, ErrorCode::InternalError); + assert_eq!(err.data, Some(serde_json::Value::String("kaboom".into()))); + } + + #[test] + fn from_anyhow_downcasts_to_existing_acp_error() { + // anyhow may wrap an existing ACP `Error`; the conversion must + // unwrap it rather than re-wrapping as a generic internal error + // so the original code (e.g. AuthRequired) survives the round-trip. + let original = Error::auth_required().data(serde_json::json!({"hint": "log in"})); + let wrapped: anyhow::Error = anyhow::Error::new(original.clone()); + + let converted: Error = wrapped.into(); + assert_eq!(converted, original); + } + + #[test] + fn from_anyhow_falls_back_to_internal_error_for_foreign_types() { + let wrapped = anyhow::anyhow!("something went wrong"); + let converted: Error = wrapped.into(); + assert_eq!(converted.code, ErrorCode::InternalError); + // The anyhow display is preserved in `data` for debuggability. + assert_eq!( + converted.data, + Some(serde_json::Value::String("something went wrong".into())) + ); + } + + #[test] + fn error_serializes_to_jsonrpc_object_shape() { + // The wire shape `{code, message}` (and optional `data`) is part of + // the JSON-RPC contract; a regression would break every peer. + let err = Error::new(-32600, "Invalid request"); + let v = serde_json::to_value(&err).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "code": -32600, + "message": "Invalid request", + }) + ); + + let err = Error::new(-32602, "bad").data(serde_json::json!({"field": "id"})); + let v = serde_json::to_value(&err).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "code": -32602, + "message": "bad", + "data": {"field": "id"}, + }) + ); + } } diff --git a/src/v1/mcp.rs b/src/v1/mcp.rs index 7f2be29c2..5e1b47d80 100644 --- a/src/v1/mcp.rs +++ b/src/v1/mcp.rs @@ -352,3 +352,169 @@ pub(crate) const MCP_CONNECT_METHOD_NAME: &str = "mcp/connect"; pub(crate) const MCP_MESSAGE_METHOD_NAME: &str = "mcp/message"; /// Method name for closing an MCP-over-ACP connection. pub(crate) const MCP_DISCONNECT_METHOD_NAME: &str = "mcp/disconnect"; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // The MCP-over-ACP wire surface is brand-new (PR #1185) and unstable. + // These tests pin the wire format because any silent regression in + // field naming, omission semantics, or method-name routing would break + // every IDE/agent pair that has wired this up. + + #[test] + fn method_name_constants_match_wire_format() { + assert_eq!(MCP_CONNECT_METHOD_NAME, "mcp/connect"); + assert_eq!(MCP_MESSAGE_METHOD_NAME, "mcp/message"); + assert_eq!(MCP_DISCONNECT_METHOD_NAME, "mcp/disconnect"); + } + + #[test] + fn connect_request_round_trip() { + let req = ConnectMcpRequest::new("project-tools-id"); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v, json!({"acpId": "project-tools-id"})); + + let parsed: ConnectMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.acp_id, McpServerAcpId::new("project-tools-id")); + assert!(parsed.meta.is_none()); + } + + #[test] + fn connect_response_round_trip() { + let resp = ConnectMcpResponse::new("conn-42"); + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v, json!({"connectionId": "conn-42"})); + + let parsed: ConnectMcpResponse = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.connection_id, McpConnectionId::new("conn-42")); + } + + #[test] + fn message_request_omits_unset_params_on_the_wire() { + // If the caller didn't set params, they MUST NOT appear on the wire + // (otherwise inner MCP peers receive a different shape than they sent). + let req = MessageMcpRequest::new("conn-1", "tools/list"); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v, json!({"connectionId": "conn-1", "method": "tools/list"})); + assert!( + !v.as_object().unwrap().contains_key("params"), + "unset params must be omitted, got {v}" + ); + } + + #[test] + fn message_request_serializes_provided_params() { + let mut params = serde_json::Map::new(); + params.insert("cursor".into(), json!("abc")); + + let req = MessageMcpRequest::new("conn-1", "tools/list").params(params.clone()); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!( + v, + json!({ + "connectionId": "conn-1", + "method": "tools/list", + "params": {"cursor": "abc"}, + }) + ); + + let parsed: MessageMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.params, Some(params)); + } + + #[test] + fn message_request_explicit_null_params_decode_to_none() { + // The docs explicitly state that omitted or null params mean + // "no inner MCP params". Both shapes MUST deserialize equivalently. + let omitted: MessageMcpRequest = + serde_json::from_value(json!({"connectionId": "conn-1", "method": "ping"})).unwrap(); + let explicit_null: MessageMcpRequest = serde_json::from_value( + json!({"connectionId": "conn-1", "method": "ping", "params": null}), + ) + .unwrap(); + + assert_eq!(omitted.params, None); + assert_eq!(explicit_null.params, None); + } + + #[test] + fn message_notification_round_trip_matches_request_shape() { + // Notification mirrors the request struct (no id wrapper concern); + // the wire surface must remain identical so a peer can treat the + // payload the same regardless of envelope flavor. + let note = MessageMcpNotification::new("conn-1", "notifications/progress"); + let v = serde_json::to_value(¬e).unwrap(); + assert_eq!( + v, + json!({ + "connectionId": "conn-1", + "method": "notifications/progress", + }) + ); + + let parsed: MessageMcpNotification = serde_json::from_value(json!({ + "connectionId": "conn-1", + "method": "notifications/progress", + "params": {"progressToken": "tok", "progress": 1} + })) + .unwrap(); + assert_eq!( + parsed.params.unwrap().get("progressToken").unwrap(), + &json!("tok") + ); + } + + #[test] + fn message_response_preserves_arbitrary_inner_json_verbatim() { + // `MessageMcpResponse` is a transparent `Arc` wrapper — + // the whole point is to forward inner MCP results without altering + // them. Serializing and re-parsing MUST yield bit-equivalent JSON. + let raw = serde_json::value::RawValue::from_string( + r#"{"tools":[{"name":"echo"}],"nextCursor":null}"#.to_string(), + ) + .unwrap(); + let resp = MessageMcpResponse::new(raw.into()); + + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v, json!({"tools": [{"name": "echo"}], "nextCursor": null})); + + // Round-tripping through `serde_json::Value` keeps the inner shape. + let reparsed: MessageMcpResponse = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(serde_json::to_value(&reparsed).unwrap(), v); + } + + #[test] + fn disconnect_request_round_trip() { + let req = DisconnectMcpRequest::new("conn-7"); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v, json!({"connectionId": "conn-7"})); + + let parsed: DisconnectMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.connection_id, McpConnectionId::new("conn-7")); + } + + #[test] + fn disconnect_response_default_is_empty_object() { + let resp = DisconnectMcpResponse::new(); + assert_eq!(serde_json::to_value(&resp).unwrap(), json!({})); + let parsed: DisconnectMcpResponse = serde_json::from_value(json!({})).unwrap(); + assert_eq!(parsed, DisconnectMcpResponse::default()); + } + + #[test] + fn meta_is_round_tripped_under_underscored_key() { + // `_meta` is the extensibility namespace; serialization MUST emit + // the leading underscore so peers can attach observability data. + let mut meta = serde_json::Map::new(); + meta.insert("trace_id".into(), json!("xyz")); + + let req = ConnectMcpRequest::new("acp").meta(meta.clone()); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["_meta"], json!({"trace_id": "xyz"})); + + let parsed: ConnectMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.meta.as_ref().unwrap(), &meta); + } +} diff --git a/src/v2/agent.rs b/src/v2/agent.rs index 993ffc2db..7401c58dd 100644 --- a/src/v2/agent.rs +++ b/src/v2/agent.rs @@ -5568,6 +5568,87 @@ mod test_serialization { assert!(matches!(deserialized, AuthMethod::Agent(_))); } + #[test] + fn test_logout_method_name_is_stable() { + assert_eq!(AGENT_METHOD_NAMES.logout, "logout"); + assert_eq!( + ClientRequest::LogoutRequest(LogoutRequest::new()).method(), + "logout" + ); + } + + #[test] + fn test_logout_request_round_trip_empty_and_with_meta() { + let req = LogoutRequest::new(); + assert_eq!(serde_json::to_value(&req).unwrap(), json!({})); + + let mut meta = serde_json::Map::new(); + meta.insert("trace_id".into(), json!("abc")); + let req = LogoutRequest::new().meta(meta.clone()); + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value, json!({"_meta": {"trace_id": "abc"}})); + + let deserialized: LogoutRequest = serde_json::from_value(value).unwrap(); + assert_eq!(deserialized.meta.as_ref().unwrap(), &meta); + } + + #[test] + fn test_logout_response_default_round_trip() { + let resp = LogoutResponse::new(); + assert_eq!(serde_json::to_value(&resp).unwrap(), json!({})); + + let parsed: LogoutResponse = serde_json::from_value(json!({})).unwrap(); + assert_eq!(parsed, LogoutResponse::default()); + + let envelope = AgentResponse::LogoutResponse(LogoutResponse::new()); + let wire = serde_json::to_value(&envelope).unwrap(); + assert_eq!(wire, json!({})); + } + + #[test] + fn test_agent_auth_capabilities_default_and_round_trip() { + let caps = AgentAuthCapabilities::new(); + assert_eq!(serde_json::to_value(&caps).unwrap(), json!({})); + + let caps = AgentAuthCapabilities::new().logout(LogoutCapabilities::new()); + let v = serde_json::to_value(&caps).unwrap(); + assert_eq!(v, json!({"logout": {}})); + + let parsed: AgentAuthCapabilities = serde_json::from_value(v).unwrap(); + assert!(parsed.logout.is_some()); + } + + #[test] + fn test_agent_auth_capabilities_tolerates_malformed_logout_field() { + let parsed: AgentAuthCapabilities = + serde_json::from_value(json!({"logout": "not an object"})).unwrap(); + assert!(parsed.logout.is_none()); + + let parsed: AgentAuthCapabilities = serde_json::from_value(json!({"logout": 42})).unwrap(); + assert!(parsed.logout.is_none()); + + let parsed: AgentAuthCapabilities = + serde_json::from_value(json!({"logout": null})).unwrap(); + assert!(parsed.logout.is_none()); + } + + #[test] + fn test_agent_capabilities_default_omits_auth() { + let parsed: AgentCapabilities = serde_json::from_value(json!({})).unwrap(); + assert!(parsed.auth.logout.is_none()); + } + + #[test] + fn test_agent_capabilities_threads_auth_through_round_trip() { + let caps = AgentCapabilities::new() + .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new())); + let v = serde_json::to_value(&caps).unwrap(); + assert_eq!(v["auth"], json!({"logout": {}})); + + let parsed: AgentCapabilities = serde_json::from_value(v).unwrap(); + assert!(parsed.auth.logout.is_some()); + } + #[cfg(feature = "unstable_session_delete")] #[test] fn test_session_delete_serialization() { diff --git a/src/v2/error.rs b/src/v2/error.rs index 212914738..31eec38f2 100644 --- a/src/v2/error.rs +++ b/src/v2/error.rs @@ -370,4 +370,139 @@ mod tests { ); } } + + #[test] + fn error_code_round_trip_to_i32() { + for error in ErrorCode::iter() { + let n: i32 = error.into(); + let back: ErrorCode = n.into(); + assert_eq!(error, back); + } + let other: ErrorCode = 12345i32.into(); + assert_eq!(other, ErrorCode::Other(12345)); + let back: i32 = other.into(); + assert_eq!(back, 12345); + } + + #[test] + fn error_constructors_set_expected_codes() { + assert_eq!(Error::parse_error().code, ErrorCode::ParseError); + assert_eq!(Error::invalid_request().code, ErrorCode::InvalidRequest); + assert_eq!(Error::method_not_found().code, ErrorCode::MethodNotFound); + assert_eq!(Error::invalid_params().code, ErrorCode::InvalidParams); + assert_eq!(Error::internal_error().code, ErrorCode::InternalError); + assert_eq!(Error::auth_required().code, ErrorCode::AuthRequired); + } + + #[test] + fn error_display_uses_message_when_present() { + let err = Error::new(-32000, "boom"); + assert_eq!(err.to_string(), "boom"); + } + + #[test] + fn error_display_falls_back_to_code_when_message_empty() { + let err = Error::new(-32600, ""); + assert_eq!(err.to_string(), "-32600"); + } + + #[test] + fn error_display_appends_data() { + let err = Error::new(-32000, "boom").data(serde_json::json!({"k": "v"})); + let rendered = err.to_string(); + assert!( + rendered.starts_with("boom: "), + "unexpected display: {rendered:?}" + ); + assert!(rendered.contains("\"k\""), "missing key in {rendered:?}"); + assert!(rendered.contains("\"v\""), "missing value in {rendered:?}"); + } + + #[test] + fn resource_not_found_attaches_uri_when_given() { + let err = Error::resource_not_found(Some("file:///tmp/missing".into())); + assert_eq!(err.code, ErrorCode::ResourceNotFound); + assert_eq!( + err.data, + Some(serde_json::json!({"uri": "file:///tmp/missing"})) + ); + } + + #[test] + fn resource_not_found_omits_data_when_uri_missing() { + let err = Error::resource_not_found(None); + assert_eq!(err.code, ErrorCode::ResourceNotFound); + assert!(err.data.is_none()); + } + + #[test] + fn from_serde_json_error_is_invalid_params_with_message_in_data() { + let serde_err = serde_json::from_str::("not a number").unwrap_err(); + let original_message = serde_err.to_string(); + + let err: Error = serde_err.into(); + assert_eq!(err.code, ErrorCode::InvalidParams); + let data = err.data.expect("data should carry the serde message"); + assert_eq!(data, serde_json::Value::String(original_message)); + } + + #[test] + fn into_internal_error_attaches_error_string() { + #[derive(Debug)] + struct MyErr; + impl std::fmt::Display for MyErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("kaboom") + } + } + impl std::error::Error for MyErr {} + + let err = Error::into_internal_error(MyErr); + assert_eq!(err.code, ErrorCode::InternalError); + assert_eq!(err.data, Some(serde_json::Value::String("kaboom".into()))); + } + + #[test] + fn from_anyhow_downcasts_to_existing_acp_error() { + let original = Error::auth_required().data(serde_json::json!({"hint": "log in"})); + let wrapped: anyhow::Error = anyhow::Error::new(original.clone()); + + let converted: Error = wrapped.into(); + assert_eq!(converted, original); + } + + #[test] + fn from_anyhow_falls_back_to_internal_error_for_foreign_types() { + let wrapped = anyhow::anyhow!("something went wrong"); + let converted: Error = wrapped.into(); + assert_eq!(converted.code, ErrorCode::InternalError); + assert_eq!( + converted.data, + Some(serde_json::Value::String("something went wrong".into())) + ); + } + + #[test] + fn error_serializes_to_jsonrpc_object_shape() { + let err = Error::new(-32600, "Invalid request"); + let v = serde_json::to_value(&err).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "code": -32600, + "message": "Invalid request", + }) + ); + + let err = Error::new(-32602, "bad").data(serde_json::json!({"field": "id"})); + let v = serde_json::to_value(&err).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "code": -32602, + "message": "bad", + "data": {"field": "id"}, + }) + ); + } } diff --git a/src/v2/mcp.rs b/src/v2/mcp.rs index 7f5ea2ce8..20d36852f 100644 --- a/src/v2/mcp.rs +++ b/src/v2/mcp.rs @@ -353,3 +353,151 @@ pub(crate) const MCP_CONNECT_METHOD_NAME: &str = "mcp/connect"; pub(crate) const MCP_MESSAGE_METHOD_NAME: &str = "mcp/message"; /// Method name for closing an MCP-over-ACP connection. pub(crate) const MCP_DISCONNECT_METHOD_NAME: &str = "mcp/disconnect"; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn method_name_constants_match_wire_format() { + assert_eq!(MCP_CONNECT_METHOD_NAME, "mcp/connect"); + assert_eq!(MCP_MESSAGE_METHOD_NAME, "mcp/message"); + assert_eq!(MCP_DISCONNECT_METHOD_NAME, "mcp/disconnect"); + } + + #[test] + fn connect_request_round_trip() { + let req = ConnectMcpRequest::new("project-tools-id"); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v, json!({"acpId": "project-tools-id"})); + + let parsed: ConnectMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.acp_id, McpServerAcpId::new("project-tools-id")); + assert!(parsed.meta.is_none()); + } + + #[test] + fn connect_response_round_trip() { + let resp = ConnectMcpResponse::new("conn-42"); + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v, json!({"connectionId": "conn-42"})); + + let parsed: ConnectMcpResponse = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.connection_id, McpConnectionId::new("conn-42")); + } + + #[test] + fn message_request_omits_unset_params_on_the_wire() { + let req = MessageMcpRequest::new("conn-1", "tools/list"); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v, json!({"connectionId": "conn-1", "method": "tools/list"})); + assert!( + !v.as_object().unwrap().contains_key("params"), + "unset params must be omitted, got {v}" + ); + } + + #[test] + fn message_request_serializes_provided_params() { + let mut params = serde_json::Map::new(); + params.insert("cursor".into(), json!("abc")); + + let req = MessageMcpRequest::new("conn-1", "tools/list").params(params.clone()); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!( + v, + json!({ + "connectionId": "conn-1", + "method": "tools/list", + "params": {"cursor": "abc"}, + }) + ); + + let parsed: MessageMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.params, Some(params)); + } + + #[test] + fn message_request_explicit_null_params_decode_to_none() { + let omitted: MessageMcpRequest = + serde_json::from_value(json!({"connectionId": "conn-1", "method": "ping"})).unwrap(); + let explicit_null: MessageMcpRequest = serde_json::from_value( + json!({"connectionId": "conn-1", "method": "ping", "params": null}), + ) + .unwrap(); + + assert_eq!(omitted.params, None); + assert_eq!(explicit_null.params, None); + } + + #[test] + fn message_notification_round_trip_matches_request_shape() { + let note = MessageMcpNotification::new("conn-1", "notifications/progress"); + let v = serde_json::to_value(¬e).unwrap(); + assert_eq!( + v, + json!({ + "connectionId": "conn-1", + "method": "notifications/progress", + }) + ); + + let parsed: MessageMcpNotification = serde_json::from_value(json!({ + "connectionId": "conn-1", + "method": "notifications/progress", + "params": {"progressToken": "tok", "progress": 1} + })) + .unwrap(); + assert_eq!( + parsed.params.unwrap().get("progressToken").unwrap(), + &json!("tok") + ); + } + + #[test] + fn message_response_preserves_arbitrary_inner_json_verbatim() { + let raw = serde_json::value::RawValue::from_string( + r#"{"tools":[{"name":"echo"}],"nextCursor":null}"#.to_string(), + ) + .unwrap(); + let resp = MessageMcpResponse::new(raw.into()); + + let v = serde_json::to_value(&resp).unwrap(); + assert_eq!(v, json!({"tools": [{"name": "echo"}], "nextCursor": null})); + + let reparsed: MessageMcpResponse = serde_json::from_value(v.clone()).unwrap(); + assert_eq!(serde_json::to_value(&reparsed).unwrap(), v); + } + + #[test] + fn disconnect_request_round_trip() { + let req = DisconnectMcpRequest::new("conn-7"); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v, json!({"connectionId": "conn-7"})); + + let parsed: DisconnectMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.connection_id, McpConnectionId::new("conn-7")); + } + + #[test] + fn disconnect_response_default_is_empty_object() { + let resp = DisconnectMcpResponse::new(); + assert_eq!(serde_json::to_value(&resp).unwrap(), json!({})); + let parsed: DisconnectMcpResponse = serde_json::from_value(json!({})).unwrap(); + assert_eq!(parsed, DisconnectMcpResponse::default()); + } + + #[test] + fn meta_is_round_tripped_under_underscored_key() { + let mut meta = serde_json::Map::new(); + meta.insert("trace_id".into(), json!("xyz")); + + let req = ConnectMcpRequest::new("acp").meta(meta.clone()); + let v = serde_json::to_value(&req).unwrap(); + assert_eq!(v["_meta"], json!({"trace_id": "xyz"})); + + let parsed: ConnectMcpRequest = serde_json::from_value(v).unwrap(); + assert_eq!(parsed.meta.as_ref().unwrap(), &meta); + } +}