1use axum::{
8 body::Body,
9 extract::{Extension, Query, State},
10 http::{Request as HttpRequest, StatusCode},
11 middleware::{self, Next},
12 response::{IntoResponse, Response},
13 routing::{get, post},
14 Json, Router,
15};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19use std::net::SocketAddr;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::core::auth_generator::{AuthCache, GenContext};
24use crate::core::http;
25use crate::core::jwt::{self, JwtConfig, TokenClaims};
26use crate::core::keyring::Keyring;
27use crate::core::manifest::{ManifestRegistry, Provider, Tool};
28use crate::core::mcp_client;
29use crate::core::response;
30use crate::core::scope::ScopeConfig;
31use crate::core::skill::{self, SkillRegistry};
32use crate::core::skillati::{RemoteSkillMeta, SkillAtiClient, SkillAtiError};
33
34pub struct ProxyState {
36 pub registry: ManifestRegistry,
37 pub skill_registry: SkillRegistry,
38 pub keyring: Keyring,
39 pub jwt_config: Option<JwtConfig>,
41 pub jwks_json: Option<Value>,
43 pub auth_cache: AuthCache,
45}
46
47#[derive(Debug, Deserialize)]
50pub struct CallRequest {
51 pub tool_name: String,
52 #[serde(default = "default_args")]
56 pub args: Value,
57 #[serde(default)]
60 pub raw_args: Option<Vec<String>>,
61}
62
63fn default_args() -> Value {
64 Value::Object(serde_json::Map::new())
65}
66
67impl CallRequest {
68 fn args_as_map(&self) -> HashMap<String, Value> {
72 match &self.args {
73 Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
74 _ => HashMap::new(),
75 }
76 }
77
78 fn args_as_positional(&self) -> Vec<String> {
81 if let Some(ref raw) = self.raw_args {
83 return raw.clone();
84 }
85 match &self.args {
86 Value::Array(arr) => arr
88 .iter()
89 .map(|v| match v {
90 Value::String(s) => s.clone(),
91 other => other.to_string(),
92 })
93 .collect(),
94 Value::String(s) => s.split_whitespace().map(String::from).collect(),
96 Value::Object(map) => {
98 if let Some(Value::Array(pos)) = map.get("_positional") {
99 return pos
100 .iter()
101 .map(|v| match v {
102 Value::String(s) => s.clone(),
103 other => other.to_string(),
104 })
105 .collect();
106 }
107 let mut result = Vec::new();
109 for (k, v) in map {
110 result.push(format!("--{k}"));
111 match v {
112 Value::String(s) => result.push(s.clone()),
113 Value::Bool(true) => {} other => result.push(other.to_string()),
115 }
116 }
117 result
118 }
119 _ => Vec::new(),
120 }
121 }
122}
123
124#[derive(Debug, Serialize)]
125pub struct CallResponse {
126 pub result: Value,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub error: Option<String>,
129}
130
131#[derive(Debug, Deserialize)]
132pub struct HelpRequest {
133 pub query: String,
134 #[serde(default)]
135 pub tool: Option<String>,
136}
137
138#[derive(Debug, Serialize)]
139pub struct HelpResponse {
140 pub content: String,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub error: Option<String>,
143}
144
145#[derive(Debug, Serialize)]
146pub struct HealthResponse {
147 pub status: String,
148 pub version: String,
149 pub tools: usize,
150 pub providers: usize,
151 pub skills: usize,
152 pub auth: String,
153}
154
155#[derive(Debug, Deserialize)]
158pub struct SkillsQuery {
159 #[serde(default)]
160 pub category: Option<String>,
161 #[serde(default)]
162 pub provider: Option<String>,
163 #[serde(default)]
164 pub tool: Option<String>,
165 #[serde(default)]
166 pub search: Option<String>,
167}
168
169#[derive(Debug, Deserialize)]
170pub struct SkillDetailQuery {
171 #[serde(default)]
172 pub meta: Option<bool>,
173 #[serde(default)]
174 pub refs: Option<bool>,
175}
176
177#[derive(Debug, Deserialize)]
178pub struct SkillResolveRequest {
179 pub scopes: Vec<String>,
180 #[serde(default)]
182 pub include_content: bool,
183}
184
185#[derive(Debug, Deserialize)]
186pub struct SkillBundleBatchRequest {
187 pub names: Vec<String>,
188}
189
190#[derive(Debug, Deserialize, Default)]
191pub struct SkillAtiCatalogQuery {
192 #[serde(default)]
193 pub search: Option<String>,
194}
195
196#[derive(Debug, Deserialize, Default)]
197pub struct SkillAtiResourcesQuery {
198 #[serde(default)]
199 pub prefix: Option<String>,
200}
201
202#[derive(Debug, Deserialize)]
203pub struct SkillAtiFileQuery {
204 pub path: String,
205}
206
207#[derive(Debug, Deserialize)]
210pub struct ToolsQuery {
211 #[serde(default)]
212 pub provider: Option<String>,
213 #[serde(default)]
214 pub search: Option<String>,
215}
216
217fn scopes_for_request(claims: Option<&TokenClaims>, state: &ProxyState) -> ScopeConfig {
220 match claims {
221 Some(claims) => ScopeConfig::from_jwt(claims),
222 None if state.jwt_config.is_none() => ScopeConfig::unrestricted(),
223 None => ScopeConfig {
224 scopes: Vec::new(),
225 sub: String::new(),
226 expires_at: 0,
227 rate_config: None,
228 },
229 }
230}
231
232fn visible_tools_for_scopes<'a>(
233 state: &'a ProxyState,
234 scopes: &ScopeConfig,
235) -> Vec<(&'a Provider, &'a Tool)> {
236 crate::core::scope::filter_tools_by_scope(state.registry.list_public_tools(), scopes)
237}
238
239fn visible_skill_names(
240 state: &ProxyState,
241 scopes: &ScopeConfig,
242) -> std::collections::HashSet<String> {
243 skill::visible_skills(&state.skill_registry, &state.registry, scopes)
244 .into_iter()
245 .map(|skill| skill.name.clone())
246 .collect()
247}
248
249fn visible_remote_skill_names(
261 state: &ProxyState,
262 scopes: &ScopeConfig,
263 catalog: &[RemoteSkillMeta],
264) -> std::collections::HashSet<String> {
265 let mut visible: std::collections::HashSet<String> = std::collections::HashSet::new();
266 if catalog.is_empty() {
267 return visible;
268 }
269 if scopes.is_wildcard() {
270 for entry in catalog {
271 visible.insert(entry.name.clone());
272 }
273 return visible;
274 }
275
276 let allowed_tool_pairs: Vec<(String, String)> =
280 crate::core::scope::filter_tools_by_scope(state.registry.list_public_tools(), scopes)
281 .into_iter()
282 .map(|(p, t)| (p.name.clone(), t.name.clone()))
283 .collect();
284 let allowed_tool_names: std::collections::HashSet<&str> =
285 allowed_tool_pairs.iter().map(|(_, t)| t.as_str()).collect();
286 let allowed_provider_names: std::collections::HashSet<&str> =
287 allowed_tool_pairs.iter().map(|(p, _)| p.as_str()).collect();
288 let allowed_categories: std::collections::HashSet<String> = state
289 .registry
290 .list_providers()
291 .into_iter()
292 .filter(|p| allowed_provider_names.contains(p.name.as_str()))
293 .filter_map(|p| p.category.clone())
294 .collect();
295
296 for scope in &scopes.scopes {
298 if let Some(skill_name) = scope.strip_prefix("skill:") {
299 if catalog.iter().any(|e| e.name == skill_name) {
300 visible.insert(skill_name.to_string());
301 }
302 }
303 }
304
305 for entry in catalog {
309 if entry
310 .tools
311 .iter()
312 .any(|t| allowed_tool_names.contains(t.as_str()))
313 || entry
314 .providers
315 .iter()
316 .any(|p| allowed_provider_names.contains(p.as_str()))
317 || entry
318 .categories
319 .iter()
320 .any(|c| allowed_categories.contains(c))
321 {
322 visible.insert(entry.name.clone());
323 }
324 }
325
326 visible
327}
328
329async fn visible_skill_names_with_remote(
333 state: &ProxyState,
334 scopes: &ScopeConfig,
335 client: &SkillAtiClient,
336) -> Result<std::collections::HashSet<String>, SkillAtiError> {
337 let mut names = visible_skill_names(state, scopes);
338 let catalog = client.catalog().await?;
339 let remote = visible_remote_skill_names(state, scopes, &catalog);
340 names.extend(remote);
341 Ok(names)
342}
343
344async fn handle_call(
345 State(state): State<Arc<ProxyState>>,
346 req: HttpRequest<Body>,
347) -> impl IntoResponse {
348 let claims = req.extensions().get::<TokenClaims>().cloned();
350
351 let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
353 Ok(b) => b,
354 Err(e) => {
355 return (
356 StatusCode::BAD_REQUEST,
357 Json(CallResponse {
358 result: Value::Null,
359 error: Some(format!("Failed to read request body: {e}")),
360 }),
361 );
362 }
363 };
364
365 let call_req: CallRequest = match serde_json::from_slice(&body_bytes) {
366 Ok(r) => r,
367 Err(e) => {
368 return (
369 StatusCode::UNPROCESSABLE_ENTITY,
370 Json(CallResponse {
371 result: Value::Null,
372 error: Some(format!("Invalid request: {e}")),
373 }),
374 );
375 }
376 };
377
378 tracing::debug!(
379 tool = %call_req.tool_name,
380 args = ?call_req.args,
381 "POST /call"
382 );
383
384 let (provider, tool) = match state.registry.get_tool(&call_req.tool_name) {
387 Some(pt) => pt,
388 None => {
389 let mut resolved = None;
393 for (idx, _) in call_req.tool_name.match_indices('_') {
394 let candidate = format!(
395 "{}:{}",
396 &call_req.tool_name[..idx],
397 &call_req.tool_name[idx + 1..]
398 );
399 if let Some(pt) = state.registry.get_tool(&candidate) {
400 tracing::debug!(
401 original = %call_req.tool_name,
402 resolved = %candidate,
403 "resolved underscore tool name to colon format"
404 );
405 resolved = Some(pt);
406 break;
407 }
408 }
409
410 match resolved {
411 Some(pt) => pt,
412 None => {
413 return (
414 StatusCode::NOT_FOUND,
415 Json(CallResponse {
416 result: Value::Null,
417 error: Some(format!("Unknown tool: '{}'", call_req.tool_name)),
418 }),
419 );
420 }
421 }
422 }
423 };
424
425 if let Some(tool_scope) = &tool.scope {
427 let scopes = match &claims {
428 Some(c) => ScopeConfig::from_jwt(c),
429 None if state.jwt_config.is_none() => ScopeConfig::unrestricted(), None => {
431 return (
432 StatusCode::FORBIDDEN,
433 Json(CallResponse {
434 result: Value::Null,
435 error: Some("Authentication required — no JWT provided".into()),
436 }),
437 );
438 }
439 };
440
441 if !scopes.is_allowed(tool_scope) {
442 return (
443 StatusCode::FORBIDDEN,
444 Json(CallResponse {
445 result: Value::Null,
446 error: Some(format!(
447 "Access denied: '{}' is not in your scopes",
448 tool.name
449 )),
450 }),
451 );
452 }
453 }
454
455 {
457 let scopes = match &claims {
458 Some(c) => ScopeConfig::from_jwt(c),
459 None => ScopeConfig::unrestricted(),
460 };
461 if let Some(ref rate_config) = scopes.rate_config {
462 if let Err(e) = crate::core::rate::check_and_record(&call_req.tool_name, rate_config) {
463 return (
464 StatusCode::TOO_MANY_REQUESTS,
465 Json(CallResponse {
466 result: Value::Null,
467 error: Some(format!("{e}")),
468 }),
469 );
470 }
471 }
472 }
473
474 let gen_ctx = GenContext {
476 jwt_sub: claims
477 .as_ref()
478 .map(|c| c.sub.clone())
479 .unwrap_or_else(|| "dev".into()),
480 jwt_scope: claims
481 .as_ref()
482 .map(|c| c.scope.clone())
483 .unwrap_or_else(|| "*".into()),
484 tool_name: call_req.tool_name.clone(),
485 timestamp: crate::core::jwt::now_secs(),
486 };
487
488 let agent_sub = claims.as_ref().map(|c| c.sub.clone()).unwrap_or_default();
490 let job_id = claims
491 .as_ref()
492 .and_then(|c| c.job_id.clone())
493 .unwrap_or_default();
494 let sandbox_id = claims
495 .as_ref()
496 .and_then(|c| c.sandbox_id.clone())
497 .unwrap_or_default();
498 tracing::info!(
499 tool = %call_req.tool_name,
500 agent = %agent_sub,
501 job_id = %job_id,
502 sandbox_id = %sandbox_id,
503 "tool call"
504 );
505 let start = std::time::Instant::now();
506
507 let response = match provider.handler.as_str() {
508 "mcp" => {
509 let args_map = call_req.args_as_map();
510 match mcp_client::execute_with_gen(
511 provider,
512 &call_req.tool_name,
513 &args_map,
514 &state.keyring,
515 Some(&gen_ctx),
516 Some(&state.auth_cache),
517 )
518 .await
519 {
520 Ok(result) => (
521 StatusCode::OK,
522 Json(CallResponse {
523 result,
524 error: None,
525 }),
526 ),
527 Err(e) => (
528 StatusCode::BAD_GATEWAY,
529 Json(CallResponse {
530 result: Value::Null,
531 error: Some(format!("MCP error: {e}")),
532 }),
533 ),
534 }
535 }
536 "cli" => {
537 let positional = call_req.args_as_positional();
538 match crate::core::cli_executor::execute_with_gen(
539 provider,
540 &positional,
541 &state.keyring,
542 Some(&gen_ctx),
543 Some(&state.auth_cache),
544 )
545 .await
546 {
547 Ok(result) => (
548 StatusCode::OK,
549 Json(CallResponse {
550 result,
551 error: None,
552 }),
553 ),
554 Err(e) => (
555 StatusCode::BAD_GATEWAY,
556 Json(CallResponse {
557 result: Value::Null,
558 error: Some(format!("CLI error: {e}")),
559 }),
560 ),
561 }
562 }
563 _ => {
564 let args_map = call_req.args_as_map();
565 let raw_response = match http::execute_tool_with_gen(
566 provider,
567 tool,
568 &args_map,
569 &state.keyring,
570 Some(&gen_ctx),
571 Some(&state.auth_cache),
572 )
573 .await
574 {
575 Ok(resp) => resp,
576 Err(e) => {
577 let duration = start.elapsed();
578 write_proxy_audit(
579 &call_req,
580 &agent_sub,
581 claims.as_ref(),
582 duration,
583 Some(&e.to_string()),
584 );
585 return (
586 StatusCode::BAD_GATEWAY,
587 Json(CallResponse {
588 result: Value::Null,
589 error: Some(format!("Upstream API error: {e}")),
590 }),
591 );
592 }
593 };
594
595 let processed = match response::process_response(&raw_response, tool.response.as_ref())
596 {
597 Ok(p) => p,
598 Err(e) => {
599 let duration = start.elapsed();
600 write_proxy_audit(
601 &call_req,
602 &agent_sub,
603 claims.as_ref(),
604 duration,
605 Some(&e.to_string()),
606 );
607 return (
608 StatusCode::INTERNAL_SERVER_ERROR,
609 Json(CallResponse {
610 result: raw_response,
611 error: Some(format!("Response processing error: {e}")),
612 }),
613 );
614 }
615 };
616
617 (
618 StatusCode::OK,
619 Json(CallResponse {
620 result: processed,
621 error: None,
622 }),
623 )
624 }
625 };
626
627 let duration = start.elapsed();
628 let error_msg = response.1.error.as_deref();
629 write_proxy_audit(&call_req, &agent_sub, claims.as_ref(), duration, error_msg);
630
631 response
632}
633
634async fn handle_help(
635 State(state): State<Arc<ProxyState>>,
636 claims: Option<Extension<TokenClaims>>,
637 Json(req): Json<HelpRequest>,
638) -> impl IntoResponse {
639 tracing::debug!(query = %req.query, tool = ?req.tool, "POST /help");
640
641 let claims = claims.map(|Extension(claims)| claims);
642 let scopes = scopes_for_request(claims.as_ref(), &state);
643
644 let (llm_provider, llm_tool) = match state.registry.get_tool("_chat_completion") {
645 Some(pt) => pt,
646 None => {
647 return (
648 StatusCode::SERVICE_UNAVAILABLE,
649 Json(HelpResponse {
650 content: String::new(),
651 error: Some("No _llm.toml manifest found. Proxy help requires a configured LLM provider.".into()),
652 }),
653 );
654 }
655 };
656
657 let api_key = match llm_provider
658 .auth_key_name
659 .as_deref()
660 .and_then(|k| state.keyring.get(k))
661 {
662 Some(key) => key.to_string(),
663 None => {
664 return (
665 StatusCode::SERVICE_UNAVAILABLE,
666 Json(HelpResponse {
667 content: String::new(),
668 error: Some("LLM API key not found in keyring".into()),
669 }),
670 );
671 }
672 };
673
674 let resolved_skills = skill::resolve_skills(&state.skill_registry, &state.registry, &scopes);
675 let local_skills_section = if resolved_skills.is_empty() {
676 String::new()
677 } else {
678 format!(
679 "## Available Skills (methodology guides)\n{}",
680 skill::build_skill_context(&resolved_skills)
681 )
682 };
683 let remote_query = req
684 .tool
685 .as_ref()
686 .map(|tool| format!("{tool} {}", req.query))
687 .unwrap_or_else(|| req.query.clone());
688 let remote_skills_section =
689 build_remote_skillati_section(&state.keyring, &remote_query, 12).await;
690 let skills_section = merge_help_skill_sections(&[local_skills_section, remote_skills_section]);
691
692 let visible_tools = visible_tools_for_scopes(&state, &scopes);
694 let system_prompt = if let Some(ref tool_name) = req.tool {
695 match build_scoped_prompt(tool_name, &visible_tools, &skills_section) {
697 Some(prompt) => prompt,
698 None => {
699 return (
700 StatusCode::FORBIDDEN,
701 Json(HelpResponse {
702 content: String::new(),
703 error: Some(format!(
704 "Scope '{tool_name}' is not visible in your current scopes."
705 )),
706 }),
707 );
708 }
709 }
710 } else {
711 let tools_context = build_tool_context(&visible_tools);
712 HELP_SYSTEM_PROMPT
713 .replace("{tools}", &tools_context)
714 .replace("{skills_section}", &skills_section)
715 };
716
717 let request_body = serde_json::json!({
718 "model": "zai-glm-4.7",
719 "messages": [
720 {"role": "system", "content": system_prompt},
721 {"role": "user", "content": req.query}
722 ],
723 "max_completion_tokens": 1536,
724 "temperature": 0.3
725 });
726
727 let client = reqwest::Client::new();
728 let url = format!(
729 "{}{}",
730 llm_provider.base_url.trim_end_matches('/'),
731 llm_tool.endpoint
732 );
733
734 let response = match client
735 .post(&url)
736 .bearer_auth(&api_key)
737 .json(&request_body)
738 .send()
739 .await
740 {
741 Ok(r) => r,
742 Err(e) => {
743 return (
744 StatusCode::BAD_GATEWAY,
745 Json(HelpResponse {
746 content: String::new(),
747 error: Some(format!("LLM request failed: {e}")),
748 }),
749 );
750 }
751 };
752
753 if !response.status().is_success() {
754 let status = response.status();
755 let body = response.text().await.unwrap_or_default();
756 return (
757 StatusCode::BAD_GATEWAY,
758 Json(HelpResponse {
759 content: String::new(),
760 error: Some(format!("LLM API error ({status}): {body}")),
761 }),
762 );
763 }
764
765 let body: Value = match response.json().await {
766 Ok(b) => b,
767 Err(e) => {
768 return (
769 StatusCode::INTERNAL_SERVER_ERROR,
770 Json(HelpResponse {
771 content: String::new(),
772 error: Some(format!("Failed to parse LLM response: {e}")),
773 }),
774 );
775 }
776 };
777
778 let content = body
779 .pointer("/choices/0/message/content")
780 .and_then(|c| c.as_str())
781 .unwrap_or("No response from LLM")
782 .to_string();
783
784 (
785 StatusCode::OK,
786 Json(HelpResponse {
787 content,
788 error: None,
789 }),
790 )
791}
792
793async fn handle_health(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
794 let auth = if state.jwt_config.is_some() {
795 "jwt"
796 } else {
797 "disabled"
798 };
799
800 Json(HealthResponse {
801 status: "ok".into(),
802 version: env!("CARGO_PKG_VERSION").into(),
803 tools: state.registry.list_public_tools().len(),
804 providers: state.registry.list_providers().len(),
805 skills: state.skill_registry.skill_count(),
806 auth: auth.into(),
807 })
808}
809
810async fn handle_jwks(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
812 match &state.jwks_json {
813 Some(jwks) => (StatusCode::OK, Json(jwks.clone())),
814 None => (
815 StatusCode::NOT_FOUND,
816 Json(serde_json::json!({"error": "JWKS not configured"})),
817 ),
818 }
819}
820
821async fn handle_mcp(
826 State(state): State<Arc<ProxyState>>,
827 claims: Option<Extension<TokenClaims>>,
828 Json(msg): Json<Value>,
829) -> impl IntoResponse {
830 let claims = claims.map(|Extension(claims)| claims);
831 let scopes = scopes_for_request(claims.as_ref(), &state);
832 let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
833 let id = msg.get("id").cloned();
834 tracing::info!(
835 %method,
836 agent = claims.as_ref().map(|c| c.sub.as_str()).unwrap_or(""),
837 job_id = claims.as_ref().and_then(|c| c.job_id.as_deref()).unwrap_or(""),
838 sandbox_id = claims.as_ref().and_then(|c| c.sandbox_id.as_deref()).unwrap_or(""),
839 "mcp call"
840 );
841
842 match method {
843 "initialize" => {
844 let result = serde_json::json!({
845 "protocolVersion": "2025-03-26",
846 "capabilities": {
847 "tools": { "listChanged": false }
848 },
849 "serverInfo": {
850 "name": "ati-proxy",
851 "version": env!("CARGO_PKG_VERSION")
852 }
853 });
854 jsonrpc_success(id, result)
855 }
856
857 "notifications/initialized" => (StatusCode::ACCEPTED, Json(Value::Null)),
858
859 "tools/list" => {
860 let visible_tools = visible_tools_for_scopes(&state, &scopes);
861 let mcp_tools: Vec<Value> = visible_tools
862 .iter()
863 .map(|(_provider, tool)| {
864 serde_json::json!({
865 "name": tool.name,
866 "description": tool.description,
867 "inputSchema": tool.input_schema.clone().unwrap_or(serde_json::json!({
868 "type": "object",
869 "properties": {}
870 }))
871 })
872 })
873 .collect();
874
875 let result = serde_json::json!({
876 "tools": mcp_tools,
877 });
878 jsonrpc_success(id, result)
879 }
880
881 "tools/call" => {
882 let params = msg.get("params").cloned().unwrap_or(Value::Null);
883 let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
884 let arguments: HashMap<String, Value> = params
885 .get("arguments")
886 .and_then(|a| serde_json::from_value(a.clone()).ok())
887 .unwrap_or_default();
888
889 if tool_name.is_empty() {
890 return jsonrpc_error(id, -32602, "Missing tool name in params.name");
891 }
892
893 let (provider, _tool) = match state.registry.get_tool(tool_name) {
894 Some(pt) => pt,
895 None => {
896 return jsonrpc_error(id, -32602, &format!("Unknown tool: '{tool_name}'"));
897 }
898 };
899
900 if let Some(tool_scope) = &_tool.scope {
901 if !scopes.is_allowed(tool_scope) {
902 return jsonrpc_error(
903 id,
904 -32001,
905 &format!("Access denied: '{}' is not in your scopes", _tool.name),
906 );
907 }
908 }
909
910 tracing::debug!(%tool_name, provider = %provider.name, "MCP tools/call");
911
912 let mcp_gen_ctx = GenContext {
913 jwt_sub: claims
914 .as_ref()
915 .map(|claims| claims.sub.clone())
916 .unwrap_or_else(|| "dev".into()),
917 jwt_scope: claims
918 .as_ref()
919 .map(|claims| claims.scope.clone())
920 .unwrap_or_else(|| "*".into()),
921 tool_name: tool_name.to_string(),
922 timestamp: crate::core::jwt::now_secs(),
923 };
924
925 let result = if provider.is_mcp() {
926 mcp_client::execute_with_gen(
927 provider,
928 tool_name,
929 &arguments,
930 &state.keyring,
931 Some(&mcp_gen_ctx),
932 Some(&state.auth_cache),
933 )
934 .await
935 } else if provider.is_cli() {
936 let raw: Vec<String> = arguments
938 .iter()
939 .flat_map(|(k, v)| {
940 let val = match v {
941 Value::String(s) => s.clone(),
942 other => other.to_string(),
943 };
944 vec![format!("--{k}"), val]
945 })
946 .collect();
947 crate::core::cli_executor::execute_with_gen(
948 provider,
949 &raw,
950 &state.keyring,
951 Some(&mcp_gen_ctx),
952 Some(&state.auth_cache),
953 )
954 .await
955 .map_err(|e| mcp_client::McpError::Transport(e.to_string()))
956 } else {
957 match http::execute_tool_with_gen(
958 provider,
959 _tool,
960 &arguments,
961 &state.keyring,
962 Some(&mcp_gen_ctx),
963 Some(&state.auth_cache),
964 )
965 .await
966 {
967 Ok(val) => Ok(val),
968 Err(e) => Err(mcp_client::McpError::Transport(e.to_string())),
969 }
970 };
971
972 match result {
973 Ok(value) => {
974 let text = match &value {
975 Value::String(s) => s.clone(),
976 other => serde_json::to_string_pretty(other).unwrap_or_default(),
977 };
978 let mcp_result = serde_json::json!({
979 "content": [{"type": "text", "text": text}],
980 "isError": false,
981 });
982 jsonrpc_success(id, mcp_result)
983 }
984 Err(e) => {
985 let mcp_result = serde_json::json!({
986 "content": [{"type": "text", "text": format!("Error: {e}")}],
987 "isError": true,
988 });
989 jsonrpc_success(id, mcp_result)
990 }
991 }
992 }
993
994 _ => jsonrpc_error(id, -32601, &format!("Method not found: '{method}'")),
995 }
996}
997
998fn jsonrpc_success(id: Option<Value>, result: Value) -> (StatusCode, Json<Value>) {
999 (
1000 StatusCode::OK,
1001 Json(serde_json::json!({
1002 "jsonrpc": "2.0",
1003 "id": id,
1004 "result": result,
1005 })),
1006 )
1007}
1008
1009fn jsonrpc_error(id: Option<Value>, code: i64, message: &str) -> (StatusCode, Json<Value>) {
1010 (
1011 StatusCode::OK,
1012 Json(serde_json::json!({
1013 "jsonrpc": "2.0",
1014 "id": id,
1015 "error": {
1016 "code": code,
1017 "message": message,
1018 }
1019 })),
1020 )
1021}
1022
1023async fn handle_tools_list(
1029 State(state): State<Arc<ProxyState>>,
1030 claims: Option<Extension<TokenClaims>>,
1031 axum::extract::Query(query): axum::extract::Query<ToolsQuery>,
1032) -> impl IntoResponse {
1033 tracing::debug!(
1034 provider = ?query.provider,
1035 search = ?query.search,
1036 "GET /tools"
1037 );
1038
1039 let claims = claims.map(|Extension(claims)| claims);
1040 let scopes = scopes_for_request(claims.as_ref(), &state);
1041 let all_tools = visible_tools_for_scopes(&state, &scopes);
1042
1043 let tools: Vec<Value> = all_tools
1044 .iter()
1045 .filter(|(provider, tool)| {
1046 if let Some(ref p) = query.provider {
1047 if provider.name != *p {
1048 return false;
1049 }
1050 }
1051 if let Some(ref q) = query.search {
1052 let q = q.to_lowercase();
1053 let name_match = tool.name.to_lowercase().contains(&q);
1054 let desc_match = tool.description.to_lowercase().contains(&q);
1055 let tag_match = tool.tags.iter().any(|t| t.to_lowercase().contains(&q));
1056 if !name_match && !desc_match && !tag_match {
1057 return false;
1058 }
1059 }
1060 true
1061 })
1062 .map(|(provider, tool)| {
1063 serde_json::json!({
1064 "name": tool.name,
1065 "description": tool.description,
1066 "provider": provider.name,
1067 "method": format!("{:?}", tool.method),
1068 "tags": tool.tags,
1069 "skills": provider.skills,
1070 "input_schema": tool.input_schema,
1071 })
1072 })
1073 .collect();
1074
1075 (StatusCode::OK, Json(Value::Array(tools)))
1076}
1077
1078async fn handle_tool_info(
1080 State(state): State<Arc<ProxyState>>,
1081 claims: Option<Extension<TokenClaims>>,
1082 axum::extract::Path(name): axum::extract::Path<String>,
1083) -> impl IntoResponse {
1084 tracing::debug!(tool = %name, "GET /tools/:name");
1085
1086 let claims = claims.map(|Extension(claims)| claims);
1087 let scopes = scopes_for_request(claims.as_ref(), &state);
1088
1089 match state
1090 .registry
1091 .get_tool(&name)
1092 .filter(|(_, tool)| match &tool.scope {
1093 Some(scope) => scopes.is_allowed(scope),
1094 None => true,
1095 }) {
1096 Some((provider, tool)) => {
1097 let mut skills: Vec<String> = provider.skills.clone();
1099 for s in state.skill_registry.skills_for_tool(&tool.name) {
1100 if !skills.contains(&s.name) {
1101 skills.push(s.name.clone());
1102 }
1103 }
1104 for s in state.skill_registry.skills_for_provider(&provider.name) {
1105 if !skills.contains(&s.name) {
1106 skills.push(s.name.clone());
1107 }
1108 }
1109
1110 (
1111 StatusCode::OK,
1112 Json(serde_json::json!({
1113 "name": tool.name,
1114 "description": tool.description,
1115 "provider": provider.name,
1116 "method": format!("{:?}", tool.method),
1117 "endpoint": tool.endpoint,
1118 "tags": tool.tags,
1119 "hint": tool.hint,
1120 "skills": skills,
1121 "input_schema": tool.input_schema,
1122 "scope": tool.scope,
1123 })),
1124 )
1125 }
1126 None => (
1127 StatusCode::NOT_FOUND,
1128 Json(serde_json::json!({"error": format!("Tool '{name}' not found")})),
1129 ),
1130 }
1131}
1132
1133async fn handle_skills_list(
1138 State(state): State<Arc<ProxyState>>,
1139 claims: Option<Extension<TokenClaims>>,
1140 axum::extract::Query(query): axum::extract::Query<SkillsQuery>,
1141) -> impl IntoResponse {
1142 tracing::debug!(
1143 category = ?query.category,
1144 provider = ?query.provider,
1145 tool = ?query.tool,
1146 search = ?query.search,
1147 "GET /skills"
1148 );
1149
1150 let claims = claims.map(|Extension(claims)| claims);
1151 let scopes = scopes_for_request(claims.as_ref(), &state);
1152 let visible_names = visible_skill_names(&state, &scopes);
1153
1154 let skills: Vec<&skill::SkillMeta> = if let Some(search_query) = &query.search {
1155 state
1156 .skill_registry
1157 .search(search_query)
1158 .into_iter()
1159 .filter(|skill| visible_names.contains(&skill.name))
1160 .collect()
1161 } else if let Some(cat) = &query.category {
1162 state
1163 .skill_registry
1164 .skills_for_category(cat)
1165 .into_iter()
1166 .filter(|skill| visible_names.contains(&skill.name))
1167 .collect()
1168 } else if let Some(prov) = &query.provider {
1169 state
1170 .skill_registry
1171 .skills_for_provider(prov)
1172 .into_iter()
1173 .filter(|skill| visible_names.contains(&skill.name))
1174 .collect()
1175 } else if let Some(t) = &query.tool {
1176 state
1177 .skill_registry
1178 .skills_for_tool(t)
1179 .into_iter()
1180 .filter(|skill| visible_names.contains(&skill.name))
1181 .collect()
1182 } else {
1183 state
1184 .skill_registry
1185 .list_skills()
1186 .iter()
1187 .filter(|skill| visible_names.contains(&skill.name))
1188 .collect()
1189 };
1190
1191 let json: Vec<Value> = skills
1192 .iter()
1193 .map(|s| {
1194 serde_json::json!({
1195 "name": s.name,
1196 "version": s.version,
1197 "description": s.description,
1198 "tools": s.tools,
1199 "providers": s.providers,
1200 "categories": s.categories,
1201 "hint": s.hint,
1202 })
1203 })
1204 .collect();
1205
1206 (StatusCode::OK, Json(Value::Array(json)))
1207}
1208
1209async fn handle_skill_detail(
1210 State(state): State<Arc<ProxyState>>,
1211 claims: Option<Extension<TokenClaims>>,
1212 axum::extract::Path(name): axum::extract::Path<String>,
1213 axum::extract::Query(query): axum::extract::Query<SkillDetailQuery>,
1214) -> impl IntoResponse {
1215 tracing::debug!(%name, meta = ?query.meta, refs = ?query.refs, "GET /skills/:name");
1216
1217 let claims = claims.map(|Extension(claims)| claims);
1218 let scopes = scopes_for_request(claims.as_ref(), &state);
1219 let visible_names = visible_skill_names(&state, &scopes);
1220
1221 let skill_meta = match state
1222 .skill_registry
1223 .get_skill(&name)
1224 .filter(|skill| visible_names.contains(&skill.name))
1225 {
1226 Some(s) => s,
1227 None => {
1228 return (
1229 StatusCode::NOT_FOUND,
1230 Json(serde_json::json!({"error": format!("Skill '{name}' not found")})),
1231 );
1232 }
1233 };
1234
1235 if query.meta.unwrap_or(false) {
1236 return (
1237 StatusCode::OK,
1238 Json(serde_json::json!({
1239 "name": skill_meta.name,
1240 "version": skill_meta.version,
1241 "description": skill_meta.description,
1242 "author": skill_meta.author,
1243 "tools": skill_meta.tools,
1244 "providers": skill_meta.providers,
1245 "categories": skill_meta.categories,
1246 "keywords": skill_meta.keywords,
1247 "hint": skill_meta.hint,
1248 "depends_on": skill_meta.depends_on,
1249 "suggests": skill_meta.suggests,
1250 "license": skill_meta.license,
1251 "compatibility": skill_meta.compatibility,
1252 "allowed_tools": skill_meta.allowed_tools,
1253 "format": skill_meta.format,
1254 })),
1255 );
1256 }
1257
1258 let content = match state.skill_registry.read_content(&name) {
1259 Ok(c) => c,
1260 Err(e) => {
1261 return (
1262 StatusCode::INTERNAL_SERVER_ERROR,
1263 Json(serde_json::json!({"error": format!("Failed to read skill: {e}")})),
1264 );
1265 }
1266 };
1267
1268 let mut response = serde_json::json!({
1269 "name": skill_meta.name,
1270 "version": skill_meta.version,
1271 "description": skill_meta.description,
1272 "content": content,
1273 });
1274
1275 if query.refs.unwrap_or(false) {
1276 if let Ok(refs) = state.skill_registry.list_references(&name) {
1277 response["references"] = serde_json::json!(refs);
1278 }
1279 }
1280
1281 (StatusCode::OK, Json(response))
1282}
1283
1284async fn handle_skill_bundle(
1288 State(state): State<Arc<ProxyState>>,
1289 claims: Option<Extension<TokenClaims>>,
1290 axum::extract::Path(name): axum::extract::Path<String>,
1291) -> impl IntoResponse {
1292 tracing::debug!(skill = %name, "GET /skills/:name/bundle");
1293
1294 let claims = claims.map(|Extension(claims)| claims);
1295 let scopes = scopes_for_request(claims.as_ref(), &state);
1296 let visible_names = visible_skill_names(&state, &scopes);
1297 if !visible_names.contains(&name) {
1298 return (
1299 StatusCode::NOT_FOUND,
1300 Json(serde_json::json!({"error": format!("Skill '{name}' not found")})),
1301 );
1302 }
1303
1304 let files = match state.skill_registry.bundle_files(&name) {
1305 Ok(f) => f,
1306 Err(_) => {
1307 return (
1308 StatusCode::NOT_FOUND,
1309 Json(serde_json::json!({"error": format!("Skill '{name}' not found")})),
1310 );
1311 }
1312 };
1313
1314 let mut file_map = serde_json::Map::new();
1316 for (path, data) in &files {
1317 match std::str::from_utf8(data) {
1318 Ok(text) => {
1319 file_map.insert(path.clone(), Value::String(text.to_string()));
1320 }
1321 Err(_) => {
1322 use base64::Engine;
1324 let encoded = base64::engine::general_purpose::STANDARD.encode(data);
1325 file_map.insert(path.clone(), serde_json::json!({"base64": encoded}));
1326 }
1327 }
1328 }
1329
1330 (
1331 StatusCode::OK,
1332 Json(serde_json::json!({
1333 "name": name,
1334 "files": file_map,
1335 })),
1336 )
1337}
1338
1339async fn handle_skills_bundle_batch(
1343 State(state): State<Arc<ProxyState>>,
1344 claims: Option<Extension<TokenClaims>>,
1345 Json(req): Json<SkillBundleBatchRequest>,
1346) -> impl IntoResponse {
1347 const MAX_BATCH: usize = 50;
1348 if req.names.len() > MAX_BATCH {
1349 return (
1350 StatusCode::BAD_REQUEST,
1351 Json(
1352 serde_json::json!({"error": format!("batch size {} exceeds limit of {MAX_BATCH}", req.names.len())}),
1353 ),
1354 );
1355 }
1356
1357 tracing::debug!(names = ?req.names, "POST /skills/bundle");
1358
1359 let claims = claims.map(|Extension(claims)| claims);
1360 let scopes = scopes_for_request(claims.as_ref(), &state);
1361 let visible_names = visible_skill_names(&state, &scopes);
1362
1363 let mut result = serde_json::Map::new();
1364 let mut missing: Vec<String> = Vec::new();
1365
1366 for name in &req.names {
1367 if !visible_names.contains(name) {
1368 missing.push(name.clone());
1369 continue;
1370 }
1371 let files = match state.skill_registry.bundle_files(name) {
1372 Ok(f) => f,
1373 Err(_) => {
1374 missing.push(name.clone());
1375 continue;
1376 }
1377 };
1378
1379 let mut file_map = serde_json::Map::new();
1380 for (path, data) in &files {
1381 match std::str::from_utf8(data) {
1382 Ok(text) => {
1383 file_map.insert(path.clone(), Value::String(text.to_string()));
1384 }
1385 Err(_) => {
1386 use base64::Engine;
1387 let encoded = base64::engine::general_purpose::STANDARD.encode(data);
1388 file_map.insert(path.clone(), serde_json::json!({"base64": encoded}));
1389 }
1390 }
1391 }
1392
1393 result.insert(name.clone(), serde_json::json!({ "files": file_map }));
1394 }
1395
1396 (
1397 StatusCode::OK,
1398 Json(serde_json::json!({ "skills": result, "missing": missing })),
1399 )
1400}
1401
1402async fn handle_skills_resolve(
1403 State(state): State<Arc<ProxyState>>,
1404 claims: Option<Extension<TokenClaims>>,
1405 Json(req): Json<SkillResolveRequest>,
1406) -> impl IntoResponse {
1407 tracing::debug!(scopes = ?req.scopes, include_content = req.include_content, "POST /skills/resolve");
1408
1409 let include_content = req.include_content;
1410 let request_scopes = ScopeConfig {
1411 scopes: req.scopes,
1412 sub: String::new(),
1413 expires_at: 0,
1414 rate_config: None,
1415 };
1416 let claims = claims.map(|Extension(claims)| claims);
1417 let caller_scopes = scopes_for_request(claims.as_ref(), &state);
1418 let visible_names = visible_skill_names(&state, &caller_scopes);
1419
1420 let resolved: Vec<&skill::SkillMeta> =
1421 skill::resolve_skills(&state.skill_registry, &state.registry, &request_scopes)
1422 .into_iter()
1423 .filter(|skill| visible_names.contains(&skill.name))
1424 .collect();
1425
1426 let json: Vec<Value> = resolved
1427 .iter()
1428 .map(|s| {
1429 let mut entry = serde_json::json!({
1430 "name": s.name,
1431 "version": s.version,
1432 "description": s.description,
1433 "tools": s.tools,
1434 "providers": s.providers,
1435 "categories": s.categories,
1436 });
1437 if include_content {
1438 if let Ok(content) = state.skill_registry.read_content(&s.name) {
1439 entry["content"] = Value::String(content);
1440 }
1441 }
1442 entry
1443 })
1444 .collect();
1445
1446 (StatusCode::OK, Json(Value::Array(json)))
1447}
1448
1449fn skillati_client(keyring: &Keyring) -> Result<SkillAtiClient, SkillAtiError> {
1450 match SkillAtiClient::from_env(keyring)? {
1451 Some(client) => Ok(client),
1452 None => Err(SkillAtiError::NotConfigured),
1453 }
1454}
1455
1456async fn handle_skillati_catalog(
1457 State(state): State<Arc<ProxyState>>,
1458 claims: Option<Extension<TokenClaims>>,
1459 Query(query): Query<SkillAtiCatalogQuery>,
1460) -> impl IntoResponse {
1461 tracing::debug!(search = ?query.search, "GET /skillati/catalog");
1462
1463 let client = match skillati_client(&state.keyring) {
1464 Ok(client) => client,
1465 Err(err) => return skillati_error_response(err),
1466 };
1467
1468 let claims = claims.map(|Extension(c)| c);
1469 let scopes = scopes_for_request(claims.as_ref(), &state);
1470
1471 match client.catalog().await {
1472 Ok(catalog) => {
1473 let mut visible_names = visible_skill_names(&state, &scopes);
1477 visible_names.extend(visible_remote_skill_names(&state, &scopes, &catalog));
1478
1479 let mut skills: Vec<_> = catalog
1480 .into_iter()
1481 .filter(|s| visible_names.contains(&s.name))
1482 .collect();
1483 if let Some(search) = query.search.as_deref() {
1484 skills = SkillAtiClient::filter_catalog(&skills, search, 25);
1485 }
1486 (
1487 StatusCode::OK,
1488 Json(serde_json::json!({
1489 "skills": skills,
1490 })),
1491 )
1492 }
1493 Err(err) => skillati_error_response(err),
1494 }
1495}
1496
1497async fn handle_skillati_read(
1498 State(state): State<Arc<ProxyState>>,
1499 claims: Option<Extension<TokenClaims>>,
1500 axum::extract::Path(name): axum::extract::Path<String>,
1501) -> impl IntoResponse {
1502 tracing::debug!(%name, "GET /skillati/:name");
1503
1504 let client = match skillati_client(&state.keyring) {
1505 Ok(client) => client,
1506 Err(err) => return skillati_error_response(err),
1507 };
1508
1509 let claims = claims.map(|Extension(c)| c);
1510 let scopes = scopes_for_request(claims.as_ref(), &state);
1511 let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1512 Ok(v) => v,
1513 Err(err) => return skillati_error_response(err),
1514 };
1515 if !visible_names.contains(&name) {
1516 return skillati_error_response(SkillAtiError::SkillNotFound(name));
1517 }
1518
1519 match client.read_skill(&name).await {
1520 Ok(activation) => (StatusCode::OK, Json(serde_json::json!(activation))),
1521 Err(err) => skillati_error_response(err),
1522 }
1523}
1524
1525async fn handle_skillati_resources(
1526 State(state): State<Arc<ProxyState>>,
1527 claims: Option<Extension<TokenClaims>>,
1528 axum::extract::Path(name): axum::extract::Path<String>,
1529 Query(query): Query<SkillAtiResourcesQuery>,
1530) -> impl IntoResponse {
1531 tracing::debug!(%name, prefix = ?query.prefix, "GET /skillati/:name/resources");
1532
1533 let client = match skillati_client(&state.keyring) {
1534 Ok(client) => client,
1535 Err(err) => return skillati_error_response(err),
1536 };
1537
1538 let claims = claims.map(|Extension(c)| c);
1539 let scopes = scopes_for_request(claims.as_ref(), &state);
1540 let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1541 Ok(v) => v,
1542 Err(err) => return skillati_error_response(err),
1543 };
1544 if !visible_names.contains(&name) {
1545 return skillati_error_response(SkillAtiError::SkillNotFound(name));
1546 }
1547
1548 match client.list_resources(&name, query.prefix.as_deref()).await {
1549 Ok(resources) => (
1550 StatusCode::OK,
1551 Json(serde_json::json!({
1552 "name": name,
1553 "prefix": query.prefix,
1554 "resources": resources,
1555 })),
1556 ),
1557 Err(err) => skillati_error_response(err),
1558 }
1559}
1560
1561async fn handle_skillati_file(
1562 State(state): State<Arc<ProxyState>>,
1563 claims: Option<Extension<TokenClaims>>,
1564 axum::extract::Path(name): axum::extract::Path<String>,
1565 Query(query): Query<SkillAtiFileQuery>,
1566) -> impl IntoResponse {
1567 tracing::debug!(%name, path = %query.path, "GET /skillati/:name/file");
1568
1569 let client = match skillati_client(&state.keyring) {
1570 Ok(client) => client,
1571 Err(err) => return skillati_error_response(err),
1572 };
1573
1574 let claims = claims.map(|Extension(c)| c);
1575 let scopes = scopes_for_request(claims.as_ref(), &state);
1576 let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1577 Ok(v) => v,
1578 Err(err) => return skillati_error_response(err),
1579 };
1580 if !visible_names.contains(&name) {
1581 return skillati_error_response(SkillAtiError::SkillNotFound(name));
1582 }
1583
1584 match client.read_path(&name, &query.path).await {
1585 Ok(file) => (StatusCode::OK, Json(serde_json::json!(file))),
1586 Err(err) => skillati_error_response(err),
1587 }
1588}
1589
1590async fn handle_skillati_refs(
1591 State(state): State<Arc<ProxyState>>,
1592 claims: Option<Extension<TokenClaims>>,
1593 axum::extract::Path(name): axum::extract::Path<String>,
1594) -> impl IntoResponse {
1595 tracing::debug!(%name, "GET /skillati/:name/refs");
1596
1597 let client = match skillati_client(&state.keyring) {
1598 Ok(client) => client,
1599 Err(err) => return skillati_error_response(err),
1600 };
1601
1602 let claims = claims.map(|Extension(c)| c);
1603 let scopes = scopes_for_request(claims.as_ref(), &state);
1604 let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1605 Ok(v) => v,
1606 Err(err) => return skillati_error_response(err),
1607 };
1608 if !visible_names.contains(&name) {
1609 return skillati_error_response(SkillAtiError::SkillNotFound(name));
1610 }
1611
1612 match client.list_references(&name).await {
1613 Ok(references) => (
1614 StatusCode::OK,
1615 Json(serde_json::json!({
1616 "name": name,
1617 "references": references,
1618 })),
1619 ),
1620 Err(err) => skillati_error_response(err),
1621 }
1622}
1623
1624async fn handle_skillati_ref(
1625 State(state): State<Arc<ProxyState>>,
1626 claims: Option<Extension<TokenClaims>>,
1627 axum::extract::Path((name, reference)): axum::extract::Path<(String, String)>,
1628) -> impl IntoResponse {
1629 tracing::debug!(%name, %reference, "GET /skillati/:name/ref/:reference");
1630
1631 let client = match skillati_client(&state.keyring) {
1632 Ok(client) => client,
1633 Err(err) => return skillati_error_response(err),
1634 };
1635
1636 let claims = claims.map(|Extension(c)| c);
1637 let scopes = scopes_for_request(claims.as_ref(), &state);
1638 let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1639 Ok(v) => v,
1640 Err(err) => return skillati_error_response(err),
1641 };
1642 if !visible_names.contains(&name) {
1643 return skillati_error_response(SkillAtiError::SkillNotFound(name));
1644 }
1645
1646 match client.read_reference(&name, &reference).await {
1647 Ok(content) => (
1648 StatusCode::OK,
1649 Json(serde_json::json!({
1650 "name": name,
1651 "reference": reference,
1652 "content": content,
1653 })),
1654 ),
1655 Err(err) => skillati_error_response(err),
1656 }
1657}
1658
1659fn skillati_error_response(err: SkillAtiError) -> (StatusCode, Json<Value>) {
1660 let status = match &err {
1661 SkillAtiError::NotConfigured
1662 | SkillAtiError::UnsupportedRegistry(_)
1663 | SkillAtiError::MissingCredentials(_)
1664 | SkillAtiError::ProxyUrlRequired => StatusCode::SERVICE_UNAVAILABLE,
1665 SkillAtiError::SkillNotFound(_) | SkillAtiError::PathNotFound { .. } => {
1666 StatusCode::NOT_FOUND
1667 }
1668 SkillAtiError::InvalidPath(_) => StatusCode::BAD_REQUEST,
1669 SkillAtiError::Gcs(_)
1670 | SkillAtiError::ProxyRequest(_)
1671 | SkillAtiError::ProxyResponse(_) => StatusCode::BAD_GATEWAY,
1672 };
1673
1674 (
1675 status,
1676 Json(serde_json::json!({
1677 "error": err.to_string(),
1678 })),
1679 )
1680}
1681
1682async fn auth_middleware(
1690 State(state): State<Arc<ProxyState>>,
1691 mut req: HttpRequest<Body>,
1692 next: Next,
1693) -> Result<Response, StatusCode> {
1694 let path = req.uri().path();
1695
1696 if path == "/health" || path == "/.well-known/jwks.json" {
1698 return Ok(next.run(req).await);
1699 }
1700
1701 let jwt_config = match &state.jwt_config {
1703 Some(c) => c,
1704 None => return Ok(next.run(req).await),
1705 };
1706
1707 let auth_header = req
1709 .headers()
1710 .get("authorization")
1711 .and_then(|v| v.to_str().ok());
1712
1713 let token = match auth_header {
1714 Some(header) if header.starts_with("Bearer ") => &header[7..],
1715 _ => return Err(StatusCode::UNAUTHORIZED),
1716 };
1717
1718 match jwt::validate(token, jwt_config) {
1720 Ok(claims) => {
1721 tracing::debug!(sub = %claims.sub, scopes = %claims.scope, "JWT validated");
1722 req.extensions_mut().insert(claims);
1723 Ok(next.run(req).await)
1724 }
1725 Err(e) => {
1726 tracing::debug!(error = %e, "JWT validation failed");
1727 Err(StatusCode::UNAUTHORIZED)
1728 }
1729 }
1730}
1731
1732pub fn build_router(state: Arc<ProxyState>) -> Router {
1736 Router::new()
1737 .route("/call", post(handle_call))
1738 .route("/help", post(handle_help))
1739 .route("/mcp", post(handle_mcp))
1740 .route("/tools", get(handle_tools_list))
1741 .route("/tools/{name}", get(handle_tool_info))
1742 .route("/skills", get(handle_skills_list))
1743 .route("/skills/resolve", post(handle_skills_resolve))
1744 .route("/skills/bundle", post(handle_skills_bundle_batch))
1745 .route("/skills/{name}", get(handle_skill_detail))
1746 .route("/skills/{name}/bundle", get(handle_skill_bundle))
1747 .route("/skillati/catalog", get(handle_skillati_catalog))
1748 .route("/skillati/{name}", get(handle_skillati_read))
1749 .route("/skillati/{name}/resources", get(handle_skillati_resources))
1750 .route("/skillati/{name}/file", get(handle_skillati_file))
1751 .route("/skillati/{name}/refs", get(handle_skillati_refs))
1752 .route("/skillati/{name}/ref/{reference}", get(handle_skillati_ref))
1753 .route("/health", get(handle_health))
1754 .route("/.well-known/jwks.json", get(handle_jwks))
1755 .layer(middleware::from_fn_with_state(
1756 state.clone(),
1757 auth_middleware,
1758 ))
1759 .with_state(state)
1760}
1761
1762pub async fn run(
1766 port: u16,
1767 bind_addr: Option<String>,
1768 ati_dir: PathBuf,
1769 _verbose: bool,
1770 env_keys: bool,
1771) -> Result<(), Box<dyn std::error::Error>> {
1772 let manifests_dir = ati_dir.join("manifests");
1774 let mut registry = ManifestRegistry::load(&manifests_dir)?;
1775 let provider_count = registry.list_providers().len();
1776
1777 let keyring_source;
1779 let keyring = if env_keys {
1780 let kr = Keyring::from_env();
1782 let key_names = kr.key_names();
1783 tracing::info!(
1784 count = key_names.len(),
1785 "loaded API keys from ATI_KEY_* env vars"
1786 );
1787 for name in &key_names {
1788 tracing::debug!(key = %name, "env key loaded");
1789 }
1790 keyring_source = "env-vars (ATI_KEY_*)";
1791 kr
1792 } else {
1793 let keyring_path = ati_dir.join("keyring.enc");
1795 if keyring_path.exists() {
1796 if let Ok(kr) = Keyring::load(&keyring_path) {
1797 keyring_source = "keyring.enc (sealed key)";
1798 kr
1799 } else if let Ok(kr) = Keyring::load_local(&keyring_path, &ati_dir) {
1800 keyring_source = "keyring.enc (persistent key)";
1801 kr
1802 } else {
1803 tracing::warn!("keyring.enc exists but could not be decrypted");
1804 keyring_source = "empty (decryption failed)";
1805 Keyring::empty()
1806 }
1807 } else {
1808 let creds_path = ati_dir.join("credentials");
1809 if creds_path.exists() {
1810 match Keyring::load_credentials(&creds_path) {
1811 Ok(kr) => {
1812 keyring_source = "credentials (plaintext)";
1813 kr
1814 }
1815 Err(e) => {
1816 tracing::warn!(error = %e, "failed to load credentials");
1817 keyring_source = "empty (credentials error)";
1818 Keyring::empty()
1819 }
1820 }
1821 } else {
1822 tracing::warn!("no keyring.enc or credentials found — running without API keys");
1823 tracing::warn!("tools requiring authentication will fail");
1824 keyring_source = "empty (no auth)";
1825 Keyring::empty()
1826 }
1827 }
1828 };
1829
1830 mcp_client::discover_all_mcp_tools(&mut registry, &keyring).await;
1833
1834 let tool_count = registry.list_public_tools().len();
1835
1836 let mcp_providers: Vec<(String, String)> = registry
1838 .list_mcp_providers()
1839 .iter()
1840 .map(|p| (p.name.clone(), p.mcp_transport_type().to_string()))
1841 .collect();
1842 let mcp_count = mcp_providers.len();
1843 let openapi_providers: Vec<String> = registry
1844 .list_openapi_providers()
1845 .iter()
1846 .map(|p| p.name.clone())
1847 .collect();
1848 let openapi_count = openapi_providers.len();
1849
1850 let skills_dir = ati_dir.join("skills");
1852 let skill_registry = SkillRegistry::load(&skills_dir).unwrap_or_else(|e| {
1853 tracing::warn!(error = %e, "failed to load skills");
1854 SkillRegistry::load(std::path::Path::new("/nonexistent-fallback")).unwrap()
1855 });
1856
1857 if let Ok(registry_url) = std::env::var("ATI_SKILL_REGISTRY") {
1858 if registry_url.strip_prefix("gcs://").is_some() {
1859 tracing::info!(
1860 registry = %registry_url,
1861 "SkillATI remote registry configured for lazy reads"
1862 );
1863 } else {
1864 tracing::warn!(url = %registry_url, "SkillATI only supports gcs:// registries");
1865 }
1866 }
1867
1868 let skill_count = skill_registry.skill_count();
1869
1870 let jwt_config = match jwt::config_from_env() {
1872 Ok(config) => config,
1873 Err(e) => {
1874 tracing::warn!(error = %e, "JWT config error");
1875 None
1876 }
1877 };
1878
1879 let auth_status = if jwt_config.is_some() {
1880 "JWT enabled"
1881 } else {
1882 "DISABLED (no JWT keys configured)"
1883 };
1884
1885 let jwks_json = jwt_config.as_ref().and_then(|config| {
1887 config
1888 .public_key_pem
1889 .as_ref()
1890 .and_then(|pem| jwt::public_key_to_jwks(pem, config.algorithm, "ati-proxy-1").ok())
1891 });
1892
1893 let state = Arc::new(ProxyState {
1894 registry,
1895 skill_registry,
1896 keyring,
1897 jwt_config,
1898 jwks_json,
1899 auth_cache: AuthCache::new(),
1900 });
1901
1902 let app = build_router(state);
1903
1904 let addr: SocketAddr = if let Some(ref bind) = bind_addr {
1905 format!("{bind}:{port}").parse()?
1906 } else {
1907 SocketAddr::from(([127, 0, 0, 1], port))
1908 };
1909
1910 tracing::info!(
1911 version = env!("CARGO_PKG_VERSION"),
1912 %addr,
1913 auth = auth_status,
1914 ati_dir = %ati_dir.display(),
1915 tools = tool_count,
1916 providers = provider_count,
1917 mcp = mcp_count,
1918 openapi = openapi_count,
1919 skills = skill_count,
1920 keyring = keyring_source,
1921 "ATI proxy server starting"
1922 );
1923 for (name, transport) in &mcp_providers {
1924 tracing::info!(provider = %name, transport = %transport, "MCP provider");
1925 }
1926 for name in &openapi_providers {
1927 tracing::info!(provider = %name, "OpenAPI provider");
1928 }
1929
1930 let listener = tokio::net::TcpListener::bind(addr).await?;
1931 axum::serve(listener, app).await?;
1932
1933 Ok(())
1934}
1935
1936fn write_proxy_audit(
1938 call_req: &CallRequest,
1939 agent_sub: &str,
1940 claims: Option<&TokenClaims>,
1941 duration: std::time::Duration,
1942 error: Option<&str>,
1943) {
1944 let entry = crate::core::audit::AuditEntry {
1945 ts: chrono::Utc::now().to_rfc3339(),
1946 tool: call_req.tool_name.clone(),
1947 args: crate::core::audit::sanitize_args(&call_req.args),
1948 status: if error.is_some() {
1949 crate::core::audit::AuditStatus::Error
1950 } else {
1951 crate::core::audit::AuditStatus::Ok
1952 },
1953 duration_ms: duration.as_millis() as u64,
1954 agent_sub: agent_sub.to_string(),
1955 job_id: claims.and_then(|c| c.job_id.clone()),
1956 sandbox_id: claims.and_then(|c| c.sandbox_id.clone()),
1957 error: error.map(|s| s.to_string()),
1958 exit_code: None,
1959 };
1960 let _ = crate::core::audit::append(&entry);
1961}
1962
1963const HELP_SYSTEM_PROMPT: &str = r#"You are a helpful assistant for an AI agent that uses external tools via the `ati` CLI.
1966
1967## Available Tools
1968{tools}
1969
1970{skills_section}
1971
1972Answer the agent's question naturally, like a knowledgeable colleague would. Keep it short but useful:
1973
1974- Explain which tools to use and why, with `ati run` commands showing realistic parameter values
1975- If multiple steps are needed, walk through them briefly in order
1976- Mention important gotchas or parameter choices that matter
1977- If skills are relevant, suggest `ati skill show <name>` for the full methodology
1978
1979Keep your answer concise — a few short paragraphs with embedded code blocks. Only recommend tools from the list above."#;
1980
1981async fn build_remote_skillati_section(keyring: &Keyring, query: &str, limit: usize) -> String {
1982 let client = match SkillAtiClient::from_env(keyring) {
1983 Ok(Some(client)) => client,
1984 Ok(None) => return String::new(),
1985 Err(err) => {
1986 tracing::warn!(error = %err, "failed to initialize SkillATI catalog for proxy help");
1987 return String::new();
1988 }
1989 };
1990
1991 let catalog = match client.catalog().await {
1992 Ok(catalog) => catalog,
1993 Err(err) => {
1994 tracing::warn!(error = %err, "failed to load SkillATI catalog for proxy help");
1995 return String::new();
1996 }
1997 };
1998
1999 let matched = SkillAtiClient::filter_catalog(&catalog, query, limit);
2000 if matched.is_empty() {
2001 return String::new();
2002 }
2003
2004 render_remote_skillati_section(&matched, catalog.len())
2005}
2006
2007fn render_remote_skillati_section(skills: &[RemoteSkillMeta], total_catalog: usize) -> String {
2008 let mut section = String::from("## Remote Skills Available Via SkillATI\n\n");
2009 section.push_str(
2010 "These skills are available remotely from the SkillATI registry. They are not installed locally. Activate one on demand with `ati skillati read <name>`, inspect bundled paths with `ati skillati resources <name>`, and fetch specific files with `ati skillati cat <name> <path>`.\n\n",
2011 );
2012
2013 for skill in skills {
2014 section.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
2015 }
2016
2017 if total_catalog > skills.len() {
2018 section.push_str(&format!(
2019 "\nOnly the most relevant {} remote skills are shown here.\n",
2020 skills.len()
2021 ));
2022 }
2023
2024 section
2025}
2026
2027fn merge_help_skill_sections(sections: &[String]) -> String {
2028 sections
2029 .iter()
2030 .filter_map(|section| {
2031 let trimmed = section.trim();
2032 if trimmed.is_empty() {
2033 None
2034 } else {
2035 Some(trimmed.to_string())
2036 }
2037 })
2038 .collect::<Vec<_>>()
2039 .join("\n\n")
2040}
2041
2042fn build_tool_context(
2043 tools: &[(
2044 &crate::core::manifest::Provider,
2045 &crate::core::manifest::Tool,
2046 )],
2047) -> String {
2048 let mut summaries = Vec::new();
2049 for (provider, tool) in tools {
2050 let mut summary = if let Some(cat) = &provider.category {
2051 format!(
2052 "- **{}** (provider: {}, category: {}): {}",
2053 tool.name, provider.name, cat, tool.description
2054 )
2055 } else {
2056 format!(
2057 "- **{}** (provider: {}): {}",
2058 tool.name, provider.name, tool.description
2059 )
2060 };
2061 if !tool.tags.is_empty() {
2062 summary.push_str(&format!("\n Tags: {}", tool.tags.join(", ")));
2063 }
2064 if provider.is_cli() && tool.input_schema.is_none() {
2066 let cmd = provider.cli_command.as_deref().unwrap_or("?");
2067 summary.push_str(&format!(
2068 "\n Usage: `ati run {} -- <args>` (passthrough to `{}`)",
2069 tool.name, cmd
2070 ));
2071 } else if let Some(schema) = &tool.input_schema {
2072 if let Some(props) = schema.get("properties") {
2073 if let Some(obj) = props.as_object() {
2074 let params: Vec<String> = obj
2075 .iter()
2076 .filter(|(_, v)| {
2077 v.get("x-ati-param-location").is_none()
2078 || v.get("description").is_some()
2079 })
2080 .map(|(k, v)| {
2081 let type_str =
2082 v.get("type").and_then(|t| t.as_str()).unwrap_or("string");
2083 let desc = v.get("description").and_then(|d| d.as_str()).unwrap_or("");
2084 format!(" --{k} ({type_str}): {desc}")
2085 })
2086 .collect();
2087 if !params.is_empty() {
2088 summary.push_str("\n Parameters:\n");
2089 summary.push_str(¶ms.join("\n"));
2090 }
2091 }
2092 }
2093 }
2094 summaries.push(summary);
2095 }
2096 summaries.join("\n\n")
2097}
2098
2099fn build_scoped_prompt(
2103 scope_name: &str,
2104 visible_tools: &[(&Provider, &Tool)],
2105 skills_section: &str,
2106) -> Option<String> {
2107 if let Some((provider, tool)) = visible_tools
2109 .iter()
2110 .find(|(_, tool)| tool.name == scope_name)
2111 {
2112 let mut details = format!(
2113 "**Name**: `{}`\n**Provider**: {} (handler: {})\n**Description**: {}\n",
2114 tool.name, provider.name, provider.handler, tool.description
2115 );
2116 if let Some(cat) = &provider.category {
2117 details.push_str(&format!("**Category**: {}\n", cat));
2118 }
2119 if provider.is_cli() {
2120 let cmd = provider.cli_command.as_deref().unwrap_or("?");
2121 details.push_str(&format!(
2122 "\n**Usage**: `ati run {} -- <args>` (passthrough to `{}`)\n",
2123 tool.name, cmd
2124 ));
2125 } else if let Some(schema) = &tool.input_schema {
2126 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
2127 let required: Vec<String> = schema
2128 .get("required")
2129 .and_then(|r| r.as_array())
2130 .map(|arr| {
2131 arr.iter()
2132 .filter_map(|v| v.as_str().map(|s| s.to_string()))
2133 .collect()
2134 })
2135 .unwrap_or_default();
2136 details.push_str("\n**Parameters**:\n");
2137 for (key, val) in props {
2138 let type_str = val.get("type").and_then(|t| t.as_str()).unwrap_or("string");
2139 let desc = val
2140 .get("description")
2141 .and_then(|d| d.as_str())
2142 .unwrap_or("");
2143 let req = if required.contains(key) {
2144 " **(required)**"
2145 } else {
2146 ""
2147 };
2148 details.push_str(&format!("- `--{key}` ({type_str}{req}): {desc}\n"));
2149 }
2150 }
2151 }
2152
2153 let prompt = format!(
2154 "You are an expert assistant for the `{}` tool, accessed via the `ati` CLI.\n\n\
2155 ## Tool Details\n{}\n\n{}\n\n\
2156 Answer the agent's question about this specific tool. Provide exact commands, explain flags and options, and give practical examples. Be concise and actionable.",
2157 tool.name, details, skills_section
2158 );
2159 return Some(prompt);
2160 }
2161
2162 let tools: Vec<(&Provider, &Tool)> = visible_tools
2164 .iter()
2165 .copied()
2166 .filter(|(provider, _)| provider.name == scope_name)
2167 .collect();
2168 if !tools.is_empty() {
2169 let tools_context = build_tool_context(&tools);
2170 let prompt = format!(
2171 "You are an expert assistant for the `{}` provider's tools, accessed via the `ati` CLI.\n\n\
2172 ## Tools in provider `{}`\n{}\n\n{}\n\n\
2173 Answer the agent's question about these tools. Provide exact `ati run` commands, explain parameters, and give practical examples. Be concise and actionable.",
2174 scope_name, scope_name, tools_context, skills_section
2175 );
2176 return Some(prompt);
2177 }
2178
2179 None
2180}