Skip to content

Commit 77e873f

Browse files
committed
chore(mcp): compose planning with merged resources and catalog checks
2 parents d551221 + ebb82b4 commit 77e873f

6 files changed

Lines changed: 385 additions & 15 deletions

File tree

‎crates/adapters/gfa-mcp/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,7 @@ EOF and process signals stop MCP and background play, then close the database. T
3333
`get_match_history` scans bounded pages of the trusted local user's database; follow `next` even when a game filter produces an empty page. `get_replay` returns paginated viewer-scoped events with canonical moves, visible reasoning, and control outcomes. It omits raw initial states and seeds, and preserves the service's rule that another seat's reasoning stays hidden during active play. A future multiuser host must restrict match history and access to its authenticated principal.
3434

3535

36+
The resource catalog lists each installed game's compact rules. URI templates expose `gfa://games/{game_id}/info` and `gfa://matches/{match_id}/{info,state,replay}` with the same host-authorized view as tools. Live resource results are private and immediately stale; no viewer override is accepted in a URI. Replay resources return the first 100 projected events plus a continuation cursor; use `get_replay` for later pages. `play_game(game_id)` embeds compact rules and the tool loop in a user-invoked MCP prompt. Live subscriptions are a separate implementation unit.
37+
38+
3639
`simulate_moves` returns compact final states for independent lines and the first illegal action in each line. `analyze_position` returns ranked recommendations with labeled provider advice and expected game returns kept separate from engine scores. Both use the host-selected seat, honor the match's stored assist permissions, and support historical turns without changing live state. Simulation usage is durably accounted by the service. Optional analysis budgets go through the same validated worker limits as REST.

‎crates/adapters/gfa-mcp/src/lib.rs‎

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
//! Official-SDK MCP adapter over the transport-independent lifecycle service.
2-
mod planning;
2+
mod history;
33
mod play;
44
mod play_types;
5+
mod resources;
56
mod types;
6-
mod history;
7-
use history::{HistoryArgs, HistoryOutput, ReplayArgs, ReplayOutput};
7+
mod planning;
8+
use planning::{Analysis, AnalysisArgs, Simulation, SimulationArgs};
89
use gfa_api_types::ApiError;
910
use gfa_core::Viewer;
1011
use gfa_service::GameService;
11-
use planning::{Analysis, AnalysisArgs, Simulation, SimulationArgs};
12+
use history::{HistoryArgs, HistoryOutput, ReplayArgs, ReplayOutput};
1213
use play_types::*;
1314
use rmcp::{
1415
model::{
@@ -51,10 +52,10 @@ impl McpServer {
5152
tool::<MatchArgs, LegalOutput>("get_legal_actions", "Get canonical move strings when choosing or correcting a move. Example: get_legal_actions({\"match_id\":\"MATCH_ID\"}).")?,
5253
tool::<MoveArgs, MovedOutput>("make_move", "Play one legal action; returns the automatic replies and new board. Example: make_move({\"match_id\":\"MATCH_ID\",\"action\":\"e2e4\",\"reasoning\":\"control the center\"}).")?,
5354
tool::<MatchArgs, StateOutput>("resign", "Concede the current match and read its final outcome. Example: resign({\"match_id\":\"MATCH_ID\"}).")?,
54-
tool::<SimulationArgs, Simulation>("simulate_moves", "Explore hypothetical continuations when match simulation is enabled; never applies moves. Example: simulate_moves({\"match_id\":\"MATCH_ID\",\"lines\":[[\"e2e4\",\"e7e5\"]]}).")?,
55-
tool::<AnalysisArgs, Analysis>("analyze_position", "Request engine recommendations only when match analysis is enabled. Example: analyze_position({\"match_id\":\"MATCH_ID\",\"nodes\":1000}).")?,
5655
tool::<HistoryArgs, HistoryOutput>("get_match_history", "Find past matches in this trusted local host's database; follow next even for empty pages. Example: get_match_history({\"game_id\":\"chess\",\"limit\":10}).")?,
5756
tool::<ReplayArgs, ReplayOutput>("get_replay", "Read recorded moves and visible reasoning in bounded pages. Example: get_replay({\"match_id\":\"MATCH_ID\",\"limit\":25}).")?,
57+
tool::<SimulationArgs, Simulation>("simulate_moves", "Explore hypothetical continuations when match simulation is enabled; never applies moves. Example: simulate_moves({\"match_id\":\"MATCH_ID\",\"lines\":[[\"e2e4\",\"e7e5\"]]}).")?,
58+
tool::<AnalysisArgs, Analysis>("analyze_position", "Request engine recommendations only when match analysis is enabled. Example: analyze_position({\"match_id\":\"MATCH_ID\",\"nodes\":1000}).")?,
5859
]) })
5960
}
6061

