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 `Value::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 and only an explicit `{}` 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
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}