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
5 changes: 5 additions & 0 deletions crates/adapters/gfa-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ named. Stdio/HTTP composition and playing tools are separate implementation unit
Dependency justification: rmcp supplies maintained protocol negotiation, wire
types and transports rather than a project-specific JSON-RPC implementation.
Reference: https://github.com/modelcontextprotocol/rust-sdk


Match controls: `create_match`, `get_state`, `get_legal_actions`, `make_move`, and `resign` use the host-authorized viewer. A spectator can read public state but cannot create or control a player. `play_as` must agree with the host-selected player seat. This local composition is not multiuser authorization; a hosted deployment needs a match-access policy before granting a viewer.

States contain the engine text board and sorted canonical action strings, without tensors, action masks, or duplicate JSON boards. Move errors retain legal actions and recovery context. Supply both `turn` and `idempotency_key` for safe retries; without an explicit turn, the adapter reads the current turn and the service still rejects concurrent changes. Opponent replies and the resulting state arrive in the same move result. Reasoning is stored through the normal service command.
37 changes: 32 additions & 5 deletions crates/adapters/gfa-mcp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! Official-SDK MCP adapter over the transport-independent lifecycle service.
mod play;
mod play_types;
mod types;
use gfa_api_types::ApiError;
use gfa_core::Viewer;
use gfa_service::GameService;
use play_types::*;
use rmcp::{
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
Expand Down Expand Up @@ -39,6 +42,11 @@ impl McpServer {
tool::<Empty, GamesOutput>("list_games", "Discover installed games before choosing one. Example: list_games({}).")?,
tool::<InfoArgs, BriefingOutput>("get_game_info", "Read rules and the API loop before playing; use match_id for an existing match. Example: get_game_info({\"game_id\":\"chess\",\"detail\":\"compact\"}).")?,
tool::<GameArgs, OpponentsOutput>("list_opponents", "Find installed opponents and their measured levels before creating a match. Example: list_opponents({\"game_id\":\"chess\"}).")?,
tool::<CreateArgs, CreatedOutput>("create_match", "Start playing after reading get_game_info; choose an installed opponent for automatic replies. Example: create_match({\"game_id\":\"chess\",\"opponent\":\"random\",\"seed\":7}).")?,
tool::<MatchArgs, StateOutput>("get_state", "Read the current board and whose turn it is before acting. Example: get_state({\"match_id\":\"MATCH_ID\"}).")?,
tool::<MatchArgs, LegalOutput>("get_legal_actions", "Get canonical move strings when choosing or correcting a move. Example: get_legal_actions({\"match_id\":\"MATCH_ID\"}).")?,
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\"}).")?,
tool::<MatchArgs, StateOutput>("resign", "Concede the current match and read its final outcome. Example: resign({\"match_id\":\"MATCH_ID\"}).")?,
]) })
}

