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#[derive(Debug, Deserialize, schemars::JsonSchema)]
48pub struct CallArgs {
49    /// operationId returned by search
50    pub operation_id: String,
51    /// Operation parameters and/or request body
52    #[serde(default)]
53    pub arguments: serde_json::Value,
54}
55
56/// Shared state every `search`/`get`/`call` tool method needs. `Clone`
57/// because rmcp constructs one instance per session (see
58/// `http::server::start_http_server`'s service factory) — every field is
59/// either cheap to clone (`String`, `Config`) or already `Arc`-wrapped.
60#[derive(Clone)]
61pub struct McpifyServer {
62    api_version: String,
63    config: Config,
64    auth_manager: Arc<Mutex<AuthManager>>,
65    tool_router: ToolRouter<McpifyServer>,
66}
67
68#[tool_router]
69impl McpifyServer {
70    /// Takes an already-`Arc<Mutex<_>>`-wrapped `AuthManager` rather than
71    /// an owned one: `http::server::start_http_server`'s service factory
72    /// constructs a fresh `McpifyServer` per session, and `AuthManager`
73    /// itself isn't `Clone` (its `Box<dyn AuthStrategy>` field isn't
74    /// object-safe to clone) — every session shares the one configured
75    /// auth manager instead, which also matches this deployment's actual
76    /// semantics (a single configured auth method, not one per session).
77    pub fn new(api_version: String, config: Config, auth_manager: Arc<Mutex<AuthManager>>) -> Self {
78        Self {
79            api_version,
80            config,
81            auth_manager,
82            tool_router: Self::tool_router(),
83        }
84    }
85
86    #[tool(
87        description = "Semantic search for GitHub v3 REST API operations using a natural-language query."
88    )]
89    async fn search(
90        &self,
91        Parameters(args): Parameters<SearchArgs>,
92    ) -> Result<CallToolResult, McpError> {
93        let api_version = self.api_version.clone();
94        self.run_tool("search", async move {
95            let conn = cached_store_connection(&api_version)?.lock().unwrap();
96            search_operations(&conn, &args.query, args.limit)
97        })
98        .await
99    }
100
101    #[tool(
102        description = "Return the schema, path, method, and documentation for a specific GitHub v3 REST API operationId."
103    )]
104    async fn get(&self, Parameters(args): Parameters<GetArgs>) -> Result<CallToolResult, McpError> {
105        let api_version = self.api_version.clone();
106        self.run_tool("get", async move {
107            let conn = cached_store_connection(&api_version)?.lock().unwrap();
108            get_operation(&conn, &args.operation_id)
109        })
110        .await
111    }
112
113    #[tool(
114        description = "Validate arguments, invoke a live GitHub v3 REST API API operation, and validate the response."
115    )]
116    async fn call(
117        &self,
118        Parameters(args): Parameters<CallArgs>,
119        context: RequestContext<RoleServer>,
120    ) -> Result<CallToolResult, McpError> {
121        let api_version = self.api_version.clone();
122        let config = self.config.clone();
123        let auth_manager = self.auth_manager.clone();
124
125        // HTTP transport only: rmcp injects this call's own
126        // `http::request::Parts` into `context.extensions` regardless of
127        // how long the session's underlying worker task lives (rmcp does
128        // this per JSON-RPC message, not just once at session creation —
129        // see `http::server::auth_gate`'s doc comment for why this is the
130        // one mechanism that actually works here). `None` on stdio, where
131        // no such extension is ever inserted.
132        let request_credentials = context
133            .extensions
134            .get::<axum::http::request::Parts>()
135            .and_then(|parts| {
136                let (header_location, header_name) = header_location_for(config.auth_method);
137                extract_request_credentials(&parts.headers, header_location, header_name).ok()
138            });
139
140        self.run_tool("call", async move {
141            // Looked up and the connection (guard) dropped *before* any
142            // `.await` below — `rusqlite::Connection` isn't `Sync`, so a
143            // `&Connection`/`MutexGuard<Connection>` held across an await
144            // point would make this future non-`Send`.
145            let endpoint = {
146                let conn = cached_store_connection(&api_version)?.lock().unwrap();
147                get_endpoint(&conn, &args.operation_id)?.ok_or_else(|| {
148                    McpifyError::NotFound(format!("unknown operationId '{}'", args.operation_id))
149                })?
150            };
151
152            let mut auth_manager = auth_manager.lock().await;
153            call_operation(
154                &endpoint,
155                &config,
156                &mut auth_manager,
157                &args.operation_id,
158                args.arguments,
159                request_credentials.as_ref(),
160            )
161            .await
162        })
163        .await
164    }
165}
166
167impl McpifyServer {
168    /// Wraps a tool's core logic with consistent MCP response formatting
169    /// and error handling, so `search`/`get`/`call` each only implement
170    /// their own business logic, not the MCP content-envelope
171    /// boilerplate — mirrors `targets::typescript`'s `tool-executor.ts`.
172    async fn run_tool<F>(&self, tool_name: &str, fut: F) -> Result<CallToolResult, McpError>
173    where
174        F: std::future::Future<Output = anyhow::Result<serde_json::Value>>,
175    {
176        match fut.await {
177            Ok(value) => {
178                let text =
179                    serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
180                Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
181            }
182            Err(err) => {
183                tracing::error!(tool = tool_name, error = %err, "tool execution failed");
184                Ok(CallToolResult::error(vec![ContentBlock::text(
185                    err.to_string(),
186                )]))
187            }
188        }
189    }
190}
191
192// `router = self.tool_router.clone()`: without it, `#[tool_handler]`
193// defaults to calling `Self::tool_router()` fresh on every `list_tools`/
194// `call_tool` request, rebuilding the router instead of reusing the one
195// `new()` already built into this instance's `tool_router` field.
196#[tool_handler(router = self.tool_router.clone())]
197impl ServerHandler for McpifyServer {
198    fn get_info(&self) -> ServerInfo {
199        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
200            .with_server_info(Implementation::from_build_env())
201            .with_protocol_version(ProtocolVersion::V_2024_11_05)
202            .with_instructions(
203                "Exposes exactly 3 tools -- search, get, call -- backed by an embedded \
204                 semantic database, so you never need the full API surface in context."
205                    .to_string(),
206            )
207    }
208}
209
210/// Runs `server` over the stdio transport until the client disconnects —
211/// the Terminal Client / Harness Server "stdio" mode's connection point
212/// (Story R5 wires this into `main.rs`'s subcommand dispatch).
213pub async fn connect_stdio<S>(server: S) -> anyhow::Result<()>
214where
215    S: rmcp::ServerHandler,
216{
217    let running = server.serve(stdio()).await?;
218    tracing::info!("MCP server connected over stdio");
219    running.waiting().await?;
220    Ok(())
221}