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 config: Config = serde_json::from_value(serde_json::json!({
255 "url": "https://api.example.test",
256 "auth_method": "pat"
257 }))
258 .unwrap();
259 McpifyServer::new(
260 "gh-2026-03-10".to_string(),
261 config,
262 Arc::new(Mutex::new(AuthManager::new(AuthMethod::Pat))),
263 )
264 }
265
266 #[test]
267 fn argument_defaults_match_the_public_tool_contract() {
268 assert_eq!(default_search_limit(), 5);
269 assert_eq!(default_call_arguments(), serde_json::json!({}));
270 let search: SearchArgs = serde_json::from_value(serde_json::json!({
271 "query": "find an operation"
272 }))
273 .unwrap();
274 assert_eq!(search.limit, 5);
275 let call: CallArgs = serde_json::from_value(serde_json::json!({
276 "operation_id": "an-operation"
277 }))
278 .unwrap();
279 assert_eq!(call.arguments, serde_json::json!({}));
280 }
281
282 #[tokio::test]
283 async fn search_and_get_return_mcp_content_envelopes() {
284 let server = server();
285 let search = server
286 .search(Parameters(SearchArgs {
287 query: "find an operation".to_string(),
288 limit: 2,
289 }))
290 .await
291 .unwrap();
292 assert_eq!(search.is_error, Some(false));
293
294 let operation_id = {
295 let conn = cached_store_connection("gh-2026-03-10").unwrap();
296 let conn = conn.lock().unwrap();
297 list_endpoints(&conn).unwrap()[0].operation_id.clone()
298 };
299 let get = server
300 .get(Parameters(GetArgs { operation_id }))
301 .await
302 .unwrap();
303 assert_eq!(get.is_error, Some(false));
304
305 let missing = server
306 .get(Parameters(GetArgs {
307 operation_id: "definitely-unknown-operation".to_string(),
308 }))
309 .await
310 .unwrap();
311 assert_eq!(missing.is_error, Some(true));
312 }
313
314 #[tokio::test]
315 async fn run_tool_formats_successes_and_failures_consistently() {
316 let server = server();
317 let success = server
318 .run_tool("coverage", async { Ok(serde_json::json!({ "ok": true })) })
319 .await
320 .unwrap();
321 assert_eq!(success.is_error, Some(false));
322 let failure = server
323 .run_tool("coverage", async { anyhow::bail!("coverage failure") })
324 .await
325 .unwrap();
326 assert_eq!(failure.is_error, Some(true));
327 }
328
329 #[test]
330 fn server_info_advertises_the_generated_tool_surface() {
331 let info = server().get_info();
332 assert_eq!(info.protocol_version, ProtocolVersion::V_2024_11_05);
333 assert!(info.capabilities.tools.is_some());
334 assert!(info.instructions.unwrap().contains("search, get, call"));
335 }
336
337 #[tokio::test]
338 async fn mcp_protocol_routes_search_get_and_call_requests() {
339 let (server_transport, client_transport) = tokio::io::duplex(64 * 1024);
340 let server_task = tokio::spawn(async move {
341 server().serve(server_transport).await?.waiting().await?;
342 anyhow::Ok(())
343 });
344 let client = TestClient.serve(client_transport).await.unwrap();
345
346 let tools = client.list_all_tools().await.unwrap();
347 assert_eq!(
348 tools
349 .iter()
350 .map(|tool| tool.name.as_ref())
351 .collect::<Vec<_>>(),
352 ["call", "get", "search"]
353 );
354 let search = client
355 .call_tool(
356 CallToolRequestParams::new("search").with_arguments(
357 serde_json::json!({ "query": "find an operation", "limit": 1 })
358 .as_object()
359 .unwrap()
360 .clone(),
361 ),
362 )
363 .await
364 .unwrap();
365 assert_eq!(search.is_error, Some(false));
366
367 let operation_id = {
368 let conn = cached_store_connection("gh-2026-03-10").unwrap();
369 let conn = conn.lock().unwrap();
370 list_endpoints(&conn).unwrap()[0].operation_id.clone()
371 };
372 let get = client
373 .call_tool(
374 CallToolRequestParams::new("get").with_arguments(
375 serde_json::json!({ "operation_id": operation_id })
376 .as_object()
377 .unwrap()
378 .clone(),
379 ),
380 )
381 .await
382 .unwrap();
383 assert_eq!(get.is_error, Some(false));
384
385 let call = client
386 .call_tool(
387 CallToolRequestParams::new("call").with_arguments(
388 serde_json::json!({ "operation_id": "definitely-unknown", "arguments": {} })
389 .as_object()
390 .unwrap()
391 .clone(),
392 ),
393 )
394 .await
395 .unwrap();
396 assert_eq!(call.is_error, Some(true));
397
398 let real_call = client
406 .call_tool(
407 CallToolRequestParams::new("call").with_arguments(
408 serde_json::json!({ "operation_id": operation_id, "arguments": {} })
409 .as_object()
410 .unwrap()
411 .clone(),
412 ),
413 )
414 .await
415 .unwrap();
416 assert_eq!(real_call.is_error, Some(true));
417
418 drop(client);
419 tokio::time::timeout(std::time::Duration::from_secs(2), server_task)
420 .await
421 .unwrap()
422 .unwrap()
423 .unwrap();
424 }
425}