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