wowlab_sentinel/mcp/tools/
rotations.rs1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use sqlx::PgPool;
4
5const DEFAULT_LIMIT: i64 = 20;
6const MAX_LIMIT: i64 = 50;
7
8#[derive(Debug, Default, Deserialize, JsonSchema)]
10#[schemars(description = "Fetch a rotation script by ID, optionally at a specific version")]
11pub(super) struct GetRotationInput {
12 #[schemars(description = "Rotation UUID (from `list_rotations`).")]
13 pub id: String,
14
15 #[serde(default)]
16 #[schemars(description = "Version number to fetch. Omit for the current/latest version.")]
17 pub version: Option<i32>,
18}
19
20#[derive(Debug, Default, Deserialize, JsonSchema)]
22#[schemars(description = "List accessible rotation scripts with optional filters")]
23pub(super) struct ListRotationsInput {
24 #[serde(default)]
25 #[schemars(description = "Filter by spec ID (e.g. 262 for Enhancement Shaman).")]
26 pub spec_id: Option<i32>,
27
28 #[serde(default)]
29 #[schemars(description = "Search rotation names (case-insensitive substring match).")]
30 pub name: Option<String>,
31
32 #[serde(default)]
33 #[schemars(description = "Max results to return. Default: 20, max: 50.")]
34 pub limit: Option<i64>,
35
36 #[serde(default)]
37 #[schemars(description = "Offset for pagination.")]
38 pub offset: Option<i64>,
39}
40
41#[derive(Debug, JsonSchema, Serialize, sqlx::FromRow)]
42pub(super) struct RotationRow {
43 #[schemars(with = "String")]
44 id: uuid::Uuid,
45 slug: String,
46 name: String,
47 script: serde_json::Value,
48 description: Option<String>,
49 spec_id: i32,
50 current_version: i32,
51 #[schemars(with = "Option<String>")]
52 forked_from_id: Option<uuid::Uuid>,
53 created_at: chrono::DateTime<chrono::Utc>,
54 updated_at: chrono::DateTime<chrono::Utc>,
55}
56
57#[derive(Debug, JsonSchema, Serialize, sqlx::FromRow)]
58pub(super) struct RotationListRow {
59 #[schemars(with = "String")]
60 id: uuid::Uuid,
61 slug: String,
62 name: String,
63 description: Option<String>,
64 spec_id: i32,
65 spec_name: Option<String>,
66 class_name: Option<String>,
67 current_version: i32,
68 created_at: chrono::DateTime<chrono::Utc>,
69 updated_at: chrono::DateTime<chrono::Utc>,
70}
71
72#[derive(Debug, JsonSchema, Serialize, sqlx::FromRow)]
73pub(super) struct RotationVersionRow {
74 #[schemars(with = "String")]
75 id: uuid::Uuid,
76 slug: String,
77 name: String,
78 script: serde_json::Value,
79 description: Option<String>,
80 spec_id: i32,
81 version: i32,
82 message: Option<String>,
83 created_at: chrono::DateTime<chrono::Utc>,
84}
85
86pub(super) async fn handle_get(
87 db: &PgPool,
88 params: GetRotationInput,
89) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
90 Ok(crate::mcp::tool_result(
91 crate::telemetry::McpTool::GetRotation,
92 execute_get(db, params).await,
93 ))
94}
95
96pub(super) async fn handle_list(
97 db: &PgPool,
98 params: ListRotationsInput,
99) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
100 Ok(crate::mcp::tool_result(
101 crate::telemetry::McpTool::ListRotations,
102 execute_list(db, params).await,
103 ))
104}
105
106#[derive(Debug, JsonSchema, Serialize)]
107#[serde(untagged)]
108pub(super) enum GetRotationOutput {
109 Current(RotationRow),
110 Version(RotationVersionRow),
111}
112
113async fn execute_get(
114 db: &PgPool,
115 input: GetRotationInput,
116) -> Result<GetRotationOutput, super::ToolError> {
117 let id = input.id.parse::<uuid::Uuid>().map_err(|error| {
118 super::ToolError::invalid_params(format!("Invalid UUID for `id`: {error}"))
119 })?;
120 let user_id = crate::mcp::current_user_id();
121
122 if let Some(version) = input.version {
123 let row = sqlx::query_file_as!(
124 RotationVersionRow,
125 "queries/mcp_get_rotation_version_by_id.sql",
126 id,
127 version,
128 user_id
129 )
130 .fetch_optional(db)
131 .await
132 .map_err(|error| super::ToolError::database("Failed to fetch rotation version", error))?;
133
134 match row {
135 Some(row) => Ok(GetRotationOutput::Version(row)),
136 None => Err(super::ToolError::invalid_params("Rotation not found")),
137 }
138 } else {
139 let row = sqlx::query_file_as!(
140 RotationRow,
141 "queries/mcp_get_rotation_by_id.sql",
142 id,
143 user_id
144 )
145 .fetch_optional(db)
146 .await
147 .map_err(|error| super::ToolError::database("Failed to fetch rotation", error))?;
148
149 match row {
150 Some(row) => Ok(GetRotationOutput::Current(row)),
151 None => Err(super::ToolError::invalid_params("Rotation not found")),
152 }
153 }
154}
155
156async fn execute_list(
157 db: &PgPool,
158 input: ListRotationsInput,
159) -> Result<Vec<RotationListRow>, super::ToolError> {
160 let user_id = crate::mcp::current_user_id();
161 let limit = input.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
162 let offset = input.offset.unwrap_or(0).max(0);
163
164 let rows = sqlx::query_file_as!(
165 RotationListRow,
166 "queries/mcp_list_rotations.sql",
167 input.spec_id,
168 input.name.as_deref(),
169 limit,
170 offset,
171 user_id
172 )
173 .fetch_all(db)
174 .await
175 .map_err(|error| super::ToolError::database("Failed to list rotations", error))?;
176
177 Ok(rows)
178}