Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/adapters/gfa-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ EOF and process signals stop MCP and background play, then close the database. T


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.


`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.
17 changes: 16 additions & 1 deletion crates/adapters/gfa-mcp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Official-SDK MCP adapter over the transport-independent lifecycle service.
mod history;
mod planning;
mod play;
mod play_types;
mod resources;
Expand All @@ -8,6 +9,7 @@ use gfa_api_types::ApiError;
use gfa_core::Viewer;
use gfa_service::GameService;
use history::{HistoryArgs, HistoryOutput, ReplayArgs, ReplayOutput};
use planning::{Analysis, AnalysisArgs, Simulation, SimulationArgs};
use play_types::*;
use rmcp::{
model::{
Expand Down Expand Up @@ -52,6 +54,8 @@ impl McpServer {
tool::<MatchArgs, StateOutput>("resign", "Concede the current match and read its final outcome. Example: resign({\"match_id\":\"MATCH_ID\"}).")?,
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}).")?,
tool::<ReplayArgs, ReplayOutput>("get_replay", "Read recorded moves and visible reasoning in bounded pages. Example: get_replay({\"match_id\":\"MATCH_ID\",\"limit\":25}).")?,
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\"]]}).")?,
tool::<AnalysisArgs, Analysis>("analyze_position", "Request engine recommendations only when match analysis is enabled. Example: analyze_position({\"match_id\":\"MATCH_ID\",\"nodes\":1000}).")?,
]) })
}

Expand Down Expand Up @@ -81,6 +85,8 @@ impl McpServer {
"list_games" => self.games(arguments),
"list_opponents" => self.opponents(arguments),
"get_game_info" => self.info(arguments).await,
"simulate_moves" => self.simulate_tool(arguments).await,
"analyze_position" => self.analyze_tool(arguments).await,
"get_match_history" => self.history_tool(arguments).await,
"get_replay" => self.replay_tool(arguments).await,
"create_match" | "get_state" | "get_legal_actions" | "make_move" | "resign" => {
Expand Down Expand Up @@ -313,7 +319,7 @@ fn success<T: Serialize>(data: T, text: String) -> Result<CallToolResult, ApiErr
result.structured_content = Some(value);
Ok(result)
}
fn failure(mut error: ApiError) -> CallToolResult {
fn compact_failure(mut error: ApiError) -> Failure {
if let Some(actions) = error
.details
.get_mut("legal_actions")
Expand All @@ -327,6 +333,15 @@ fn failure(mut error: ApiError) -> CallToolResult {
strings.sort();
*actions = strings.into_iter().map(Value::String).collect();
}
Failure {
code: error.code,
message: error.message,
hint: error.hint,
details: error.details,
}
}
fn failure(error: ApiError) -> CallToolResult {
let error = compact_failure(error);
let mut text = format!("{}: {}\n{}", error.code, error.message, error.hint);
if !error.details.is_null() {
text.push_str(&format!("\nContext: {}", error.details));
Expand Down
237 changes: 237 additions & 0 deletions crates/adapters/gfa-mcp/src/planning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
use crate::{decode, internal, play_types::StateOutput, success, types::Failure, McpServer};
use gfa_api_types::{
AnalysisRequest, ApiError, OpponentConfig, SearchBudget, SimulateRequest, SimulationFrom,
SimulationOutput, UciOptions,
};
use rmcp::{model::CallToolResult, schemars::JsonSchema};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Deserialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
#[serde(deny_unknown_fields)]
pub(crate) struct SimulationArgs {
pub match_id: String,
/// Up to 16 independent lines, each with at most 32 canonical moves.
pub lines: Vec<Vec<Value>>,
pub from_turn: Option<u64>,
/// Independent planning seed; never changes the match RNG.
pub seed: Option<u64>,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct LineError {
pub index: usize,
pub error: Failure,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct Line {
pub moves_applied: u32,
pub state: StateOutput,
pub error: Option<LineError>,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct Simulation {
pub game_id: String,
pub seed: u64,
pub lines: Vec<Line>,
}
#[derive(Deserialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
#[serde(deny_unknown_fields)]
pub(crate) struct AnalysisArgs {
pub match_id: String,
pub turn: Option<u64>,
/// Installed analysis provider; defaults to Stockfish, reference, minimax, MCTS, then random.
pub opponent: Option<String>,
pub level: Option<u8>,
pub nodes: Option<u64>,
pub depth: Option<u8>,
pub time_ms: Option<u64>,
/// 1..16 ranked lines; only supported by UCI providers.
pub multi_pv: Option<u8>,
pub seed: Option<u64>,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct Recommendation {
pub action: String,
/// Expected game return, never a centipawn or mate score.
pub evaluation: Option<f64>,
pub principal_variation: Vec<String>,
pub nodes: u64,
pub depth: u8,
pub budget_exhausted: bool,
/// Provider explanation and labeled score units, if available.
pub advice: Option<Value>,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct Analysis {
pub game_id: String,
pub opponent: String,
pub seed: u64,
pub variations: Vec<Recommendation>,
}

impl McpServer {
pub(crate) async fn simulate_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
let args: SimulationArgs = decode(arguments)?;
let seat = self.player()?;
let game = self.service.get_match(&args.match_id).await?.game_id;
let result = self
.service
.simulate(
&game,
SimulateRequest {
from: SimulationFrom::Match {
match_id: args.match_id,
seat,
turn: args.from_turn,
},
config: json!({}),
lines: args.lines,
seed: args.seed,
output: SimulationOutput::Final,
},
self.viewer,
)
.await?;
let lines = result
.lines
.into_iter()
.map(|line| {
let state = StateOutput::from_state(
line.states.into_iter().last().ok_or_else(internal)?,
self.viewer,
)?;
Ok(Line {
moves_applied: line.moves_applied,
state,
error: line.error.map(|error| LineError {
index: error.index,
error: crate::compact_failure(error.error),
}),
})
})
.collect::<Result<Vec<_>, ApiError>>()?;
let text = lines
.iter()
.enumerate()
.map(|(index, line)| {
let error = line
.error
.as_ref()
.map(|failure| {
format!(
"\nStopped at move {}: {} — {}",
failure.index, failure.error.message, failure.error.hint
)
})
.unwrap_or_default();
format!(
"Line {}: {} moves applied{}\n{}",
index + 1,
line.moves_applied,
error,
line.state.text()
)
})
.collect::<Vec<_>>()
.join("\n\n");
success(
Simulation {
game_id: game,
seed: result.seed,
lines,
},
text,
)
}
pub(crate) async fn analyze_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
let args: AnalysisArgs = decode(arguments)?;
let seat = self.player()?;
let game = self.service.get_match(&args.match_id).await?.game_id;
let opponent = if let Some(opponent) = args.opponent {
opponent
} else {
let catalog = self.service.list_opponents(&game)?;
["stockfish", "reference", "minimax", "mcts", "random"]
.into_iter()
.find(|id| catalog.iter().any(|candidate| candidate.id == *id))
.ok_or_else(|| {
ApiError::new(
"ENGINE_UNAVAILABLE",
"No analysis provider is installed",
"Install a compatible opponent.",
)
})?
.to_string()
};
let result = self
.service
.analyze(
AnalysisRequest {
game_id: game.clone(),
from: SimulationFrom::Match {
match_id: args.match_id,
seat,
turn: args.turn,
},
config: json!({}),
opponent: OpponentConfig {
id: opponent,
level: args.level,
limits: SearchBudget {
nodes: args.nodes,
depth: args.depth,
time_ms: args.time_ms,
},
uci: args.multi_pv.map(|multipv| UciOptions {
multipv: Some(multipv),
..Default::default()
}),
},
seed: args.seed,
},
self.viewer,
)
.await?;
let variations = result
.variations
.into_iter()
.map(|choice| {
Ok(Recommendation {
action: choice.action.string,
evaluation: choice.info.evaluation,
principal_variation: choice.info.principal_variation,
nodes: choice.info.nodes,
depth: choice.info.depth,
budget_exhausted: choice.info.budget_exhausted,
advice: choice
.info
.advice
.map(serde_json::to_value)
.transpose()
.map_err(|_| internal())?,
})
})
.collect::<Result<Vec<_>, ApiError>>()?;
let text = variations.iter().enumerate().map(|(index,line)|format!(
"{}. {} — expected return: {:?}, depth: {}, nodes: {}, budget exhausted: {}\nPV: {}\nAdvice: {}",
index+1,line.action,line.evaluation,line.depth,line.nodes,line.budget_exhausted,line.principal_variation.join(" "),
line.advice.as_ref().map_or_else(||"none".into(),Value::to_string)
)).collect::<Vec<_>>().join("\n");
success(
Analysis {
game_id: game,
opponent: result.opponent,
seed: result.seed,
variations,
},
text,
)
}
}
2 changes: 1 addition & 1 deletion crates/adapters/gfa-mcp/src/play.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ impl McpServer {
.await
.unwrap_or_else(failure)
}
fn player(&self) -> Result<u8, ApiError> {
pub(crate) fn player(&self) -> Result<u8, ApiError> {
match self.viewer {
Viewer::Player(seat) => Ok(seat),
_ => Err(ApiError::new(
Expand Down
3 changes: 3 additions & 0 deletions crates/apps/gfa-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,6 @@ mod mcp_history_tests;

#[cfg(test)]
mod mcp_resource_tests;

#[cfg(test)]
mod mcp_plan_tests;
Loading
Loading