Skip to main content

wowlab_sentinel/mcp/tools/
mod.rs

1mod 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);