Skip to main content

context_harness/
traits.rs

1//! Extension traits for custom connectors and tools.
2//!
3//! This module provides the trait-based extension system for Context Harness.
4//! Users can implement [`Connector`] and [`Tool`] in Rust to create compiled
5//! extensions that run alongside built-in and Lua-scripted ones.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌──────────────────────────────────────────┐
11//! │           ConnectorRegistry              │
12//! │  ┌─────────┐ ┌─────────┐ ┌────────────┐ │
13//! │  │Built-in │ │  Lua    │ │  Custom    │ │
14//! │  │FS/Git/S3│ │ Script  │ │ (Rust)     │ │
15//! │  └─────────┘ └─────────┘ └────────────┘ │
16//! └──────────────┬───────────────────────────┘
17//!                ▼
18//!          run_sync() → ingest pipeline
19//! ```
20//!
21//! ```text
22//! ┌──────────────────────────────────────────┐
23//! │              ToolRegistry                │
24//! │  ┌─────────┐ ┌─────────┐ ┌────────────┐ │
25//! │  │Built-in │ │  Lua    │ │  Custom    │ │
26//! │  │search   │ │ Script  │ │ (Rust)     │ │
27//! │  │get/src  │ │ Tools   │ │  Tools     │ │
28//! │  └─────────┘ └─────────┘ └────────────┘ │
29//! └──────────────┬───────────────────────────┘
30//!                ▼
31//!          run_server() → MCP HTTP API
32//! ```
33//!
34//! # Usage
35//!
36//! ```rust
37//! use context_harness::traits::{ConnectorRegistry, ToolRegistry};
38//!
39//! let mut connectors = ConnectorRegistry::new();
40//! // connectors.register(Box::new(MyConnector::new()));
41//!
42//! let mut tools = ToolRegistry::new();
43//! // tools.register(Box::new(MyTool::new()));
44//! ```
45//!
46//! See `docs/RUST_TRAITS.md` for the full specification and examples.
47
48use anyhow::Result;
49use async_trait::async_trait;
50use serde_json::Value;
51use std::sync::Arc;
52
53use crate::config::Config;
54use crate::get::{get_document, DocumentResponse};
55use crate::models::SourceItem;
56use crate::search::{search_documents, SearchResultItem};
57use crate::sources::{get_sources, SourceStatus};
58use crate::workspace::{
59    fan_out, FanOut, RouterError, ServerMode, WorkspaceRouter, ALL_SELECTOR, FAN_OUT_CONCURRENCY,
60};
61
62// ═══════════════════════════════════════════════════════════════════════
63// Connector Trait
64// ═══════════════════════════════════════════════════════════════════════
65
66/// A data source connector that produces documents for ingestion.
67///
68/// Implement this trait to create a custom connector in Rust. The
69/// connector is responsible for scanning an external data source and
70/// returning a list of [`SourceItem`]s that flow through the standard
71/// ingestion pipeline (normalization → chunking → embedding).
72///
73/// # Lifecycle
74///
75/// 1. The connector is registered via [`ConnectorRegistry::register`].
76/// 2. [`scan`](Connector::scan) is called during `ctx sync custom:<name>`.
77/// 3. Returned items are normalized, chunked, and indexed.
78///
79/// # Example
80///
81/// ```rust
82/// use async_trait::async_trait;
83/// use anyhow::Result;
84/// use context_harness::models::SourceItem;
85/// use context_harness::traits::Connector;
86/// use chrono::Utc;
87///
88/// pub struct DatabaseConnector {
89///     connection_string: String,
90/// }
91///
92/// #[async_trait]
93/// impl Connector for DatabaseConnector {
94///     fn name(&self) -> &str { "database" }
95///     fn description(&self) -> &str { "Ingest rows from a database table" }
96///     fn connector_type(&self) -> &str { "custom" }
97///
98///     async fn scan(&self) -> Result<Vec<SourceItem>> {
99///         // ... query database and return SourceItems
100///         Ok(vec![])
101///     }
102/// }
103/// ```
104#[async_trait]
105pub trait Connector: Send + Sync {
106    /// Returns the connector instance name (e.g. `"docs"`, `"platform"`).
107    ///
108    /// Combined with [`connector_type`](Connector::connector_type) to form
109    /// the source label: `"{type}:{name}"`.
110    fn name(&self) -> &str;
111
112    /// Returns a one-line description of what this connector does.
113    ///
114    /// Used in `ctx sources` output and documentation.
115    fn description(&self) -> &str;
116
117    /// Returns the connector type identifier (e.g. `"filesystem"`, `"git"`, `"s3"`, `"custom"`).
118    ///
119    /// Built-in connectors return their type name; custom (user-defined)
120    /// connectors default to `"custom"`.
121    fn connector_type(&self) -> &str {
122        "custom"
123    }
124
125    /// Returns the source label used to tag documents from this connector.
126    ///
127    /// Defaults to `"{connector_type}:{name}"` (e.g. `"git:platform"`).
128    fn source_label(&self) -> String {
129        format!("{}:{}", self.connector_type(), self.name())
130    }
131
132    /// Scan the data source and return all items to ingest.
133    ///
134    /// Called on the tokio async runtime. May perform I/O operations
135    /// (HTTP requests, database queries, file reads).
136    ///
137    /// # Returns
138    ///
139    /// A vector of [`SourceItem`]s. Each item flows through the standard
140    /// ingestion pipeline. Items with empty `body` or `source_id` are
141    /// skipped with a warning.
142    async fn scan(&self) -> Result<Vec<SourceItem>>;
143}
144
145// ═══════════════════════════════════════════════════════════════════════
146// Tool Trait
147// ═══════════════════════════════════════════════════════════════════════
148
149/// A custom MCP tool that agents can discover and call.
150///
151/// Implement this trait to create a compiled Rust tool. Tools are
152/// registered at server startup and exposed via `GET /tools/list`
153/// for agent discovery and `POST /tools/{name}` for invocation.
154///
155/// # Lifecycle
156///
157/// 1. The tool is registered via [`ToolRegistry::register`].
158/// 2. [`name`](Tool::name), [`description`](Tool::description), and
159///    [`parameters_schema`](Tool::parameters_schema) are called at startup
160///    for the tool list.
161/// 3. [`execute`](Tool::execute) is called each time an agent invokes
162///    the tool.
163///
164/// # Example
165///
166/// ```rust
167/// use async_trait::async_trait;
168/// use anyhow::Result;
169/// use serde_json::{json, Value};
170/// use context_harness::traits::{Tool, ToolContext};
171///
172/// pub struct HealthCheckTool;
173///
174/// #[async_trait]
175/// impl Tool for HealthCheckTool {
176///     fn name(&self) -> &str { "health_check" }
177///     fn description(&self) -> &str { "Check connector health" }
178///
179///     fn parameters_schema(&self) -> Value {
180///         json!({
181///             "type": "object",
182///             "properties": {},
183///             "required": []
184///         })
185///     }
186///
187///     async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
188///         let sources = ctx.sources()?;
189///         Ok(json!({ "sources": sources.len() }))
190///     }
191/// }
192/// ```
193#[async_trait]
194pub trait Tool: Send + Sync {
195    /// Returns the tool's name.
196    ///
197    /// Used as the route path (`POST /tools/{name}`) and in
198    /// `GET /tools/list` responses. Should be a lowercase
199    /// identifier with underscores (e.g., `"create_ticket"`).
200    fn name(&self) -> &str;
201
202    /// Returns a one-line description for agent discovery.
203    ///
204    /// Agents use this to decide whether to call the tool.
205    fn description(&self) -> &str;
206
207    /// Whether this tool is a built-in (true for search/get/sources).
208    ///
209    /// Built-in tools are marked with `"builtin": true` in the
210    /// `GET /tools/list` response. Defaults to `false`.
211    fn is_builtin(&self) -> bool {
212        false
213    }
214
215    /// Returns the OpenAI function-calling JSON Schema for parameters.
216    ///
217    /// Must be a valid JSON Schema object with `type: "object"`,
218    /// `properties`, and optionally `required`.
219    fn parameters_schema(&self) -> Value;
220
221    /// Execute the tool with validated parameters.
222    ///
223    /// Called each time an agent invokes the tool via `POST /tools/{name}`.
224    ///
225    /// # Arguments
226    ///
227    /// * `params` — JSON parameters (always a JSON object).
228    /// * `ctx` — Bridge to the Context Harness knowledge base.
229    ///
230    /// # Returns
231    ///
232    /// A JSON value that will be wrapped in `{ "result": ... }` in the
233    /// HTTP response.
234    async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value>;
235}
236
237// ═══════════════════════════════════════════════════════════════════════
238// ToolContext
239// ═══════════════════════════════════════════════════════════════════════
240
241/// Options for [`ToolContext::search`].
242#[derive(Debug, Default)]
243pub struct SearchOptions {
244    /// Search mode: `"keyword"`, `"semantic"`, or `"hybrid"`.
245    pub mode: Option<String>,
246    /// Maximum number of results.
247    pub limit: Option<i64>,
248    /// Filter by source connector (e.g., `"git:platform"`).
249    pub source: Option<String>,
250}
251
252/// Context bridge for tool execution.
253///
254/// Provides tools with access to the Context Harness knowledge base
255/// during execution. Created by the server for each tool invocation.
256///
257/// All methods delegate to the same core functions used by the CLI
258/// and HTTP server, ensuring tools have identical capabilities.
259pub struct ToolContext {
260    /// The active workspace config used by the convenience methods below and by
261    /// compatibility-mode built-in tools. In multi mode this is the default
262    /// workspace's config; router-aware tools resolve per call instead.
263    config: Arc<Config>,
264    /// The workspace router. In compatibility mode this is a one-workspace
265    /// router; router-aware (multi-mode) tools dispatch through it.
266    router: Arc<WorkspaceRouter>,
267    /// Whether this context serves the pre-router flat shapes or labeled shapes.
268    mode: ServerMode,
269}
270
271impl ToolContext {
272    /// Create a single-workspace (compatibility) tool context from a config.
273    ///
274    /// Internally wraps the config in a one-workspace [`WorkspaceRouter`], so a
275    /// compatibility context is just "a router with one workspace".
276    pub fn new(config: Arc<Config>) -> Self {
277        let router = Arc::new(WorkspaceRouter::single(config.clone()));
278        Self {
279            config,
280            router,
281            mode: ServerMode::Compat,
282        }
283    }
284
285    /// Create a tool context backed by a (possibly multi-workspace) router.
286    ///
287    /// The convenience methods default to the router's default workspace; the
288    /// router-aware built-in tools resolve the request's `workspace` selector
289    /// against [`ToolContext::router`] per call.
290    pub fn routed(router: Arc<WorkspaceRouter>, mode: ServerMode) -> Self {
291        let config = router.default_config();
292        Self {
293            config,
294            router,
295            mode,
296        }
297    }
298
299    /// The server mode this context serves.
300    pub fn mode(&self) -> ServerMode {
301        self.mode
302    }
303
304    /// The workspace router, for router-aware tools.
305    pub fn router(&self) -> &Arc<WorkspaceRouter> {
306        &self.router
307    }
308
309    /// The active workspace config (the default workspace in multi mode).
310    pub fn config(&self) -> &Arc<Config> {
311        &self.config
312    }
313
314    /// Search the knowledge base.
315    ///
316    /// Equivalent to `POST /tools/search` or `ctx search`.
317    ///
318    /// # Example
319    ///
320    /// ```rust,no_run
321    /// # use context_harness::traits::{ToolContext, SearchOptions};
322    /// # async fn example(ctx: &ToolContext) -> anyhow::Result<()> {
323    /// let results = ctx.search("deployment runbook", SearchOptions {
324    ///     mode: Some("hybrid".to_string()),
325    ///     limit: Some(5),
326    ///     ..Default::default()
327    /// }).await?;
328    /// # Ok(())
329    /// # }
330    /// ```
331    pub async fn search(&self, query: &str, opts: SearchOptions) -> Result<Vec<SearchResultItem>> {
332        search_documents(
333            &self.config,
334            query,
335            opts.mode.as_deref().unwrap_or("keyword"),
336            opts.source.as_deref(),
337            None,
338            opts.limit,
339            false,
340        )
341        .await
342    }
343
344    /// Retrieve a document by UUID.
345    ///
346    /// Equivalent to `POST /tools/get` or `ctx get`.
347    pub async fn get(&self, id: &str) -> Result<DocumentResponse> {
348        get_document(&self.config, id).await
349    }
350
351    /// List all configured connectors and their status.
352    ///
353    /// Equivalent to `GET /tools/sources` or `ctx sources`.
354    pub fn sources(&self) -> Result<Vec<SourceStatus>> {
355        Ok(get_sources(&self.config))
356    }
357}
358
359// ═══════════════════════════════════════════════════════════════════════
360// Built-in Tool Implementations
361// ═══════════════════════════════════════════════════════════════════════
362
363/// Built-in search tool. Delegates to [`ToolContext::search`].
364pub struct SearchTool;
365
366#[async_trait]
367impl Tool for SearchTool {
368    fn name(&self) -> &str {
369        "search"
370    }
371
372    fn description(&self) -> &str {
373        "Search the knowledge base"
374    }
375
376    fn is_builtin(&self) -> bool {
377        true
378    }
379
380    fn parameters_schema(&self) -> Value {
381        serde_json::json!({
382            "type": "object",
383            "properties": {
384                "query": { "type": "string", "description": "Search query" },
385                "mode": { "type": "string", "enum": ["keyword", "semantic", "hybrid"], "default": "keyword" },
386                "limit": { "type": "integer", "description": "Max results", "default": 12 },
387                "filters": {
388                    "type": "object",
389                    "properties": {
390                        "source": { "type": "string", "description": "Filter by connector source" },
391                        "since": { "type": "string", "description": "Only results updated after this date (YYYY-MM-DD)" }
392                    }
393                }
394            },
395            "required": ["query"]
396        })
397    }
398
399    async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
400        let query = params["query"].as_str().unwrap_or("");
401        if query.trim().is_empty() {
402            anyhow::bail!("query must not be empty");
403        }
404
405        let mode = params["mode"].as_str().unwrap_or("keyword");
406        let limit = params["limit"].as_i64().unwrap_or(12);
407
408        let source = params
409            .get("filters")
410            .and_then(|f| f.get("source"))
411            .and_then(|s| s.as_str());
412        let since = params
413            .get("filters")
414            .and_then(|f| f.get("since"))
415            .and_then(|s| s.as_str());
416
417        let results =
418            search_documents(&ctx.config, query, mode, source, since, Some(limit), false).await?;
419
420        Ok(serde_json::json!({ "results": results }))
421    }
422}
423
424/// Built-in document retrieval tool. Delegates to [`get_document`].
425pub struct GetTool;
426
427#[async_trait]
428impl Tool for GetTool {
429    fn name(&self) -> &str {
430        "get"
431    }
432
433    fn description(&self) -> &str {
434        "Retrieve a document by UUID"
435    }
436
437    fn is_builtin(&self) -> bool {
438        true
439    }
440
441    fn parameters_schema(&self) -> Value {
442        serde_json::json!({
443            "type": "object",
444            "properties": {
445                "id": { "type": "string", "description": "Document UUID" }
446            },
447            "required": ["id"]
448        })
449    }
450
451    async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
452        let id = params["id"].as_str().unwrap_or("");
453        if id.trim().is_empty() {
454            anyhow::bail!("id must not be empty");
455        }
456
457        let doc = get_document(&ctx.config, id).await?;
458        Ok(serde_json::to_value(&doc)?)
459    }
460}
461
462/// Built-in sources listing tool. Delegates to [`get_sources`].
463pub struct SourcesTool;
464
465#[async_trait]
466impl Tool for SourcesTool {
467    fn name(&self) -> &str {
468        "sources"
469    }
470
471    fn description(&self) -> &str {
472        "List connector configuration and health status"
473    }
474
475    fn is_builtin(&self) -> bool {
476        true
477    }
478
479    fn parameters_schema(&self) -> Value {
480        serde_json::json!({
481            "type": "object",
482            "properties": {}
483        })
484    }
485
486    async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
487        let sources = get_sources(&ctx.config);
488        Ok(serde_json::json!({ "sources": sources }))
489    }
490}
491
492// ═══════════════════════════════════════════════════════════════════════
493// Router-aware built-in tools (multi-workspace mode)
494// ═══════════════════════════════════════════════════════════════════════
495//
496// These mirror the compat built-ins but accept an optional `workspace`
497// selector, dispatch through the request's [`WorkspaceRouter`], and emit the
498// workspace-labeled (grouped) shapes (SPEC-0014 R18–R37). They are registered
499// only in multi-workspace mode; compatibility mode keeps the unit-struct tools
500// above unchanged (the additive invariant). `workspace = "all"` fan-out is
501// Phase 2 — the router currently rejects it with `unsupported_workspace_selector`.
502
503/// Add the optional `workspace` selector to a compat schema's `properties`.
504fn schema_with_workspace_selector(mut schema: Value) -> Value {
505    if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
506        props.insert(
507            "workspace".to_string(),
508            serde_json::json!({
509                "type": "string",
510                "description": "Workspace id to target (omit for the default workspace)"
511            }),
512        );
513    }
514    schema
515}
516
517/// Build one `{ workspace, items }` group, tagging every item with `workspace`
518/// and a `qualified_id` (R29/R30/R36).
519fn search_group(workspace: &str, results: Vec<SearchResultItem>) -> Value {
520    let items: Vec<Value> = results
521        .into_iter()
522        .map(|item| {
523            let mut obj = serde_json::to_value(&item).unwrap_or(Value::Null);
524            if let Value::Object(map) = &mut obj {
525                let doc_id = map
526                    .get("id")
527                    .and_then(|v| v.as_str())
528                    .unwrap_or_default()
529                    .to_string();
530                map.insert(
531                    "workspace".to_string(),
532                    Value::String(workspace.to_string()),
533                );
534                map.insert(
535                    "qualified_id".to_string(),
536                    Value::String(format!("{workspace}:{doc_id}")),
537                );
538            }
539            obj
540        })
541        .collect();
542    serde_json::json!({ "workspace": workspace, "items": items })
543}
544
545/// A single-workspace grouped response: one group, no errors (R29/R30).
546fn shape_grouped_search(workspace: &str, results: Vec<SearchResultItem>) -> Value {
547    serde_json::json!({ "results": [ search_group(workspace, results) ], "errors": [] })
548}
549
550/// Build one `{ workspace, sources }` group for the `sources` tool.
551fn sources_group(workspace: &str, sources: Vec<SourceStatus>) -> Value {
552    serde_json::json!({ "workspace": workspace, "sources": sources })
553}
554
555/// Build one `errors[]` entry for a failed workspace (R37):
556/// `{ workspace, code, message }`.
557fn error_entry(workspace: &str, err: &RouterError) -> Value {
558    serde_json::json!({
559        "workspace": workspace,
560        "code": err.code(),
561        "message": err.to_string(),
562    })
563}
564
565/// Fan out a `search` across every enabled workspace (R32–R37): concurrent,
566/// per-workspace deadline, per-workspace `limit`, grouped results, and one
567/// `errors[]` entry per failed/timed-out workspace.
568async fn search_all(
569    ctx: &ToolContext,
570    query: String,
571    mode: String,
572    source: Option<String>,
573    since: Option<String>,
574    limit: i64,
575) -> Result<Value> {
576    let router = ctx.router();
577    let (healthy, seed_errors) = router.resolve_all();
578    let deadline = router.search_deadline();
579    let deadline_ms = deadline.as_millis() as u64;
580
581    let items: Vec<(String, Arc<Config>)> = healthy
582        .iter()
583        .map(|rt| (rt.id.clone(), rt.config.clone()))
584        .collect();
585
586    let outcomes = fan_out(
587        items,
588        deadline,
589        FAN_OUT_CONCURRENCY,
590        move |config: Arc<Config>| {
591            let query = query.clone();
592            let mode = mode.clone();
593            let source = source.clone();
594            let since = since.clone();
595            async move {
596                search_documents(
597                    &config,
598                    &query,
599                    &mode,
600                    source.as_deref(),
601                    since.as_deref(),
602                    Some(limit),
603                    false,
604                )
605                .await
606            }
607        },
608    )
609    .await;
610
611    let mut groups: Vec<Value> = Vec::new();
612    let mut error_pairs: Vec<(String, Value)> = seed_errors
613        .iter()
614        .map(|(id, err)| (id.clone(), error_entry(id, err)))
615        .collect();
616
617    for (id, outcome) in outcomes {
618        match outcome {
619            FanOut::Ok(results) => groups.push(search_group(&id, results)),
620            FanOut::Failed(msg) => error_pairs.push((
621                id.clone(),
622                error_entry(
623                    &id,
624                    &RouterError::WorkspaceUnavailable {
625                        id: id.clone(),
626                        reason: msg,
627                    },
628                ),
629            )),
630            FanOut::TimedOut => error_pairs.push((
631                id.clone(),
632                error_entry(
633                    &id,
634                    &RouterError::WorkspaceTimeout {
635                        id: id.clone(),
636                        deadline_ms,
637                    },
638                ),
639            )),
640        }
641    }
642
643    // errors[] follows the stable registry order (DESIGN-0008).
644    let ws_order: Vec<String> = ctx.router().list().iter().map(|rt| rt.id.clone()).collect();
645    error_pairs.sort_by_key(|(id, _)| ws_order.iter().position(|w| w == id).unwrap_or(usize::MAX));
646    let errors: Vec<Value> = error_pairs.into_iter().map(|(_, v)| v).collect();
647
648    Ok(serde_json::json!({ "results": groups, "errors": errors }))
649}
650
651/// Multi-workspace `search`: resolves the `workspace` selector and groups results.
652pub struct RoutedSearchTool;
653
654#[async_trait]
655impl Tool for RoutedSearchTool {
656    fn name(&self) -> &str {
657        "search"
658    }
659
660    fn description(&self) -> &str {
661        "Search the knowledge base (optionally scoped to a workspace)"
662    }
663
664    fn is_builtin(&self) -> bool {
665        true
666    }
667
668    fn parameters_schema(&self) -> Value {
669        schema_with_workspace_selector(SearchTool.parameters_schema())
670    }
671
672    async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
673        let query = params["query"].as_str().unwrap_or("");
674        if query.trim().is_empty() {
675            anyhow::bail!("query must not be empty");
676        }
677        let mode = params["mode"].as_str().unwrap_or("keyword");
678        let limit = params["limit"].as_i64().unwrap_or(12);
679        let source = params
680            .get("filters")
681            .and_then(|f| f.get("source"))
682            .and_then(|s| s.as_str());
683        let since = params
684            .get("filters")
685            .and_then(|f| f.get("since"))
686            .and_then(|s| s.as_str());
687
688        let selector = params["workspace"].as_str();
689        if selector == Some(ALL_SELECTOR) {
690            return search_all(
691                ctx,
692                query.to_string(),
693                mode.to_string(),
694                source.map(str::to_string),
695                since.map(str::to_string),
696                limit,
697            )
698            .await;
699        }
700        let runtime = ctx.router().resolve(selector)?;
701
702        let results = search_documents(
703            &runtime.config,
704            query,
705            mode,
706            source,
707            since,
708            Some(limit),
709            false,
710        )
711        .await?;
712        Ok(shape_grouped_search(&runtime.id, results))
713    }
714}
715
716/// Multi-workspace `get`: supports a qualified id (`<ws>:<doc>`) or an explicit
717/// `workspace` selector, with conflict detection (R38–R43).
718pub struct RoutedGetTool;
719
720#[async_trait]
721impl Tool for RoutedGetTool {
722    fn name(&self) -> &str {
723        "get"
724    }
725
726    fn description(&self) -> &str {
727        "Retrieve a document by id or qualified id (<workspace>:<id>)"
728    }
729
730    fn is_builtin(&self) -> bool {
731        true
732    }
733
734    fn parameters_schema(&self) -> Value {
735        schema_with_workspace_selector(GetTool.parameters_schema())
736    }
737
738    async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
739        let id = params["id"].as_str().unwrap_or("");
740        if id.trim().is_empty() {
741            anyhow::bail!("id must not be empty");
742        }
743        let field = params["workspace"].as_str();
744        let (qualified_prefix, raw_id) = ctx.router().split_qualified_id(id);
745
746        // A qualified id and an explicit workspace field must agree (R41/R42).
747        if let (Some(q), Some(f)) = (qualified_prefix, field) {
748            if q != f {
749                return Err(RouterError::WorkspaceIdConflict {
750                    field: f.to_string(),
751                    qualified: q.to_string(),
752                }
753                .into());
754            }
755        }
756
757        let selector = qualified_prefix.or(field);
758        let runtime = ctx.router().resolve(selector)?;
759
760        let doc = get_document(&runtime.config, raw_id).await?;
761        let mut obj = serde_json::to_value(&doc)?;
762        if let Value::Object(map) = &mut obj {
763            map.insert("workspace".to_string(), Value::String(runtime.id.clone()));
764            map.insert(
765                "qualified_id".to_string(),
766                Value::String(format!("{}:{}", runtime.id, raw_id)),
767            );
768        }
769        Ok(obj)
770    }
771}
772
773/// Multi-workspace `sources`: connector status for one workspace, grouped and
774/// redacted (R44/R48/R49).
775pub struct RoutedSourcesTool;
776
777#[async_trait]
778impl Tool for RoutedSourcesTool {
779    fn name(&self) -> &str {
780        "sources"
781    }
782
783    fn description(&self) -> &str {
784        "List connector status for a workspace (optionally scoped to a workspace)"
785    }
786
787    fn is_builtin(&self) -> bool {
788        true
789    }
790
791    fn parameters_schema(&self) -> Value {
792        schema_with_workspace_selector(SourcesTool.parameters_schema())
793    }
794
795    async fn execute(&self, params: Value, ctx: &ToolContext) -> Result<Value> {
796        let selector = params["workspace"].as_str();
797        if selector == Some(ALL_SELECTOR) {
798            let (healthy, seed_errors) = ctx.router().resolve_all();
799            let results: Vec<Value> = healthy
800                .iter()
801                .map(|rt| sources_group(&rt.id, get_sources(&rt.config)))
802                .collect();
803            let errors: Vec<Value> = seed_errors
804                .iter()
805                .map(|(id, err)| error_entry(id, err))
806                .collect();
807            return Ok(serde_json::json!({ "results": results, "errors": errors }));
808        }
809        let runtime = ctx.router().resolve(selector)?;
810        let sources = get_sources(&runtime.config);
811        Ok(serde_json::json!({
812            "results": [ sources_group(&runtime.id, sources) ],
813            "errors": []
814        }))
815    }
816}
817
818/// Multi-workspace discovery tool: lists registered workspaces, their health,
819/// and which is the default — without exposing any secret config values (R46–R49).
820pub struct WorkspacesTool;
821
822#[async_trait]
823impl Tool for WorkspacesTool {
824    fn name(&self) -> &str {
825        "workspaces"
826    }
827
828    fn description(&self) -> &str {
829        "List registered workspaces, their health, and the default workspace"
830    }
831
832    fn is_builtin(&self) -> bool {
833        true
834    }
835
836    fn parameters_schema(&self) -> Value {
837        serde_json::json!({ "type": "object", "properties": {} })
838    }
839
840    async fn execute(&self, _params: Value, ctx: &ToolContext) -> Result<Value> {
841        let router = ctx.router();
842        let default = router.default_id();
843        let workspaces: Vec<Value> = router
844            .list()
845            .iter()
846            .map(|rt| {
847                let health = match &rt.health {
848                    crate::workspace::WorkspaceHealth::Ok => serde_json::json!({ "status": "ok" }),
849                    crate::workspace::WorkspaceHealth::Unavailable(reason) => {
850                        serde_json::json!({ "status": "unavailable", "reason": reason })
851                    }
852                };
853                serde_json::json!({
854                    "id": rt.id,
855                    "root": rt.root.as_ref().map(|p| p.display().to_string()),
856                    "enabled": rt.enabled,
857                    "default": Some(rt.id.as_str()) == default,
858                    "resolution": rt.resolution.as_str(),
859                    "health": health,
860                })
861            })
862            .collect();
863        Ok(serde_json::json!({ "workspaces": workspaces }))
864    }
865}
866
867// ═══════════════════════════════════════════════════════════════════════
868// Registries
869// ═══════════════════════════════════════════════════════════════════════
870
871/// Registry for connectors (built-in and custom).
872///
873/// Use [`ConnectorRegistry::from_config`] to create a registry pre-loaded
874/// with all built-in connectors from the config file, then optionally
875/// call [`register`](ConnectorRegistry::register) to add custom ones.
876///
877/// # Example
878///
879/// ```rust
880/// use context_harness::traits::ConnectorRegistry;
881///
882/// let mut connectors = ConnectorRegistry::new();
883/// // connectors.register(Box::new(MyConnector::new()));
884/// ```
885pub struct ConnectorRegistry {
886    connectors: Vec<Box<dyn Connector>>,
887}
888
889impl ConnectorRegistry {
890    /// Create an empty connector registry.
891    pub fn new() -> Self {
892        Self {
893            connectors: Vec::new(),
894        }
895    }
896
897    /// Create a registry pre-loaded with all built-in connectors from the config.
898    ///
899    /// This resolves all filesystem, git, S3, and script connector instances
900    /// from the TOML config and wraps them as trait objects.
901    pub fn from_config(config: &Config) -> Self {
902        use crate::connector_fs::FilesystemConnector;
903        use crate::connector_git::GitConnector;
904        use crate::connector_s3::S3Connector;
905        use crate::connector_script::ScriptConnector;
906
907        let mut registry = Self::new();
908
909        for (name, cfg) in &config.connectors.filesystem {
910            registry.register(Box::new(FilesystemConnector::new(
911                name.clone(),
912                cfg.clone(),
913            )));
914        }
915        for (name, cfg) in &config.connectors.git {
916            registry.register(Box::new(GitConnector::new(
917                name.clone(),
918                cfg.clone(),
919                config.db.path.clone(),
920            )));
921        }
922        for (name, cfg) in &config.connectors.s3 {
923            registry.register(Box::new(S3Connector::new(name.clone(), cfg.clone())));
924        }
925        for (name, cfg) in &config.connectors.script {
926            registry.register(Box::new(ScriptConnector::new(name.clone(), cfg.clone())));
927        }
928
929        registry
930    }
931
932    /// Register a connector.
933    pub fn register(&mut self, connector: Box<dyn Connector>) {
934        self.connectors.push(connector);
935    }
936
937    /// Get all registered connectors.
938    pub fn connectors(&self) -> &[Box<dyn Connector>] {
939        &self.connectors
940    }
941
942    /// Get connectors filtered by type (e.g. `"git"`, `"filesystem"`).
943    pub fn connectors_by_type(&self, connector_type: &str) -> Vec<&dyn Connector> {
944        self.connectors
945            .iter()
946            .filter(|c| c.connector_type() == connector_type)
947            .map(|c| c.as_ref())
948            .collect()
949    }
950
951    /// Find a specific connector by type and name.
952    pub fn find(&self, connector_type: &str, name: &str) -> Option<&dyn Connector> {
953        self.connectors
954            .iter()
955            .find(|c| c.connector_type() == connector_type && c.name() == name)
956            .map(|c| c.as_ref())
957    }
958
959    /// Check if the registry is empty.
960    pub fn is_empty(&self) -> bool {
961        self.connectors.is_empty()
962    }
963
964    /// Return the count of registered connectors.
965    pub fn len(&self) -> usize {
966        self.connectors.len()
967    }
968}
969
970impl Default for ConnectorRegistry {
971    fn default() -> Self {
972        Self::new()
973    }
974}
975
976/// Registry for tools (built-in, Lua, and custom Rust).
977///
978/// Use [`ToolRegistry::with_builtins`] to create a registry pre-loaded
979/// with the core `search`, `get`, and `sources` tools, then optionally
980/// call [`register`](ToolRegistry::register) to add custom ones.
981///
982/// # Example
983///
984/// ```rust
985/// use context_harness::traits::ToolRegistry;
986///
987/// let mut tools = ToolRegistry::with_builtins();
988/// // tools.register(Box::new(MyTool::new()));
989/// ```
990pub struct ToolRegistry {
991    tools: Vec<Box<dyn Tool>>,
992}
993
994impl ToolRegistry {
995    /// Create an empty tool registry.
996    pub fn new() -> Self {
997        Self { tools: Vec::new() }
998    }
999
1000    /// Create a tool registry pre-loaded with built-in tools (search, get, sources).
1001    pub fn with_builtins() -> Self {
1002        let mut registry = Self::new();
1003        registry.register(Box::new(SearchTool));
1004        registry.register(Box::new(GetTool));
1005        registry.register(Box::new(SourcesTool));
1006        registry
1007    }
1008
1009    /// Create a tool registry with the router-aware built-ins for
1010    /// multi-workspace mode: `search`/`get`/`sources` accept a `workspace`
1011    /// selector and the `workspaces` discovery tool is added. Workspace-local
1012    /// Lua/Rust tools are intentionally not registered in multi mode in Phase 1
1013    /// (SPEC-0014 R54).
1014    pub fn with_builtins_multi() -> Self {
1015        let mut registry = Self::new();
1016        registry.register(Box::new(RoutedSearchTool));
1017        registry.register(Box::new(RoutedGetTool));
1018        registry.register(Box::new(RoutedSourcesTool));
1019        registry.register(Box::new(WorkspacesTool));
1020        registry
1021    }
1022
1023    /// Register a tool.
1024    pub fn register(&mut self, tool: Box<dyn Tool>) {
1025        self.tools.push(tool);
1026    }
1027
1028    /// Get all registered tools.
1029    pub fn tools(&self) -> &[Box<dyn Tool>] {
1030        &self.tools
1031    }
1032
1033    /// Find a tool by name.
1034    pub fn find(&self, name: &str) -> Option<&dyn Tool> {
1035        self.tools
1036            .iter()
1037            .find(|t| t.name() == name)
1038            .map(|t| t.as_ref())
1039    }
1040
1041    /// Check if the registry is empty.
1042    pub fn is_empty(&self) -> bool {
1043        self.tools.is_empty()
1044    }
1045
1046    /// Return the count of registered tools.
1047    pub fn len(&self) -> usize {
1048        self.tools.len()
1049    }
1050}
1051
1052impl Default for ToolRegistry {
1053    fn default() -> Self {
1054        Self::new()
1055    }
1056}
1057
1058#[cfg(test)]
1059mod phase2_tests {
1060    use super::*;
1061    use crate::workspace::RouterError;
1062
1063    #[test]
1064    fn search_group_tags_items_with_workspace_and_qualified_id() {
1065        let item = SearchResultItem {
1066            id: "01ABC".to_string(),
1067            score: 0.5,
1068            title: Some("T".to_string()),
1069            source: "filesystem".to_string(),
1070            source_id: "f".to_string(),
1071            updated_at: "2026-01-01T00:00:00Z".to_string(),
1072            snippet: "snip".to_string(),
1073            source_url: None,
1074            explain: None,
1075        };
1076        let group = search_group("beta", vec![item]);
1077        assert_eq!(group["workspace"], "beta");
1078        let items = group["items"].as_array().unwrap();
1079        assert_eq!(items[0]["workspace"], "beta");
1080        assert_eq!(items[0]["qualified_id"], "beta:01ABC");
1081    }
1082
1083    #[test]
1084    fn error_entry_has_workspace_code_message() {
1085        let err = RouterError::WorkspaceTimeout {
1086            id: "beta".to_string(),
1087            deadline_ms: 5000,
1088        };
1089        let entry = error_entry("beta", &err);
1090        assert_eq!(entry["workspace"], "beta");
1091        assert_eq!(entry["code"], "workspace_timeout");
1092        assert!(entry["message"].as_str().unwrap().contains("5000"));
1093    }
1094}