@@ -84,10 +85,10 @@ impl McpServer {
8485
"list_games" => self.games(arguments),
8586
"list_opponents" => self.opponents(arguments),
8687
"get_game_info" => self.info(arguments).await,
87-
"get_match_history" => self.history_tool(arguments).await,
88-
"get_replay" => self.replay_tool(arguments).await,
8988
"simulate_moves" => self.simulate_tool(arguments).await,
9089
"analyze_position" => self.analyze_tool(arguments).await,
90+
"get_match_history" => self.history_tool(arguments).await,
91+
"get_replay" => self.replay_tool(arguments).await,
9192
"create_match" | "get_state" | "get_legal_actions" | "make_move" | "resign" => {
9293
Ok(self.play(name, arguments).await)
9394
}
@@ -199,11 +200,57 @@ impl McpServer {
199200
}
200201
impl ServerHandler for McpServer {
201202
fn get_info(&self) -> ServerConfig {
202-
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
203-
.with_server_info(Implementation::new("GamesForAI", env!("CARGO_PKG_VERSION")))
204-
.with_instructions(
205-
"Read get_game_info before playing. Tool errors include recovery hints.",
206-
)
203+
ServerConfig::new(
204+
ServerCapabilities::builder()
205+
.enable_tools()
206+
.enable_resources()
207+
.enable_prompts()
208+
.build(),
209+
)
210+
.with_server_info(Implementation::new("GamesForAI", env!("CARGO_PKG_VERSION")))
211+
.with_instructions("Read get_game_info before playing. Tool errors include recovery hints.")
212+
}
213+
async fn list_resources(
214+
&self,
215+
request: Option<PaginatedRequestParams>,
216+
_: RequestContext<RoleServer>,
217+
) -> Result<rmcp::model::ListResourcesResult, ErrorData> {
218+
resources::no_cursor(request)?;
219+
Ok(self.resources())
220+
}
221+
async fn list_resource_templates(
222+
&self,
223+
request: Option<PaginatedRequestParams>,
224+
_: RequestContext<RoleServer>,
225+
) -> Result<rmcp::model::ListResourceTemplatesResult, ErrorData> {
226+
resources::no_cursor(request)?;
227+
Ok(self.templates())
228+
}
229+
async fn read_resource(
230+
&self,
231+
request: rmcp::model::ReadResourceRequestParams,
232+
_: RequestContext<RoleServer>,
233+
) -> Result<rmcp::model::ReadResourceResponse, ErrorData> {
234+
self.resource(&request.uri).await.map(Into::into)
235+
}
236+
async fn list_prompts(
237+
&self,
238+
request: Option<PaginatedRequestParams>,
239+
_: RequestContext<RoleServer>,
240+
) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
241+
resources::no_cursor(request)?;
242+
Ok(self.prompts())
243+
}
244+
async fn get_prompt(
245+
&self,
246+
request: rmcp::model::GetPromptRequestParams,
247+
_: RequestContext<RoleServer>,
248+
) -> Result<rmcp::model::GetPromptResponse, ErrorData> {
249+
self.play_prompt(
250+
&request.name,
251+
Value::Object(request.arguments.unwrap_or_default()),
252+
)
253+
.map(Into::into)
207254
}
208255
fn get_tool(&self, name: &str) -> Option<Tool> {
209256
self.tools.iter().find(|tool| tool.name == name).cloned()
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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+
}

‎crates/apps/gfa-server/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,5 +264,8 @@ pub use mcp_host::serve_stdio;
264264
#[cfg(test)]
265265
mod mcp_history_tests;
266266

267+
#[cfg(test)]
268+
mod mcp_resource_tests;
269+
267270
#[cfg(test)]
268271
mod mcp_plan_tests;

0 commit comments

Comments
 (0)