Expand Down Expand Up @@ -68,6 +76,9 @@ impl McpServer {
"list_games" => self.games(arguments),
"list_opponents" => self.opponents(arguments),
"get_game_info" => self.info(arguments).await,
"create_match" | "get_state" | "get_legal_actions" | "make_move" | "resign" => {
Ok(self.play(name, arguments).await)
}
_ => {
return Err(ErrorData::new(
rmcp::model::ErrorCode::METHOD_NOT_FOUND,
Expand Down Expand Up @@ -249,20 +260,36 @@ fn success<T: Serialize>(data: T, text: String) -> Result<CallToolResult, ApiErr
result.structured_content = Some(value);
Ok(result)
}
fn failure(error: ApiError) -> CallToolResult {
let mut result = CallToolResult::error(vec![ContentBlock::text(format!(
"{}: {}\n{}",
error.code, error.message, error.hint
))]);
fn failure(mut error: ApiError) -> CallToolResult {
if let Some(actions) = error
.details
.get_mut("legal_actions")
.and_then(Value::as_array_mut)
{
let mut strings = actions
.iter()
.filter_map(|action| action["string"].as_str())
.map(str::to_owned)
.collect::<Vec<_>>();
strings.sort();
*actions = strings.into_iter().map(Value::String).collect();
}
let mut text = format!("{}: {}\n{}", error.code, error.message, error.hint);
if !error.details.is_null() {
text.push_str(&format!("\nContext: {}", error.details));
}
let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
result.structured_content = Some(json!(Output::<Value>::Failure {
error: Failure {
code: error.code,
message: error.message,
hint: error.hint,
details: error.details,
}
}));
result
}

fn internal() -> ApiError {
ApiError::new(
"INVALID_MCP_RESPONSE",
Expand Down
232 changes: 232 additions & 0 deletions crates/adapters/gfa-mcp/src/play.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
use crate::play_types::*;
use crate::{decode, failure, internal, success, McpServer};
use gfa_api_types::{
ApiError, Assists, ControlRequest, CreateMatch, MoveRequest, OpponentConfig, Seat, Start,
};
use gfa_core::Viewer;
use rmcp::model::CallToolResult;
use serde_json::{json, Value};

impl McpServer {
pub(crate) async fn play(&self, name: &str, arguments: Value) -> CallToolResult {
self.play_result(name, arguments)
.await
.unwrap_or_else(failure)
}
fn player(&self) -> Result<u8, ApiError> {
match self.viewer {
Viewer::Player(seat) => Ok(seat),
_ => Err(ApiError::new(
"FORBIDDEN",
"A spectator cannot control a player",
"Connect with a host-authorized player seat.",
)),
}
}
async fn play_result(&self, name: &str, arguments: Value) -> Result<CallToolResult, ApiError> {
match name {
"create_match" => self.create_game(decode(arguments)?).await,
"make_move" => self.move_game(decode(arguments)?).await,
"get_state" | "get_legal_actions" | "resign" => {
let args: MatchArgs = decode(arguments)?;
let state = self.service.get_state(&args.match_id, self.viewer).await?;
if name == "resign" {
let seat = self.player()?;
let state = self
.service
.resign(
&args.match_id,
ControlRequest {
seat,
turn: state.turn,
},
)
.await?;
let output = StateOutput::from_state(state, self.viewer)?;
return success(&output, output.text());
}
let output = StateOutput::from_state(state, self.viewer)?;
if name == "get_legal_actions" {
let notation = "Copy a canonical string exactly into make_move.action; do not infer an action from the board.".to_string();
let text = format!(
"Turn {}. To act: {:?}.\n{}\n{}",
output.turn,
output.to_act,
notation,
output.legal_actions.join(", ")
);
success(
LegalOutput {
match_id: output.match_id,
turn: output.turn,
to_act: output.to_act,
legal_actions: output.legal_actions,
notation,
},
text,
)
} else {
success(&output, output.text())
}
}
_ => Err(internal()),
}
}
async fn create_game(&self, args: CreateArgs) -> Result<CallToolResult, ApiError> {
let seat = self.player()?;
if args.play_as.is_some_and(|requested| requested != seat) {
return Err(ApiError::new(
"FORBIDDEN",
"Requested seat differs from the host-authorized seat",
"Omit play_as or connect with the requested player seat.",
));
}
if args.opponent.is_none() && args.level.is_some() {
return Err(ApiError::new(
"INVALID_CONFIG",
"A level requires an opponent",
"Select an installed opponent or omit level.",
));
}
let spec = self
.service
.list_games()
.into_iter()
.find(|game| game.id == args.game_id)
.ok_or_else(|| {
ApiError::new(
"UNKNOWN_GAME",
"Game is not installed",
"Choose a game from list_games.",
)
})?;
let seats = if let Some(id) = args.opponent {
if spec.num_players != [2, 2] || seat > 1 {
return Err(ApiError::new(
"INVALID_CONFIG",
"Automatic opposition requires a two-player game",
"Omit opponent for this game.",
));
}
let mut seats = vec![Seat::SelfPlayer; 2];
seats[usize::from(1 - seat)] = Seat::Opponent {
opponent: OpponentConfig {
id,
level: args.level,
limits: Default::default(),
uci: None,
},
seed: None,
};
seats
} else {
vec![]
};
let created = self
.service
.create_match_with_info(
CreateMatch {
game_id: args.game_id,
config: args.config.unwrap_or(json!({})),
seed: args.seed,
start: args.start.map(|position| Start::Position { position }),
seats,
assists: Assists {
allow_analysis: args.allow_analysis,
allow_simulation: args.allow_simulation,
},
include_info: true,
},
self.viewer,
)
.await?;
let state = StateOutput::from_state(created.state, self.viewer)?;
let mut info = created.info.ok_or_else(internal)?;
// Match briefing's live state uses the same compact projection as the tool.
if let Some(section) = info
.sections
.iter_mut()
.find(|section| section.id == "match")
{
if let Some(data) = section.data.as_object_mut() {
data.insert(
"state".into(),
serde_json::to_value(&state).map_err(|_| internal())?,
);
}
}
info.estimate_tokens().map_err(|_| internal())?;
let text = format!(
"{}\n\n{}",
info.markdown().map_err(|_| internal())?,
state.text()
);
success(
CreatedOutput {
state,
info: info.into(),
},
text,
)
}
async fn move_game(&self, args: MoveArgs) -> Result<CallToolResult, ApiError> {
let seat = self.player()?;
if args.idempotency_key.is_some() && args.turn.is_none() {
return Err(ApiError::new(
"INVALID_REQUEST",
"Idempotent moves require their original turn",
"Supply the turn from get_state together with idempotency_key.",
));
}
let turn = if let Some(turn) = args.turn {
turn
} else {
self.service
.get_state(&args.match_id, self.viewer)
.await?
.turn
};
let moved = self
.service
.make_move(
&args.match_id,
MoveRequest {
seat,
turn,
action: args.action,
reasoning: args.reasoning,
},
args.idempotency_key.as_deref(),
)
.await?;
let output = MovedOutput {
accepted_action: moved.accepted_action.string,
opponent_actions: moved
.opponent_actions
.into_iter()
.map(|reply| Reply {
seat: reply.seat,
turn: reply.turn,
action: reply.action.map(|action| action.string),
})
.collect(),
state: StateOutput::from_state(moved.state, self.viewer)?,
};
let text = format!(
"Accepted: {}\nOpponent replies: {}\n{}",
output.accepted_action,
output
.opponent_actions
.iter()
.map(|reply| format!(
"seat {}: {}",
reply.seat,
reply.action.as_deref().unwrap_or("private action")
))
.collect::<Vec<_>>()
.join("; "),
output.state.text()
);
success(output, text)
}
}
Loading
Loading