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 @@ -28,3 +28,6 @@ States contain the engine text board and sorted canonical action strings, withou
Launch a local client with `gfa mcp --sqlite ./gfa.sqlite`. Standard output is reserved for MCP JSON-RPC; diagnostics go to stderr. `--seat 1` authorizes the second player (for example Black); `--spectator` is read-only. Database and Stockfish options are shared with `gfa serve`. No TCP listener is opened in stdio mode. The launcher controls access to the local database and selected seat; this mode is intended for one trusted local user.

EOF and process signals stop MCP and background play, then close the database. The CLI bounds runtime shutdown because Tokio's stdin reader uses a blocking read that a client may leave open after a signal.


`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.
230 changes: 230 additions & 0 deletions crates/adapters/gfa-mcp/src/history.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
use crate::{decode, internal, success, McpServer};
use gfa_api_types::{ApiError, EventData, EventsQuery, MatchHistoryQuery, MatchStatus};
use rmcp::{model::CallToolResult, schemars::JsonSchema};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

fn page_size() -> u32 {
25
}
#[derive(Deserialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
#[serde(deny_unknown_fields)]
pub(crate) struct HistoryArgs {
pub game_id: Option<String>,
/// Maximum records scanned per page, 1..100; defaults to 25.
#[serde(default = "page_size")]
pub limit: u32,
/// Cursor returned by the previous page, including empty filtered pages.
pub after: Option<String>,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct MatchSummary {
pub match_id: String,
pub game_id: String,
pub status: String,
pub turn: u64,
pub returns: Vec<f64>,
pub created_at_ms: u64,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct HistoryOutput {
pub matches: Vec<MatchSummary>,
pub next: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
#[serde(deny_unknown_fields)]
pub(crate) struct ReplayArgs {
pub match_id: String,
/// Exclusive sequence from the preceding replay page.
pub since: Option<u64>,
/// Maximum events per page, 1..100; defaults to 25.
#[serde(default = "page_size")]
pub limit: u32,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct ReplayEntry {
pub sequence: u64,
pub kind: String,
pub turn: Option<u64>,
pub seat: Option<u8>,
pub action: Option<String>,
pub reasoning: Option<String>,
/// Viewer-scoped annotations and public metadata; never a raw storage event.
pub details: Value,
}
#[derive(Serialize, JsonSchema)]
#[schemars(crate = "rmcp::schemars")]
pub(crate) struct ReplayOutput {
pub match_id: String,
pub revision: u64,
pub events: Vec<ReplayEntry>,
pub next: Option<u64>,
}
impl McpServer {
pub(crate) async fn history_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
let args: HistoryArgs = decode(arguments)?;
let page = self
.service
.list_matches(MatchHistoryQuery {
game_id: args.game_id,
limit: args.limit,
after: args.after,
status: None,
})
.await?;
let matches = page
.matches
.into_iter()
.map(|entry| MatchSummary {
match_id: entry.match_id,
game_id: entry.game_id,
status: match entry.status {
MatchStatus::Active => "active",
MatchStatus::Finished => "finished",
}
.into(),
turn: entry.turn,
returns: entry.returns,
created_at_ms: entry.created_at_ms,
})
.collect::<Vec<_>>();
let text = format!(
"{}\nNext cursor: {}",
matches
.iter()
.map(|entry| format!(
"{}: {} — {}, {} moves, returns {:?}",
entry.match_id, entry.game_id, entry.status, entry.turn, entry.returns
))
.collect::<Vec<_>>()
.join("\n"),
page.next.as_deref().unwrap_or("none")
);
success(
HistoryOutput {
matches,
next: page.next,
},
text,
)
}
pub(crate) async fn replay_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
let args: ReplayArgs = decode(arguments)?;
let page = self
.service
.get_events(
&args.match_id,
EventsQuery {
since: args.since,
limit: args.limit,
seat: None,
},
self.viewer,
)
.await?;
let mut events = Vec::with_capacity(page.events.len());
for entry in page.events {
let mut output = ReplayEntry {
sequence: entry.sequence,
kind: String::new(),
turn: None,
seat: None,
action: None,
reasoning: None,
details: Value::Null,
};
match entry.event {
EventData::Created {
game_id,
engine_version,
config,
assists,
at_ms,
..
} => {
output.kind = "created".into();
output.details = json!({"game_id":game_id,"engine_version":engine_version,"config":config,"assists":assists,"at_ms":at_ms});
}
EventData::Action {
turn,
seat,
action,
reasoning,
opponent_info,
events,
at_ms,
} => {
output.kind = "action".into();
output.turn = Some(turn);
output.seat = Some(seat);
output.action = action.map(|action| action.string);
output.reasoning = reasoning;
output.details =
json!({"opponent_info":opponent_info,"events":events,"at_ms":at_ms});
}
EventData::ForkedFrom { source } => {
output.kind = "forked_from".into();
output.details = serde_json::to_value(source).map_err(|_| internal())?;
}
EventData::Resigned { turn, seat, at_ms } => {
output.kind = "resigned".into();
output.turn = Some(turn);
output.seat = Some(seat);
output.details = json!({"at_ms":at_ms});
}
EventData::DrawOffered { turn, seat, at_ms } => {
output.kind = "draw_offered".into();
output.turn = Some(turn);
output.seat = Some(seat);
output.details = json!({"at_ms":at_ms});
}
EventData::Finished {
turn,
returns,
terminated,
truncated,
} => {
output.kind = "finished".into();
output.turn = Some(turn);
output.details =
json!({"returns":returns,"terminated":terminated,"truncated":truncated});
}
}
events.push(output);
}
let text = format!(
"Match: {}. Revision: {}. Next sequence: {:?}.\n{}",
args.match_id,
page.revision,
page.next,
events
.iter()
.map(|entry| format!(
"#{} {} turn={:?} seat={:?} action={} reasoning={} details={}",
entry.sequence,
entry.kind,
entry.turn,
entry.seat,
entry.action.as_deref().unwrap_or("none/private"),
entry.reasoning.as_deref().unwrap_or("none/private"),
entry.details
))
.collect::<Vec<_>>()
.join("\n")
);
success(
ReplayOutput {
match_id: args.match_id,
revision: page.revision,
events,
next: page.next,
},
text,
)
}
}
6 changes: 6 additions & 0 deletions crates/adapters/gfa-mcp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//! Official-SDK MCP adapter over the transport-independent lifecycle service.
mod history;
mod play;
mod play_types;
mod types;
use gfa_api_types::ApiError;
use gfa_core::Viewer;
use gfa_service::GameService;
use history::{HistoryArgs, HistoryOutput, ReplayArgs, ReplayOutput};
use play_types::*;
use rmcp::{
model::{
Expand Down Expand Up @@ -47,6 +49,8 @@ impl McpServer {
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\"}).")?,
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}).")?,
]) })
}

Expand Down Expand Up @@ -76,6 +80,8 @@ impl McpServer {
"list_games" => self.games(arguments),
"list_opponents" => self.opponents(arguments),
"get_game_info" => self.info(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" => {
Ok(self.play(name, arguments).await)
}
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 @@ -260,3 +260,6 @@ mod mcp_play_tests;

mod mcp_host;
pub use mcp_host::serve_stdio;

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