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