1pub(crate) mod schema;
2mod tools;
3
4crate::db_client!(McpDb, "mcp", max = 8, statement_timeout_secs = 30);
5
6tokio::task_local! {
7 static MCP_USER_ID: Option<uuid::Uuid>;
8}
9
10pub(crate) fn current_user_id() -> Option<uuid::Uuid> {
11 MCP_USER_ID.try_with(|id| *id).ok().flatten()
12}
13
14fn request_user_id(context: &RequestContext<RoleServer>) -> Option<uuid::Uuid> {
15 context
16 .extensions
17 .get::<axum::http::request::Parts>()
18 .and_then(user_id_from_http_parts)
19}
20
21fn user_id_from_http_parts(parts: &axum::http::request::Parts) -> Option<uuid::Uuid> {
22 parts
23 .extensions
24 .get::<crate::http::auth::AuthenticatedUser>()
25 .map(|user| user.user_id)
26}
27
28macro_rules! mcp_tool {
30 ($fn_name:ident, $order:expr, $desc:literal, $param:ty => $output:ty,
31 tips = [$($tip:literal),* $(,)?],
32 |$srv:ident, $p:ident| async $body:block
33 ) => {
34 paste::paste! {
35 pub(super) fn [< build_ $fn_name _route >]() -> rmcp::handler::server::router::tool::ToolRoute<$crate::mcp::McpHandler> {
36 use rmcp::{
37 handler::server::{tool::schema_for_type, wrapper::Parameters},
38 model::ToolAnnotations,
39 };
40
41 let tool = rmcp::model::Tool::new(
42 stringify!($fn_name),
43 $desc,
44 $crate::mcp::portable_schema(schema_for_type::<Parameters<$param>>()),
45 )
46 .with_raw_output_schema($crate::mcp::output_schema_for::<$output>())
47 .with_annotations(
48 ToolAnnotations::new()
49 .read_only(true)
50 .destructive(false)
51 .idempotent(true)
52 .open_world(false),
53 );
54
55 rmcp::handler::server::router::tool::ToolRoute::new_dyn(tool, |ctx| {
56 Box::pin(async move {
57 let $srv: &$crate::mcp::McpHandler = ctx.service;
58 let user_id = $crate::mcp::request_user_id(&ctx.request_context);
59 let arguments = ctx.arguments.unwrap_or_default();
60
61 $crate::mcp::with_metrics(
62 $crate::telemetry::McpTool::[< $fn_name:camel >],
63 || $crate::mcp::MCP_USER_ID.scope(user_id, async move {
64 let $p: $param = match rmcp::handler::server::tool::parse_json_object(
65 arguments,
66 ) {
67 Ok(params) => params,
68 Err(error) => {
69 return Ok($crate::mcp::invalid_params_result(
70 $crate::telemetry::McpTool::[< $fn_name:camel >],
71 error.message,
72 ));
73 }
74 };
75
76 (async move $body).await
77 }),
78 ).await.map(Into::into)
79 })
80 })
81 }
82
83 inventory::submit! {
84 $crate::mcp::schema::McpToolMeta {
85 name: stringify!($fn_name),
86 summary: $desc,
87 order: $order,
88 tips: &[$($tip),*],
89 }
90 }
91 }
92 };
93}
94
95pub(crate) use mcp_tool;
96
97macro_rules! mcp_table {
99 (meta, $cat:literal, $desc:literal, [ $($tokens:tt)* ]) => {
100 $crate::mcp::mcp_table_parse!(@cols
101 $crate::mcp::schema::McpTableId::Metadata,
102 $cat, $desc, [], [], $($tokens)*);
103 };
104 ($table:path, $cat:literal, $desc:literal, [ $($tokens:tt)* ]) => {
105 $crate::mcp::mcp_table_parse!(@cols
106 $crate::mcp::schema::McpTableId::GameData($table),
107 $cat, $desc, [], [], $($tokens)*);
108 };
109}
110
111pub(crate) use mcp_table;
112
113macro_rules! mcp_table_parse {
115 (@cols $identity:expr, $cat:literal, $desc:literal,
116 [$($cols:tt)*], [$($schemas:tt)*],
117 $col:literal : Json($($ty:tt)+), $($rest:tt)*) => {
118 $crate::mcp::mcp_table_parse!(@cols $identity, $cat, $desc,
119 [$($cols)* { $col, Json },],
120 [$($schemas)* { $identity, $col, $($ty)+ },],
121 $($rest)*);
122 };
123 (@cols $identity:expr, $cat:literal, $desc:literal,
124 [$($cols:tt)*], [$($schemas:tt)*],
125 $col:literal : $typ:ident, $($rest:tt)*) => {
126 $crate::mcp::mcp_table_parse!(@cols $identity, $cat, $desc,
127 [$($cols)* { $col, $typ },],
128 [$($schemas)*],
129 $($rest)*);
130 };
131 (@cols $identity:expr, $cat:literal, $desc:literal,
132 [$($cols:tt)*], [$($schemas:tt)*],
133 $col:literal : Json($($ty:tt)+)) => {
134 $crate::mcp::mcp_table_parse!(@emit $identity, $cat, $desc,
135 [$($cols)* { $col, Json },],
136 [$($schemas)* { $identity, $col, $($ty)+ },]);
137 };
138 (@cols $identity:expr, $cat:literal, $desc:literal,
139 [$($cols:tt)*], [$($schemas:tt)*],
140 $col:literal : $typ:ident) => {
141 $crate::mcp::mcp_table_parse!(@emit $identity, $cat, $desc,
142 [$($cols)* { $col, $typ },],
143 [$($schemas)*]);
144 };
145 (@cols $identity:expr, $cat:literal, $desc:literal,
146 [$($cols:tt)*], [$($schemas:tt)*], ) => {
147 $crate::mcp::mcp_table_parse!(@emit $identity, $cat, $desc,
148 [$($cols)*], [$($schemas)*]);
149 };
150
151 (@emit $identity:expr, $cat:literal, $desc:literal,
152 [$({ $col:literal, $typ:ident },)*],
153 [$({ $table:expr, $jcol:literal, $($jty:tt)+ },)*]) => {
154 inventory::submit! {
155 $crate::mcp::schema::Table {
156 identity: $identity,
157 category: $cat,
158 description: $desc,
159 columns: &[
160 $( $crate::mcp::schema::Column {
161 name: $col,
162 typ: $crate::mcp::schema::ColType::$typ,
163 } ),*
164 ],
165 }
166 }
167 $(
168 inventory::submit! {
169 $crate::mcp::schema::JsonColumnSchema {
170 table: $table,
171 column: $jcol,
172 schema_fn: || {
173 serde_json::to_value(
174 schemars::schema_for!($($jty)+)
175 )
176 .unwrap_or_default()
177 },
178 }
179 }
180 )*
181 };
182}
183
184use std::{borrow::Cow, sync::Arc};
185
186pub(crate) use mcp_table_parse;
187use rmcp::{
188 ErrorData, ServerHandler,
189 handler::server::router::tool::ToolRouter,
190 model::{
191 CacheScope, CallToolResult, ContentBlock, DiscoverResult, Implementation, ListToolsResult,
192 MetaObject, PaginatedRequestParams, ServerCapabilities, ServerInfo,
193 },
194 service::{RequestContext, RoleServer},
195 tool_handler,
196 transport::streamable_http_server::{
197 StreamableHttpServerConfig, session::local::LocalSessionManager,
198 tower::StreamableHttpService,
199 },
200};
201use sqlx::PgPool;
202use wowlab_common::{markdown::Doc, time::Instant};
203
204use crate::{
205 telemetry::{self, McpErrorType, McpTool},
206 utils::meta,
207};
208
209const SUPPORTED_PROTOCOL_VERSIONS: &[rmcp::model::ProtocolVersion] =
210 rmcp::model::ProtocolVersion::KNOWN_VERSIONS;
211const DISCOVERABLE_PROTOCOL_VERSIONS: &[rmcp::model::ProtocolVersion] =
212 &[rmcp::model::ProtocolVersion::V_2026_07_28];
213const DISCOVERY_CACHE_TTL_MS: u64 = 300_000;
214const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
215
216fn portable_schema(schema: Arc<rmcp::model::JsonObject>) -> Arc<rmcp::model::JsonObject> {
217 use schemars::transform::{RestrictFormats, Transform};
218
219 let mut schema = schemars::Schema::from(Arc::unwrap_or_clone(schema));
220
221 RestrictFormats::default().transform(&mut schema);
222 let serde_json::Value::Object(schema) = serde_json::Value::from(schema) else {
223 unreachable!("tool schemas have object roots")
224 };
225
226 Arc::new(schema)
227}
228
229fn output_schema_for<T>() -> Arc<rmcp::model::JsonObject>
230where
231 T: schemars::JsonSchema,
232{
233 let generator = schemars::generate::SchemaSettings::draft2020_12()
234 .for_serialize()
235 .into_generator();
236 let schema = generator.into_root_schema_for::<T>();
237 let serde_json::Value::Object(mut schema) = serde_json::Value::from(schema) else {
238 unreachable!("JSON schemas have object roots")
239 };
240
241 schema.remove("title");
242 schema.remove("description");
243
244 portable_schema(Arc::new(schema))
245}
246
247const fn current_protocol_version() -> rmcp::model::ProtocolVersion {
248 rmcp::model::ProtocolVersion::V_2026_07_28
249}
250
251pub(crate) async fn with_metrics<F, Fut>(tool: McpTool, f: F) -> Result<CallToolResult, ErrorData>
252where
253 F: FnOnce() -> Fut,
254 Fut: Future<Output = Result<CallToolResult, ErrorData>>,
255{
256 let start = Instant::now();
257
258 telemetry::record_mcp_request(tool);
259 let result = f().await;
260
261 if result.is_ok() {
262 telemetry::record_mcp_duration(tool, start.elapsed().as_secs_f64());
263 }
264
265 result
266}
267
268pub(crate) trait McpFailure: std::fmt::Display {
269 fn error_type(&self) -> McpErrorType;
270}
271
272fn mcp_error(tool: McpTool, error: &impl McpFailure) -> CallToolResult {
273 let message = error.to_string();
274
275 telemetry::record_mcp_error(tool, error.error_type());
276
277 with_result_meta(CallToolResult::error(vec![ContentBlock::text(message)]))
278}
279
280fn invalid_params_result(tool: McpTool, message: impl Into<String>) -> CallToolResult {
281 struct InvalidParams(String);
282
283 impl std::fmt::Display for InvalidParams {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.write_str(&self.0)
286 }
287 }
288
289 impl McpFailure for InvalidParams {
290 fn error_type(&self) -> McpErrorType {
291 McpErrorType::InvalidParams
292 }
293 }
294
295 mcp_error(tool, &InvalidParams(message.into()))
296}
297
298fn json_result(v: impl serde::Serialize) -> CallToolResult {
299 match serde_json::to_value(v) {
300 Ok(value) => with_result_meta(CallToolResult::structured(value)),
301 Err(error) => with_result_meta(CallToolResult::error(vec![ContentBlock::text(format!(
302 "Failed to serialize tool result: {error}"
303 ))])),
304 }
305}
306
307fn text_result(text: impl Into<String>) -> CallToolResult {
308 let text = text.into();
309 let mut result = CallToolResult::structured(serde_json::Value::String(text.clone()));
310
311 result.content = vec![ContentBlock::text(text)];
312
313 with_result_meta(result)
314}
315
316fn server_implementation() -> Implementation {
317 Implementation::new("wowlab", env!("CARGO_PKG_VERSION"))
318 .with_title("WoW Lab MCP Server")
319 .with_website_url(meta::WEBSITE)
320}
321
322fn result_meta() -> MetaObject {
323 let mut meta = MetaObject::new();
324 let server_info = serde_json::to_value(server_implementation())
325 .expect("server implementation is serializable");
326
327 meta.insert(SERVER_INFO_META_KEY.to_owned(), server_info);
328
329 meta
330}
331
332fn with_result_meta(result: CallToolResult) -> CallToolResult {
333 result.with_meta(Some(result_meta()))
334}
335
336fn tool_result<T, E>(tool: McpTool, result: Result<T, E>) -> CallToolResult
337where
338 T: serde::Serialize,
339 E: McpFailure,
340{
341 match result {
342 Ok(value) => json_result(value),
343 Err(error) => mcp_error(tool, &error),
344 }
345}
346
347#[derive(Debug, Default, schemars::JsonSchema, serde::Deserialize)]
348#[schemars(description = "Fetch project documentation.")]
349pub(crate) struct GetDocsInput {
350 #[serde(default)]
351 #[schemars(description = "Return full content instead of index. Default: false.")]
352 pub full: Option<bool>,
353}
354
355#[derive(Debug, Default, schemars::JsonSchema, serde::Deserialize)]
356#[schemars(description = "No parameters.")]
357#[expect(
358 clippy::empty_structs_with_brackets,
359 reason = "MCP input must remain a JSON object rather than a unit value"
360)]
361pub(crate) struct EmptyInput {}
362
363#[derive(Clone)]
364pub(crate) struct McpHandler {
365 pub db: PgPool,
366 pub mcp: crate::state::McpStateHandle,
367 tool_router: ToolRouter<Self>,
368}
369
370impl McpHandler {
371 pub(crate) fn new(db: PgPool, mcp: crate::state::McpStateHandle) -> Self {
372 Self {
373 db,
374 mcp,
375 tool_router: build_tool_router(),
376 }
377 }
378}
379
380fn build_tool_router() -> ToolRouter<McpHandler> {
381 ToolRouter::new()
382 .with_route(tools::build_get_docs_route())
383 .with_route(tools::build_list_tables_route())
384 .with_route(tools::build_get_schema_route())
385 .with_route(tools::build_query_route())
386 .with_route(tools::build_query_batch_route())
387 .with_route(tools::build_query_count_route())
388 .with_route(tools::build_resolve_effects_route())
389 .with_route(tools::build_decode_loadout_route())
390 .with_route(tools::build_get_rotation_route())
391 .with_route(tools::build_list_rotations_route())
392 .with_route(tools::build_list_spell_labels_route())
393 .with_route(tools::build_parse_simc_route())
394}
395
396impl std::fmt::Debug for McpHandler {
397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398 f.debug_struct("McpHandler")
399 .field("db", &"<PgPool>")
400 .field("mcp", &self.mcp)
401 .finish_non_exhaustive()
402 }
403}
404
405#[tool_handler(router = self.tool_router)]
406impl ServerHandler for McpHandler {
407 fn supported_protocol_versions(&self) -> Cow<'static, [rmcp::model::ProtocolVersion]> {
408 Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS)
409 }
410
411 fn get_info(&self) -> ServerInfo {
412 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
413 .with_protocol_version(current_protocol_version())
414 .with_server_info(server_implementation())
415 .with_instructions(mcp_instructions())
416 }
417
418 async fn list_tools(
419 &self,
420 _request: Option<PaginatedRequestParams>,
421 _context: RequestContext<RoleServer>,
422 ) -> Result<ListToolsResult, ErrorData> {
423 Ok(list_tools_result(&self.tool_router))
424 }
425
426 async fn discover(
427 &self,
428 _context: RequestContext<RoleServer>,
429 ) -> Result<DiscoverResult, ErrorData> {
430 Ok(DiscoverResult::from_server_info(
431 DISCOVERABLE_PROTOCOL_VERSIONS.to_vec(),
432 self.get_info(),
433 )
434 .with_ttl_ms(DISCOVERY_CACHE_TTL_MS)
435 .with_cache_scope(CacheScope::Public))
436 }
437}
438
439fn list_tools_result(router: &ToolRouter<McpHandler>) -> ListToolsResult {
440 let mut result = ListToolsResult::with_all_items(router.list_all())
441 .with_ttl_ms(DISCOVERY_CACHE_TTL_MS)
442 .with_cache_scope(CacheScope::Public);
443
444 result.meta = Some(result_meta());
445
446 result
447}
448
449pub(crate) fn create_service(
450 db: PgPool,
451 mcp: crate::state::McpStateHandle,
452) -> StreamableHttpService<McpHandler, LocalSessionManager> {
453 StreamableHttpService::new(
454 move || Ok(McpHandler::new(db.clone(), Arc::clone(&mcp))),
455 Arc::new(LocalSessionManager::default()),
456 server_config(),
457 )
458}
459
460fn server_config() -> StreamableHttpServerConfig {
461 StreamableHttpServerConfig::default()
462 .with_legacy_session_mode(true)
463 .with_json_response(true)
464 .with_sse_keep_alive(None)
465 .with_stateless_protocol_metadata_required(true)
466 .disable_allowed_hosts()
467}
468
469fn mcp_instructions() -> String {
470 let doc = schema::build_workflow_doc(
471 Doc::new()
472 .line("WoW game data and rotation API.")
473 .blank()
474 .h2("Workflow"),
475 )
476 .blank()
477 .h2("Tips");
478
479 schema::build_tips_doc(doc).build()
480}
481
482#[cfg(test)]
483mod tests;