wowlab_sentinel/mcp/tools/
mod.rs1mod docs;
2mod effects;
3mod labels;
4mod loadout;
5mod parse_simc;
6mod query;
7mod rotations;
8
9use super::schema;
10use crate::mcp::mcp_tool;
11
12#[derive(Debug, thiserror::Error)]
13pub(super) enum ToolError {
14 #[error("{message}")]
15 InvalidParams { message: String },
16 #[error("{context}: {source}")]
17 Database {
18 context: String,
19 #[source]
20 source: sqlx::Error,
21 },
22 #[error("{context}: {source}")]
23 Internal {
24 context: String,
25 #[source]
26 source: Box<dyn std::error::Error + Send + Sync>,
27 },
28}
29
30impl ToolError {
31 pub(super) fn invalid_params(message: impl Into<String>) -> Self {
32 Self::InvalidParams {
33 message: message.into(),
34 }
35 }
36
37 pub(super) fn database(context: impl Into<String>, source: sqlx::Error) -> Self {
38 Self::Database {
39 context: context.into(),
40 source,
41 }
42 }
43
44 pub(super) fn internal(
45 context: impl Into<String>,
46 source: impl std::error::Error + Send + Sync + 'static,
47 ) -> Self {
48 Self::Internal {
49 context: context.into(),
50 source: Box::new(source),
51 }
52 }
53}
54
55impl crate::mcp::McpFailure for ToolError {
56 fn error_type(&self) -> crate::telemetry::McpErrorType {
57 match self {
58 Self::InvalidParams { .. } => crate::telemetry::McpErrorType::InvalidParams,
59 Self::Database { .. } => crate::telemetry::McpErrorType::Database,
60 Self::Internal { .. } => crate::telemetry::McpErrorType::Internal,
61 }
62 }
63}
64
65#[cfg(test)]
66mod tests {
67 use googletest::prelude::*;
68
69 use super::ToolError;
70 use crate::{mcp::McpFailure, telemetry::McpErrorType};
71
72 #[gtest]
73 fn tool_errors_have_stable_telemetry_types() {
74 let invalid = ToolError::invalid_params("bad input");
75 let database = ToolError::database("query failed", sqlx::Error::RowNotFound);
76 let internal = ToolError::internal(
77 "decode failed",
78 std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data"),
79 );
80
81 assert_eq!(invalid.error_type(), McpErrorType::InvalidParams);
82 assert_eq!(database.error_type(), McpErrorType::Database);
83 assert_eq!(internal.error_type(), McpErrorType::Internal);
84 }
85}
86
87mcp_tool!(
88 get_docs,
89 1,
90 "Read project documentation. Returns the index by default, pass `full=true` for full content.",
91 crate::mcp::GetDocsInput => String,
92 tips = ["Use `get_docs` first to understand the project before querying game data."],
93 |srv, params| async { docs::handle(&srv.mcp, params).await }
94);
95
96mcp_tool!(
97 list_tables,
98 2,
99 "List all tables with category, description, and column count.",
100 crate::mcp::EmptyInput => Vec<schema::TableSummary>,
101 tips = ["Call `list_tables` before `get_schema` to avoid large responses."],
102 |srv, _params| async {
103 Ok(crate::mcp::json_result(
104 srv.mcp.schema.list_tables_compact(),
105 ))
106 }
107);
108
109mcp_tool!(
110 get_schema,
111 3,
112 "Get column details for a table. Pass a `table` name to see columns and types, omit for all.",
113 schema::GetSchemaInput => Vec<schema::TableSchema>,
114 tips = [],
115 |srv, params| async {
116 Ok(crate::mcp::tool_result(
117 crate::telemetry::McpTool::GetSchema,
118 srv.mcp.schema.get_schema(params.table.as_deref()),
119 ))
120 }
121);
122
123mcp_tool!(
124 query,
125 4,
126 "Fetch rows from a game data table with filters, sorting, and pagination.",
127 query::TableQuery => Vec<serde_json::Value>,
128 tips = [
129 "`op=contains` for fuzzy name search, `op=in` for ID lists.",
130 "All filters are AND-ed. Use `query_batch` for multiple tables.",
131 "Rows include every column. For tables with many `json` columns (see `get_schema`), run `query_count` first or narrow the `limit` — large payloads can be rejected by some MCP clients.",
132 ],
133 |srv, params| async { query::handle_query(&srv.db, &srv.mcp.schema, params).await }
134);
135
136mcp_tool!(
137 query_batch,
138 5,
139 "Execute multiple queries in one request (max 50). Each runs independently.",
140 query::TableBatchQuery => Vec<query::BatchQueryResult>,
141 tips = [],
142 |srv, params| async { query::handle_query_batch(&srv.db, &srv.mcp.schema, params).await }
143);
144
145mcp_tool!(
146 query_count,
147 6,
148 "Count rows matching filters without returning row data. Same filters as `query`.",
149 query::CountQuery => query::CountResult,
150 tips = ["Use `query_count` to check result size before fetching rows."],
151 |srv, params| async { query::handle_query_count(&srv.db, &srv.mcp.schema, params).await }
152);
153
154mcp_tool!(
155 resolve_effects,
156 7,
157 "Resolve spell and item effects with trigger spell chains (max 50 IDs).",
158 effects::ResolveEffectsInput => effects::ResolveEffectsOutput,
159 tips = ["Use `resolve_effects` to understand what a spell or item does mechanically."],
160 |srv, params| async { effects::handle(&srv.db, params).await }
161);
162
163mcp_tool!(
164 decode_loadout,
165 8,
166 "Decode a base64 talent loadout string into structured talent data with spell names.",
167 loadout::DecodeLoadoutInput => wowlab_loadout::EnrichedLoadout,
168 tips = ["Talent strings come from SimC `talents=` lines or game client export."],
169 |srv, params| async { loadout::handle(&srv.db, params).await }
170);
171
172mcp_tool!(
173 get_rotation,
174 9,
175 "Fetch a rotation script by ID, optionally at a specific version.",
176 rotations::GetRotationInput => rotations::GetRotationOutput,
177 tips = ["Use `list_rotations` to find rotation IDs, then `get_rotation` to fetch the script."],
178 |srv, params| async { rotations::handle_get(&srv.db, params).await }
179);
180
181mcp_tool!(
182 list_spell_labels,
183 10,
184 "List all known spell label IDs with their names. Use these IDs to filter `game.spells` by the `labels` jsonb column (e.g. `op=jsonb_array_contains, value=20` for Rogue spells).",
185 labels::ListSpellLabelsInput => Vec<labels::SpellLabelEntry>,
186 tips = [
187 "Filter spells by label: query `game.spells` with `column=labels, op=jsonb_array_contains, value=<label_id>`."
188 ],
189 |_srv, params| async { labels::handle(params).await }
190);
191
192mcp_tool!(
193 list_rotations,
194 11,
195 "List accessible rotation scripts with optional filters by spec and name.",
196 rotations::ListRotationsInput => Vec<rotations::RotationListRow>,
197 tips = [
198 "Use `list_rotations` to find available rotations, then `get_rotation` to fetch the script."
199 ],
200 |srv, params| async { rotations::handle_list(&srv.db, params).await }
201);
202
203mcp_tool!(
204 parse_simc,
205 12,
206 "Parse a SimulationCraft profile string into structured character, equipment, talents, and metadata.",
207 parse_simc::ParseSimcInput => wowlab_parsers::Profile,
208 tips = [
209 "Use `parse_simc` to ingest a user-pasted `/simc` export before querying or simulating it."
210 ],
211 |_srv, params| async { parse_simc::handle(params).await }
212);