context_harness/
mcp.rs

1//! MCP JSON-RPC protocol bridge.
2//!
3//! Adapts the existing [`ToolRegistry`] / [`AgentRegistry`] and REST API
4//! into a proper MCP Streamable HTTP endpoint that Cursor and other MCP
5//! clients can connect to using the standard JSON-RPC protocol.
6//!
7//! * **Tools** are exposed as MCP tools via `list_tools` / `call_tool`.
8//! * **Agents** are exposed as MCP prompts via `list_prompts` / `get_prompt`.
9
10use std::borrow::Cow;
11use std::sync::Arc;
12
13use rmcp::model::*;
14use rmcp::{ErrorData as McpError, ServerHandler};
15
16use crate::agents::AgentRegistry;
17use crate::config::Config;
18use crate::traits::{ToolContext, ToolRegistry};
19
20/// Bridges the existing registries to the MCP JSON-RPC protocol.
21///
22/// Each MCP session receives a clone of this struct (everything is
23/// behind `Arc`), so all sessions share the same tool set and agents.
24#[derive(Clone)]
25pub struct McpBridge {
26    config: Arc<Config>,
27    tools: Arc<ToolRegistry>,
28    extra_tools: Arc<ToolRegistry>,
29    agents: Arc<AgentRegistry>,
30    extra_agents: Arc<AgentRegistry>,
31}
32
33impl McpBridge {
34    pub fn new(
35        config: Arc<Config>,
36        tools: Arc<ToolRegistry>,
37        extra_tools: Arc<ToolRegistry>,
38        agents: Arc<AgentRegistry>,
39        extra_agents: Arc<AgentRegistry>,
40    ) -> Self {
41        Self {
42            config,
43            tools,
44            extra_tools,
45            agents,
46            extra_agents,
47        }
48    }
49
50    fn find_tool(&self, name: &str) -> Option<&dyn crate::traits::Tool> {
51        self.tools
52            .find(name)
53            .or_else(|| self.extra_tools.find(name))
54    }
55
56    fn find_agent(&self, name: &str) -> Option<&dyn crate::agents::Agent> {
57        self.agents
58            .find(name)
59            .or_else(|| self.extra_agents.find(name))
60    }
61
62    /// Convert a context-harness tool into an rmcp `Tool` descriptor.
63    fn to_mcp_tool(tool: &dyn crate::traits::Tool) -> Tool {
64        let schema_value = tool.parameters_schema();
65        let input_schema: Arc<serde_json::Map<String, serde_json::Value>> = match schema_value {
66            serde_json::Value::Object(map) => Arc::new(map),
67            _ => Arc::new(serde_json::Map::new()),
68        };
69
70        Tool {
71            name: Cow::Owned(tool.name().to_string()),
72            title: None,
73            description: Some(Cow::Owned(tool.description().to_string())),
74            input_schema,
75            output_schema: None,
76            annotations: Some(ToolAnnotations::new().read_only(true)),
77            execution: None,
78            icons: None,
79            meta: None,
80        }
81    }
82
83    /// Convert a context-harness agent into an rmcp `Prompt` descriptor.
84    fn to_mcp_prompt(agent: &dyn crate::agents::Agent) -> Prompt {
85        let arguments: Option<Vec<PromptArgument>> = {
86            let args = agent.arguments();
87            if args.is_empty() {
88                None
89            } else {
90                Some(
91                    args.into_iter()
92                        .map(|a| PromptArgument {
93                            name: a.name,
94                            title: None,
95                            description: Some(a.description),
96                            required: Some(a.required),
97                        })
98                        .collect(),
99                )
100            }
101        };
102
103        Prompt {
104            name: agent.name().to_string(),
105            title: None,
106            description: Some(agent.description().to_string()),
107            arguments,
108            icons: None,
109            meta: None,
110        }
111    }
112}
113
114impl ServerHandler for McpBridge {
115    fn get_info(&self) -> ServerInfo {
116        ServerInfo {
117            protocol_version: ProtocolVersion::LATEST,
118            capabilities: ServerCapabilities::builder()
119                .enable_tools()
120                .enable_prompts()
121                .build(),
122            server_info: Implementation {
123                name: "context-harness".to_string(),
124                title: Some("Context Harness".to_string()),
125                version: env!("CARGO_PKG_VERSION").to_string(),
126                description: None,
127                icons: None,
128                website_url: None,
129            },
130            instructions: Some(
131                "Context Harness — local-first context ingestion and retrieval for AI tools. \
132                 Use the search tool to find relevant documents, get to retrieve a specific \
133                 document by ID, and sources to list connector status. \
134                 Agents are available as prompts — use list_prompts to discover them."
135                    .to_string(),
136            ),
137        }
138    }
139
140    // ── Tools ────────────────────────────────────────────────────────────
141
142    fn list_tools(
143        &self,
144        _request: Option<PaginatedRequestParams>,
145        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
146    ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
147        let mut tools: Vec<Tool> = self
148            .tools
149            .tools()
150            .iter()
151            .map(|t| Self::to_mcp_tool(t.as_ref()))
152            .collect();
153        for t in self.extra_tools.tools() {
154            tools.push(Self::to_mcp_tool(t.as_ref()));
155        }
156        std::future::ready(Ok(ListToolsResult::with_all_items(tools)))
157    }
158
159    fn get_tool(&self, name: &str) -> Option<Tool> {
160        self.find_tool(name).map(Self::to_mcp_tool)
161    }
162
163    async fn call_tool(
164        &self,
165        request: CallToolRequestParams,
166        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
167    ) -> Result<CallToolResult, McpError> {
168        let tool = self.find_tool(&request.name).ok_or_else(|| {
169            McpError::new(
170                ErrorCode::METHOD_NOT_FOUND,
171                format!("no tool registered with name: {}", request.name),
172                None,
173            )
174        })?;
175
176        let params = request
177            .arguments
178            .map(serde_json::Value::Object)
179            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
180
181        let ctx = ToolContext::new(self.config.clone());
182        match tool.execute(params, &ctx).await {
183            Ok(result) => {
184                let text = serde_json::to_string_pretty(&result).unwrap_or_default();
185                Ok(CallToolResult::success(vec![Content::text(text)]))
186            }
187            Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
188        }
189    }
190
191    // ── Prompts (agents) ─────────────────────────────────────────────────
192
193    fn list_prompts(
194        &self,
195        _request: Option<PaginatedRequestParams>,
196        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
197    ) -> impl std::future::Future<Output = Result<ListPromptsResult, McpError>> + Send + '_ {
198        let mut prompts: Vec<Prompt> = self
199            .agents
200            .agents()
201            .iter()
202            .map(|a| Self::to_mcp_prompt(a.as_ref()))
203            .collect();
204        for a in self.extra_agents.agents() {
205            prompts.push(Self::to_mcp_prompt(a.as_ref()));
206        }
207        std::future::ready(Ok(ListPromptsResult::with_all_items(prompts)))
208    }
209
210    async fn get_prompt(
211        &self,
212        request: GetPromptRequestParams,
213        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
214    ) -> Result<GetPromptResult, McpError> {
215        let agent = self.find_agent(&request.name).ok_or_else(|| {
216            McpError::new(
217                ErrorCode::METHOD_NOT_FOUND,
218                format!("no agent registered with name: {}", request.name),
219                None,
220            )
221        })?;
222
223        let args = request
224            .arguments
225            .map(serde_json::Value::Object)
226            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
227
228        let ctx = ToolContext::new(self.config.clone());
229        let resolved = agent.resolve(args, &ctx).await.map_err(|e| {
230            McpError::new(
231                ErrorCode::INTERNAL_ERROR,
232                format!("agent '{}': {}", request.name, e),
233                None,
234            )
235        })?;
236
237        let mut messages: Vec<PromptMessage> = Vec::new();
238
239        // System prompt as a user-role message (MCP prompts don't have a
240        // system role, so we prepend it as user context).
241        if !resolved.system.is_empty() {
242            messages.push(PromptMessage::new_text(
243                PromptMessageRole::User,
244                &resolved.system,
245            ));
246        }
247
248        for msg in &resolved.messages {
249            let role = match msg.role.as_str() {
250                "assistant" => PromptMessageRole::Assistant,
251                _ => PromptMessageRole::User,
252            };
253            messages.push(PromptMessage::new_text(role, &msg.content));
254        }
255
256        Ok(GetPromptResult {
257            description: Some(agent.description().to_string()),
258            messages,
259        })
260    }
261}