Skip to content

Commit 30cba60

Browse files
authored
Merge pull request #53 from CurtisTechSolutions/feat/mcp-planning-tools
feat(mcp): add hypothetical simulation and engine analysis
2 parents ebb82b4 + fdefdc2 commit 30cba60

7 files changed

Lines changed: 435 additions & 4 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ EOF and process signals stop MCP and background play, then close the database. T
3434

3535

3636
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+
39+
`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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Official-SDK MCP adapter over the transport-independent lifecycle service.
22
mod history;
3+
mod planning;
34
mod play;
45
mod play_types;
56
mod resources;
@@ -8,6 +9,7 @@ use gfa_api_types::ApiError;
89
use gfa_core::Viewer;
910
use gfa_service::GameService;
1011
use history::{HistoryArgs, HistoryOutput, ReplayArgs, ReplayOutput};
12+
use planning::{Analysis, AnalysisArgs, Simulation, SimulationArgs};
1113
use play_types::*;
1214
use rmcp::{
1315
model::{
@@ -52,6 +54,8 @@ impl McpServer {
5254
tool::<MatchArgs, StateOutput>("resign", "Concede the current match and read its final outcome. Example: resign({\"match_id\":\"MATCH_ID\"}).")?,
5355
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}).")?,
5456
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}).")?,
5559
]) })
5660
}
5761

@@ -81,6 +85,8 @@ impl McpServer {
8185
"list_games" => self.games(arguments),
8286
"list_opponents" => self.opponents(arguments),
8387
"get_game_info" => self.info(arguments).await,
88+
"simulate_moves" => self.simulate_tool(arguments).await,
89+
"analyze_position" => self.analyze_tool(arguments).await,
8490
"get_match_history" => self.history_tool(arguments).await,
8591
"get_replay" => self.replay_tool(arguments).await,
8692
"create_match" | "get_state" | "get_legal_actions" | "make_move" | "resign" => {
@@ -313,7 +319,7 @@ fn success<T: Serialize>(data: T, text: String) -> Result<CallToolResult, ApiErr
313319
result.structured_content = Some(value);
314320
Ok(result)
315321
}
316-
fn failure(mut error: ApiError) -> CallToolResult {
322+
fn compact_failure(mut error: ApiError) -> Failure {
317323
if let Some(actions) = error
318324
.details
319325
.get_mut("legal_actions")
@@ -327,6 +333,15 @@ fn failure(mut error: ApiError) -> CallToolResult {
327333
strings.sort();
328334
*actions = strings.into_iter().map(Value::String).collect();
329335
}
336+
Failure {
337+
code: error.code,
338+
message: error.message,
339+
hint: error.hint,
340+
details: error.details,
341+
}
342+
}
343+
fn failure(error: ApiError) -> CallToolResult {
344+
let error = compact_failure(error);
330345
let mut text = format!("{}: {}\n{}", error.code, error.message, error.hint);
331346
if !error.details.is_null() {
332347
text.push_str(&format!("\nContext: {}", error.details));
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
use crate::{decode, internal, play_types::StateOutput, success, types::Failure, McpServer};
2+
use gfa_api_types::{
3+
AnalysisRequest, ApiError, OpponentConfig, SearchBudget, SimulateRequest, SimulationFrom,
4+
SimulationOutput, UciOptions,
5+
};
6+
use rmcp::{model::CallToolResult, schemars::JsonSchema};
7+
use serde::{Deserialize, Serialize};
8+
use serde_json::{json, Value};
9+
10+
#[derive(Deserialize, JsonSchema)]
11+
#[schemars(crate = "rmcp::schemars")]
12+
#[serde(deny_unknown_fields)]
13+
pub(crate) struct SimulationArgs {
14+
pub match_id: String,
15+
/// Up to 16 independent lines, each with at most 32 canonical moves.
16+
pub lines: Vec<Vec<Value>>,
17+
pub from_turn: Option<u64>,
18+
/// Independent planning seed; never changes the match RNG.
19+
pub seed: Option<u64>,
20+
}
21+
#[derive(Serialize, JsonSchema)]
22+
#[schemars(crate = "rmcp::schemars")]
23+
pub(crate) struct LineError {
24+
pub index: usize,
25+
pub error: Failure,
26+
}
27+
#[derive(Serialize, JsonSchema)]
28+
#[schemars(crate = "rmcp::schemars")]
29+
pub(crate) struct Line {
30+
pub moves_applied: u32,
31+
pub state: StateOutput,
32+
pub error: Option<LineError>,
33+
}
34+
#[derive(Serialize, JsonSchema)]
35+
#[schemars(crate = "rmcp::schemars")]
36+
pub(crate) struct Simulation {
37+
pub game_id: String,
38+
pub seed: u64,
39+
pub lines: Vec<Line>,
40+
}
41+
#[derive(Deserialize, JsonSchema)]
42+
#[schemars(crate = "rmcp::schemars")]
43+
#[serde(deny_unknown_fields)]
44+
pub(crate) struct AnalysisArgs {
45+
pub match_id: String,
46+
pub turn: Option<u64>,
47+
/// Installed analysis provider; defaults to Stockfish, reference, minimax, MCTS, then random.
48+
pub opponent: Option<String>,
49+
pub level: Option<u8>,
50+
pub nodes: Option<u64>,
51+
pub depth: Option<u8>,
52+
pub time_ms: Option<u64>,
53+
/// 1..16 ranked lines; only supported by UCI providers.
54+
pub multi_pv: Option<u8>,
55+
pub seed: Option<u64>,
56+
}
57+
#[derive(Serialize, JsonSchema)]
58+
#[schemars(crate = "rmcp::schemars")]
59+
pub(crate) struct Recommendation {
60+
pub action: String,
61+
/// Expected game return, never a centipawn or mate score.
62+
pub evaluation: Option<f64>,
63+
pub principal_variation: Vec<String>,
64+
pub nodes: u64,
65+
pub depth: u8,
66+
pub budget_exhausted: bool,
67+
/// Provider explanation and labeled score units, if available.
68+
pub advice: Option<Value>,
69+
}
70+
#[derive(Serialize, JsonSchema)]
71+
#[schemars(crate = "rmcp::schemars")]
72+
pub(crate) struct Analysis {
73+
pub game_id: String,
74+
pub opponent: String,
75+
pub seed: u64,
76+
pub variations: Vec<Recommendation>,
77+
}
78+
79+
impl McpServer {
80+
pub(crate) async fn simulate_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
81+
let args: SimulationArgs = decode(arguments)?;
82+
let seat = self.player()?;
83+
let game = self.service.get_match(&args.match_id).await?.game_id;
84+
let result = self
85+
.service
86+
.simulate(
87+
&game,
88+
SimulateRequest {
89+
from: SimulationFrom::Match {
90+
match_id: args.match_id,
91+
seat,
92+
turn: args.from_turn,
93+
},
94+
config: json!({}),
95+
lines: args.lines,
96+
seed: args.seed,
97+
output: SimulationOutput::Final,
98+
},
99+
self.viewer,
100+
)
101+
.await?;
102+
let lines = result
103+
.lines
104+
.into_iter()
105+
.map(|line| {
106+
let state = StateOutput::from_state(
107+
line.states.into_iter().last().ok_or_else(internal)?,
108+
self.viewer,
109+
)?;
110+
Ok(Line {
111+
moves_applied: line.moves_applied,
112+
state,
113+
error: line.error.map(|error| LineError {
114+
index: error.index,
115+
error: crate::compact_failure(error.error),
116+
}),
117+
})
118+
})
119+
.collect::<Result<Vec<_>, ApiError>>()?;
120+
let text = lines
121+
.iter()
122+
.enumerate()
123+
.map(|(index, line)| {
124+
let error = line
125+
.error
126+
.as_ref()
127+
.map(|failure| {
128+
format!(
129+
"\nStopped at move {}: {} — {}",
130+
failure.index, failure.error.message, failure.error.hint
131+
)
132+
})
133+
.unwrap_or_default();
134+
format!(
135+
"Line {}: {} moves applied{}\n{}",
136+
index + 1,
137+
line.moves_applied,
138+
error,
139+
line.state.text()
140+
)
141+
})
142+
.collect::<Vec<_>>()
143+
.join("\n\n");
144+
success(
145+
Simulation {
146+
game_id: game,
147+
seed: result.seed,
148+
lines,
149+
},
150+
text,
151+
)
152+
}
153+
pub(crate) async fn analyze_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
154+
let args: AnalysisArgs = decode(arguments)?;
155+
let seat = self.player()?;
156+
let game = self.service.get_match(&args.match_id).await?.game_id;
157+
let opponent = if let Some(opponent) = args.opponent {
158+
opponent
159+
} else {
160+
let catalog = self.service.list_opponents(&game)?;
161+
["stockfish", "reference", "minimax", "mcts", "random"]
162+
.into_iter()
163+
.find(|id| catalog.iter().any(|candidate| candidate.id == *id))
164+
.ok_or_else(|| {
165+
ApiError::new(
166+
"ENGINE_UNAVAILABLE",
167+
"No analysis provider is installed",
168+
"Install a compatible opponent.",
169+
)
170+
})?
171+
.to_string()
172+
};
173+
let result = self
174+
.service
175+
.analyze(
176+
AnalysisRequest {
177+
game_id: game.clone(),
178+
from: SimulationFrom::Match {
179+
match_id: args.match_id,
180+
seat,
181+
turn: args.turn,
182+
},
183+
config: json!({}),
184+
opponent: OpponentConfig {
185+
id: opponent,
186+
level: args.level,
187+
limits: SearchBudget {
188+
nodes: args.nodes,
189+
depth: args.depth,
190+
time_ms: args.time_ms,
191+
},
192+
uci: args.multi_pv.map(|multipv| UciOptions {
193+
multipv: Some(multipv),
194+
..Default::default()
195+
}),
196+
},
197+
seed: args.seed,
198+
},
199+
self.viewer,
200+
)
201+
.await?;
202+
let variations = result
203+
.variations
204+
.into_iter()
205+
.map(|choice| {
206+
Ok(Recommendation {
207+
action: choice.action.string,
208+
evaluation: choice.info.evaluation,
209+
principal_variation: choice.info.principal_variation,
210+
nodes: choice.info.nodes,
211+
depth: choice.info.depth,
212+
budget_exhausted: choice.info.budget_exhausted,
213+
advice: choice
214+
.info
215+
.advice
216+
.map(serde_json::to_value)
217+
.transpose()
218+
.map_err(|_| internal())?,
219+
})
220+
})
221+
.collect::<Result<Vec<_>, ApiError>>()?;
222+
let text = variations.iter().enumerate().map(|(index,line)|format!(
223+
"{}. {} — expected return: {:?}, depth: {}, nodes: {}, budget exhausted: {}\nPV: {}\nAdvice: {}",
224+
index+1,line.action,line.evaluation,line.depth,line.nodes,line.budget_exhausted,line.principal_variation.join(" "),
225+
line.advice.as_ref().map_or_else(||"none".into(),Value::to_string)
226+
)).collect::<Vec<_>>().join("\n");
227+
success(
228+
Analysis {
229+
game_id: game,
230+
opponent: result.opponent,
231+
seed: result.seed,
232+
variations,
233+
},
234+
text,
235+
)
236+
}
237+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ impl McpServer {
1313
.await
1414
.unwrap_or_else(failure)
1515
}
16-
fn player(&self) -> Result<u8, ApiError> {
16+
pub(crate) fn player(&self) -> Result<u8, ApiError> {
1717
match self.viewer {
1818
Viewer::Player(seat) => Ok(seat),
1919
_ => Err(ApiError::new(

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,6 @@ mod mcp_history_tests;
266266

267267
#[cfg(test)]
268268
mod mcp_resource_tests;
269+
270+
#[cfg(test)]
271+
mod mcp_plan_tests;

0 commit comments

Comments
 (0)