1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use wowlab_types::{sim::FastMap, table_registry::GameDataTable};
7
8mod tables;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
11pub(crate) enum McpTableId {
12 GameData(GameDataTable),
13 Metadata,
14}
15
16impl McpTableId {
17 pub(crate) const fn database_name(self) -> &'static str {
18 match self {
19 Self::GameData(table) => table.database_name(),
20 Self::Metadata => "game.meta",
21 }
22 }
23}
24
25#[derive(Clone, Copy, Debug, Eq, JsonSchema, PartialEq, Serialize)]
27#[serde(rename_all = "snake_case")]
28#[non_exhaustive]
29pub(crate) enum ColType {
30 Int,
31 Text,
32 Bool,
33 Float,
34 Json,
35 IntArray,
36 Timestamp,
37}
38
39#[derive(Clone, Debug, JsonSchema, Serialize)]
41pub(crate) struct Column {
42 pub name: &'static str,
43 #[serde(rename = "type")]
44 pub typ: ColType,
45}
46
47#[derive(Debug)]
48pub(crate) struct Table {
49 pub identity: McpTableId,
50 pub category: &'static str,
51 pub description: &'static str,
52 pub columns: &'static [Column],
53}
54
55impl Table {
56 pub(crate) const fn name(&self) -> &'static str {
57 self.identity.database_name()
58 }
59}
60
61inventory::collect!(Table);
62
63#[derive(Debug)]
64pub(crate) struct JsonColumnSchema {
65 pub table: McpTableId,
66 pub column: &'static str,
67 pub schema_fn: fn() -> Value,
68}
69
70inventory::collect!(JsonColumnSchema);
71
72#[derive(Debug, Eq, PartialEq, thiserror::Error)]
73pub(crate) enum SchemaCatalogError {
74 #[error("duplicate MCP descriptor for {table}")]
75 DuplicateDescriptor { table: &'static str },
76 #[error("MCP descriptor registered for hidden table {table}")]
77 HiddenDescriptor { table: &'static str },
78 #[error("missing MCP descriptor for exposed table {table}")]
79 MissingDescriptor { table: &'static str },
80 #[error("missing MCP descriptor for game.meta")]
81 MissingMetadata,
82 #[error("duplicate JSON schema for {table}.{column}")]
83 DuplicateJsonSchema {
84 table: &'static str,
85 column: &'static str,
86 },
87 #[error("JSON schema registered for unknown column {table}.{column}")]
88 UnknownJsonColumn {
89 table: &'static str,
90 column: &'static str,
91 },
92}
93
94#[derive(Debug, Eq, PartialEq, thiserror::Error)]
95#[error("Unknown table: {name}")]
96pub(crate) struct UnknownTableError {
97 name: String,
98}
99
100impl crate::mcp::McpFailure for UnknownTableError {
101 fn error_type(&self) -> crate::telemetry::McpErrorType {
102 crate::telemetry::McpErrorType::InvalidParams
103 }
104}
105
106#[derive(Debug)]
107pub(crate) struct SchemaCatalog {
108 tables: Vec<&'static Table>,
109 tables_by_name: FastMap<&'static str, &'static Table>,
110 json_columns: FastMap<(McpTableId, &'static str), Value>,
111}
112
113#[derive(Debug, JsonSchema, Serialize)]
114pub(crate) struct TableSummary {
115 pub name: &'static str,
116 pub category: &'static str,
117 pub description: &'static str,
118 pub column_count: usize,
119}
120
121#[derive(Debug, JsonSchema, Serialize)]
122pub(crate) struct ColumnSchema {
123 pub name: &'static str,
124 #[serde(rename = "type")]
125 pub typ: ColType,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub json_schema: Option<Value>,
128}
129
130#[derive(Debug, JsonSchema, Serialize)]
131pub(crate) struct TableSchema {
132 pub name: &'static str,
133 pub category: &'static str,
134 pub description: &'static str,
135 pub columns: Vec<ColumnSchema>,
136}
137
138#[derive(Debug, Default, Deserialize, JsonSchema)]
139#[schemars(description = "Get column details for a specific table, or all tables if omitted.")]
140pub(crate) struct GetSchemaInput {
141 #[serde(default)]
142 #[schemars(description = "Table name (e.g. `game.spells`). Omit to get all tables.")]
143 pub table: Option<String>,
144}
145
146impl SchemaCatalog {
147 pub(crate) fn try_new() -> Result<Self, SchemaCatalogError> {
148 Self::from_descriptors(
149 inventory::iter::<Table>,
150 inventory::iter::<JsonColumnSchema>,
151 )
152 }
153
154 fn from_descriptors(
155 tables: impl IntoIterator<Item = &'static Table>,
156 json_schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
157 ) -> Result<Self, SchemaCatalogError> {
158 let descriptors = collect_descriptors(tables)?;
159 let ordered = order_descriptors(descriptors)?;
160 let tables_by_name = ordered
161 .iter()
162 .map(|table| (table.name(), *table))
163 .collect::<FastMap<_, _>>();
164 let json_columns = collect_json_schemas(&ordered, json_schemas)?;
165
166 Ok(Self {
167 tables: ordered,
168 tables_by_name,
169 json_columns,
170 })
171 }
172
173 #[cfg(test)]
174 pub(crate) fn tables(&self) -> &[&'static Table] {
175 &self.tables
176 }
177
178 pub(crate) fn table(&self, name: &str) -> Result<&'static Table, UnknownTableError> {
179 self.tables_by_name
180 .get(name)
181 .copied()
182 .ok_or_else(|| UnknownTableError {
183 name: name.to_owned(),
184 })
185 }
186
187 pub(crate) fn list_tables_compact(&self) -> Vec<TableSummary> {
188 self.tables
189 .iter()
190 .map(|table| TableSummary {
191 name: table.name(),
192 category: table.category,
193 description: table.description,
194 column_count: table.columns.len(),
195 })
196 .collect()
197 }
198
199 pub(crate) fn get_schema(
200 &self,
201 table: Option<&str>,
202 ) -> Result<Vec<TableSchema>, UnknownTableError> {
203 match table {
204 Some(name) => Ok(vec![self.table_schema(self.table(name)?)]),
205 None => Ok(self
206 .tables
207 .iter()
208 .map(|table| self.table_schema(table))
209 .collect()),
210 }
211 }
212
213 fn table_schema(&self, table: &Table) -> TableSchema {
214 TableSchema {
215 name: table.name(),
216 category: table.category,
217 description: table.description,
218 columns: table
219 .columns
220 .iter()
221 .map(|column| ColumnSchema {
222 name: column.name,
223 typ: column.typ,
224 json_schema: (column.typ == ColType::Json)
225 .then(|| self.json_schema_for(table.identity, column.name))
226 .flatten(),
227 })
228 .collect(),
229 }
230 }
231
232 fn json_schema_for(&self, table: McpTableId, column: &str) -> Option<Value> {
233 self.json_columns.get(&(table, column)).cloned()
234 }
235}
236
237fn collect_descriptors(
238 tables: impl IntoIterator<Item = &'static Table>,
239) -> Result<FastMap<McpTableId, &'static Table>, SchemaCatalogError> {
240 let mut descriptors = FastMap::default();
241
242 for table in tables {
243 if let McpTableId::GameData(game_table) = table.identity
244 && !game_table.is_mcp_exposed()
245 {
246 return Err(SchemaCatalogError::HiddenDescriptor {
247 table: table.name(),
248 });
249 }
250
251 if descriptors.insert(table.identity, table).is_some() {
252 return Err(SchemaCatalogError::DuplicateDescriptor {
253 table: table.name(),
254 });
255 }
256 }
257
258 Ok(descriptors)
259}
260
261fn order_descriptors(
262 mut descriptors: FastMap<McpTableId, &'static Table>,
263) -> Result<Vec<&'static Table>, SchemaCatalogError> {
264 let mut ordered = Vec::with_capacity(
265 GameDataTable::iter()
266 .filter(|table| table.is_mcp_exposed())
267 .count()
268 + 1,
269 );
270
271 for table in GameDataTable::iter().filter(|table| table.is_mcp_exposed()) {
272 let descriptor = descriptors.remove(&McpTableId::GameData(table)).ok_or(
273 SchemaCatalogError::MissingDescriptor {
274 table: table.database_name(),
275 },
276 )?;
277
278 ordered.push(descriptor);
279 }
280
281 ordered.push(
282 descriptors
283 .remove(&McpTableId::Metadata)
284 .ok_or(SchemaCatalogError::MissingMetadata)?,
285 );
286 ordered.sort_by_key(|table| table.name());
287
288 Ok(ordered)
289}
290
291fn collect_json_schemas(
292 tables: &[&'static Table],
293 schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
294) -> Result<FastMap<(McpTableId, &'static str), Value>, SchemaCatalogError> {
295 let mut json_columns = FastMap::default();
296
297 for schema in schemas {
298 let table = table_for_json_schema(tables, schema)?;
299
300 if json_columns
301 .insert((schema.table, schema.column), (schema.schema_fn)())
302 .is_some()
303 {
304 return Err(SchemaCatalogError::DuplicateJsonSchema {
305 table: table.name(),
306 column: schema.column,
307 });
308 }
309 }
310
311 Ok(json_columns)
312}
313
314fn table_for_json_schema(
315 tables: &[&'static Table],
316 schema: &JsonColumnSchema,
317) -> Result<&'static Table, SchemaCatalogError> {
318 let table = tables
319 .iter()
320 .copied()
321 .find(|table| table.identity == schema.table)
322 .ok_or(SchemaCatalogError::UnknownJsonColumn {
323 table: schema.table.database_name(),
324 column: schema.column,
325 })?;
326
327 if table
328 .columns
329 .iter()
330 .all(|column| column.name != schema.column || column.typ != ColType::Json)
331 {
332 return Err(SchemaCatalogError::UnknownJsonColumn {
333 table: table.name(),
334 column: schema.column,
335 });
336 }
337
338 Ok(table)
339}
340
341pub(crate) fn get_column<'a>(table: &'a Table, column: &str) -> Option<&'a Column> {
342 table
343 .columns
344 .iter()
345 .find(|candidate| candidate.name == column)
346}
347
348#[derive(Clone, Copy, Debug, serde::Serialize)]
350#[non_exhaustive]
351pub struct McpToolMeta {
352 pub name: &'static str,
354 pub summary: &'static str,
356 pub order: u8,
358 pub tips: &'static [&'static str],
360}
361
362inventory::collect!(McpToolMeta);
363
364pub(crate) fn all_tool_metas() -> Vec<&'static McpToolMeta> {
365 let mut tools = inventory::iter::<McpToolMeta>
366 .into_iter()
367 .collect::<Vec<_>>();
368
369 tools.sort_by_key(|tool| tool.order);
370
371 tools
372}
373
374pub(crate) fn build_workflow_doc(
375 mut doc: wowlab_common::markdown::Doc,
376) -> wowlab_common::markdown::Doc {
377 for (index, tool) in all_tool_metas().iter().enumerate() {
378 doc = workflow_step(doc, index + 1, tool);
379 }
380
381 doc
382}
383
384fn workflow_step(
385 doc: wowlab_common::markdown::Doc,
386 index: usize,
387 tool: &McpToolMeta,
388) -> wowlab_common::markdown::Doc {
389 let description = format!("`{}` — {}", tool.name, tool.summary);
390
391 doc.numbered(index, &description)
392}
393
394pub(crate) fn build_tips_doc(
395 mut doc: wowlab_common::markdown::Doc,
396) -> wowlab_common::markdown::Doc {
397 for tool in all_tool_metas() {
398 for tip in tool.tips {
399 doc = doc.bullet(tip);
400 }
401 }
402
403 doc
404}
405
406#[cfg(test)]
407mod tests;