1use axum::{
56 extract::{Path, State},
57 http::StatusCode,
58 response::{IntoResponse, Response},
59 routing::{get, post},
60 Json, Router,
61};
62use rmcp::transport::streamable_http_server::{
63 session::local::LocalSessionManager, StreamableHttpService,
64};
65use serde::Serialize;
66use std::sync::Arc;
67use tower_http::cors::{Any, CorsLayer};
68
69use crate::config::Config;
70use crate::mcp::McpBridge;
71use crate::profile_script::{load_profile_definitions, LuaProfileAdapter};
72use crate::profiles::{ProfileInfo, ProfileRegistry};
73use crate::registry::RegistryManager;
74use crate::tool_script::{load_tool_definitions, validate_params, LuaToolAdapter, ToolInfo};
75use crate::traits::{ToolContext, ToolRegistry};
76use crate::workspace::{RouterError, ServerMode, WorkspaceRouter};
77
78#[derive(Clone)]
80struct AppState {
81 router: Arc<WorkspaceRouter>,
84 mode: ServerMode,
86 tools: Arc<ToolRegistry>,
88 profiles: Arc<ProfileRegistry>,
90}
91
92type ExtState = (Arc<ToolRegistry>, Arc<ProfileRegistry>);
94
95#[allow(dead_code)] pub async fn run_server(config: &Config) -> anyhow::Result<()> {
113 run_server_with_extensions(
114 config,
115 Arc::new(ToolRegistry::new()),
116 Arc::new(ProfileRegistry::new()),
117 )
118 .await
119}
120
121#[allow(dead_code)] pub async fn run_server_with_extensions(
148 config: &Config,
149 extra_tools: Arc<ToolRegistry>,
150 extra_profiles: Arc<ProfileRegistry>,
151) -> anyhow::Result<()> {
152 run_server_with_resources(config, extra_tools, extra_profiles, &[]).await
153}
154
155pub async fn run_server_with_resources(
159 config: &Config,
160 extra_tools: Arc<ToolRegistry>,
161 extra_profiles: Arc<ProfileRegistry>,
162 directories: &[crate::agent_resource::ResourceDirectory],
163) -> anyhow::Result<()> {
164 let bind_addr = config.server.bind.clone();
165 let config = Arc::new(config.clone());
166
167 let mut tool_registry = ToolRegistry::with_builtins();
169
170 let lua_defs = load_tool_definitions(&config)?;
172 let configured_tool_names: Vec<String> = lua_defs.iter().map(|d| d.name.clone()).collect();
173 for def in lua_defs {
174 tool_registry.register(Box::new(LuaToolAdapter::new(def, config.clone())));
175 }
176
177 let reg_mgr = RegistryManager::from_config(&config);
179 for ext in reg_mgr.list_tools() {
180 if configured_tool_names.iter().any(|n| n == &ext.name) {
181 continue;
182 }
183 if !ext.script_path.exists() {
184 continue;
185 }
186 let tool_cfg = crate::config::ScriptToolConfig {
187 path: ext.script_path.clone(),
188 timeout: 30,
189 extra: toml::Table::new(),
190 };
191 match crate::tool_script::load_single_tool(&ext.name, &tool_cfg) {
192 Ok(def) => {
193 tool_registry.register(Box::new(LuaToolAdapter::new(def, config.clone())));
194 }
195 Err(e) => {
196 eprintln!(
197 "Warning: failed to load registry tool '{}': {}",
198 ext.name, e
199 );
200 }
201 }
202 }
203
204 let tool_count = tool_registry.len() + extra_tools.len();
206 if tool_count > 3 {
207 println!("Registered {} tools:", tool_count);
208 for t in tool_registry.tools() {
209 let tag = if t.is_builtin() { "builtin" } else { "lua" };
210 println!(" POST /tools/{} — {} ({})", t.name(), t.description(), tag);
211 }
212 for t in extra_tools.tools() {
213 println!(" POST /tools/{} — {} (rust)", t.name(), t.description());
214 }
215 }
216
217 let mut profile_registry = ProfileRegistry::from_config(&config)?;
219
220 let lua_profiles = load_profile_definitions(&config)?;
222 let configured_profile_names: Vec<String> =
223 lua_profiles.iter().map(|d| d.name.clone()).collect();
224 for def in lua_profiles {
225 profile_registry.register(Box::new(LuaProfileAdapter::new(def, config.clone())));
226 }
227
228 for ext in reg_mgr.list_profiles() {
230 if configured_profile_names.iter().any(|n| n == &ext.name) {
231 continue;
232 }
233 if !ext.script_path.exists() {
234 continue;
235 }
236 if ext.script_path.extension().is_some_and(|e| e == "lua") {
237 let profile_cfg = crate::config::ScriptProfileConfig {
238 path: ext.script_path.clone(),
239 timeout: 30,
240 extra: toml::Table::new(),
241 };
242 match crate::profile_script::load_single_profile(&ext.name, &profile_cfg) {
243 Ok(def) => {
244 profile_registry
245 .register(Box::new(LuaProfileAdapter::new(def, config.clone())));
246 }
247 Err(e) => {
248 eprintln!(
249 "Warning: failed to load registry profile '{}': {}",
250 ext.name, e
251 );
252 }
253 }
254 }
255 }
256
257 register_resource_prompts(
258 config.as_ref(),
259 directories,
260 &mut profile_registry,
261 &extra_profiles,
262 )?;
263
264 let profile_count = profile_registry.len() + extra_profiles.len();
265 if profile_count > 0 {
266 println!("Registered {} profiles:", profile_count);
267 for a in profile_registry.profiles() {
268 println!(
269 " POST /profiles/{}/prompt — {} ({})",
270 a.name(),
271 a.description(),
272 a.source()
273 );
274 }
275 for a in extra_profiles.profiles() {
276 println!(
277 " POST /profiles/{}/prompt — {} ({})",
278 a.name(),
279 a.description(),
280 a.source()
281 );
282 }
283 }
284
285 let tools = Arc::new(tool_registry);
286 let profiles = Arc::new(profile_registry);
287
288 let router = Arc::new(WorkspaceRouter::single(config.clone()));
291
292 serve_router(
293 router,
294 ServerMode::Compat,
295 bind_addr,
296 false,
297 tools,
298 profiles,
299 extra_tools,
300 extra_profiles,
301 )
302 .await
303}
304
305pub async fn run_server_multi(
313 router: Arc<WorkspaceRouter>,
314 bind: String,
315 allow_remote: bool,
316) -> anyhow::Result<()> {
317 let tools = Arc::new(ToolRegistry::with_builtins_multi());
318 let profiles = Arc::new(ProfileRegistry::new());
319 let extra_tools = Arc::new(ToolRegistry::new());
320 let extra_profiles = Arc::new(ProfileRegistry::new());
321
322 println!("Multi-workspace MCP mode. Registered workspaces:");
323 for rt in router.list() {
324 let default = if Some(rt.id.as_str()) == router.default_id() {
325 " (default)"
326 } else {
327 ""
328 };
329 let health = if rt.health.is_ok() {
330 "ok"
331 } else {
332 "unavailable"
333 };
334 println!(
335 " {}{} — enabled={} health={} resolution={}",
336 rt.id,
337 default,
338 rt.enabled,
339 health,
340 rt.resolution.as_str()
341 );
342 }
343
344 serve_router(
345 router,
346 ServerMode::Multi,
347 bind,
348 allow_remote,
349 tools,
350 profiles,
351 extra_tools,
352 extra_profiles,
353 )
354 .await
355}
356
357fn is_loopback_bind(bind: &str) -> bool {
359 let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
360 let host = host.trim_start_matches('[').trim_end_matches(']');
361 if host.eq_ignore_ascii_case("localhost") {
362 return true;
363 }
364 host.parse::<std::net::IpAddr>()
365 .map(|ip| ip.is_loopback())
366 .unwrap_or(false)
367}
368
369#[allow(clippy::too_many_arguments)]
371async fn serve_router(
372 router: Arc<WorkspaceRouter>,
373 mode: ServerMode,
374 bind_addr: String,
375 allow_remote: bool,
376 tools: Arc<ToolRegistry>,
377 profiles: Arc<ProfileRegistry>,
378 extra_tools: Arc<ToolRegistry>,
379 extra_profiles: Arc<ProfileRegistry>,
380) -> anyhow::Result<()> {
381 if !is_loopback_bind(&bind_addr) {
385 if mode == ServerMode::Multi && !allow_remote {
386 anyhow::bail!(
387 "refusing to bind multi-workspace server to non-loopback address '{bind_addr}': \
388 this exposes every registered workspace to other hosts. Bind to 127.0.0.1, or \
389 pass --allow-remote to override."
390 );
391 }
392 eprintln!(
393 "warning: binding to non-loopback address '{bind_addr}'. The MCP server has \
394 permissive CORS and no authentication; only bind to addresses reachable by \
395 trusted clients."
396 );
397 }
398
399 let state = AppState {
400 router: router.clone(),
401 mode,
402 tools: tools.clone(),
403 profiles: profiles.clone(),
404 };
405
406 let mcp_tools = tools.clone();
408 let mcp_extra = extra_tools.clone();
409 let mcp_profiles = profiles.clone();
410 let mcp_extra_profiles = extra_profiles.clone();
411 let mcp_router = router.clone();
412
413 let extra_state = (extra_tools.clone(), extra_profiles);
414 let mcp_service = StreamableHttpService::new(
415 move || {
416 Ok(McpBridge::new(
417 mcp_router.clone(),
418 mode,
419 mcp_tools.clone(),
420 mcp_extra.clone(),
421 mcp_profiles.clone(),
422 mcp_extra_profiles.clone(),
423 ))
424 },
425 Arc::new(LocalSessionManager::default()),
426 Default::default(),
427 );
428
429 let cors = CorsLayer::new()
430 .allow_origin(Any)
431 .allow_methods(Any)
432 .allow_headers(Any);
433
434 let app = Router::new()
435 .route("/tools/list", get(handle_list_tools))
436 .route("/tools/{name}", post(handle_tool_call))
437 .route("/profiles/list", get(handle_list_profiles))
438 .route("/profiles/{name}/prompt", post(handle_resolve_profile))
439 .route("/agents/list", get(handle_list_agents_compat))
440 .route("/agents/{name}/prompt", post(handle_resolve_profile))
441 .route("/health", get(handle_health))
442 .with_state((state, extra_state))
443 .nest_service("/mcp", mcp_service)
444 .layer(cors);
445
446 println!("MCP server listening on http://{}", bind_addr);
447 println!(" MCP endpoint: http://{}/mcp", bind_addr);
448
449 let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
450 axum::serve(listener, app).await?;
451
452 Ok(())
453}
454
455#[derive(Serialize)]
459struct ErrorBody {
460 error: ErrorDetail,
461}
462
463#[derive(Serialize)]
465struct ErrorDetail {
466 code: String,
468 message: String,
470}
471
472struct AppError {
474 status: StatusCode,
475 code: String,
476 message: String,
477}
478
479impl IntoResponse for AppError {
480 fn into_response(self) -> Response {
481 let body = ErrorBody {
482 error: ErrorDetail {
483 code: self.code,
484 message: self.message,
485 },
486 };
487 (self.status, Json(body)).into_response()
488 }
489}
490
491fn bad_request(message: impl Into<String>) -> AppError {
493 AppError {
494 status: StatusCode::BAD_REQUEST,
495 code: "bad_request".to_string(),
496 message: message.into(),
497 }
498}
499
500fn not_found(message: impl Into<String>) -> AppError {
502 AppError {
503 status: StatusCode::NOT_FOUND,
504 code: "not_found".to_string(),
505 message: message.into(),
506 }
507}
508
509fn timeout_error(message: impl Into<String>) -> AppError {
511 AppError {
512 status: StatusCode::REQUEST_TIMEOUT,
513 code: "timeout".to_string(),
514 message: message.into(),
515 }
516}
517
518fn tool_error(message: impl Into<String>) -> AppError {
520 AppError {
521 status: StatusCode::INTERNAL_SERVER_ERROR,
522 code: "tool_error".to_string(),
523 message: message.into(),
524 }
525}
526
527fn classify_tool_error(tool_name: &str, err: anyhow::Error) -> AppError {
532 if let Some(re) = err.downcast_ref::<RouterError>() {
534 let status = match re {
535 RouterError::UnknownWorkspace(_) => StatusCode::NOT_FOUND,
536 RouterError::WorkspaceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
537 RouterError::WorkspaceTimeout { .. } => StatusCode::SERVICE_UNAVAILABLE,
538 _ => StatusCode::BAD_REQUEST,
539 };
540 return AppError {
541 status,
542 code: re.code().to_string(),
543 message: format!("{tool_name}: {re}"),
544 };
545 }
546
547 let msg = err.to_string();
548
549 if msg.contains("not found") {
550 not_found(format!("{}: {}", tool_name, msg))
551 } else if msg.contains("must not be empty")
552 || msg.contains("embeddings")
553 || msg.contains("disabled")
554 || msg.contains("invalid")
555 {
556 let mut e = bad_request(format!("{}: {}", tool_name, msg));
558 if msg.contains("embeddings") || msg.contains("disabled") {
560 e.code = "embeddings_disabled".to_string();
561 }
562 e
563 } else if msg.contains("timed out") {
564 timeout_error(format!("{}: {}", tool_name, msg))
565 } else {
566 tool_error(format!("{}: {}", tool_name, msg))
567 }
568}
569
570#[derive(Serialize)]
574struct HealthResponse {
575 status: String,
577 version: String,
579}
580
581async fn handle_health() -> Json<HealthResponse> {
586 Json(HealthResponse {
587 status: "ok".to_string(),
588 version: env!("CARGO_PKG_VERSION").to_string(),
589 })
590}
591
592#[derive(Serialize)]
596struct ToolListResponse {
597 tools: Vec<ToolInfo>,
599}
600
601async fn handle_list_tools(
607 State((state, (extra_tools, _extra_profiles))): State<(AppState, ExtState)>,
608) -> Json<ToolListResponse> {
609 let mut tools: Vec<ToolInfo> = state
610 .tools
611 .tools()
612 .iter()
613 .map(|t| ToolInfo {
614 name: t.name().to_string(),
615 description: t.description().to_string(),
616 builtin: t.is_builtin(),
617 parameters: t.parameters_schema(),
618 })
619 .collect();
620
621 for t in extra_tools.tools() {
623 tools.push(ToolInfo {
624 name: t.name().to_string(),
625 description: t.description().to_string(),
626 builtin: false,
627 parameters: t.parameters_schema(),
628 });
629 }
630
631 Json(ToolListResponse { tools })
632}
633
634async fn handle_tool_call(
645 State((state, (extra_tools, _extra_profiles))): State<(AppState, ExtState)>,
646 Path(name): Path<String>,
647 Json(params): Json<serde_json::Value>,
648) -> Result<Json<serde_json::Value>, AppError> {
649 let tool = state
651 .tools
652 .find(&name)
653 .or_else(|| extra_tools.find(&name))
654 .ok_or_else(|| not_found(format!("no tool registered with name: {}", name)))?;
655
656 let validated_params = validate_params(&tool.parameters_schema(), ¶ms)
658 .map_err(|e| bad_request(e.to_string()))?;
659
660 let ctx = ToolContext::routed(state.router.clone(), state.mode);
662 let result = tool
663 .execute(validated_params, &ctx)
664 .await
665 .map_err(|e| classify_tool_error(&name, e))?;
666
667 Ok(Json(serde_json::json!({ "result": result })))
668}
669
670#[derive(Serialize)]
673struct ProfileListResponse {
674 profiles: Vec<ProfileInfo>,
675}
676
677#[derive(Serialize)]
679struct AgentListResponse {
680 agents: Vec<ProfileInfo>,
681}
682
683fn profile_info(state: &AppState, extra_profiles: &ProfileRegistry) -> Vec<ProfileInfo> {
684 state
685 .profiles
686 .profiles()
687 .iter()
688 .chain(extra_profiles.profiles().iter())
689 .map(|profile| ProfileInfo {
690 name: profile.name().to_string(),
691 description: profile.description().to_string(),
692 tools: profile.tools(),
693 source: profile.source().to_string(),
694 arguments: profile.arguments(),
695 })
696 .collect()
697}
698
699async fn handle_list_profiles(
701 State((state, (_extra_tools, extra_profiles))): State<(AppState, ExtState)>,
702) -> Json<ProfileListResponse> {
703 Json(ProfileListResponse {
704 profiles: profile_info(&state, &extra_profiles),
705 })
706}
707
708async fn handle_list_agents_compat(
710 State((state, (_extra_tools, extra_profiles))): State<(AppState, ExtState)>,
711) -> Json<AgentListResponse> {
712 Json(AgentListResponse {
713 agents: profile_info(&state, &extra_profiles),
714 })
715}
716
717async fn handle_resolve_profile(
719 State((state, (_extra_tools, extra_profiles))): State<(AppState, ExtState)>,
720 Path(name): Path<String>,
721 Json(args): Json<serde_json::Value>,
722) -> Result<Json<serde_json::Value>, AppError> {
723 let profile = state
724 .profiles
725 .find(&name)
726 .or_else(|| extra_profiles.find(&name))
727 .ok_or_else(|| not_found(format!("no profile registered with name: {}", name)))?;
728
729 let ctx = ToolContext::routed(state.router.clone(), state.mode);
730 let prompt = profile
731 .resolve(args, &ctx)
732 .await
733 .map_err(|e| tool_error(format!("profile '{}': {}", name, e)))?;
734
735 Ok(Json(serde_json::to_value(prompt).map_err(|e| {
736 tool_error(format!("failed to serialize profile prompt: {}", e))
737 })?))
738}
739
740pub fn register_resource_prompts(
743 config: &Config,
744 directories: &[crate::agent_resource::ResourceDirectory],
745 profiles: &mut ProfileRegistry,
746 extra_profiles: &ProfileRegistry,
747) -> anyhow::Result<()> {
748 let resources = crate::agent_resource::load_resources(directories, config)?;
749 for name in resources.keys() {
750 anyhow::ensure!(
751 profiles.find(name).is_none() && extra_profiles.find(name).is_none(),
752 "agent resource conflicts with registered profile '{name}'; rename the resource or profile"
753 );
754 }
755 for resource in resources.into_values() {
756 profiles.register(Box::new(resource.definition.prompt_profile()));
757 }
758 Ok(())
759}