sqlserver_mcp_catalog/core/
mcp_server.rs1use 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 pub query: String,
36 #[serde(default = "default_search_limit")]
38 pub limit: usize,
39}
40
41#[derive(Debug, Deserialize, schemars::JsonSchema)]
42pub struct GetArgs {
43 pub operation_id: String,
45}
46
47fn default_call_arguments() -> serde_json::Value {
52 serde_json::json!({})
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct CallArgs {
57 pub operation_id: String,
59 #[serde(default = "default_call_arguments")]
61 pub arguments: serde_json::Value,
62}
63
64#[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 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 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 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#[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
218pub 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}