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::prompt::PromptRouter;
6use rmcp::handler::server::router::tool::ToolRouter;
7use rmcp::handler::server::wrapper::Parameters;
8use rmcp::model::{
9    CallToolResult, ContentBlock, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
10};
11use rmcp::service::RequestContext;
12use rmcp::transport::stdio;
13use rmcp::{
14    ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, prompt_handler, schemars, tool,
15    tool_handler, tool_router,
16};
17use serde::Deserialize;
18use tokio::sync::Mutex;
19
20use crate::auth::auth_manager::AuthManager;
21use crate::core::config_schema::Config;
22use crate::core::errors::McpifyError;
23use crate::data::store::{cached_store_connection, get_endpoint};
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: 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 SQL Server 2025 - master/msdb/sandbox combined catalog 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)?
106                .lock()
107                .unwrap_or_else(std::sync::PoisonError::into_inner);
108            search_operations(&conn, &args.query, args.limit)
109        })
110        .await
111    }
112
113    #[tool(
114        description = "Return the schema, path, method, and documentation for a specific SQL Server 2025 - master/msdb/sandbox combined catalog operationId."
115    )]
116    async fn get(&self, Parameters(args): Parameters<GetArgs>) -> Result<CallToolResult, McpError> {
117        let api_version = self.api_version.clone();
118        self.run_tool("get", async move {
119            let conn = cached_store_connection(&api_version)?
120                .lock()
121                .unwrap_or_else(std::sync::PoisonError::into_inner);
122            get_operation(&conn, &args.operation_id)
123        })
124        .await
125    }
126
127    #[tool(
128        description = "Validate arguments, invoke a live SQL Server 2025 - master/msdb/sandbox combined catalog API operation, and validate the response."
129    )]
130    async fn call(
131        &self,
132        Parameters(args): Parameters<CallArgs>,
133        _context: RequestContext<RoleServer>,
134    ) -> Result<CallToolResult, McpError> {
135        let api_version = self.api_version.clone();
136        let config = self.config.clone();
137        let auth_manager = self.auth_manager.clone();
138
139        self.run_tool("call", async move {
140            // Looked up and the connection (guard) dropped *before* any
141            // `.await` below — `rusqlite::Connection` isn't `Sync`, so a
142            // `&Connection`/`MutexGuard<Connection>` held across an await
143            // point would make this future non-`Send`.
144            let endpoint = {
145                let conn = cached_store_connection(&api_version)?
146                    .lock()
147                    .unwrap_or_else(std::sync::PoisonError::into_inner);
148                get_endpoint(&conn, &args.operation_id)?.ok_or_else(|| {
149                    McpifyError::NotFound(format!("unknown operationId '{}'", args.operation_id))
150                })?
151            };
152
153            let mut auth_manager = auth_manager.lock().await;
154            call_operation(
155                &endpoint,
156                &config,
157                &mut auth_manager,
158                &args.operation_id,
159                args.arguments,
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())]
197#[prompt_handler(router = self.prompt_router.clone())]
198impl ServerHandler for McpifyServer {
199    fn get_info(&self) -> ServerInfo {
200        ServerInfo::new(
201            ServerCapabilities::builder()
202                .enable_tools()
203                .enable_prompts()
204                .build(),
205        )
206        .with_server_info(Implementation::from_build_env())
207        .with_protocol_version(ProtocolVersion::V_2024_11_05)
208        .with_instructions(
209            "Exposes exactly 3 tools -- search, get, call -- backed by an embedded \
210             semantic database, so you never need the full API surface in context. \
211             Also exposes MCP prompts -- start with the `sqlserver-workflow` prompt \
212             for guided, multi-step help with common SQL Server operational tasks."
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
236    fn server() -> McpifyServer {
237        let config: Config = serde_json::from_value(serde_json::json!({
238            "url": "localhost",
239            "auth_method": "sql_server"
240        }))
241        .unwrap();
242        McpifyServer::new(
243            "2025".to_string(),
244            config,
245            Arc::new(Mutex::new(AuthManager::new(AuthMethod::SqlServer))),
246        )
247    }
248
249    #[tokio::test]
250    async fn successful_tool_execution_returns_pretty_json_text() {
251        let result = server()
252            .run_tool("test", async { Ok(serde_json::json!({ "answer": 42 })) })
253            .await
254            .unwrap();
255
256        assert_eq!(result.is_error, Some(false));
257        assert_eq!(result.content.len(), 1);
258        assert_eq!(
259            result.content[0].as_text().unwrap().text,
260            "{\n  \"answer\": 42\n}"
261        );
262    }
263
264    #[tokio::test]
265    async fn failed_tool_execution_returns_a_caller_visible_error() {
266        let result = server()
267            .run_tool("test", async {
268                Err::<serde_json::Value, _>(anyhow::anyhow!("operation failed"))
269            })
270            .await
271            .unwrap();
272
273        assert_eq!(result.is_error, Some(true));
274        assert_eq!(
275            result.content[0].as_text().unwrap().text,
276            "operation failed"
277        );
278    }
279
280    #[test]
281    fn server_info_advertises_only_the_curated_tool_surface() {
282        let info = server().get_info();
283        assert!(info.capabilities.tools.is_some());
284        assert!(info.capabilities.prompts.is_some());
285        assert!(info.instructions.unwrap().contains("exactly 3 tools"));
286    }
287}