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, prompt_handler, schemars, tool,
14 tool_handler, 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 prompt_router: rmcp::handler::server::router::prompt::PromptRouter<McpifyServer>,
75}
76
77#[tool_router]
78impl McpifyServer {
79 pub fn new(api_version: String, config: Config, auth_manager: Arc<Mutex<AuthManager>>) -> Self {
87 Self {
88 api_version,
89 config,
90 auth_manager,
91 tool_router: Self::tool_router(),
92 prompt_router: Self::prompt_router(),
93 }
94 }
95
96 #[tool(
97 description = "Semantic search for GitHub v3 REST API operations using a natural-language query."
98 )]
99 async fn search(
100 &self,
101 Parameters(args): Parameters<SearchArgs>,
102 ) -> Result<CallToolResult, McpError> {
103 let api_version = self.api_version.clone();
104 self.run_tool("search", async move {
105 let conn = cached_store_connection(&api_version)?.lock().unwrap();
106 search_operations(&conn, &args.query, args.limit)
107 })
108 .await
109 }
110
111 #[tool(
112 description = "Return the schema, path, method, and documentation for a specific GitHub v3 REST API operationId."
113 )]
114 async fn get(&self, Parameters(args): Parameters<GetArgs>) -> Result<CallToolResult, McpError> {
115 let api_version = self.api_version.clone();
116 self.run_tool("get", async move {
117 let conn = cached_store_connection(&api_version)?.lock().unwrap();
118 get_operation(&conn, &args.operation_id)
119 })
120 .await
121 }
122
123 #[tool(
124 description = "Validate arguments, invoke a live GitHub v3 REST API API operation, and validate the response."
125 )]
126 async fn call(
127 &self,
128 Parameters(args): Parameters<CallArgs>,
129 context: RequestContext<RoleServer>,
130 ) -> Result<CallToolResult, McpError> {
131 let api_version = self.api_version.clone();
132 let config = self.config.clone();
133 let auth_manager = self.auth_manager.clone();
134
135 let request_credentials = context
143 .extensions
144 .get::<axum::http::request::Parts>()
145 .and_then(|parts| {
146 let (header_location, header_name) = header_location_for(config.auth_method);
147 extract_request_credentials(&parts.headers, header_location, header_name).ok()
148 });
149
150 self.run_tool("call", async move {
151 let endpoint = {
156 let conn = cached_store_connection(&api_version)?.lock().unwrap();
157 get_endpoint(&conn, &args.operation_id)?.ok_or_else(|| {
158 McpifyError::NotFound(format!("unknown operationId '{}'", args.operation_id))
159 })?
160 };
161
162 let mut auth_manager = auth_manager.lock().await;
163 call_operation(
164 &endpoint,
165 &config,
166 &mut auth_manager,
167 &args.operation_id,
168 args.arguments,
169 request_credentials.as_ref(),
170 )
171 .await
172 })
173 .await
174 }
175}
176
177impl McpifyServer {
178 async fn run_tool<F>(&self, tool_name: &str, fut: F) -> Result<CallToolResult, McpError>
183 where
184 F: std::future::Future<Output = anyhow::Result<serde_json::Value>>,
185 {
186 match fut.await {
187 Ok(value) => {
188 let text =
189 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
190 Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
191 }
192 Err(err) => {
193 tracing::error!(tool = tool_name, error = %err, "tool execution failed");
194 Ok(CallToolResult::error(vec![ContentBlock::text(
195 err.to_string(),
196 )]))
197 }
198 }
199 }
200}
201
202#[tool_handler(router = self.tool_router.clone())]
207#[prompt_handler(router = self.prompt_router.clone())]
208impl ServerHandler for McpifyServer {
209 fn get_info(&self) -> ServerInfo {
210 ServerInfo::new(
211 ServerCapabilities::builder()
212 .enable_tools()
213 .enable_prompts()
214 .build(),
215 )
216 .with_server_info(Implementation::from_build_env())
217 .with_protocol_version(ProtocolVersion::V_2024_11_05)
218 .with_instructions(
219 "Exposes exactly 3 tools -- search, get, call -- backed by an embedded \
220 semantic database, so you never need the full API surface in context. \
221 Also exposes MCP prompts -- start with the `github-workflow` prompt for \
222 guided, multi-step help with common GitHub management tasks."
223 .to_string(),
224 )
225 }
226}
227
228pub async fn connect_stdio<S>(server: S) -> anyhow::Result<()>
232where
233 S: rmcp::ServerHandler,
234{
235 let running = server.serve(stdio()).await?;
236 tracing::info!("MCP server connected over stdio");
237 running.waiting().await?;
238 Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::core::config_schema::AuthMethod;
245 use crate::data::store::list_endpoints;
246 use rmcp::model::CallToolRequestParams;
247
248 #[derive(Debug, Clone, Default)]
249 struct TestClient;
250
251 impl rmcp::ClientHandler for TestClient {}
252
253 fn server() -> McpifyServer {
254 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
256 let address = listener.local_addr().unwrap();
257 drop(listener);
258 let config: Config = serde_json::from_value(serde_json::json!({
259 "url": format!("http://{address}"),
260 "auth_method": "pat"
261 }))
262 .unwrap();
263 McpifyServer::new(
264 "gh-2026-03-10".to_string(),
265 config,
266 Arc::new(Mutex::new(AuthManager::new(AuthMethod::Pat))),
267 )
268 }
269
270 #[test]
271 fn argument_defaults_match_the_public_tool_contract() {
272 assert_eq!(default_search_limit(), 5);
273 assert_eq!(default_call_arguments(), serde_json::json!({}));
274 let search: SearchArgs = serde_json::from_value(serde_json::json!({
275 "query": "find an operation"
276 }))
277 .unwrap();
278 assert_eq!(search.limit, 5);
279 let call: CallArgs = serde_json::from_value(serde_json::json!({
280 "operation_id": "an-operation"
281 }))
282 .unwrap();
283 assert_eq!(call.arguments, serde_json::json!({}));
284 }
285
286 #[tokio::test]
287 async fn search_and_get_return_mcp_content_envelopes() {
288 let server = server();
289 let search = server
290 .search(Parameters(SearchArgs {
291 query: "find an operation".to_string(),
292 limit: 2,
293 }))
294 .await
295 .unwrap();
296 assert_eq!(search.is_error, Some(false));
297
298 let operation_id = {
299 let conn = cached_store_connection("gh-2026-03-10").unwrap();
300 let conn = conn.lock().unwrap();
301 list_endpoints(&conn).unwrap()[0].operation_id.clone()
302 };
303 let get = server
304 .get(Parameters(GetArgs { operation_id }))
305 .await
306 .unwrap();
307 assert_eq!(get.is_error, Some(false));
308
309 let missing = server
310 .get(Parameters(GetArgs {
311 operation_id: "definitely-unknown-operation".to_string(),
312 }))
313 .await
314 .unwrap();
315 assert_eq!(missing.is_error, Some(true));
316 }
317
318 #[tokio::test]
319 async fn run_tool_formats_successes_and_failures_consistently() {
320 let server = server();
321 let success = server
322 .run_tool("coverage", async { Ok(serde_json::json!({ "ok": true })) })
323 .await
324 .unwrap();
325 assert_eq!(success.is_error, Some(false));
326 let failure = server
327 .run_tool("coverage", async { anyhow::bail!("coverage failure") })
328 .await
329 .unwrap();
330 assert_eq!(failure.is_error, Some(true));
331 }
332
333 #[test]
334 fn server_info_advertises_the_generated_tool_surface() {
335 let info = server().get_info();
336 assert_eq!(info.protocol_version, ProtocolVersion::V_2024_11_05);
337 assert!(info.capabilities.tools.is_some());
338 assert!(info.instructions.unwrap().contains("search, get, call"));
339 }
340
341 #[tokio::test]
342 async fn mcp_protocol_routes_search_get_and_call_requests() {
343 tokio::time::timeout(
344 std::time::Duration::from_secs(120),
345 mcp_protocol_routes_search_get_and_call_requests_inner(),
346 )
347 .await
348 .expect(
349 "mcp protocol test timed out after 120s — the server task likely panicked mid-request",
350 );
351 }
352
353 async fn mcp_protocol_routes_search_get_and_call_requests_inner() {
354 let (server_transport, client_transport) = tokio::io::duplex(64 * 1024);
355 let server_task = tokio::spawn(async move {
356 server().serve(server_transport).await?.waiting().await?;
357 anyhow::Ok(())
358 });
359 let client = TestClient.serve(client_transport).await.unwrap();
360
361 let tools = client.list_all_tools().await.unwrap();
362 assert_eq!(
363 tools
364 .iter()
365 .map(|tool| tool.name.as_ref())
366 .collect::<Vec<_>>(),
367 ["call", "get", "search"]
368 );
369 let search = client
370 .call_tool(
371 CallToolRequestParams::new("search").with_arguments(
372 serde_json::json!({ "query": "find an operation", "limit": 1 })
373 .as_object()
374 .unwrap()
375 .clone(),
376 ),
377 )
378 .await
379 .unwrap();
380 assert_eq!(search.is_error, Some(false));
381
382 let operation_id = {
383 let conn = cached_store_connection("gh-2026-03-10").unwrap();
384 let conn = conn.lock().unwrap();
385 list_endpoints(&conn).unwrap()[0].operation_id.clone()
386 };
387 let get = client
388 .call_tool(
389 CallToolRequestParams::new("get").with_arguments(
390 serde_json::json!({ "operation_id": operation_id })
391 .as_object()
392 .unwrap()
393 .clone(),
394 ),
395 )
396 .await
397 .unwrap();
398 assert_eq!(get.is_error, Some(false));
399
400 let call = client
401 .call_tool(
402 CallToolRequestParams::new("call").with_arguments(
403 serde_json::json!({ "operation_id": "definitely-unknown", "arguments": {} })
404 .as_object()
405 .unwrap()
406 .clone(),
407 ),
408 )
409 .await
410 .unwrap();
411 assert_eq!(call.is_error, Some(true));
412
413 let real_call = client
421 .call_tool(
422 CallToolRequestParams::new("call").with_arguments(
423 serde_json::json!({ "operation_id": operation_id, "arguments": {} })
424 .as_object()
425 .unwrap()
426 .clone(),
427 ),
428 )
429 .await
430 .unwrap();
431 assert_eq!(real_call.is_error, Some(true));
432
433 drop(client);
434 tokio::time::timeout(std::time::Duration::from_secs(2), server_task)
435 .await
436 .unwrap()
437 .unwrap()
438 .unwrap();
439 }
440}