1use std::path::PathBuf;
4
5use agent_client_protocol::schema::AuthMethod;
6use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
7pub use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
8pub use rmcp::model::CreateElicitationRequestParams;
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: CreateElicitationRequestParams,
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) -> agent_client_protocol::schema::Meta {
238 to_aether_meta(self)
239 }
240
241 #[must_use]
242 pub fn from_meta(meta: Option<&agent_client_protocol::schema::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) -> agent_client_protocol::schema::Meta {
261 to_aether_meta(&self)
262 }
263
264 #[must_use]
265 pub fn from_meta(meta: Option<&agent_client_protocol::schema::Meta>) -> Self {
266 from_aether_meta(meta)
267 }
268}
269
270fn to_aether_meta<T: Serialize>(value: &T) -> agent_client_protocol::schema::Meta {
271 let mut meta = agent_client_protocol::schema::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<&agent_client_protocol::schema::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
291pub use mcp_utils::client::UrlElicitationCompleteParams;
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
295#[notification(method = "_aether/mcp_event")]
296pub enum McpNotification {
297 ServerStatus { servers: Vec<McpServerStatusEntry> },
298 UrlElicitationComplete(UrlElicitationCompleteParams),
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
303#[notification(method = "_aether/mcp_request")]
304pub enum McpRequest {
305 Authenticate { session_id: String, server_name: String },
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)]
312#[notification(method = "_aether/sub_agent_progress")]
313pub struct SubAgentProgressParams {
314 pub parent_tool_id: String,
315 pub task_id: String,
316 pub agent_name: String,
317 pub event: SubAgentEvent,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
325pub enum SubAgentEvent {
326 ToolCall { request: SubAgentToolRequest },
327 ToolCallUpdate { update: SubAgentToolCallUpdate },
328 ToolResult { result: SubAgentToolResult },
329 ToolError { error: SubAgentToolError },
330 Done,
331 Other,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct SubAgentToolRequest {
336 pub id: String,
337 pub name: String,
338 pub arguments: String,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct SubAgentToolCallUpdate {
343 pub id: String,
344 pub chunk: String,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct SubAgentToolResult {
349 pub id: String,
350 pub name: String,
351 pub result_meta: Option<ToolResultMeta>,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct SubAgentToolError {
356 pub id: String,
357 pub name: String,
358}
359
360#[cfg(test)]
361mod tests {
362 use agent_client_protocol::JsonRpcMessage;
363 use agent_client_protocol::schema::AuthMethodAgent;
364
365 use super::*;
366
367 #[test]
368 fn wire_method_names_are_prefixed() {
369 assert_eq!(ContextClearedParams::default().method(), "_aether/context_cleared");
370 assert_eq!(AuthMethodsUpdatedParams { auth_methods: vec![] }.method(), "_aether/auth_methods_updated");
371 assert_eq!(McpNotification::ServerStatus { servers: vec![] }.method(), "_aether/mcp_event");
372 assert_eq!(
373 McpRequest::Authenticate { session_id: String::new(), server_name: String::new() }.method(),
374 "_aether/mcp_request"
375 );
376 assert_eq!(PromptSearchParams { query: String::new(), limit: None }.method(), "_aether/prompt_search");
377 assert_eq!(SessionPreviewParams { session_id: String::new() }.method(), "_aether/session_preview");
378 assert_eq!(WorkspaceListParams { session_id: String::new() }.method(), "_aether/workspace_list");
379 let move_params =
380 WorkspaceMoveParams { session_id: String::new(), target: WorkspaceMoveTarget::New { name: String::new() } };
381 assert_eq!(move_params.method(), "_aether/workspace_move");
382 }
383
384 #[test]
385 fn context_usage_params_roundtrip() {
386 let params = ContextUsageParams {
387 usage: ContextUsage {
388 usage_ratio: Some(0.75),
389 context_limit: Some(100_000),
390 input_tokens: 75_000,
391 output_tokens: 1_200,
392 cache_read_tokens: Some(40_000),
393 cache_creation_tokens: Some(2_000),
394 reasoning_tokens: Some(500),
395 total_input_tokens: 200_000,
396 total_output_tokens: 8_000,
397 total_cache_read_tokens: 90_000,
398 total_cache_creation_tokens: 5_000,
399 total_reasoning_tokens: 1_500,
400 },
401 };
402
403 let untyped = params.to_untyped_message().expect("serializable");
404 assert_eq!(untyped.method(), "_aether/context_usage");
405 let parsed = ContextUsageParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
406 assert_eq!(parsed, params);
407 }
408
409 #[test]
410 fn context_usage_params_omits_unset_optional_token_fields() {
411 let params = ContextUsageParams {
412 usage: ContextUsage {
413 usage_ratio: Some(0.1),
414 context_limit: Some(1_000),
415 input_tokens: 100,
416 ..ContextUsage::default()
417 },
418 };
419
420 let raw = serde_json::to_string(¶ms).unwrap();
421 assert!(!raw.contains("\"cache_read_tokens\""));
422 assert!(!raw.contains("\"cache_creation_tokens\""));
423 assert!(!raw.contains("\"reasoning_tokens\""));
424 }
425
426 #[test]
427 fn context_compaction_params_roundtrip() {
428 for active in [true, false] {
429 let params = ContextCompactionParams { active };
430 let untyped = params.to_untyped_message().expect("serializable");
431 assert_eq!(untyped.method(), "_aether/context_compaction");
432 let parsed = ContextCompactionParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
433 assert_eq!(parsed, params);
434 }
435 }
436
437 #[test]
438 fn context_cleared_params_roundtrip() {
439 let params = ContextClearedParams::default();
440 let untyped = params.to_untyped_message().expect("serializable");
441 assert_eq!(untyped.method(), "_aether/context_cleared");
442 let parsed = ContextClearedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
443 assert_eq!(parsed, params);
444 }
445
446 #[test]
447 fn auth_methods_updated_roundtrip() {
448 let params = AuthMethodsUpdatedParams {
449 auth_methods: vec![
450 AuthMethod::Agent(AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
451 AuthMethod::Agent(AuthMethodAgent::new("openrouter", "OpenRouter")),
452 ],
453 };
454
455 let untyped = params.to_untyped_message().expect("serializable");
456 assert_eq!(untyped.method(), "_aether/auth_methods_updated");
457 let parsed = AuthMethodsUpdatedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
458 assert_eq!(parsed, params);
459 }
460
461 #[test]
462 fn mcp_request_authenticate_roundtrip() {
463 let msg = McpRequest::Authenticate {
464 session_id: "session-0".to_string(),
465 server_name: "my oauth server".to_string(),
466 };
467
468 let untyped = msg.to_untyped_message().expect("serializable");
469 assert_eq!(untyped.method(), "_aether/mcp_request");
470 let parsed = McpRequest::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
471 assert_eq!(parsed, msg);
472 }
473
474 #[test]
475 fn mcp_notification_server_status_roundtrip() {
476 let msg = McpNotification::ServerStatus {
477 servers: vec![
478 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
479 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
480 .with_auth_capability(McpServerAuthCapability::OAuth),
481 McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
482 ],
483 };
484
485 let untyped = msg.to_untyped_message().expect("serializable");
486 assert_eq!(untyped.method(), "_aether/mcp_event");
487 let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
488 assert_eq!(parsed, msg);
489 }
490
491 #[test]
492 fn mcp_notification_url_elicitation_complete_roundtrip() {
493 let msg = McpNotification::UrlElicitationComplete(UrlElicitationCompleteParams {
494 server_name: "github".to_string(),
495 elicitation_id: "el-456".to_string(),
496 });
497
498 let untyped = msg.to_untyped_message().expect("serializable");
499 let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
500 assert_eq!(parsed, msg);
501 }
502
503 #[test]
504 fn sub_agent_progress_params_roundtrip() {
505 let params = SubAgentProgressParams {
506 parent_tool_id: "call_123".to_string(),
507 task_id: "task_abc".to_string(),
508 agent_name: "explorer".to_string(),
509 event: SubAgentEvent::Done,
510 };
511
512 let untyped = params.to_untyped_message().expect("serializable");
513 assert_eq!(untyped.method(), "_aether/sub_agent_progress");
514 }
515
516 #[test]
517 fn elicitation_params_roundtrip() {
518 use rmcp::model::{ElicitationSchema, EnumSchema};
519
520 let params = ElicitationParams {
521 server_name: "github".to_string(),
522 request: CreateElicitationRequestParams::FormElicitationParams {
523 meta: None,
524 message: "Pick a color".to_string(),
525 requested_schema: ElicitationSchema::builder()
526 .required_enum_schema(
527 "color",
528 EnumSchema::builder(vec!["red".into(), "green".into(), "blue".into()]).untitled().build(),
529 )
530 .build()
531 .unwrap(),
532 },
533 };
534
535 let untyped = params.to_untyped_message().expect("serializable");
536 assert_eq!(untyped.method(), "_aether/elicitation");
537 let parsed = ElicitationParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
538 assert_eq!(parsed, params);
539 }
540
541 #[test]
542 fn elicitation_params_url_variant_has_mode_field() {
543 let params = ElicitationParams {
544 server_name: "github".to_string(),
545 request: CreateElicitationRequestParams::UrlElicitationParams {
546 meta: None,
547 message: "Authorize GitHub".to_string(),
548 url: "https://github.com/login/oauth".to_string(),
549 elicitation_id: "el-123".to_string(),
550 },
551 };
552
553 let json = serde_json::to_string(¶ms).unwrap();
554 assert!(json.contains("\"mode\":\"url\""));
555 assert!(json.contains("\"server_name\":\"github\""));
556 }
557
558 #[test]
559 fn mcp_server_status_entry_serde_roundtrip() {
560 let entry = McpServerStatusEntry::new("test-server", McpServerStatus::Connected { tool_count: 3 })
561 .with_auth_capability(McpServerAuthCapability::OAuth);
562
563 let json = serde_json::to_string(&entry).unwrap();
564 assert!(json.contains("\"auth_capability\":\"OAuth\""));
565 assert!(json.contains("\"proxied\":false"));
566 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
567 assert_eq!(parsed, entry);
568 assert!(!parsed.proxied);
569 assert!(parsed.can_authenticate());
570 }
571
572 #[test]
573 fn mcp_server_status_entry_proxied_serde_roundtrip() {
574 let entry = McpServerStatusEntry::new("math", McpServerStatus::NeedsOAuth)
575 .with_auth_capability(McpServerAuthCapability::OAuth)
576 .with_proxied(true);
577
578 let json = serde_json::to_string(&entry).unwrap();
579 assert!(json.contains("\"proxied\":true"));
580 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
581 assert_eq!(parsed, entry);
582 }
583
584 #[test]
585 fn deserialize_tool_call_event() {
586 let json = r#"{"ToolCall":{"request":{"id":"c1","name":"grep","arguments":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
587 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
588 assert!(matches!(event, SubAgentEvent::ToolCall { .. }));
589 }
590
591 #[test]
592 fn deserialize_tool_call_update_event() {
593 let json = r#"{"ToolCallUpdate":{"update":{"id":"c1","chunk":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
594 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
595 assert!(matches!(event, SubAgentEvent::ToolCallUpdate { .. }));
596 }
597
598 #[test]
599 fn deserialize_tool_result_event() {
600 let json = r#"{"ToolResult":{"result":{"id":"c1","name":"grep","result_meta":{"display":{"title":"Grep","value":"'test' in src (3 matches)"}}}}}"#;
601 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
602 match event {
603 SubAgentEvent::ToolResult { result } => {
604 let result_meta = result.result_meta.expect("expected result_meta");
605 assert_eq!(result_meta.display.title, "Grep");
606 }
607 other => panic!("Expected ToolResult, got {other:?}"),
608 }
609 }
610
611 #[test]
612 fn deserialize_tool_error_event() {
613 let json = r#"{"ToolError":{"error":{"id":"c1","name":"grep"}}}"#;
614 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
615 assert!(matches!(event, SubAgentEvent::ToolError { .. }));
616 }
617
618 #[test]
619 fn deserialize_done_event() {
620 let event: SubAgentEvent = serde_json::from_str(r#""Done""#).unwrap();
621 assert!(matches!(event, SubAgentEvent::Done));
622 }
623
624 #[test]
625 fn deserialize_other_variant() {
626 let event: SubAgentEvent = serde_json::from_str(r#""Other""#).unwrap();
627 assert!(matches!(event, SubAgentEvent::Other));
628 }
629
630 #[test]
631 fn tool_result_meta_map_roundtrip() {
632 let meta: ToolResultMeta = ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into();
633 let map = meta.clone().into_map();
634 let parsed = ToolResultMeta::from_map(&map).expect("should deserialize ToolResultMeta");
635 assert_eq!(parsed, meta);
636 }
637}