Skip to main content

context_harness/
server.rs

1//! MCP-compatible HTTP server.
2//!
3//! Exposes Context Harness functionality via a JSON HTTP API suitable for
4//! integration with Cursor, Claude, and other MCP-compatible AI tools.
5//!
6//! All tools — built-in (search, get, sources), Lua scripts, and custom Rust
7//! trait implementations — are registered in a unified [`ToolRegistry`] and
8//! dispatched through the same `POST /tools/{name}` handler.
9//!
10//! Profiles (named personas with system prompts and tool scoping) are registered
11//! in a [`ProfileRegistry`] and discoverable/resolvable via dedicated endpoints.
12//!
13//! # Endpoints
14//!
15//! | Method | Path | Description |
16//! |--------|------|-------------|
17//! | `GET`  | `/tools/list` | List all registered tools with schemas |
18//! | `POST` | `/tools/{name}` | Call any registered tool by name |
19//! | `GET`  | `/profiles/list` | List all registered profiles with metadata |
20//! | `POST` | `/profiles/{name}/prompt` | Resolve a profile's system prompt |
21//! | `GET`  | `/agents/list` | Deprecated compatibility alias |
22//! | `POST` | `/agents/{name}/prompt` | Deprecated compatibility alias |
23//! | `GET`  | `/health` | Health check (returns version) |
24//!
25//! # Error Contract
26//!
27//! All error responses follow the schema defined in `docs/SCHEMAS.md`:
28//!
29//! ```json
30//! { "error": { "code": "bad_request", "message": "query must not be empty" } }
31//! ```
32//!
33//! Error codes: `bad_request` (400), `not_found` (404), `embeddings_disabled` (400),
34//! `timeout` (408), `tool_error` (500), `internal` (500).
35//!
36//! # CORS
37//!
38//! All origins, methods, and headers are permitted to support browser-based
39//! clients and cross-origin MCP tool calls.
40//!
41//! # Cursor Integration
42//!
43//! Start the server and point Cursor at the `/mcp` endpoint:
44//!
45//! ```json
46//! {
47//!   "mcpServers": {
48//!     "context-harness": {
49//!       "url": "http://127.0.0.1:7331/mcp"
50//!     }
51//!   }
52//! }
53//! ```
54
55use 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/// Shared application state passed to all route handlers via Axum's `State` extractor.
79#[derive(Clone)]
80struct AppState {
81    /// Workspace router (one-workspace in compatibility mode; multi under
82    /// `--workspaces`). Handlers build a [`ToolContext`] from this per request.
83    router: Arc<WorkspaceRouter>,
84    /// Whether the server emits flat (compat) or workspace-labeled (multi) shapes.
85    mode: ServerMode,
86    /// Unified tool registry containing built-in, Lua, and custom Rust tools.
87    tools: Arc<ToolRegistry>,
88    /// Profile registry containing TOML, Lua, and custom Rust profiles.
89    profiles: Arc<ProfileRegistry>,
90}
91
92/// Extra extensions (custom Rust tools and profiles) passed alongside the main `AppState`.
93type ExtState = (Arc<ToolRegistry>, Arc<ProfileRegistry>);
94
95/// Starts the MCP-compatible HTTP server.
96///
97/// Binds to the address configured in `[server].bind` and registers all
98/// route handlers. The server runs indefinitely until the process is terminated.
99///
100/// This is the standard entry point used by the `ctx serve mcp` command.
101/// For custom binaries with Rust extensions, use
102/// [`run_server_with_extensions`] instead.
103///
104/// # Arguments
105///
106/// - `config` — application configuration (database path, retrieval settings, bind address).
107///
108/// # Returns
109///
110/// Returns `Ok(())` when the server shuts down, or an error if binding fails.
111#[allow(dead_code)] // Public library entry point; the CLI supplies resource directories.
112pub 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/// Starts the MCP server with custom Rust tool and profile extensions.
122///
123/// Like [`run_server`], but accepts a [`ToolRegistry`] and [`ProfileRegistry`]
124/// containing custom extensions that will be served alongside built-in,
125/// TOML-defined, and Lua-scripted entries.
126///
127/// Custom tools appear in `GET /tools/list` and can be called via
128/// `POST /tools/{name}`. Custom profiles appear in `GET /profiles/list` and
129/// can be resolved via `POST /profiles/{name}/prompt`.
130///
131/// # Example
132///
133/// ```rust,no_run
134/// use context_harness::server::run_server_with_extensions;
135/// use context_harness::traits::ToolRegistry;
136/// use context_harness::profiles::ProfileRegistry;
137/// use std::sync::Arc;
138///
139/// # async fn example(config: &context_harness::config::Config) -> anyhow::Result<()> {
140/// let tools = ToolRegistry::new();
141/// let profiles = ProfileRegistry::new();
142/// run_server_with_extensions(config, Arc::new(tools), Arc::new(profiles)).await?;
143/// # Ok(())
144/// # }
145/// ```
146#[allow(dead_code)] // Public library entry point.
147pub 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
155/// Start a single-workspace server with explicitly selected standalone resources.
156/// Resources project static prompts only; no runtime, credentials, or client
157/// processes are initialized. Existing library entry points do not discover cwd.
158pub 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    // ── Tools ──
168    let mut tool_registry = ToolRegistry::with_builtins();
169
170    // Load and register Lua tools from config
171    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    // Auto-discover tools from registries (lower precedence than config)
178    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    // Print registered tools
205    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    // ── Profiles ──
218    let mut profile_registry = ProfileRegistry::from_config(&config)?;
219
220    // Load and register Lua profiles from config
221    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    // Auto-discover legacy prompt extensions from registries (lower precedence than config)
229    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    // Compatibility mode is a router with one workspace; the wire contract is
289    // selected by mode, not by workspace count (SPEC-0014 R14/R15).
290    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
305/// Starts the multi-workspace MCP server (`ctx serve mcp --workspaces`).
306///
307/// Routes the built-in `search` / `get` / `sources` / `workspaces` tools across
308/// the registered workspaces in `router`. Per SPEC-0014 R54, only built-in
309/// tools are exposed in multi-workspace mode in Phase 1 — workspace-local Lua
310/// and registry tools/profiles are not loaded. `bind` comes from the registry's
311/// `[defaults].bind` (R16); `allow_remote` permits a non-loopback bind.
312pub 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
357/// Whether a `host:port` bind address targets the loopback interface.
358fn 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/// Shared Axum wiring for both compatibility and multi-workspace modes.
370#[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    // Trust model (SPEC-0014 trust-model section): loopback bind is the
382    // load-bearing control. A non-loopback bind is refused in multi-workspace
383    // mode (which fronts every registered store) unless explicitly allowed.
384    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    // MCP Streamable HTTP endpoint at /mcp — clone before moving into extra_state
407    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// ============ Error response ============
456
457/// JSON error response body, matching `docs/SCHEMAS.md` error schema.
458#[derive(Serialize)]
459struct ErrorBody {
460    error: ErrorDetail,
461}
462
463/// Inner error detail with a machine-readable code and human-readable message.
464#[derive(Serialize)]
465struct ErrorDetail {
466    /// Machine-readable error code (e.g., `"bad_request"`, `"not_found"`).
467    code: String,
468    /// Human-readable error message.
469    message: String,
470}
471
472/// Internal error type that converts into an Axum HTTP response.
473struct 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
491/// Constructs a 400 Bad Request error.
492fn 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
500/// Constructs a 404 Not Found error.
501fn 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
509/// Constructs a 408 Request Timeout error.
510fn 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
518/// Constructs a 500 error for tool execution failures.
519fn 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
527/// Inspects tool execution errors and maps them to the most appropriate
528/// HTTP status code. This allows built-in tools to signal client errors
529/// (e.g. empty query → 400, document not found → 404) without needing
530/// a custom error type in the `Tool` trait.
531fn classify_tool_error(tool_name: &str, err: anyhow::Error) -> AppError {
532    // Router errors carry their own SPEC-0014 R64 code; surface it directly.
533    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        // Validation / configuration errors → 400
557        let mut e = bad_request(format!("{}: {}", tool_name, msg));
558        // Preserve more specific error codes for known patterns
559        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// ============ GET /health ============
571
572/// JSON response body for `GET /health`.
573#[derive(Serialize)]
574struct HealthResponse {
575    /// Always `"ok"` when the server is running.
576    status: String,
577    /// The crate version from `Cargo.toml`.
578    version: String,
579}
580
581/// Handler for `GET /health`.
582///
583/// Returns a simple health check response with the server status and version.
584/// This endpoint is used by load balancers and monitoring tools.
585async 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// ============ GET /tools/list ============
593
594/// JSON response body for `GET /tools/list`.
595#[derive(Serialize)]
596struct ToolListResponse {
597    /// All registered tools.
598    tools: Vec<ToolInfo>,
599}
600
601/// Handler for `GET /tools/list`.
602///
603/// Returns all registered tools with their OpenAI function-calling parameter
604/// schemas. Built-in tools have `builtin: true`; Lua and custom Rust tools
605/// have `builtin: false`.
606async 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    // Append extra custom Rust tools
622    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
634// ============ POST /tools/{name} ============
635
636/// Handler for `POST /tools/{name}`.
637///
638/// Unified tool dispatch. Looks up the tool by name in the registry
639/// (checking the main registry first, then extras), validates parameters,
640/// and executes it.
641///
642/// Returns `404` if the tool is not found, `400` for parameter validation
643/// errors, `408` for timeout, and `500` for execution errors.
644async 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    // Look up the tool in the main registry, then extras
650    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    // Validate parameters against the tool's schema
657    let validated_params = validate_params(&tool.parameters_schema(), &params)
658        .map_err(|e| bad_request(e.to_string()))?;
659
660    // Execute via the Tool trait
661    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// ============ Profile endpoints ============
671
672#[derive(Serialize)]
673struct ProfileListResponse {
674    profiles: Vec<ProfileInfo>,
675}
676
677/// Historical response shape retained by `GET /agents/list`.
678#[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
699/// Canonical profile discovery endpoint.
700async 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
708/// Deprecated prompt-agent discovery endpoint with its historical JSON key.
709async 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
717/// Resolve a profile via the canonical route or its legacy agent alias.
718async 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
740/// Register standalone prompt projections, rejecting collisions with all legacy
741/// sources (including registry Lua and caller-provided Rust profiles) atomically.
742pub 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}