1use std::path::PathBuf;
4
5use agent_client_protocol::schema::v1::{AuthMethod, Meta};
6use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
7pub use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
8pub use rmcp::model::ElicitRequestParams;
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10
11pub use mcp_utils::status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry};
12
13pub const AETHER_META_NAMESPACE: &str = "contextbridge/aether";
14
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
23pub struct ContextUsage {
24 pub usage_ratio: Option<f64>,
26 pub context_limit: Option<u32>,
28 pub input_tokens: u32,
30 #[serde(default)]
32 pub output_tokens: u32,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub cache_read_tokens: Option<u32>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub cache_creation_tokens: Option<u32>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub reasoning_tokens: Option<u32>,
42 #[serde(default)]
44 pub total_input_tokens: u64,
45 #[serde(default)]
47 pub total_output_tokens: u64,
48 #[serde(default)]
50 pub total_cache_read_tokens: u64,
51 #[serde(default)]
53 pub total_cache_creation_tokens: u64,
54 #[serde(default)]
56 pub total_reasoning_tokens: u64,
57}
58
59impl ContextUsage {
60 pub fn total_tokens(&self) -> u64 {
62 self.total_input_tokens + self.total_output_tokens
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcNotification)]
68#[notification(method = "_aether/context_usage")]
69pub struct ContextUsageParams {
70 #[serde(flatten)]
71 pub usage: ContextUsage,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
76#[notification(method = "_aether/context_compaction")]
77pub struct ContextCompactionParams {
78 pub active: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonRpcNotification)]
83#[notification(method = "_aether/context_cleared")]
84pub struct ContextClearedParams {}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
88#[notification(method = "_aether/auth_methods_updated")]
89pub struct AuthMethodsUpdatedParams {
90 pub auth_methods: Vec<AuthMethod>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcRequest)]
99#[request(method = "_aether/elicitation", response = ElicitationResponse)]
100pub struct ElicitationParams {
101 pub server_name: String,
102 pub request: ElicitRequestParams,
103}
104
105pub use rmcp::model::ElicitationAction;
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
109#[request(method = "_aether/prompt_search", response = PromptSearchResponse)]
110#[serde(rename_all = "camelCase")]
111pub struct PromptSearchParams {
112 pub query: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub limit: Option<usize>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
119#[serde(rename_all = "camelCase")]
120pub struct PromptSearchResponse {
121 pub query: String,
122 pub results: Vec<PromptSearchResult>,
123 pub truncated: bool,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct PromptSearchResult {
133 pub session_id: String,
134 pub cwd: PathBuf,
135 pub session_created_at: String,
136 pub prompt: String,
137 pub match_start: usize,
138 pub match_end: usize,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
142#[request(method = "_aether/session_preview", response = SessionPreviewResponse)]
143#[serde(rename_all = "camelCase")]
144pub struct SessionPreviewParams {
145 pub session_id: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
149#[serde(rename_all = "camelCase")]
150pub struct SessionPreviewResponse {
151 pub session_id: String,
152 pub cwd: PathBuf,
153 pub created_at: String,
154 pub model: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub selected_mode: Option<String>,
157 pub transcript: Vec<SessionPreviewTurn>,
158 pub tool_call_count: usize,
159 pub truncated: bool,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase")]
164pub struct SessionPreviewTurn {
165 pub role: SessionPreviewRole,
166 pub text: String,
167}
168
169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(rename_all = "camelCase")]
171pub enum SessionPreviewRole {
172 User,
173 Assistant,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
178#[request(method = "_aether/workspace_list", response = WorkspaceListResponse)]
179#[serde(rename_all = "camelCase")]
180pub struct WorkspaceListParams {
181 pub session_id: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
187#[serde(rename_all = "camelCase")]
188pub struct WorkspaceListResponse {
189 pub workspaces: Vec<WorkspaceEntry>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "camelCase")]
194pub struct WorkspaceEntry {
195 pub path: PathBuf,
196 pub is_current: bool,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
201#[request(method = "_aether/workspace_move", response = WorkspaceMoveResponse)]
202#[serde(rename_all = "camelCase")]
203pub struct WorkspaceMoveParams {
204 pub session_id: String,
205 pub target: WorkspaceMoveTarget,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209#[serde(tag = "kind", rename_all = "camelCase")]
210pub enum WorkspaceMoveTarget {
211 Existing { path: PathBuf },
212 New { name: String },
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
216#[serde(rename_all = "camelCase")]
217pub struct WorkspaceMoveResponse {
218 pub new_cwd: PathBuf,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
222#[serde(rename_all = "camelCase")]
223pub struct SessionDisplayMeta {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub model: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub selected_mode: Option<String>,
228}
229
230impl SessionDisplayMeta {
231 #[must_use]
232 pub fn new(model: impl Into<String>, selected_mode: Option<String>) -> Self {
233 Self { model: Some(model.into()), selected_mode }
234 }
235
236 #[must_use]
237 pub fn to_meta(&self) -> Meta {
238 to_aether_meta(self)
239 }
240
241 #[must_use]
242 pub fn from_meta(meta: Option<&Meta>) -> Self {
243 from_aether_meta(meta)
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
248#[serde(rename_all = "camelCase")]
249pub struct AetherCapabilities {
250 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
251 pub prompt_search: bool,
252 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
253 pub session_preview: bool,
254 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255 pub workspace_move: bool,
256}
257
258impl AetherCapabilities {
259 #[must_use]
260 pub fn to_meta(self) -> Meta {
261 to_aether_meta(&self)
262 }
263
264 #[must_use]
265 pub fn from_meta(meta: Option<&Meta>) -> Self {
266 from_aether_meta(meta)
267 }
268}
269
270fn to_aether_meta<T: Serialize>(value: &T) -> Meta {
271 let mut meta = Meta::new();
272 meta.insert(AETHER_META_NAMESPACE.to_string(), serde_json::json!(value));
273 meta
274}
275
276fn from_aether_meta<T: DeserializeOwned + Default>(meta: Option<&Meta>) -> T {
277 meta.and_then(|m| m.get(AETHER_META_NAMESPACE))
278 .cloned()
279 .and_then(|value| serde_json::from_value(value).ok())
280 .unwrap_or_default()
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcResponse)]
285pub struct ElicitationResponse {
286 pub action: ElicitationAction,
287 pub content: Option<serde_json::Value>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
293#[notification(method = "_aether/mcp_event")]
294pub enum McpNotification {
295 ServerStatus { servers: Vec<McpServerStatusEntry> },
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
300#[notification(method = "_aether/mcp_request")]
301pub enum McpRequest {
302 Authenticate { session_id: String, server_name: String },
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)]
309#[notification(method = "_aether/sub_agent_progress")]
310pub struct SubAgentProgressParams {
311 pub parent_tool_id: String,
312 pub task_id: String,
313 pub agent_name: String,
314 pub event: SubAgentEvent,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
322pub enum SubAgentEvent {
323 ToolCall { request: SubAgentToolRequest },
324 ToolCallUpdate { update: SubAgentToolCallUpdate },
325 ToolResult { result: SubAgentToolResult },
326 ToolError { error: SubAgentToolError },
327 Done,
328 Other,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct SubAgentToolRequest {
333 pub id: String,
334 pub name: String,
335 pub arguments: String,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct SubAgentToolCallUpdate {
340 pub id: String,
341 pub chunk: String,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct SubAgentToolResult {
346 pub id: String,
347 pub name: String,
348 pub result_meta: Option<ToolResultMeta>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct SubAgentToolError {
353 pub id: String,
354 pub name: String,
355}
356
357#[cfg(test)]
358mod tests {
359 use agent_client_protocol::JsonRpcMessage;
360 use agent_client_protocol::schema::v1::AuthMethodAgent;
361
362 use super::*;
363
364 #[test]
365 fn wire_method_names_are_prefixed() {
366 assert_eq!(ContextClearedParams::default().method(), "_aether/context_cleared");
367 assert_eq!(AuthMethodsUpdatedParams { auth_methods: vec![] }.method(), "_aether/auth_methods_updated");
368 assert_eq!(McpNotification::ServerStatus { servers: vec![] }.method(), "_aether/mcp_event");
369 assert_eq!(
370 McpRequest::Authenticate { session_id: String::new(), server_name: String::new() }.method(),
371 "_aether/mcp_request"
372 );
373 assert_eq!(PromptSearchParams { query: String::new(), limit: None }.method(), "_aether/prompt_search");
374 assert_eq!(SessionPreviewParams { session_id: String::new() }.method(), "_aether/session_preview");
375 assert_eq!(WorkspaceListParams { session_id: String::new() }.method(), "_aether/workspace_list");
376 let move_params =
377 WorkspaceMoveParams { session_id: String::new(), target: WorkspaceMoveTarget::New { name: String::new() } };
378 assert_eq!(move_params.method(), "_aether/workspace_move");
379 }
380
381 #[test]
382 fn context_usage_params_roundtrip() {
383 let params = ContextUsageParams {
384 usage: ContextUsage {
385 usage_ratio: Some(0.75),
386 context_limit: Some(100_000),
387 input_tokens: 75_000,
388 output_tokens: 1_200,
389 cache_read_tokens: Some(40_000),
390 cache_creation_tokens: Some(2_000),
391 reasoning_tokens: Some(500),
392 total_input_tokens: 200_000,
393 total_output_tokens: 8_000,
394 total_cache_read_tokens: 90_000,
395 total_cache_creation_tokens: 5_000,
396 total_reasoning_tokens: 1_500,
397 },
398 };
399
400 let untyped = params.to_untyped_message().expect("serializable");
401 assert_eq!(untyped.method(), "_aether/context_usage");
402 let parsed = ContextUsageParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
403 assert_eq!(parsed, params);
404 }
405
406 #[test]
407 fn context_usage_params_omits_unset_optional_token_fields() {
408 let params = ContextUsageParams {
409 usage: ContextUsage {
410 usage_ratio: Some(0.1),
411 context_limit: Some(1_000),
412 input_tokens: 100,
413 ..ContextUsage::default()
414 },
415 };
416
417 let raw = serde_json::to_string(¶ms).unwrap();
418 assert!(!raw.contains("\"cache_read_tokens\""));
419 assert!(!raw.contains("\"cache_creation_tokens\""));
420 assert!(!raw.contains("\"reasoning_tokens\""));
421 }
422
423 #[test]
424 fn context_compaction_params_roundtrip() {
425 for active in [true, false] {
426 let params = ContextCompactionParams { active };
427 let untyped = params.to_untyped_message().expect("serializable");
428 assert_eq!(untyped.method(), "_aether/context_compaction");
429 let parsed = ContextCompactionParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
430 assert_eq!(parsed, params);
431 }
432 }
433
434 #[test]
435 fn context_cleared_params_roundtrip() {
436 let params = ContextClearedParams::default();
437 let untyped = params.to_untyped_message().expect("serializable");
438 assert_eq!(untyped.method(), "_aether/context_cleared");
439 let parsed = ContextClearedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
440 assert_eq!(parsed, params);
441 }
442
443 #[test]
444 fn auth_methods_updated_roundtrip() {
445 let params = AuthMethodsUpdatedParams {
446 auth_methods: vec![
447 AuthMethod::Agent(AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
448 AuthMethod::Agent(AuthMethodAgent::new("openrouter", "OpenRouter")),
449 ],
450 };
451
452 let untyped = params.to_untyped_message().expect("serializable");
453 assert_eq!(untyped.method(), "_aether/auth_methods_updated");
454 let parsed = AuthMethodsUpdatedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
455 assert_eq!(parsed, params);
456 }
457
458 #[test]
459 fn mcp_request_authenticate_roundtrip() {
460 let msg = McpRequest::Authenticate {
461 session_id: "session-0".to_string(),
462 server_name: "my oauth server".to_string(),
463 };
464
465 let untyped = msg.to_untyped_message().expect("serializable");
466 assert_eq!(untyped.method(), "_aether/mcp_request");
467 let parsed = McpRequest::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
468 assert_eq!(parsed, msg);
469 }
470
471 #[test]
472 fn mcp_notification_server_status_roundtrip() {
473 let msg = McpNotification::ServerStatus {
474 servers: vec![
475 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
476 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
477 .with_auth_capability(McpServerAuthCapability::OAuth),
478 McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
479 ],
480 };
481
482 let untyped = msg.to_untyped_message().expect("serializable");
483 assert_eq!(untyped.method(), "_aether/mcp_event");
484 let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
485 assert_eq!(parsed, msg);
486 }
487
488 #[test]
489 fn sub_agent_progress_params_roundtrip() {
490 let params = SubAgentProgressParams {
491 parent_tool_id: "call_123".to_string(),
492 task_id: "task_abc".to_string(),
493 agent_name: "explorer".to_string(),
494 event: SubAgentEvent::Done,
495 };
496
497 let untyped = params.to_untyped_message().expect("serializable");
498 assert_eq!(untyped.method(), "_aether/sub_agent_progress");
499 }
500
501 #[test]
502 fn elicitation_params_roundtrip() {
503 use rmcp::model::{ElicitationSchema, EnumSchema};
504
505 let params = ElicitationParams {
506 server_name: "github".to_string(),
507 request: ElicitRequestParams::FormElicitationParams {
508 meta: None,
509 message: "Pick a color".to_string(),
510 requested_schema: ElicitationSchema::builder()
511 .required_enum_schema(
512 "color",
513 EnumSchema::builder(vec!["red".into(), "green".into(), "blue".into()]).untitled().build(),
514 )
515 .build()
516 .unwrap(),
517 },
518 };
519
520 let untyped = params.to_untyped_message().expect("serializable");
521 assert_eq!(untyped.method(), "_aether/elicitation");
522 let parsed = ElicitationParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
523 assert_eq!(parsed, params);
524 }
525
526 #[test]
527 fn elicitation_params_url_variant_has_mode_field() {
528 let params = ElicitationParams {
529 server_name: "github".to_string(),
530 request: ElicitRequestParams::UrlElicitationParams {
531 meta: None,
532 message: "Authorize GitHub".to_string(),
533 url: "https://github.com/login/oauth".to_string(),
534 elicitation_id: "el-123".to_string(),
535 },
536 };
537
538 let json = serde_json::to_string(¶ms).unwrap();
539 assert!(json.contains("\"mode\":\"url\""));
540 assert!(json.contains("\"server_name\":\"github\""));
541 }
542
543 #[test]
544 fn mcp_server_status_entry_serde_roundtrip() {
545 let entry = McpServerStatusEntry::new("test-server", McpServerStatus::Connected { tool_count: 3 })
546 .with_auth_capability(McpServerAuthCapability::OAuth);
547
548 let json = serde_json::to_string(&entry).unwrap();
549 assert!(json.contains("\"auth_capability\":\"OAuth\""));
550 assert!(json.contains("\"deferTools\":false"));
551 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
552 assert_eq!(parsed, entry);
553 assert!(!parsed.deferred_tools);
554 assert!(parsed.can_authenticate());
555 }
556
557 #[test]
558 fn mcp_server_status_entry_deferred_tools_serde_roundtrip() {
559 let entry = McpServerStatusEntry::new("math", McpServerStatus::NeedsOAuth)
560 .with_auth_capability(McpServerAuthCapability::OAuth)
561 .with_deferred_tools(true);
562
563 let json = serde_json::to_string(&entry).unwrap();
564 assert!(json.contains("\"deferTools\":true"));
565 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
566 assert_eq!(parsed, entry);
567 }
568
569 #[test]
570 fn deserialize_tool_call_event() {
571 let json = r#"{"ToolCall":{"request":{"id":"c1","name":"grep","arguments":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
572 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
573 assert!(matches!(event, SubAgentEvent::ToolCall { .. }));
574 }
575
576 #[test]
577 fn deserialize_tool_call_update_event() {
578 let json = r#"{"ToolCallUpdate":{"update":{"id":"c1","chunk":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
579 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
580 assert!(matches!(event, SubAgentEvent::ToolCallUpdate { .. }));
581 }
582
583 #[test]
584 fn deserialize_tool_result_event() {
585 let json = r#"{"ToolResult":{"result":{"id":"c1","name":"grep","result_meta":{"display":{"title":"Grep","value":"'test' in src (3 matches)"}}}}}"#;
586 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
587 match event {
588 SubAgentEvent::ToolResult { result } => {
589 let result_meta = result.result_meta.expect("expected result_meta");
590 assert_eq!(result_meta.display.title, "Grep");
591 }
592 other => panic!("Expected ToolResult, got {other:?}"),
593 }
594 }
595
596 #[test]
597 fn deserialize_tool_error_event() {
598 let json = r#"{"ToolError":{"error":{"id":"c1","name":"grep"}}}"#;
599 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
600 assert!(matches!(event, SubAgentEvent::ToolError { .. }));
601 }
602
603 #[test]
604 fn deserialize_done_event() {
605 let event: SubAgentEvent = serde_json::from_str(r#""Done""#).unwrap();
606 assert!(matches!(event, SubAgentEvent::Done));
607 }
608
609 #[test]
610 fn deserialize_other_variant() {
611 let event: SubAgentEvent = serde_json::from_str(r#""Other""#).unwrap();
612 assert!(matches!(event, SubAgentEvent::Other));
613 }
614
615 #[test]
616 fn tool_result_meta_map_roundtrip() {
617 let meta: ToolResultMeta = ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into();
618 let map = meta.clone().into_map();
619 let parsed = ToolResultMeta::from_map(&map).expect("should deserialize ToolResultMeta");
620 assert_eq!(parsed, meta);
621 }
622}