|
| 1 | +use crate::{decode, play_types::StateOutput, types::GameArgs, McpServer}; |
| 2 | +use gfa_api_types::{ApiError, InfoDetail}; |
| 3 | +use gfa_core::Viewer; |
| 4 | +use rmcp::{ |
| 5 | + model::{ |
| 6 | + GetPromptResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, |
| 7 | + PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, ReadResourceResult, |
| 8 | + Resource, ResourceContents, ResourceTemplate, Role, |
| 9 | + }, |
| 10 | + ErrorData, |
| 11 | +}; |
| 12 | +use serde_json::{json, Value}; |
| 13 | + |
| 14 | +pub(crate) fn no_cursor(request: Option<PaginatedRequestParams>) -> Result<(), ErrorData> { |
| 15 | + if request.is_some_and(|request| request.cursor.is_some()) { |
| 16 | + return Err(ErrorData::invalid_params( |
| 17 | + "This catalog has no further page", |
| 18 | + None, |
| 19 | + )); |
| 20 | + } |
| 21 | + Ok(()) |
| 22 | +} |
| 23 | +fn protocol(error: ApiError) -> ErrorData { |
| 24 | + ErrorData::invalid_params( |
| 25 | + format!("{}: {} — {}", error.code, error.message, error.hint), |
| 26 | + Some(json!({"code":error.code,"hint":error.hint})), |
| 27 | + ) |
| 28 | +} |
| 29 | +impl McpServer { |
| 30 | + pub(crate) fn resources(&self) -> ListResourcesResult { |
| 31 | + ListResourcesResult::with_all_items( |
| 32 | + self.service |
| 33 | + .list_games() |
| 34 | + .into_iter() |
| 35 | + .map(|game| { |
| 36 | + Resource::new( |
| 37 | + format!("gfa://games/{}/info", game.id), |
| 38 | + format!("{} rules", game.name), |
| 39 | + ) |
| 40 | + .with_description("Compact game briefing") |
| 41 | + .with_mime_type("text/markdown") |
| 42 | + }) |
| 43 | + .collect(), |
| 44 | + ) |
| 45 | + } |
| 46 | + pub(crate) fn templates(&self) -> ListResourceTemplatesResult { |
| 47 | + ListResourceTemplatesResult::with_all_items( |
| 48 | + [ |
| 49 | + ("gfa://games/{game_id}/info", "Game rules", "text/markdown"), |
| 50 | + ( |
| 51 | + "gfa://matches/{match_id}/info", |
| 52 | + "Match briefing", |
| 53 | + "text/markdown", |
| 54 | + ), |
| 55 | + ( |
| 56 | + "gfa://matches/{match_id}/state", |
| 57 | + "Live match state", |
| 58 | + "application/json", |
| 59 | + ), |
| 60 | + ( |
| 61 | + "gfa://matches/{match_id}/replay", |
| 62 | + "Recorded moves", |
| 63 | + "application/json", |
| 64 | + ), |
| 65 | + ] |
| 66 | + .into_iter() |
| 67 | + .map(|(uri, name, mime)| ResourceTemplate::new(uri, name).with_mime_type(mime)) |
| 68 | + .collect(), |
| 69 | + ) |
| 70 | + } |
| 71 | + pub(crate) fn prompts(&self) -> ListPromptsResult { |
| 72 | + ListPromptsResult::with_all_items(vec![Prompt::new( |
| 73 | + "play_game", |
| 74 | + Some("Read the compact rules and follow the MCP game loop."), |
| 75 | + Some(vec![PromptArgument::new("game_id") |
| 76 | + .with_required(true) |
| 77 | + .with_description("Registered game ID from list_games")]), |
| 78 | + )]) |
| 79 | + } |
| 80 | + /// Read a bounded resource using exactly this adapter's host-authorized viewer. |
| 81 | + pub async fn resource(&self, uri: &str) -> Result<ReadResourceResult, ErrorData> { |
| 82 | + let suffix = uri |
| 83 | + .strip_prefix("gfa://") |
| 84 | + .ok_or_else(|| ErrorData::invalid_params("Expected a gfa:// resource URI", None))?; |
| 85 | + if uri.len() > 512 |
| 86 | + || suffix |
| 87 | + .bytes() |
| 88 | + .any(|byte| !byte.is_ascii_alphanumeric() && !b"/_-".contains(&byte)) |
| 89 | + { |
| 90 | + return Err(ErrorData::invalid_params( |
| 91 | + "Resource URI contains invalid characters", |
| 92 | + None, |
| 93 | + )); |
| 94 | + } |
| 95 | + let parts = suffix.split('/').collect::<Vec<_>>(); |
| 96 | + let (text, mime) = match parts.as_slice() { |
| 97 | + ["games", id, "info"] => { |
| 98 | + let seat = match self.viewer { |
| 99 | + Viewer::Player(seat) => Some(seat), |
| 100 | + _ => None, |
| 101 | + }; |
| 102 | + let info = self |
| 103 | + .service |
| 104 | + .get_game_info(id, &json!({}), seat, InfoDetail::Compact) |
| 105 | + .map_err(protocol)?; |
| 106 | + ( |
| 107 | + info.markdown().map_err(|_| protocol(crate::internal()))?, |
| 108 | + "text/markdown", |
| 109 | + ) |
| 110 | + } |
| 111 | + ["matches", id, "info"] => { |
| 112 | + let info = self |
| 113 | + .service |
| 114 | + .get_match_info(id, self.viewer, InfoDetail::Compact) |
| 115 | + .await |
| 116 | + .map_err(protocol)?; |
| 117 | + ( |
| 118 | + info.markdown().map_err(|_| protocol(crate::internal()))?, |
| 119 | + "text/markdown", |
| 120 | + ) |
| 121 | + } |
| 122 | + ["matches", id, "state"] => { |
| 123 | + let state = self |
| 124 | + .service |
| 125 | + .get_state(id, self.viewer) |
| 126 | + .await |
| 127 | + .map_err(protocol)?; |
| 128 | + let output = StateOutput::from_state(state, self.viewer).map_err(protocol)?; |
| 129 | + ( |
| 130 | + serde_json::to_string(&output).map_err(|_| protocol(crate::internal()))?, |
| 131 | + "application/json", |
| 132 | + ) |
| 133 | + } |
| 134 | + ["matches", id, "replay"] => { |
| 135 | + let output = self |
| 136 | + .replay_tool(json!({"match_id":id,"limit":100})) |
| 137 | + .await |
| 138 | + .map_err(protocol)?; |
| 139 | + let data = output |
| 140 | + .structured_content |
| 141 | + .and_then(|value| value.get("data").cloned()) |
| 142 | + .ok_or_else(|| protocol(crate::internal()))?; |
| 143 | + ( |
| 144 | + serde_json::to_string(&data).map_err(|_| protocol(crate::internal()))?, |
| 145 | + "application/json", |
| 146 | + ) |
| 147 | + } |
| 148 | + _ => { |
| 149 | + return Err(ErrorData::invalid_params( |
| 150 | + "Unknown GamesForAI resource", |
| 151 | + None, |
| 152 | + )) |
| 153 | + } |
| 154 | + }; |
| 155 | + if text.len() > 1024 * 1024 { |
| 156 | + return Err(ErrorData::invalid_params( |
| 157 | + "Resource exceeds 1 MiB; use compact paginated tools", |
| 158 | + None, |
| 159 | + )); |
| 160 | + } |
| 161 | + let mut result = |
| 162 | + ReadResourceResult::new(vec![ResourceContents::text(text, uri).with_mime_type(mime)]); |
| 163 | + result.ttl_ms = Some(0); |
| 164 | + result.cache_scope = Some(rmcp::model::CacheScope::Private); |
| 165 | + Ok(result) |
| 166 | + } |
| 167 | + /// Build a user-invoked play prompt. MCP prompt messages have user/assistant roles. |
| 168 | + pub fn play_prompt(&self, name: &str, arguments: Value) -> Result<GetPromptResult, ErrorData> { |
| 169 | + if name != "play_game" { |
| 170 | + return Err(ErrorData::invalid_params("Unknown prompt", None)); |
| 171 | + } |
| 172 | + if serde_json::to_vec(&arguments).map_or(true, |value| value.len() > 8192) { |
| 173 | + return Err(ErrorData::invalid_params( |
| 174 | + "Prompt arguments exceed 8 KiB", |
| 175 | + None, |
| 176 | + )); |
| 177 | + } |
| 178 | + let args: GameArgs = decode(arguments).map_err(protocol)?; |
| 179 | + let seat = match self.viewer { |
| 180 | + Viewer::Player(seat) => Some(seat), |
| 181 | + _ => None, |
| 182 | + }; |
| 183 | + let info = self |
| 184 | + .service |
| 185 | + .get_game_info(&args.game_id, &json!({}), seat, InfoDetail::Compact) |
| 186 | + .map_err(protocol)?; |
| 187 | + let text=format!("Play {} through GamesForAI tools. Read the rules below, then list_opponents and create_match. Choose only listed legal actions. Each make_move response includes automatic opponent replies and the new state. If a move fails, use its hint and legal actions to retry. Optional reasoning is recorded. Use simulation or analysis only when the match permits it. Stop when terminated or truncated is true; report the recorded outcome.\n\n{}",args.game_id,info.markdown().map_err(|_|protocol(crate::internal()))?); |
| 188 | + Ok( |
| 189 | + GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)]) |
| 190 | + .with_description(format!("Play {}", args.game_id)), |
| 191 | + ) |
| 192 | + } |
| 193 | +} |
0 commit comments