1use std::path::PathBuf;
4
5use agent_client_protocol::schema::v2::{AuthMethod, Meta, SessionId};
6use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
7pub use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
8use serde::{Deserialize, Serialize};
9
10pub use mcp_utils::status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry};
11
12use crate::meta::{from_meta, to_meta};
13
14pub const AETHER_META_NAMESPACE: &str = "contextbridge/aether";
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "camelCase")]
19pub struct RemoteServerInfo {
20 pub cwd: PathBuf,
21 pub session_id: Option<SessionId>,
22}
23
24impl RemoteServerInfo {
25 #[must_use]
26 pub fn to_meta(&self) -> Meta {
27 to_meta(&RemoteInitializationMeta { remote: Some(self.clone()) }, Some(AETHER_META_NAMESPACE))
28 .unwrap_or_default()
29 }
30
31 #[must_use]
32 pub fn from_meta(meta: Option<&Meta>) -> Option<Self> {
33 from_meta::<RemoteInitializationMeta>(meta, Some(AETHER_META_NAMESPACE)).remote
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcNotification)]
39#[notification(method = "_aether/session_usage")]
40pub struct SessionUsageParams {
41 pub usage: llm::SessionUsageEvent,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonRpcNotification)]
46#[notification(method = "_aether/context_cleared")]
47pub struct ContextClearedParams {}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
51#[notification(method = "_aether/auth_methods_updated")]
52pub struct AuthMethodsUpdatedParams {
53 pub auth_methods: Vec<AuthMethod>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
58#[request(method = "_aether/prompt_search", response = PromptSearchResponse)]
59#[serde(rename_all = "camelCase")]
60pub struct PromptSearchParams {
61 pub query: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub limit: Option<usize>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
68#[serde(rename_all = "camelCase")]
69pub struct PromptSearchResponse {
70 pub query: String,
71 pub results: Vec<PromptSearchResult>,
72 pub truncated: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase")]
81pub struct PromptSearchResult {
82 pub session_id: String,
83 pub cwd: PathBuf,
84 pub session_created_at: String,
85 pub prompt: String,
86 pub match_start: usize,
87 pub match_end: usize,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
91#[request(method = "_aether/session_preview", response = SessionPreviewResponse)]
92#[serde(rename_all = "camelCase")]
93pub struct SessionPreviewParams {
94 pub session_id: String,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
98#[serde(rename_all = "camelCase")]
99pub struct SessionPreviewResponse {
100 pub session_id: String,
101 pub cwd: PathBuf,
102 pub created_at: String,
103 pub model: String,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub selected_mode: Option<String>,
106 pub transcript: Vec<SessionPreviewTurn>,
107 pub tool_call_count: usize,
108 pub truncated: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "camelCase")]
113pub struct SessionPreviewTurn {
114 pub role: SessionPreviewRole,
115 pub text: String,
116}
117
118#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "camelCase")]
120pub enum SessionPreviewRole {
121 User,
122 Assistant,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
127#[request(method = "_aether/workspace_list", response = WorkspaceListResponse)]
128#[serde(rename_all = "camelCase")]
129pub struct WorkspaceListParams {
130 pub session_id: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
136#[serde(rename_all = "camelCase")]
137pub struct WorkspaceListResponse {
138 pub workspaces: Vec<WorkspaceEntry>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "camelCase")]
143pub struct WorkspaceEntry {
144 pub path: PathBuf,
145 pub is_current: bool,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
150#[request(method = "_aether/workspace_move", response = WorkspaceMoveResponse)]
151#[serde(rename_all = "camelCase")]
152pub struct WorkspaceMoveParams {
153 pub session_id: String,
154 pub target: WorkspaceMoveTarget,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(tag = "kind", rename_all = "camelCase")]
159pub enum WorkspaceMoveTarget {
160 Existing { path: PathBuf },
161 New { name: String },
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
165#[serde(rename_all = "camelCase")]
166pub struct WorkspaceMoveResponse {
167 pub new_cwd: PathBuf,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
171#[serde(rename_all = "camelCase")]
172pub struct SessionDisplayMeta {
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub model: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub selected_mode: Option<String>,
177}
178
179impl SessionDisplayMeta {
180 #[must_use]
181 pub fn new(model: impl Into<String>, selected_mode: Option<String>) -> Self {
182 Self { model: Some(model.into()), selected_mode }
183 }
184
185 #[must_use]
186 pub fn to_meta(&self) -> Meta {
187 to_meta(self, Some(AETHER_META_NAMESPACE)).unwrap_or_default()
188 }
189
190 #[must_use]
191 pub fn from_meta(meta: Option<&Meta>) -> Self {
192 from_meta(meta, Some(AETHER_META_NAMESPACE))
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
197#[serde(rename_all = "camelCase")]
198pub struct AetherCapabilities {
199 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
200 pub prompt_search: bool,
201 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
202 pub session_preview: bool,
203 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
204 pub workspace_move: bool,
205}
206
207impl AetherCapabilities {
208 #[must_use]
209 pub fn to_meta(self) -> Meta {
210 to_meta(&self, Some(AETHER_META_NAMESPACE)).unwrap_or_default()
211 }
212
213 #[must_use]
214 pub fn from_meta(meta: Option<&Meta>) -> Self {
215 from_meta(meta, Some(AETHER_META_NAMESPACE))
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
221#[notification(method = "_aether/mcp_event")]
222pub enum McpNotification {
223 ServerStatus { servers: Vec<McpServerStatusEntry> },
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
228#[notification(method = "_aether/mcp_request")]
229pub enum McpRequest {
230 Authenticate { session_id: String, server_name: String },
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)]
237#[notification(method = "_aether/sub_agent_progress")]
238pub struct SubAgentProgressParams {
239 pub parent_tool_id: String,
240 pub task_id: String,
241 pub agent_name: String,
242 pub event: SubAgentEvent,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
250pub enum SubAgentEvent {
251 ToolCall { request: SubAgentToolRequest },
252 ToolCallUpdate { update: SubAgentToolCallUpdate },
253 ToolResult { result: SubAgentToolResult },
254 ToolError { error: SubAgentToolError },
255 Done,
256 Other,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct SubAgentToolRequest {
261 pub id: String,
262 pub name: String,
263 pub arguments: String,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct SubAgentToolCallUpdate {
268 pub id: String,
269 pub chunk: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SubAgentToolResult {
274 pub id: String,
275 pub name: String,
276 pub result_meta: Option<ToolResultMeta>,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SubAgentToolError {
281 pub id: String,
282 pub name: String,
283}
284
285#[derive(Default, Serialize, Deserialize)]
286struct RemoteInitializationMeta {
287 remote: Option<RemoteServerInfo>,
288}
289
290#[cfg(test)]
291mod tests {
292 use agent_client_protocol::JsonRpcMessage;
293 use agent_client_protocol::schema::v2::AuthMethodAgent;
294
295 use super::*;
296
297 #[test]
298 fn wire_method_names_are_prefixed() {
299 assert_eq!(ContextClearedParams::default().method(), "_aether/context_cleared");
300 assert_eq!(AuthMethodsUpdatedParams { auth_methods: vec![] }.method(), "_aether/auth_methods_updated");
301 assert_eq!(McpNotification::ServerStatus { servers: vec![] }.method(), "_aether/mcp_event");
302 assert_eq!(
303 McpRequest::Authenticate { session_id: String::new(), server_name: String::new() }.method(),
304 "_aether/mcp_request"
305 );
306 assert_eq!(PromptSearchParams { query: String::new(), limit: None }.method(), "_aether/prompt_search");
307 assert_eq!(SessionPreviewParams { session_id: String::new() }.method(), "_aether/session_preview");
308 assert_eq!(WorkspaceListParams { session_id: String::new() }.method(), "_aether/workspace_list");
309 let move_params =
310 WorkspaceMoveParams { session_id: String::new(), target: WorkspaceMoveTarget::New { name: String::new() } };
311 assert_eq!(move_params.method(), "_aether/workspace_move");
312 }
313
314 #[test]
315 fn context_cleared_params_roundtrip() {
316 let params = ContextClearedParams::default();
317 let untyped = params.to_untyped_message().expect("serializable");
318 assert_eq!(untyped.method(), "_aether/context_cleared");
319 let parsed = ContextClearedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
320 assert_eq!(parsed, params);
321 }
322
323 #[test]
324 fn auth_methods_updated_roundtrip() {
325 let params = AuthMethodsUpdatedParams {
326 auth_methods: vec![
327 AuthMethod::Agent(AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
328 AuthMethod::Agent(AuthMethodAgent::new("openrouter", "OpenRouter")),
329 ],
330 };
331
332 let untyped = params.to_untyped_message().expect("serializable");
333 assert_eq!(untyped.method(), "_aether/auth_methods_updated");
334 let parsed = AuthMethodsUpdatedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
335 assert_eq!(parsed, params);
336 }
337
338 #[test]
339 fn mcp_request_authenticate_roundtrip() {
340 let msg = McpRequest::Authenticate {
341 session_id: "session-0".to_string(),
342 server_name: "my oauth server".to_string(),
343 };
344
345 let untyped = msg.to_untyped_message().expect("serializable");
346 assert_eq!(untyped.method(), "_aether/mcp_request");
347 let parsed = McpRequest::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
348 assert_eq!(parsed, msg);
349 }
350
351 #[test]
352 fn mcp_notification_server_status_roundtrip() {
353 let msg = McpNotification::ServerStatus {
354 servers: vec![
355 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
356 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
357 .with_auth_capability(McpServerAuthCapability::OAuth),
358 McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
359 ],
360 };
361
362 let untyped = msg.to_untyped_message().expect("serializable");
363 assert_eq!(untyped.method(), "_aether/mcp_event");
364 let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
365 assert_eq!(parsed, msg);
366 }
367
368 #[test]
369 fn sub_agent_progress_params_roundtrip() {
370 let params = SubAgentProgressParams {
371 parent_tool_id: "call_123".to_string(),
372 task_id: "task_abc".to_string(),
373 agent_name: "explorer".to_string(),
374 event: SubAgentEvent::Done,
375 };
376
377 let untyped = params.to_untyped_message().expect("serializable");
378 assert_eq!(untyped.method(), "_aether/sub_agent_progress");
379 }
380
381 #[test]
382 fn mcp_server_status_entry_serde_roundtrip() {
383 let entry = McpServerStatusEntry::new("test-server", McpServerStatus::Connected { tool_count: 3 })
384 .with_auth_capability(McpServerAuthCapability::OAuth);
385
386 let json = serde_json::to_string(&entry).unwrap();
387 assert!(json.contains("\"auth_capability\":\"OAuth\""));
388 assert!(json.contains("\"deferTools\":false"));
389 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
390 assert_eq!(parsed, entry);
391 assert!(!parsed.deferred_tools);
392 assert!(parsed.can_authenticate());
393 }
394
395 #[test]
396 fn mcp_server_status_entry_deferred_tools_serde_roundtrip() {
397 let entry = McpServerStatusEntry::new("math", McpServerStatus::NeedsOAuth)
398 .with_auth_capability(McpServerAuthCapability::OAuth)
399 .with_deferred_tools(true);
400
401 let json = serde_json::to_string(&entry).unwrap();
402 assert!(json.contains("\"deferTools\":true"));
403 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
404 assert_eq!(parsed, entry);
405 }
406
407 #[test]
408 fn deserialize_tool_call_event() {
409 let json = r#"{"ToolCall":{"request":{"id":"c1","name":"grep","arguments":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
410 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
411 assert!(matches!(event, SubAgentEvent::ToolCall { .. }));
412 }
413
414 #[test]
415 fn deserialize_tool_call_update_event() {
416 let json = r#"{"ToolCallUpdate":{"update":{"id":"c1","chunk":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
417 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
418 assert!(matches!(event, SubAgentEvent::ToolCallUpdate { .. }));
419 }
420
421 #[test]
422 fn deserialize_tool_result_event() {
423 let json = r#"{"ToolResult":{"result":{"id":"c1","name":"grep","result_meta":{"display":{"title":"Grep","value":"'test' in src (3 matches)"}}}}}"#;
424 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
425 match event {
426 SubAgentEvent::ToolResult { result } => {
427 let result_meta = result.result_meta.expect("expected result_meta");
428 assert_eq!(result_meta.display.title, "Grep");
429 }
430 other => panic!("Expected ToolResult, got {other:?}"),
431 }
432 }
433
434 #[test]
435 fn deserialize_tool_error_event() {
436 let json = r#"{"ToolError":{"error":{"id":"c1","name":"grep"}}}"#;
437 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
438 assert!(matches!(event, SubAgentEvent::ToolError { .. }));
439 }
440
441 #[test]
442 fn deserialize_done_event() {
443 let event: SubAgentEvent = serde_json::from_str(r#""Done""#).unwrap();
444 assert!(matches!(event, SubAgentEvent::Done));
445 }
446
447 #[test]
448 fn deserialize_other_variant() {
449 let event: SubAgentEvent = serde_json::from_str(r#""Other""#).unwrap();
450 assert!(matches!(event, SubAgentEvent::Other));
451 }
452
453 #[test]
454 fn tool_result_meta_map_roundtrip() {
455 let meta: ToolResultMeta = ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into();
456 let map = meta.clone().into_map();
457 let parsed = ToolResultMeta::from_map(&map).expect("should deserialize ToolResultMeta");
458 assert_eq!(parsed, meta);
459 }
460}