Skip to main content

github_mcp/core/
mcp_server.rs

1// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
2
3use 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    /// Natural-language description of the operation you need
35    pub query: String,
36    /// Maximum number of results
37    #[serde(default = "default_search_limit")]
38    pub limit: usize,
39}
40
41#[derive(Debug, Deserialize, schemars::JsonSchema)]
42pub struct GetArgs {
43    /// operationId returned by search
44    pub operation_id: String,
45}
46
47/// A missing `arguments` field defaults to `{}`, not `null` — every
48/// operation's generated input JSON Schema unconditionally declares
49/// `"type": "object"`, even for zero-param operations, so `null` always
50/// fails validation while `{}` always passes.
51fn default_call_arguments() -> serde_json::Value {
52    serde_json::json!({})
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct CallArgs {
57    /// operationId returned by search
58    pub operation_id: String,
59    /// Operation parameters and/or request body. Defaults to `{}` when omitted.
60    #[serde(default = "default_call_arguments")]
61    pub arguments: serde_json::Value,
62}
63
64/// Shared state every `search`/`get`/`call` tool method needs. `Clone`
65/// because rmcp constructs one instance per session (see
66/// `http::server::start_http_server`'s service factory) — every field is
67/// either cheap to clone (`String`, `Config`) or already `Arc`-wrapped.
68#[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    /// Takes an already-`Arc<Mutex<_>>`-wrapped `AuthManager` rather than
80    /// an owned one: `http::server::start_http_server`'s service factory
81    /// constructs a fresh `McpifyServer` per session, and `AuthManager`
82    /// itself isn't `Clone` (its `Box<dyn AuthStrategy>` field isn't
83    /// object-safe to clone) — every session shares the one configured
84    /// auth manager instead, which also matches this deployment's actual
85    /// semantics (a single configured auth method, not one per session).
86    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        // HTTP transport only: rmcp injects this call's own
136        // `http::request::Parts` into `context.extensions` regardless of
137        // how long the session's underlying worker task lives (rmcp does
138        // this per JSON-RPC message, not just once at session creation —
139        // see `http::server::auth_gate`'s doc comment for why this is the
140        // one mechanism that actually works here). `None` on stdio, where
141        // no such extension is ever inserted.
142        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            // Looked up and the connection (guard) dropped *before* any
152            // `.await` below — `rusqlite::Connection` isn't `Sync`, so a
153            // `&Connection`/`MutexGuard<Connection>` held across an await
154            // point would make this future non-`Send`.
155            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    /// Wraps a tool's core logic with consistent MCP response formatting
179    /// and error handling, so `search`/`get`/`call` each only implement
180    /// their own business logic, not the MCP content-envelope
181    /// boilerplate — mirrors `targets::typescript`'s `tool-executor.ts`.
182    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// `router = self.tool_router.clone()`: without it, `#[tool_handler]`
203// defaults to calling `Self::tool_router()` fresh on every `list_tools`/
204// `call_tool` request, rebuilding the router instead of reusing the one
205// `new()` already built into this instance's `tool_router` field.
206#[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
228/// Runs `server` over the stdio transport until the client disconnects —
229/// the Terminal Client / Harness Server "stdio" mode's connection point
230/// (Story R5 wires this into `main.rs`'s subcommand dispatch).
231pub 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        // Keep the known-operation call local and guaranteed to fail fast.
255        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        // A *known* operationId exercises the rest of `call`'s pipeline
414        // (endpoint lookup succeeding, locking the shared `AuthManager`,
415        // and invoking `call_operation`) — unlike the unknown-operationId
416        // case above, which returns before ever reaching that code. The
417        // live request itself still fails (`server()`'s config points at
418        // a non-routable test domain), so this is still an error result,
419        // just from a different, later stage of the pipeline.
420        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}