Skip to main content

wowlab_sentinel/mcp/schema/
mod.rs

1//! Typed MCP projection of the canonical game-data table registry.
2
3use 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/// Column data type.
26#[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/// MCP column definition.
40#[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
100#[derive(Debug)]
101pub(crate) struct SchemaCatalog {
102    tables: Vec<&'static Table>,
103    tables_by_name: FastMap<&'static str, &'static Table>,
104    json_columns: FastMap<(McpTableId, &'static str), Value>,
105}
106
107#[rustfmt::skip]
108const CATEGORY_ORDER: &[(&str, &str)] = &[
109    // #t:aligned
110    ("core"          , "Core")          ,
111    ("spell_metadata", "Spell Metadata"),
112    ("item_scaling"  , "Item Scaling")  ,
113    ("armor_damage"  , "Armor & Damage"),
114    ("combat_scaling", "Combat Scaling"),
115    ("loot"          , "Loot & Drops")  ,
116    ("seasons"       , "Seasons & PvP") ,
117    ("currencies"    , "Currencies")    ,
118    ("supporting"    , "Supporting")    ,
119    ("ui_reference"  , "UI Reference")  ,
120];
121
122#[derive(Debug, Serialize)]
123pub(crate) struct TableSummary {
124    pub name: &'static str,
125    pub category: &'static str,
126    pub description: &'static str,
127    pub column_count: usize,
128}
129
130#[derive(Debug, Serialize)]
131pub(crate) struct ColumnSchema {
132    pub name: &'static str,
133    #[serde(rename = "type")]
134    pub typ: ColType,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub json_schema: Option<Value>,
137}
138
139#[derive(Debug, Serialize)]
140pub(crate) struct TableSchema {
141    pub name: &'static str,
142    pub category: &'static str,
143    pub description: &'static str,
144    pub columns: Vec<ColumnSchema>,
145}
146
147#[derive(Debug, Default, Deserialize, JsonSchema)]
148#[schemars(description = "Get column details for a specific table, or all tables if omitted.")]
149pub(crate) struct GetSchemaInput {
150    #[serde(default)]
151    #[schemars(description = "Table name (e.g. `game.spells`). Omit to get all tables.")]
152    pub table: Option<String>,
153}
154
155impl SchemaCatalog {
156    pub(crate) fn try_new() -> Result<Self, SchemaCatalogError> {
157        Self::from_descriptors(
158            inventory::iter::<Table>,
159            inventory::iter::<JsonColumnSchema>,
160        )
161    }
162
163    fn from_descriptors(
164        tables: impl IntoIterator<Item = &'static Table>,
165        json_schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
166    ) -> Result<Self, SchemaCatalogError> {
167        let descriptors = collect_descriptors(tables)?;
168        let ordered = order_descriptors(descriptors)?;
169        let tables_by_name = ordered
170            .iter()
171            .map(|table| (table.name(), *table))
172            .collect::<FastMap<_, _>>();
173        let json_columns = collect_json_schemas(&ordered, json_schemas)?;
174
175        Ok(Self {
176            tables: ordered,
177            tables_by_name,
178            json_columns,
179        })
180    }
181
182    #[cfg(test)]
183    pub(crate) fn tables(&self) -> &[&'static Table] {
184        &self.tables
185    }
186
187    pub(crate) fn table(&self, name: &str) -> Result<&'static Table, UnknownTableError> {
188        self.tables_by_name
189            .get(name)
190            .copied()
191            .ok_or_else(|| UnknownTableError {
192                name: name.to_owned(),
193            })
194    }
195
196    pub(crate) fn list_tables_compact(&self) -> Vec<TableSummary> {
197        self.tables
198            .iter()
199            .map(|table| TableSummary {
200                name: table.name(),
201                category: table.category,
202                description: table.description,
203                column_count: table.columns.len(),
204            })
205            .collect()
206    }
207
208    pub(crate) fn get_schema(
209        &self,
210        table: Option<&str>,
211    ) -> Result<Vec<TableSchema>, UnknownTableError> {
212        match table {
213            Some(name) => Ok(vec![self.table_schema(self.table(name)?)]),
214            None => Ok(self
215                .tables
216                .iter()
217                .map(|table| self.table_schema(table))
218                .collect()),
219        }
220    }
221
222    pub(crate) fn build_tables_doc(
223        &self,
224        mut doc: wowlab_common::markdown::Doc,
225    ) -> wowlab_common::markdown::Doc {
226        for &(category, label) in CATEGORY_ORDER {
227            let category_tables = self
228                .tables
229                .iter()
230                .filter(|table| table.category == category)
231                .collect::<Vec<_>>();
232
233            if category_tables.is_empty() {
234                continue;
235            }
236
237            doc = doc.h3(label);
238
239            for table in category_tables {
240                doc = table_column_detail(doc, table);
241            }
242        }
243
244        doc
245    }
246
247    fn table_schema(&self, table: &Table) -> TableSchema {
248        TableSchema {
249            name: table.name(),
250            category: table.category,
251            description: table.description,
252            columns: table
253                .columns
254                .iter()
255                .map(|column| ColumnSchema {
256                    name: column.name,
257                    typ: column.typ,
258                    json_schema: (column.typ == ColType::Json)
259                        .then(|| self.json_schema_for(table.identity, column.name))
260                        .flatten(),
261                })
262                .collect(),
263        }
264    }
265
266    fn json_schema_for(&self, table: McpTableId, column: &str) -> Option<Value> {
267        self.json_columns.get(&(table, column)).cloned()
268    }
269}
270
271fn collect_descriptors(
272    tables: impl IntoIterator<Item = &'static Table>,
273) -> Result<FastMap<McpTableId, &'static Table>, SchemaCatalogError> {
274    let mut descriptors = FastMap::default();
275
276    for table in tables {
277        if let McpTableId::GameData(game_table) = table.identity
278            && !game_table.is_mcp_exposed()
279        {
280            return Err(SchemaCatalogError::HiddenDescriptor {
281                table: table.name(),
282            });
283        }
284
285        if descriptors.insert(table.identity, table).is_some() {
286            return Err(SchemaCatalogError::DuplicateDescriptor {
287                table: table.name(),
288            });
289        }
290    }
291
292    Ok(descriptors)
293}
294
295fn order_descriptors(
296    mut descriptors: FastMap<McpTableId, &'static Table>,
297) -> Result<Vec<&'static Table>, SchemaCatalogError> {
298    let mut ordered = Vec::with_capacity(
299        GameDataTable::iter()
300            .filter(|table| table.is_mcp_exposed())
301            .count()
302            + 1,
303    );
304
305    for table in GameDataTable::iter().filter(|table| table.is_mcp_exposed()) {
306        let descriptor = descriptors.remove(&McpTableId::GameData(table)).ok_or(
307            SchemaCatalogError::MissingDescriptor {
308                table: table.database_name(),
309            },
310        )?;
311
312        ordered.push(descriptor);
313    }
314
315    ordered.push(
316        descriptors
317            .remove(&McpTableId::Metadata)
318            .ok_or(SchemaCatalogError::MissingMetadata)?,
319    );
320    ordered.sort_by_key(|table| table.name());
321
322    Ok(ordered)
323}
324
325fn collect_json_schemas(
326    tables: &[&'static Table],
327    schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
328) -> Result<FastMap<(McpTableId, &'static str), Value>, SchemaCatalogError> {
329    let mut json_columns = FastMap::default();
330
331    for schema in schemas {
332        let table = table_for_json_schema(tables, schema)?;
333
334        if json_columns
335            .insert((schema.table, schema.column), (schema.schema_fn)())
336            .is_some()
337        {
338            return Err(SchemaCatalogError::DuplicateJsonSchema {
339                table: table.name(),
340                column: schema.column,
341            });
342        }
343    }
344
345    Ok(json_columns)
346}
347
348fn table_for_json_schema(
349    tables: &[&'static Table],
350    schema: &JsonColumnSchema,
351) -> Result<&'static Table, SchemaCatalogError> {
352    let table = tables
353        .iter()
354        .copied()
355        .find(|table| table.identity == schema.table)
356        .ok_or(SchemaCatalogError::UnknownJsonColumn {
357            table: schema.table.database_name(),
358            column: schema.column,
359        })?;
360
361    if table
362        .columns
363        .iter()
364        .all(|column| column.name != schema.column || column.typ != ColType::Json)
365    {
366        return Err(SchemaCatalogError::UnknownJsonColumn {
367            table: table.name(),
368            column: schema.column,
369        });
370    }
371
372    Ok(table)
373}
374
375fn table_column_detail(
376    doc: wowlab_common::markdown::Doc,
377    table: &Table,
378) -> wowlab_common::markdown::Doc {
379    let detail = format!("{} cols", table.columns.len());
380
381    doc.def_detail(table.name(), &detail, table.description)
382}
383
384pub(crate) fn get_column<'a>(table: &'a Table, column: &str) -> Option<&'a Column> {
385    table
386        .columns
387        .iter()
388        .find(|candidate| candidate.name == column)
389}
390
391/// Metadata registered for one tool exposed by the MCP server.
392#[derive(Clone, Copy, Debug, serde::Serialize)]
393#[non_exhaustive]
394pub struct McpToolMeta {
395    /// Protocol-level tool name.
396    pub name: &'static str,
397    /// Concise description presented to MCP clients.
398    pub summary: &'static str,
399    /// Recommended workflow position.
400    pub order: u8,
401    /// Usage guidance presented to MCP clients.
402    pub tips: &'static [&'static str],
403}
404
405inventory::collect!(McpToolMeta);
406
407pub(crate) fn all_tool_metas() -> Vec<&'static McpToolMeta> {
408    let mut tools = inventory::iter::<McpToolMeta>
409        .into_iter()
410        .collect::<Vec<_>>();
411
412    tools.sort_by_key(|tool| tool.order);
413
414    tools
415}
416
417pub(crate) fn build_workflow_doc(
418    mut doc: wowlab_common::markdown::Doc,
419) -> wowlab_common::markdown::Doc {
420    for (index, tool) in all_tool_metas().iter().enumerate() {
421        doc = workflow_step(doc, index + 1, tool);
422    }
423
424    doc
425}
426
427fn workflow_step(
428    doc: wowlab_common::markdown::Doc,
429    index: usize,
430    tool: &McpToolMeta,
431) -> wowlab_common::markdown::Doc {
432    let description = format!("`{}` — {}", tool.name, tool.summary);
433
434    doc.numbered(index, &description)
435}
436
437pub(crate) fn build_tips_doc(
438    mut doc: wowlab_common::markdown::Doc,
439) -> wowlab_common::markdown::Doc {
440    for tool in all_tool_metas() {
441        for tip in tool.tips {
442            doc = doc.bullet(tip);
443        }
444    }
445
446    doc
447}
448
449#[cfg(test)]
450mod tests;