Skip to main content

sqlserver_mcp_catalog/core/
mcp_server.rs

1// SQL Server 2025 - master/msdb/sandbox combined catalog 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;
20use crate::core::config_schema::Config;
21use crate::core::errors::McpifyError;
22use crate::data::store::{cached_store_connection, get_endpoint};
23use crate::tools::call_tool::call_operation;
24use crate::tools::get_tool::get_operation;
25use crate::tools::search_tool::search_operations;
26
27fn default_search_limit() -> usize {
28    5
29}
30
31#[derive(Debug, Deserialize, schemars::JsonSchema)]
32pub struct SearchArgs {
33    /// Natural-language description of the operation you need
34    pub query: String,
35    /// Maximum number of results
36    #[serde(default = "default_search_limit")]
37    pub limit: usize,
38}
39
40#[derive(Debug, Deserialize, schemars::JsonSchema)]
41pub struct GetArgs {
42    /// operationId returned by search
43    pub operation_id: String,
44}
45
46/// A missing `arguments` field defaults to `{}`, not `null` — every
47/// operation's generated input JSON Schema unconditionally declares
48/// `"type": "object"`, even for zero-param operations, so `null` always
49/// fails validation while `{}` always passes.
50fn default_call_arguments() -> serde_json::Value {
51    serde_json::json!({})
52}
53
54#[derive(Debug, Deserialize, schemars::JsonSchema)]
55pub struct CallArgs {
56    /// operationId returned by search
57    pub operation_id: String,
58    /// Operation parameters and/or request body. Defaults to `{}` when omitted.
59    #[serde(default = "default_call_arguments")]
60    pub arguments: serde_json::Value,
61}
62
63/// Shared state every `search`/`get`/`call` tool method needs. `Clone`
64/// because rmcp constructs one instance per session (see
65/// `http::server::start_http_server`'s service factory) — every field is
66/// either cheap to clone (`String`, `Config`) or already `Arc`-wrapped.
67#[derive(Clone)]
68pub struct McpifyServer {
69    api_version: String,
70    config: Config,
71    auth_manager: Arc<Mutex<AuthManager>>,
72    tool_router: ToolRouter<McpifyServer>,
73}
74
75#[tool_router]
76impl McpifyServer {
77    /// Takes an already-`Arc<Mutex<_>>`-wrapped `AuthManager` rather than
78    /// an owned one: `http::server::start_http_server`'s service factory
79    /// constructs a fresh `McpifyServer` per session, and `AuthManager`
80    /// itself isn't `Clone` (its `Box<dyn AuthStrategy>` field isn't
81    /// object-safe to clone) — every session shares the one configured
82    /// auth manager instead, which also matches this deployment's actual
83    /// semantics (a single configured auth method, not one per session).
84    pub fn new(api_version: String, config: Config, auth_manager: Arc<Mutex<AuthManager>>) -> Self {
85        Self {
86            api_version,
87            config,
88            auth_manager,
89            tool_router: Self::tool_router(),
90        }
91    }
92
93    #[tool(
94        description = "Semantic search for SQL Server 2025 - master/msdb/sandbox combined catalog operations using a natural-language query."
95    )]
96    async fn search(
97        &self,
98        Parameters(args): Parameters<SearchArgs>,
99    ) -> Result<CallToolResult, McpError> {
100        let api_version = self.api_version.clone();
101        self.run_tool("search", async move {
102            let conn = cached_store_connection(&api_version)?.lock().unwrap();
103            search_operations(&conn, &args.query, args.limit)
104        })
105        .await
106    }
107
108    #[tool(
109        description = "Return the schema, path, method, and documentation for a specific SQL Server 2025 - master/msdb/sandbox combined catalog operationId."
110    )]
111    async fn get(&self, Parameters(args): Parameters<GetArgs>) -> Result<CallToolResult, McpError> {
112        let api_version = self.api_version.clone();
113        self.run_tool("get", async move {
114            let conn = cached_store_connection(&api_version)?.lock().unwrap();
115            get_operation(&conn, &args.operation_id)
116        })
117        .await
118    }
119
120    #[tool(
121        description = "Validate arguments, invoke a live SQL Server 2025 - master/msdb/sandbox combined catalog API operation, and validate the response."
122    )]
123    async fn call(
124        &self,
125        Parameters(args): Parameters<CallArgs>,
126        _context: RequestContext<RoleServer>,
127    ) -> Result<CallToolResult, McpError> {
128        let api_version = self.api_version.clone();
129        let config = self.config.clone();
130        let auth_manager = self.auth_manager.clone();
131
132        self.run_tool("call", async move {
133            // Looked up and the connection (guard) dropped *before* any
134            // `.await` below — `rusqlite::Connection` isn't `Sync`, so a
135            // `&Connection`/`MutexGuard<Connection>` held across an await
136            // point would make this future non-`Send`.
137            let endpoint = {
138                let conn = cached_store_connection(&api_version)?.lock().unwrap();
139                get_endpoint(&conn, &args.operation_id)?.ok_or_else(|| {
140                    McpifyError::NotFound(format!("unknown operationId '{}'", args.operation_id))
141                })?
142            };
143
144            let mut auth_manager = auth_manager.lock().await;
145            call_operation(
146                &endpoint,
147                &config,
148                &mut auth_manager,
149                &args.operation_id,
150                args.arguments,
151            )
152            .await
153        })
154        .await
155    }
156}
157
158impl McpifyServer {
159    /// Wraps a tool's core logic with consistent MCP response formatting
160    /// and error handling, so `search`/`get`/`call` each only implement
161    /// their own business logic, not the MCP content-envelope
162    /// boilerplate — mirrors `targets::typescript`'s `tool-executor.ts`.
163    async fn run_tool<F>(&self, tool_name: &str, fut: F) -> Result<CallToolResult, McpError>
164    where
165        F: std::future::Future<Output = anyhow::Result<serde_json::Value>>,
166    {
167        match fut.await {
168            Ok(value) => {
169                let text =
170                    serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
171                Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
172            }
173            Err(err) => {
174                tracing::error!(tool = tool_name, error = %err, "tool execution failed");
175                Ok(CallToolResult::error(vec![ContentBlock::text(
176                    err.to_string(),
177                )]))
178            }
179        }
180    }
181}
182
183// `router = self.tool_router.clone()`: without it, `#[tool_handler]`
184// defaults to calling `Self::tool_router()` fresh on every `list_tools`/
185// `call_tool` request, rebuilding the router instead of reusing the one
186// `new()` already built into this instance's `tool_router` field.
187#[tool_handler(router = self.tool_router.clone())]
188impl ServerHandler for McpifyServer {
189    fn get_info(&self) -> ServerInfo {
190        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
191            .with_server_info(Implementation::from_build_env())
192            .with_protocol_version(ProtocolVersion::V_2024_11_05)
193            .with_instructions(
194                "Exposes exactly 3 tools -- search, get, call -- backed by an embedded \
195                 semantic database, so you never need the full API surface in context."
196                    .to_string(),
197            )
198    }
199}
200
201/// Runs `server` over the stdio transport until the client disconnects —
202/// the Terminal Client / Harness Server "stdio" mode's connection point
203/// (Story R5 wires this into `main.rs`'s subcommand dispatch).
204pub async fn connect_stdio<S>(server: S) -> anyhow::Result<()>
205where
206    S: rmcp::ServerHandler,
207{
208    let running = server.serve(stdio()).await?;
209    tracing::info!("MCP server connected over stdio");
210    running.waiting().await?;
211    Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::core::config_schema::AuthMethod;
218
219    fn server() -> McpifyServer {
220        let config: Config = serde_json::from_value(serde_json::json!({
221            "url": "localhost",
222            "auth_method": "sql_server"
223        }))
224        .unwrap();
225        McpifyServer::new(
226            "2025".to_string(),
227            config,
228            Arc::new(Mutex::new(AuthManager::new(AuthMethod::SqlServer))),
229        )
230    }
231
232    #[tokio::test]
233    async fn successful_tool_execution_returns_pretty_json_text() {
234        let result = server()
235            .run_tool("test", async { Ok(serde_json::json!({ "answer": 42 })) })
236            .await
237            .unwrap();
238
239        assert_eq!(result.is_error, Some(false));
240        assert_eq!(result.content.len(), 1);
241        assert_eq!(
242            result.content[0].as_text().unwrap().text,
243            "{\n  \"answer\": 42\n}"
244        );
245    }
246
247    #[tokio::test]
248    async fn failed_tool_execution_returns_a_caller_visible_error() {
249        let result = server()
250            .run_tool("test", async {
251                Err::<serde_json::Value, _>(anyhow::anyhow!("operation failed"))
252            })
253            .await
254            .unwrap();
255
256        assert_eq!(result.is_error, Some(true));
257        assert_eq!(
258            result.content[0].as_text().unwrap().text,
259            "operation failed"
260        );
261    }
262
263    #[test]
264    fn server_info_advertises_only_the_curated_tool_surface() {
265        let info = server().get_info();
266        assert!(info.capabilities.tools.is_some());
267        assert!(info.instructions.unwrap().contains("exactly 3 tools"));
268    }
269}