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    IndexesConstraintsArgs, MasterWorkflowArgs, SecurityProvisioningArgs, SqlAgentJobsArgs,
15    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 (active requests/sessions, \
133                        wait stats, blocking/locks, transactions, resource governor, I/O)."
134    )]
135    async fn sqlserver_workflow_performance_diagnostics_prompt(&self) -> Vec<PromptMessage> {
136        vec![PromptMessage::new_text(
137            Role::User,
138            include_str!("content/performance_diagnostics.md"),
139        )]
140    }
141}
142
143// `#[prompt_router]` generates `prompt_router()` as `pub(crate)` (see the
144// module doc comment above), so — unlike `src/core/mcp_server.rs`'s tool
145// tests — these can't live in a separate `tests/*.rs` integration binary
146// (that compiles against only the crate's *public* API). They're colocated
147// here instead, matching this repo's own convention of keeping a file's
148// tests alongside the code they exercise (see `mcp_server.rs`, `store.rs`).
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::auth::auth_manager::AuthManager;
153    use crate::core::config_schema::AuthMethod;
154
155    fn server() -> McpifyServer {
156        let config: crate::core::config_schema::Config =
157            serde_json::from_value(serde_json::json!({
158                "url": "localhost",
159                "auth_method": "sql_server"
160            }))
161            .unwrap();
162        McpifyServer::new(
163            "2025".to_string(),
164            config,
165            std::sync::Arc::new(tokio::sync::Mutex::new(AuthManager::new(
166                AuthMethod::SqlServer,
167            ))),
168        )
169    }
170
171    #[test]
172    fn prompt_router_registers_every_prompt_name() {
173        let prompts = McpifyServer::prompt_router().list_all();
174        let names: std::collections::BTreeSet<&str> =
175            prompts.iter().map(|p| p.name.as_str()).collect();
176        assert_eq!(
177            names,
178            std::collections::BTreeSet::from([
179                "sqlserver_workflow",
180                "sqlserver_workflow_sql_agent_jobs",
181                "sqlserver_workflow_schema_exploration",
182                "sqlserver_workflow_indexes_constraints",
183                "sqlserver_workflow_security_provisioning",
184                "sqlserver_workflow_server_administration",
185                "sqlserver_workflow_performance_diagnostics",
186            ])
187        );
188    }
189
190    #[test]
191    fn security_provisioning_advertises_every_argument_as_optional() {
192        let prompts = McpifyServer::prompt_router().list_all();
193        let security = prompts
194            .iter()
195            .find(|p| p.name == "sqlserver_workflow_security_provisioning")
196            .expect("sqlserver_workflow_security_provisioning is registered");
197        let arguments = security
198            .arguments
199            .as_ref()
200            .expect("prompt declares arguments");
201        let names: std::collections::BTreeSet<&str> =
202            arguments.iter().map(|a| a.name.as_str()).collect();
203        assert_eq!(
204            names,
205            std::collections::BTreeSet::from(["login_name", "database", "role_name"])
206        );
207        for arg in arguments {
208            assert_ne!(
209                arg.required,
210                Some(true),
211                "{} must not be a required argument",
212                arg.name
213            );
214        }
215    }
216
217    #[tokio::test]
218    async fn master_prompt_links_to_the_sql_agent_jobs_sub_workflow() {
219        let messages = server()
220            .sqlserver_workflow_prompt(Parameters(MasterWorkflowArgs { goal: None }))
221            .await;
222        let text = &messages[0].content.as_text().unwrap().text;
223        assert!(text.contains("sqlserver_workflow_sql_agent_jobs"));
224    }
225
226    #[tokio::test]
227    async fn sql_agent_jobs_prompt_echoes_supplied_args_and_lists_missing_ones() {
228        let messages = server()
229            .sqlserver_workflow_sql_agent_jobs_prompt(Parameters(SqlAgentJobsArgs {
230                job_name: Some("nightly_backup".to_string()),
231                database: None,
232            }))
233            .await;
234        let text = &messages[0].content.as_text().unwrap().text;
235        assert!(text.contains("- job_name: nightly_backup"));
236        assert!(text.contains("- database"));
237        assert!(!text.contains("- database: "));
238    }
239}