Skip to main content

lean_ctx/core/a2a/
agent_card.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct AgentCard {
5    pub name: String,
6    pub description: String,
7    pub url: Option<String>,
8    pub version: String,
9    pub protocol_version: String,
10    pub capabilities: AgentCapabilities,
11    pub skills: Vec<AgentSkill>,
12    pub authentication: AuthenticationInfo,
13    pub default_input_modes: Vec<String>,
14    pub default_output_modes: Vec<String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AgentCapabilities {
19    pub streaming: bool,
20    pub push_notifications: bool,
21    pub state_transition_history: bool,
22    pub tools: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AgentSkill {
27    pub id: String,
28    pub name: String,
29    pub description: String,
30    pub tags: Vec<String>,
31    pub examples: Vec<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AuthenticationInfo {
36    pub schemes: Vec<String>,
37}
38
39/// `auth_required` mirrors the HTTP server's bearer-token middleware, so the
40/// card never tells a client it may call a token-protected server without one
41/// (#1913).
42pub fn generate_agent_card(
43    tools: &[String],
44    version: &str,
45    port: Option<u16>,
46    auth_required: bool,
47) -> AgentCard {
48    let url = port.map(|p| format!("http://127.0.0.1:{p}"));
49    let scheme = if auth_required { "bearer" } else { "none" };
50
51    AgentCard {
52        name: "lean-ctx".to_string(),
53        description: "Context Engineering Infrastructure Layer — intelligent compression, \
54                       code knowledge graph, structured agent memory, and semantic code search \
55                       via MCP"
56            .to_string(),
57        url,
58        version: version.to_string(),
59        protocol_version: "0.1.0".to_string(),
60        capabilities: AgentCapabilities {
61            streaming: false,
62            push_notifications: false,
63            state_transition_history: true,
64            tools: tools.to_vec(),
65        },
66        skills: build_skills(),
67        authentication: AuthenticationInfo {
68            schemes: vec![scheme.to_string()],
69        },
70        default_input_modes: vec!["text/plain".to_string(), "application/json".to_string()],
71        default_output_modes: vec!["text/plain".to_string(), "application/json".to_string()],
72    }
73}
74
75fn build_skills() -> Vec<AgentSkill> {
76    vec![
77        AgentSkill {
78            id: "context-compression".to_string(),
79            name: "Context Compression".to_string(),
80            description: "Intelligent file and shell output compression with 8 modes, \
81                           neural token pipeline, and LITM-aware positioning"
82                .to_string(),
83            tags: vec![
84                "compression".to_string(),
85                "tokens".to_string(),
86                "optimization".to_string(),
87            ],
88            examples: vec![
89                "Read a file with automatic mode selection".to_string(),
90                "Compress shell output preserving key information".to_string(),
91            ],
92        },
93        AgentSkill {
94            id: "code-knowledge-graph".to_string(),
95            name: "Code Knowledge Graph".to_string(),
96            description: "Property graph with tree-sitter deep queries, import resolution, \
97                           impact analysis, and architecture detection"
98                .to_string(),
99            tags: vec![
100                "graph".to_string(),
101                "analysis".to_string(),
102                "architecture".to_string(),
103            ],
104            examples: vec![
105                "What breaks if I change function X?".to_string(),
106                "Show me the architecture clusters".to_string(),
107            ],
108        },
109        AgentSkill {
110            id: "agent-memory".to_string(),
111            name: "Structured Agent Memory".to_string(),
112            description: "Semantic project knowledge with cross-session persistence, \
113                           embedding-based recall, and lifecycle management"
114                .to_string(),
115            tags: vec![
116                "memory".to_string(),
117                "knowledge".to_string(),
118                "persistence".to_string(),
119            ],
120            examples: vec![
121                "Remember a project pattern for future sessions".to_string(),
122                "Recall what happened last time I deployed".to_string(),
123            ],
124        },
125        AgentSkill {
126            id: "semantic-search".to_string(),
127            name: "Semantic Code Search".to_string(),
128            description: "Hybrid BM25 + dense embedding search with tree-sitter AST-aware \
129                           chunking and reciprocal rank fusion"
130                .to_string(),
131            tags: vec![
132                "search".to_string(),
133                "embeddings".to_string(),
134                "code".to_string(),
135            ],
136            examples: vec![
137                "Find code related to authentication handling".to_string(),
138                "Search for error recovery patterns".to_string(),
139            ],
140        },
141    ]
142}
143
144/// Build an agent card from the current runtime state for HTTP endpoints.
145pub fn build_agent_card(project_root: &str, auth_required: bool) -> serde_json::Value {
146    let version = env!("CARGO_PKG_VERSION");
147    let card = generate_agent_card(&default_tool_list(), version, None, auth_required);
148
149    serde_json::json!({
150        "name": card.name,
151        "description": card.description,
152        "url": card.url,
153        "version": card.version,
154        "protocolVersion": card.protocol_version,
155        "provider": {
156            "organization": "LeanCTX",
157            "url": "https://leanctx.com"
158        },
159        "documentationUrl": "https://leanctx.com/docs",
160        "capabilities": {
161            "streaming": card.capabilities.streaming,
162            "pushNotifications": card.capabilities.push_notifications,
163            "stateTransitionHistory": card.capabilities.state_transition_history,
164            "tools": card.capabilities.tools,
165        },
166        "skills": card.skills.iter().map(|s| serde_json::json!({
167            "id": s.id,
168            "name": s.name,
169            "description": s.description,
170            "tags": s.tags,
171            "examples": s.examples,
172            "inputModes": ["text/plain", "application/json"],
173            "outputModes": ["text/plain", "application/json"],
174        })).collect::<Vec<_>>(),
175        "authentication": {
176            "schemes": card.authentication.schemes,
177        },
178        "defaultInputModes": card.default_input_modes,
179        "defaultOutputModes": card.default_output_modes,
180        "supportsAuthenticatedExtendedCard": false,
181        "projectRoot": project_root,
182    })
183}
184
185/// Keeps the tools of `names` that the MCP server publishes (#1913).
186///
187/// Discovery cards must never list a tool that `tools/list` hides: local
188/// collaboration substrate (ctx_agent, ctx_handoff, …), deprecated aliases,
189/// and tools compiled out by a feature flag stay off every card.
190pub fn advertised_tools(names: &[&str]) -> Vec<String> {
191    let registered = crate::core::tool_catalog::registered_tool_names();
192    names
193        .iter()
194        .filter(|name| {
195            registered.iter().any(|r| r == *name)
196                && crate::core::dynamic_tools::is_publicly_advertised_tool(name)
197        })
198        .map(|name| (*name).to_string())
199        .collect()
200}
201
202fn default_tool_list() -> Vec<String> {
203    advertised_tools(&[
204        "ctx_read",
205        "ctx_shell",
206        "ctx_search",
207        "ctx_tree",
208        "ctx_session",
209        "ctx_knowledge",
210        "ctx_compose",
211        "ctx_graph",
212        "ctx_pack",
213    ])
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn generates_valid_card() {
222        let tools = vec!["ctx_read".to_string(), "ctx_shell".to_string()];
223        let card = generate_agent_card(&tools, "3.0.0", Some(3344), false);
224
225        assert_eq!(card.name, "lean-ctx");
226        assert_eq!(card.capabilities.tools.len(), 2);
227        assert_eq!(card.skills.len(), 4);
228        assert_eq!(card.url, Some("http://127.0.0.1:3344".to_string()));
229        assert_eq!(card.authentication.schemes, vec!["none"]);
230    }
231
232    #[test]
233    fn card_serializes_to_valid_json() {
234        let card = generate_agent_card(&["ctx_read".to_string()], "3.0.0", None, false);
235        let json = serde_json::to_string_pretty(&card).unwrap();
236        assert!(json.contains("lean-ctx"));
237        assert!(json.contains("context-compression"));
238
239        let parsed: AgentCard = serde_json::from_str(&json).unwrap();
240        assert_eq!(parsed.name, card.name);
241    }
242
243    // #1913: a token-protected server must not advertise anonymous access.
244    #[test]
245    fn auth_scheme_follows_the_server_token() {
246        assert_eq!(
247            build_agent_card("p", true)["authentication"]["schemes"],
248            serde_json::json!(["bearer"])
249        );
250        assert_eq!(
251            build_agent_card("p", false)["authentication"]["schemes"],
252            serde_json::json!(["none"])
253        );
254    }
255
256    // #1913: every advertised tool is one `tools/list` publishes, and the card
257    // no longer claims the local-only coordination substrate.
258}