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)?.lock().unwrap();
106 search_operations(&conn, &args.query, args.limit)
107 })
108 .await
109 }
110
111 #[tool(
112 description = "Return the schema, path, method, and documentation for a specific SQL Server 2025 - master/msdb/sandbox combined catalog operationId."
113 )]
114 async fn get(&self, Parameters(args): Parameters<GetArgs>) -> Result<CallToolResult, McpError> {
115 let api_version = self.api_version.clone();
116 self.run_tool("get", async move {
117 let conn = cached_store_connection(&api_version)?.lock().unwrap();
118 get_operation(&conn, &args.operation_id)
119 })
120 .await
121 }
122
123 #[tool(
124 description = "Validate arguments, invoke a live SQL Server 2025 - master/msdb/sandbox combined catalog API operation, and validate the response."
125 )]
126 async fn call(
127 &self,
128 Parameters(args): Parameters<CallArgs>,
129 _context: RequestContext<RoleServer>,
130 ) -> Result<CallToolResult, McpError> {
131 let api_version = self.api_version.clone();
132 let config = self.config.clone();
133 let auth_manager = self.auth_manager.clone();
134
135 self.run_tool("call", async move {
136 let endpoint = {
141 let conn = cached_store_connection(&api_version)?.lock().unwrap();
142 get_endpoint(&conn, &args.operation_id)?.ok_or_else(|| {
143 McpifyError::NotFound(format!("unknown operationId '{}'", args.operation_id))
144 })?
145 };
146
147 let mut auth_manager = auth_manager.lock().await;
148 call_operation(
149 &endpoint,
150 &config,
151 &mut auth_manager,
152 &args.operation_id,
153 args.arguments,
154 )
155 .await
156 })
157 .await
158 }
159}
160
161impl McpifyServer {
162 async fn run_tool<F>(&self, tool_name: &str, fut: F) -> Result<CallToolResult, McpError>
167 where
168 F: std::future::Future<Output = anyhow::Result<serde_json::Value>>,
169 {
170 match fut.await {
171 Ok(value) => {
172 let text =
173 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
174 Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
175 }
176 Err(err) => {
177 tracing::error!(tool = tool_name, error = %err, "tool execution failed");
178 Ok(CallToolResult::error(vec![ContentBlock::text(
179 err.to_string(),
180 )]))
181 }
182 }
183 }
184}
185
186#[tool_handler(router = self.tool_router.clone())]
191#[prompt_handler(router = self.prompt_router.clone())]
192impl ServerHandler for McpifyServer {
193 fn get_info(&self) -> ServerInfo {
194 ServerInfo::new(
195 ServerCapabilities::builder()
196 .enable_tools()
197 .enable_prompts()
198 .build(),
199 )
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 Also exposes MCP prompts -- start with the `sqlserver-workflow` prompt \
206 for guided, multi-step help with common SQL Server operational tasks."
207 .to_string(),
208 )
209 }
210}
211
212pub async fn connect_stdio<S>(server: S) -> anyhow::Result<()>
216where
217 S: rmcp::ServerHandler,
218{
219 let running = server.serve(stdio()).await?;
220 tracing::info!("MCP server connected over stdio");
221 running.waiting().await?;
222 Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::core::config_schema::AuthMethod;
229
230 fn server() -> McpifyServer {
231 let config: Config = serde_json::from_value(serde_json::json!({
232 "url": "localhost",
233 "auth_method": "sql_server"
234 }))
235 .unwrap();
236 McpifyServer::new(
237 "2025".to_string(),
238 config,
239 Arc::new(Mutex::new(AuthManager::new(AuthMethod::SqlServer))),
240 )
241 }
242
243 #[tokio::test]
244 async fn successful_tool_execution_returns_pretty_json_text() {
245 let result = server()
246 .run_tool("test", async { Ok(serde_json::json!({ "answer": 42 })) })
247 .await
248 .unwrap();
249
250 assert_eq!(result.is_error, Some(false));
251 assert_eq!(result.content.len(), 1);
252 assert_eq!(
253 result.content[0].as_text().unwrap().text,
254 "{\n \"answer\": 42\n}"
255 );
256 }
257
258 #[tokio::test]
259 async fn failed_tool_execution_returns_a_caller_visible_error() {
260 let result = server()
261 .run_tool("test", async {
262 Err::<serde_json::Value, _>(anyhow::anyhow!("operation failed"))
263 })
264 .await
265 .unwrap();
266
267 assert_eq!(result.is_error, Some(true));
268 assert_eq!(
269 result.content[0].as_text().unwrap().text,
270 "operation failed"
271 );
272 }
273
274 #[test]
275 fn server_info_advertises_only_the_curated_tool_surface() {
276 let info = server().get_info();
277 assert!(info.capabilities.tools.is_some());
278 assert!(info.capabilities.prompts.is_some());
279 assert!(info.instructions.unwrap().contains("exactly 3 tools"));
280 }
281}