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, 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    /// 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}
75
76#[tool_router]
77impl McpifyServer {
78    /// Takes an already-`Arc<Mutex<_>>`-wrapped `AuthManager` rather than
79    /// an owned one: `http::server::start_http_server`'s service factory
80    /// constructs a fresh `McpifyServer` per session, and `AuthManager`
81    /// itself isn't `Clone` (its `Box<dyn AuthStrategy>` field isn't
82    /// object-safe to clone) — every session shares the one configured
83    /// auth manager instead, which also matches this deployment's actual
84    /// semantics (a single configured auth method, not one per session).
85    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        // HTTP transport only: rmcp injects this call's own
134        // `http::request::Parts` into `context.extensions` regardless of
135        // how long the session's underlying worker task lives (rmcp does
136        // this per JSON-RPC message, not just once at session creation —
137        // see `http::server::auth_gate`'s doc comment for why this is the
138        // one mechanism that actually works here). `None` on stdio, where
139        // no such extension is ever inserted.
140        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            // Looked up and the connection (guard) dropped *before* any
150            // `.await` below — `rusqlite::Connection` isn't `Sync`, so a
151            // `&Connection`/`MutexGuard<Connection>` held across an await
152            // point would make this future non-`Send`.
153            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    /// Wraps a tool's core logic with consistent MCP response formatting
177    /// and error handling, so `search`/`get`/`call` each only implement
178    /// their own business logic, not the MCP content-envelope
179    /// boilerplate — mirrors `targets::typescript`'s `tool-executor.ts`.
180    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// `router = self.tool_router.clone()`: without it, `#[tool_handler]`
201// defaults to calling `Self::tool_router()` fresh on every `list_tools`/
202// `call_tool` request, rebuilding the router instead of reusing the one
203// `new()` already built into this instance's `tool_router` field.
204#[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
218/// Runs `server` over the stdio transport until the client disconnects —
219/// the Terminal Client / Harness Server "stdio" mode's connection point
220/// (Story R5 wires this into `main.rs`'s subcommand dispatch).
221pub 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}