Skip to main content

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