1use std::sync::Arc;
4
5use rmcp::handler::server::router::tool::ToolRouter;
6use rmcp::handler::server::wrapper::Parameters;
7use rmcp::model::{
8 CallToolResult, ContentBlock, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
9};
10use rmcp::service::RequestContext;
11use rmcp::transport::stdio;
12use rmcp::{
13 ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, schemars, tool, tool_handler,
14 tool_router,
15};
16use serde::Deserialize;
17use tokio::sync::Mutex;
18
19use crate::auth::auth_manager::{AuthManager, header_location_for};
20use crate::core::config_schema::Config;
21use crate::core::errors::McpifyError;
22use crate::data::store::{cached_store_connection, get_endpoint};
23use crate::http::auth_extractor::extract_request_credentials;
24use crate::tools::call_tool::call_operation;
25use crate::tools::get_tool::get_operation;
26use crate::tools::search_tool::search_operations;
27
28fn default_search_limit() -> usize {
29 5
30}
31
32#[derive(Debug, Deserialize, schemars::JsonSchema)]
33pub struct SearchArgs {
34 pub query: String,
36 #[serde(default = "default_search_limit")]
38 pub limit: usize,
39}
40
41#[derive(Debug, Deserialize, schemars::JsonSchema)]
42pub struct GetArgs {
43 pub operation_id: String,
45}
46
47fn default_call_arguments() -> serde_json::Value {
52 serde_json::json!({})
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct CallArgs {
57 pub operation_id: String,
59 #[serde(default = "default_call_arguments")]
61 pub arguments: serde_json::Value,
62}
63
64#[derive(Clone)]
69pub struct McpifyServer {
70 api_version: String,
71 config: Config,
72 auth_manager: Arc<Mutex<AuthManager>>,
73 tool_router: ToolRouter<McpifyServer>,
74}
75
76#[tool_router]
77impl McpifyServer {
78 pub fn new(api_version: String, config: Config, auth_manager: Arc<Mutex<AuthManager>>) -> Self {
86 Self {
87 api_version,
88 config,
89 auth_manager,
90 tool_router: Self::tool_router(),
91 }
92 }
93
94 #[tool(
95 description = "Semantic search for GitHub v3 REST API operations using a natural-language query."
96 )]
97 async fn search(
98 &self,
99 Parameters(args): Parameters<SearchArgs>,
100 ) -> Result<CallToolResult, McpError> {
101 let api_version = self.api_version.clone();
102 self.run_tool("search", async move {
103 let conn = cached_store_connection(&api_version)?.lock().unwrap();
104 search_operations(&conn, &args.query, args.limit)
105 })
106 .await
107 }
108
109 #[tool(
110 description = "Return the schema, path, method, and documentation for a specific GitHub v3 REST API operationId."
111 )]
112 async fn get(&self, Parameters(args): Parameters<GetArgs>) -> Result<CallToolResult, McpError> {
113 let api_version = self.api_version.clone();
114 self.run_tool("get", async move {
115 let conn = cached_store_connection(&api_version)?.lock().unwrap();
116 get_operation(&conn, &args.operation_id)
117 })
118 .await
119 }
120
121 #[tool(
122 description = "Validate arguments, invoke a live GitHub v3 REST API API operation, and validate the response."
123 )]
124 async fn call(
125 &self,
126 Parameters(args): Parameters<CallArgs>,
127 context: RequestContext<RoleServer>,
128 ) -> Result<CallToolResult, McpError> {
129 let api_version = self.api_version.clone();
130 let config = self.config.clone();
131 let auth_manager = self.auth_manager.clone();
132
133 let request_credentials = context
141 .extensions
142 .get::<axum::http::request::Parts>()
143 .and_then(|parts| {
144 let (header_location, header_name) = header_location_for(config.auth_method);
145 extract_request_credentials(&parts.headers, header_location, header_name).ok()
146 });
147
148 self.run_tool("call", async move {
149 let endpoint = {
154 let conn = cached_store_connection(&api_version)?.lock().unwrap();
155 get_endpoint(&conn, &args.operation_id)?.ok_or_else(|| {
156 McpifyError::NotFound(format!("unknown operationId '{}'", args.operation_id))
157 })?
158 };
159
160 let mut auth_manager = auth_manager.lock().await;
161 call_operation(
162 &endpoint,
163 &config,
164 &mut auth_manager,
165 &args.operation_id,
166 args.arguments,
167 request_credentials.as_ref(),
168 )
169 .await
170 })
171 .await
172 }
173}
174
175impl McpifyServer {
176 async fn run_tool<F>(&self, tool_name: &str, fut: F) -> Result<CallToolResult, McpError>
181 where
182 F: std::future::Future<Output = anyhow::Result<serde_json::Value>>,
183 {
184 match fut.await {
185 Ok(value) => {
186 let text =
187 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
188 Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
189 }
190 Err(err) => {
191 tracing::error!(tool = tool_name, error = %err, "tool execution failed");
192 Ok(CallToolResult::error(vec![ContentBlock::text(
193 err.to_string(),
194 )]))
195 }
196 }
197 }
198}
199
200#[tool_handler(router = self.tool_router.clone())]
205impl ServerHandler for McpifyServer {
206 fn get_info(&self) -> ServerInfo {
207 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
208 .with_server_info(Implementation::from_build_env())
209 .with_protocol_version(ProtocolVersion::V_2024_11_05)
210 .with_instructions(
211 "Exposes exactly 3 tools -- search, get, call -- backed by an embedded \
212 semantic database, so you never need the full API surface in context."
213 .to_string(),
214 )
215 }
216}
217
218pub async fn connect_stdio<S>(server: S) -> anyhow::Result<()>
222where
223 S: rmcp::ServerHandler,
224{
225 let running = server.serve(stdio()).await?;
226 tracing::info!("MCP server connected over stdio");
227 running.waiting().await?;
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::core::config_schema::AuthMethod;
235 use crate::data::store::list_endpoints;
236 use rmcp::model::CallToolRequestParams;
237
238 #[derive(Debug, Clone, Default)]
239 struct TestClient;
240
241 impl rmcp::ClientHandler for TestClient {}
242
243 fn server() -> McpifyServer {
244 let config: Config = serde_json::from_value(serde_json::json!({
245 "url": "https://api.example.test",
246 "auth_method": "pat"
247 }))
248 .unwrap();
249 McpifyServer::new(
250 "gh-2026-03-10".to_string(),
251 config,
252 Arc::new(Mutex::new(AuthManager::new(AuthMethod::Pat))),
253 )
254 }
255
256 #[test]
257 fn argument_defaults_match_the_public_tool_contract() {
258 assert_eq!(default_search_limit(), 5);
259 assert_eq!(default_call_arguments(), serde_json::json!({}));
260 let search: SearchArgs = serde_json::from_value(serde_json::json!({
261 "query": "find an operation"
262 }))
263 .unwrap();
264 assert_eq!(search.limit, 5);
265 let call: CallArgs = serde_json::from_value(serde_json::json!({
266 "operation_id": "an-operation"
267 }))
268 .unwrap();
269 assert_eq!(call.arguments, serde_json::json!({}));
270 }
271
272 #[tokio::test]
273 async fn search_and_get_return_mcp_content_envelopes() {
274 let server = server();
275 let search = server
276 .search(Parameters(SearchArgs {
277 query: "find an operation".to_string(),
278 limit: 2,
279 }))
280 .await
281 .unwrap();
282 assert_eq!(search.is_error, Some(false));
283
284 let operation_id = {
285 let conn = cached_store_connection("gh-2026-03-10").unwrap();
286 let conn = conn.lock().unwrap();
287 list_endpoints(&conn).unwrap()[0].operation_id.clone()
288 };
289 let get = server
290 .get(Parameters(GetArgs { operation_id }))
291 .await
292 .unwrap();
293 assert_eq!(get.is_error, Some(false));
294
295 let missing = server
296 .get(Parameters(GetArgs {
297 operation_id: "definitely-unknown-operation".to_string(),
298 }))
299 .await
300 .unwrap();
301 assert_eq!(missing.is_error, Some(true));
302 }
303
304 #[tokio::test]
305 async fn run_tool_formats_successes_and_failures_consistently() {
306 let server = server();
307 let success = server
308 .run_tool("coverage", async { Ok(serde_json::json!({ "ok": true })) })
309 .await
310 .unwrap();
311 assert_eq!(success.is_error, Some(false));
312 let failure = server
313 .run_tool("coverage", async { anyhow::bail!("coverage failure") })
314 .await
315 .unwrap();
316 assert_eq!(failure.is_error, Some(true));
317 }
318
319 #[test]
320 fn server_info_advertises_the_generated_tool_surface() {
321 let info = server().get_info();
322 assert_eq!(info.protocol_version, ProtocolVersion::V_2024_11_05);
323 assert!(info.capabilities.tools.is_some());
324 assert!(info.instructions.unwrap().contains("search, get, call"));
325 }
326
327 #[tokio::test]
328 async fn mcp_protocol_routes_search_get_and_call_requests() {
329 let (server_transport, client_transport) = tokio::io::duplex(64 * 1024);
330 let server_task = tokio::spawn(async move {
331 server().serve(server_transport).await?.waiting().await?;
332 anyhow::Ok(())
333 });
334 let client = TestClient.serve(client_transport).await.unwrap();
335
336 let tools = client.list_all_tools().await.unwrap();
337 assert_eq!(
338 tools
339 .iter()
340 .map(|tool| tool.name.as_ref())
341 .collect::<Vec<_>>(),
342 ["call", "get", "search"]
343 );
344 let search = client
345 .call_tool(
346 CallToolRequestParams::new("search").with_arguments(
347 serde_json::json!({ "query": "find an operation", "limit": 1 })
348 .as_object()
349 .unwrap()
350 .clone(),
351 ),
352 )
353 .await
354 .unwrap();
355 assert_eq!(search.is_error, Some(false));
356
357 let operation_id = {
358 let conn = cached_store_connection("gh-2026-03-10").unwrap();
359 let conn = conn.lock().unwrap();
360 list_endpoints(&conn).unwrap()[0].operation_id.clone()
361 };
362 let get = client
363 .call_tool(
364 CallToolRequestParams::new("get").with_arguments(
365 serde_json::json!({ "operation_id": operation_id })
366 .as_object()
367 .unwrap()
368 .clone(),
369 ),
370 )
371 .await
372 .unwrap();
373 assert_eq!(get.is_error, Some(false));
374
375 let call = client
376 .call_tool(
377 CallToolRequestParams::new("call").with_arguments(
378 serde_json::json!({ "operation_id": "definitely-unknown", "arguments": {} })
379 .as_object()
380 .unwrap()
381 .clone(),
382 ),
383 )
384 .await
385 .unwrap();
386 assert_eq!(call.is_error, Some(true));
387
388 drop(client);
389 tokio::time::timeout(std::time::Duration::from_secs(2), server_task)
390 .await
391 .unwrap()
392 .unwrap()
393 .unwrap();
394 }
395}