Skip to main content

sqlserver_mcp_catalog/prompts/
router.rs

1//! `#[prompt_router]`-decorated `impl McpifyServer` block — one method per
2//! MCP prompt. Kept separate from `src/core/mcp_server.rs`'s `#[tool_router]`
3//! block (see `docs/mcp-prompts-workflow-plan.md`). `vis = "pub(crate)"` is
4//! required here (unlike the co-located `tool_router`) because this impl
5//! block lives in a different module than `McpifyServer::new()`, which calls
6//! the generated `Self::prompt_router()`.
7
8use rmcp::handler::server::wrapper::Parameters;
9use rmcp::model::{PromptMessage, Role};
10use rmcp::{prompt, prompt_router};
11
12use crate::core::mcp_server::McpifyServer;
13use crate::prompts::{
14    IndexTuningRecommendationsArgs, IndexesConstraintsArgs, MasterWorkflowArgs,
15    SecurityProvisioningArgs, SqlAgentJobsArgs, render_context_header,
16};
17
18#[prompt_router(vis = "pub(crate)")]
19impl McpifyServer {
20    #[prompt(
21        name = "sqlserver_workflow",
22        description = "Start here. Presents the available SQL Server operational \
23                        workflows, routes to the right guided sub-workflow based on \
24                        the user's goal, and — where the environment supports it — \
25                        delegates that whole sub-workflow to an isolated sub-task to \
26                        spare this conversation's context window."
27    )]
28    async fn sqlserver_workflow_prompt(
29        &self,
30        Parameters(args): Parameters<MasterWorkflowArgs>,
31    ) -> Vec<PromptMessage> {
32        let header = render_context_header(&[("goal", args.goal.as_deref())]);
33        vec![PromptMessage::new_text(
34            Role::User,
35            format!("{header}\n{}", include_str!("content/master.md")),
36        )]
37    }
38
39    #[prompt(
40        name = "sqlserver_workflow_sql_agent_jobs",
41        description = "Guided SQL Agent job setup: create a job, add one or more \
42                        steps, attach a schedule, and start it — each step gated on \
43                        the previous one being confirmed to exist, with cleanup \
44                        guidance for test runs."
45    )]
46    async fn sqlserver_workflow_sql_agent_jobs_prompt(
47        &self,
48        Parameters(args): Parameters<SqlAgentJobsArgs>,
49    ) -> Vec<PromptMessage> {
50        let header = render_context_header(&[
51            ("job_name", args.job_name.as_deref()),
52            ("database", args.database.as_deref()),
53        ]);
54        vec![PromptMessage::new_text(
55            Role::User,
56            format!("{header}\n{}", include_str!("content/sql_agent_jobs.md")),
57        )]
58    }
59
60    #[prompt(
61        name = "sqlserver_workflow_schema_exploration",
62        description = "Discover databases/schemas/tables/views/columns/types/triggers, \
63                        either via `sys.*` catalog views or `INFORMATION_SCHEMA.*`."
64    )]
65    async fn sqlserver_workflow_schema_exploration_prompt(&self) -> Vec<PromptMessage> {
66        vec![PromptMessage::new_text(
67            Role::User,
68            include_str!("content/schema_exploration.md"),
69        )]
70    }
71
72    #[prompt(
73        name = "sqlserver_workflow_indexes_constraints",
74        description = "Inspect indexes, index columns, foreign keys, and check \
75                        constraints on a table; estimate compression savings."
76    )]
77    async fn sqlserver_workflow_indexes_constraints_prompt(
78        &self,
79        Parameters(args): Parameters<IndexesConstraintsArgs>,
80    ) -> Vec<PromptMessage> {
81        let header = render_context_header(&[
82            ("database", args.database.as_deref()),
83            ("schema", args.schema.as_deref()),
84            ("table", args.table.as_deref()),
85        ]);
86        vec![PromptMessage::new_text(
87            Role::User,
88            format!(
89                "{header}\n{}",
90                include_str!("content/indexes_constraints.md")
91            ),
92        )]
93    }
94
95    #[prompt(
96        name = "sqlserver_workflow_security_provisioning",
97        description = "Guided login/user/role provisioning, including the \
98                        built-in-vs-custom-role fork and the new-login-vs-existing-login fork."
99    )]
100    async fn sqlserver_workflow_security_provisioning_prompt(
101        &self,
102        Parameters(args): Parameters<SecurityProvisioningArgs>,
103    ) -> Vec<PromptMessage> {
104        let header = render_context_header(&[
105            ("login_name", args.login_name.as_deref()),
106            ("database", args.database.as_deref()),
107            ("role_name", args.role_name.as_deref()),
108        ]);
109        vec![PromptMessage::new_text(
110            Role::User,
111            format!(
112                "{header}\n{}",
113                include_str!("content/security_provisioning.md")
114            ),
115        )]
116    }
117
118    #[prompt(
119        name = "sqlserver_workflow_server_administration",
120        description = "Server/database config, renaming objects, disk-space usage, \
121                        dependency lookup, bulk per-table/per-db operations, linked servers."
122    )]
123    async fn sqlserver_workflow_server_administration_prompt(&self) -> Vec<PromptMessage> {
124        vec![PromptMessage::new_text(
125            Role::User,
126            include_str!("content/server_administration.md"),
127        )]
128    }
129
130    #[prompt(
131        name = "sqlserver_workflow_performance_diagnostics",
132        description = "Thin pointer to the right read-only signal (wait stats, query plans, \
133                        transactions, resource governor, I/O, In-Memory OLTP/columnstore \
134                        health, OS-level pressure)."
135    )]
136    async fn sqlserver_workflow_performance_diagnostics_prompt(&self) -> Vec<PromptMessage> {
137        vec![PromptMessage::new_text(
138            Role::User,
139            include_str!("content/performance_diagnostics.md"),
140        )]
141    }
142
143    #[prompt(
144        name = "sqlserver_workflow_blocking_and_locks",
145        description = "Diagnose a blocking chain down to its head blocker and last \
146                        statement, then — only with explicit user confirmation — \
147                        terminate the blocking session and verify the chain cleared."
148    )]
149    async fn sqlserver_workflow_blocking_and_locks_prompt(&self) -> Vec<PromptMessage> {
150        vec![PromptMessage::new_text(
151            Role::User,
152            include_str!("content/blocking_and_locks.md"),
153        )]
154    }
155
156    #[prompt(
157        name = "sqlserver_workflow_index_tuning_recommendations",
158        description = "Find missing-index candidates ranked by estimated improvement, \
159                        cross-check for overlap with existing indexes, and — only with \
160                        explicit user confirmation — create and verify the index."
161    )]
162    async fn sqlserver_workflow_index_tuning_recommendations_prompt(
163        &self,
164        Parameters(args): Parameters<IndexTuningRecommendationsArgs>,
165    ) -> Vec<PromptMessage> {
166        let header = render_context_header(&[
167            ("database", args.database.as_deref()),
168            ("schema", args.schema.as_deref()),
169            ("table", args.table.as_deref()),
170        ]);
171        vec![PromptMessage::new_text(
172            Role::User,
173            format!(
174                "{header}\n{}",
175                include_str!("content/index_tuning_recommendations.md")
176            ),
177        )]
178    }
179}
180
181// `#[prompt_router]` generates `prompt_router()` as `pub(crate)` (see the
182// module doc comment above), so — unlike `src/core/mcp_server.rs`'s tool
183// tests — these can't live in a separate `tests/*.rs` integration binary
184// (that compiles against only the crate's *public* API). They're colocated
185// here instead, matching this repo's own convention of keeping a file's
186// tests alongside the code they exercise (see `mcp_server.rs`, `store.rs`).
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::auth::auth_manager::AuthManager;
191    use crate::core::config_schema::AuthMethod;
192
193    fn server() -> McpifyServer {
194        let config: crate::core::config_schema::Config =
195            serde_json::from_value(serde_json::json!({
196                "url": "localhost",
197                "auth_method": "sql_server"
198            }))
199            .unwrap();
200        McpifyServer::new(
201            "2025".to_string(),
202            config,
203            std::sync::Arc::new(tokio::sync::Mutex::new(AuthManager::new(
204                AuthMethod::SqlServer,
205            ))),
206        )
207    }
208
209    #[test]
210    fn prompt_router_registers_every_prompt_name() {
211        let prompts = McpifyServer::prompt_router().list_all();
212        let names: std::collections::BTreeSet<&str> =
213            prompts.iter().map(|p| p.name.as_str()).collect();
214        assert_eq!(
215            names,
216            std::collections::BTreeSet::from([
217                "sqlserver_workflow",
218                "sqlserver_workflow_sql_agent_jobs",
219                "sqlserver_workflow_schema_exploration",
220                "sqlserver_workflow_indexes_constraints",
221                "sqlserver_workflow_security_provisioning",
222                "sqlserver_workflow_server_administration",
223                "sqlserver_workflow_performance_diagnostics",
224                "sqlserver_workflow_blocking_and_locks",
225                "sqlserver_workflow_index_tuning_recommendations",
226            ])
227        );
228    }
229
230    #[test]
231    fn security_provisioning_advertises_every_argument_as_optional() {
232        let prompts = McpifyServer::prompt_router().list_all();
233        let security = prompts
234            .iter()
235            .find(|p| p.name == "sqlserver_workflow_security_provisioning")
236            .expect("sqlserver_workflow_security_provisioning is registered");
237        let arguments = security
238            .arguments
239            .as_ref()
240            .expect("prompt declares arguments");
241        let names: std::collections::BTreeSet<&str> =
242            arguments.iter().map(|a| a.name.as_str()).collect();
243        assert_eq!(
244            names,
245            std::collections::BTreeSet::from(["login_name", "database", "role_name"])
246        );
247        for arg in arguments {
248            assert_ne!(
249                arg.required,
250                Some(true),
251                "{} must not be a required argument",
252                arg.name
253            );
254        }
255    }
256
257    #[tokio::test]
258    async fn master_prompt_links_to_the_sql_agent_jobs_sub_workflow() {
259        let messages = server()
260            .sqlserver_workflow_prompt(Parameters(MasterWorkflowArgs { goal: None }))
261            .await;
262        let text = &messages[0].content.as_text().unwrap().text;
263        assert!(text.contains("sqlserver_workflow_sql_agent_jobs"));
264    }
265
266    #[tokio::test]
267    async fn sql_agent_jobs_prompt_echoes_supplied_args_and_lists_missing_ones() {
268        let messages = server()
269            .sqlserver_workflow_sql_agent_jobs_prompt(Parameters(SqlAgentJobsArgs {
270                job_name: Some("nightly_backup".to_string()),
271                database: None,
272            }))
273            .await;
274        let text = &messages[0].content.as_text().unwrap().text;
275        assert!(text.contains("- job_name: nightly_backup"));
276        assert!(text.contains("- database"));
277        assert!(!text.contains("- database: "));
278    }
279
280    #[tokio::test]
281    async fn blocking_and_locks_prompt_gates_termination_on_explicit_confirmation() {
282        let messages = server()
283            .sqlserver_workflow_blocking_and_locks_prompt()
284            .await;
285        let text = &messages[0].content.as_text().unwrap().text;
286        assert!(text.to_lowercase().contains("confirm"));
287        assert!(text.contains("sp_executesql"));
288    }
289
290    #[tokio::test]
291    async fn index_tuning_recommendations_prompt_echoes_supplied_args_and_gates_on_confirmation() {
292        let messages = server()
293            .sqlserver_workflow_index_tuning_recommendations_prompt(Parameters(
294                IndexTuningRecommendationsArgs {
295                    database: Some("sandbox".to_string()),
296                    schema: None,
297                    table: Some("orders".to_string()),
298                },
299            ))
300            .await;
301        let text = &messages[0].content.as_text().unwrap().text;
302        assert!(text.contains("- database: sandbox"));
303        assert!(text.contains("- table: orders"));
304        assert!(text.contains("- schema"));
305        assert!(text.to_lowercase().contains("confirm"));
306    }
307
308    #[tokio::test]
309    async fn performance_diagnostics_points_to_the_dedicated_blocking_workflow() {
310        let messages = server()
311            .sqlserver_workflow_performance_diagnostics_prompt()
312            .await;
313        let text = &messages[0].content.as_text().unwrap().text;
314        assert!(text.contains("sqlserver_workflow_blocking_and_locks"));
315        assert!(text.contains("sqlserver_workflow_index_tuning_recommendations"));
316    }
317}