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 @@ -31,3 +31,6 @@ EOF and process signals stop MCP and background play, then close the database. T


`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.


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.
57 changes: 52 additions & 5 deletions crates/adapters/gfa-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
mod history;
mod play;
mod play_types;
mod resources;
mod types;
use gfa_api_types::ApiError;
use gfa_core::Viewer;
Expand Down Expand Up @@ -193,11 +194,57 @@ impl McpServer {
}
impl ServerHandler for McpServer {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("GamesForAI", env!("CARGO_PKG_VERSION")))
.with_instructions(
"Read get_game_info before playing. Tool errors include recovery hints.",
)
ServerConfig::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.build(),
)
.with_server_info(Implementation::new("GamesForAI", env!("CARGO_PKG_VERSION")))
.with_instructions("Read get_game_info before playing. Tool errors include recovery hints.")
}
async fn list_resources(
&self,
request: Option<PaginatedRequestParams>,
_: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListResourcesResult, ErrorData> {
resources::no_cursor(request)?;
Ok(self.resources())
}
async fn list_resource_templates(
&self,
request: Option<PaginatedRequestParams>,
_: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListResourceTemplatesResult, ErrorData> {
resources::no_cursor(request)?;
Ok(self.templates())
}
async fn read_resource(
&self,
request: rmcp::model::ReadResourceRequestParams,
_: RequestContext<RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, ErrorData> {
self.resource(&request.uri).await.map(Into::into)
}
async fn list_prompts(
&self,
request: Option<PaginatedRequestParams>,
_: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
resources::no_cursor(request)?;
Ok(self.prompts())
}
async fn get_prompt(
&self,
request: rmcp::model::GetPromptRequestParams,
_: RequestContext<RoleServer>,
) -> Result<rmcp::model::GetPromptResponse, ErrorData> {
self.play_prompt(
&request.name,
Value::Object(request.arguments.unwrap_or_default()),
)
.map(Into::into)
}
fn get_tool(&self, name: &str) -> Option<Tool> {
self.tools.iter().find(|tool| tool.name == name).cloned()
Expand Down
193 changes: 193 additions & 0 deletions crates/adapters/gfa-mcp/src/resources.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
use crate::{decode, play_types::StateOutput, types::GameArgs, McpServer};
use gfa_api_types::{ApiError, InfoDetail};
use gfa_core::Viewer;
use rmcp::{
model::{
GetPromptResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, ReadResourceResult,
Resource, ResourceContents, ResourceTemplate, Role,
},
ErrorData,
};
use serde_json::{json, Value};

pub(crate) fn no_cursor(request: Option<PaginatedRequestParams>) -> Result<(), ErrorData> {
if request.is_some_and(|request| request.cursor.is_some()) {
return Err(ErrorData::invalid_params(
"This catalog has no further page",
None,
));
}
Ok(())
}
fn protocol(error: ApiError) -> ErrorData {
ErrorData::invalid_params(
format!("{}: {} — {}", error.code, error.message, error.hint),
Some(json!({"code":error.code,"hint":error.hint})),
)
}
impl McpServer {
pub(crate) fn resources(&self) -> ListResourcesResult {
ListResourcesResult::with_all_items(
self.service
.list_games()
.into_iter()
.map(|game| {
Resource::new(
format!("gfa://games/{}/info", game.id),
format!("{} rules", game.name),
)
.with_description("Compact game briefing")
.with_mime_type("text/markdown")
})
.collect(),
)
}
pub(crate) fn templates(&self) -> ListResourceTemplatesResult {
ListResourceTemplatesResult::with_all_items(
[
("gfa://games/{game_id}/info", "Game rules", "text/markdown"),
(
"gfa://matches/{match_id}/info",
"Match briefing",
"text/markdown",
),
(
"gfa://matches/{match_id}/state",
"Live match state",
"application/json",
),
(
"gfa://matches/{match_id}/replay",
"Recorded moves",
"application/json",
),
]
.into_iter()
.map(|(uri, name, mime)| ResourceTemplate::new(uri, name).with_mime_type(mime))
.collect(),
)
}
pub(crate) fn prompts(&self) -> ListPromptsResult {
ListPromptsResult::with_all_items(vec![Prompt::new(
"play_game",
Some("Read the compact rules and follow the MCP game loop."),
Some(vec![PromptArgument::new("game_id")
.with_required(true)
.with_description("Registered game ID from list_games")]),
)])
}
/// Read a bounded resource using exactly this adapter's host-authorized viewer.
pub async fn resource(&self, uri: &str) -> Result<ReadResourceResult, ErrorData> {
let suffix = uri
.strip_prefix("gfa://")
.ok_or_else(|| ErrorData::invalid_params("Expected a gfa:// resource URI", None))?;
if uri.len() > 512
|| suffix
.bytes()
.any(|byte| !byte.is_ascii_alphanumeric() && !b"/_-".contains(&byte))
{
return Err(ErrorData::invalid_params(
"Resource URI contains invalid characters",
None,
));
}
let parts = suffix.split('/').collect::<Vec<_>>();
let (text, mime) = match parts.as_slice() {
["games", id, "info"] => {
let seat = match self.viewer {
Viewer::Player(seat) => Some(seat),
_ => None,
};
let info = self
.service
.get_game_info(id, &json!({}), seat, InfoDetail::Compact)
.map_err(protocol)?;
(
info.markdown().map_err(|_| protocol(crate::internal()))?,
"text/markdown",
)
}
["matches", id, "info"] => {
let info = self
.service
.get_match_info(id, self.viewer, InfoDetail::Compact)
.await
.map_err(protocol)?;
(
info.markdown().map_err(|_| protocol(crate::internal()))?,
"text/markdown",
)
}
["matches", id, "state"] => {
let state = self
.service
.get_state(id, self.viewer)
.await
.map_err(protocol)?;
let output = StateOutput::from_state(state, self.viewer).map_err(protocol)?;
(
serde_json::to_string(&output).map_err(|_| protocol(crate::internal()))?,
"application/json",
)
}
["matches", id, "replay"] => {
let output = self
.replay_tool(json!({"match_id":id,"limit":100}))
.await
.map_err(protocol)?;
let data = output
.structured_content
.and_then(|value| value.get("data").cloned())
.ok_or_else(|| protocol(crate::internal()))?;
(
serde_json::to_string(&data).map_err(|_| protocol(crate::internal()))?,
"application/json",
)
}
_ => {
return Err(ErrorData::invalid_params(
"Unknown GamesForAI resource",
None,
))
}
};
if text.len() > 1024 * 1024 {
return Err(ErrorData::invalid_params(
"Resource exceeds 1 MiB; use compact paginated tools",
None,
));
}
let mut result =
ReadResourceResult::new(vec![ResourceContents::text(text, uri).with_mime_type(mime)]);
result.ttl_ms = Some(0);
result.cache_scope = Some(rmcp::model::CacheScope::Private);
Ok(result)
}
/// Build a user-invoked play prompt. MCP prompt messages have user/assistant roles.
pub fn play_prompt(&self, name: &str, arguments: Value) -> Result<GetPromptResult, ErrorData> {
if name != "play_game" {
return Err(ErrorData::invalid_params("Unknown prompt", None));
}
if serde_json::to_vec(&arguments).map_or(true, |value| value.len() > 8192) {
return Err(ErrorData::invalid_params(
"Prompt arguments exceed 8 KiB",
None,
));
}
let args: GameArgs = decode(arguments).map_err(protocol)?;
let seat = match self.viewer {
Viewer::Player(seat) => Some(seat),
_ => None,
};
let info = self
.service
.get_game_info(&args.game_id, &json!({}), seat, InfoDetail::Compact)
.map_err(protocol)?;
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()))?);
Ok(
GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)])
.with_description(format!("Play {}", args.game_id)),
)
}
}
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 @@ -263,3 +263,6 @@ pub use mcp_host::serve_stdio;

#[cfg(test)]
mod mcp_history_tests;

#[cfg(test)]
mod mcp_resource_tests;
112 changes: 112 additions & 0 deletions crates/apps/gfa-server/src/mcp_resource_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use super::{
mcp_play_tests::invoke,
tests::{config, fixture},
*,
};
use gfa_core::Viewer;
use gfa_mcp::McpServer;
use rmcp::{
model::{GetPromptRequestParams, ReadResourceRequestParams},
ServiceExt,
};
use serde_json::{json, Value};

#[tokio::test]
async fn mcp_official_client_reads_resources_and_play_prompt() -> Result<(), ServerError> {
let directory = tempfile::tempdir()?;
let app = fixture(&config(&directory)).await?;
let adapter = McpServer::new(app.service.clone(), Viewer::Player(0))?;
let created = invoke(
&adapter,
"create_match",
json!({"game_id":"chess","seed":44}),
false,
)
.await?;
let id = created["state"]["match_id"].as_str().ok_or("id")?;
let (client_io, server_io) = tokio::io::duplex(128 * 1024);
let task = tokio::spawn(async move { adapter.serve(server_io).await });
let client = ().serve(client_io).await?;
let server = task.await??;
let catalog = client.list_all_resources().await?;
assert_eq!(catalog.len(), 4);
assert!(catalog
.iter()
.any(|resource| resource.uri == "gfa://games/chess/info"));
let templates = client.list_all_resource_templates().await?;
assert_eq!(templates.len(), 4);
let prompts = client.list_all_prompts().await?;
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].name, "play_game");
let rules = client
.read_resource(ReadResourceRequestParams::new("gfa://games/chess/info"))
.await?;
let rules = serde_json::to_value(rules)?;
assert!(rules["contents"][0]["text"]
.as_str()
.ok_or("rules")?
.contains("action_format"));
let prompt = client
.get_prompt(
GetPromptRequestParams::new("play_game").with_arguments(
json!({"game_id":"chess"})
.as_object()
.ok_or("args")?
.clone(),
),
)
.await?;
let prompt = serde_json::to_value(prompt)?;
let text = prompt["messages"][0]["content"]["text"]
.as_str()
.ok_or("prompt")?;
assert!(text.contains("make_move") && text.contains("e2e4") && text.contains("terminated"));
for suffix in ["info", "state", "replay"] {
let resource = client
.read_resource(ReadResourceRequestParams::new(format!(
"gfa://matches/{id}/{suffix}"
)))
.await?;
let resource = serde_json::to_value(resource)?;
let content = resource["contents"][0]["text"].as_str().ok_or("content")?;
assert!(!content.is_empty());
if suffix == "state" {
let state: Value = serde_json::from_str(content)?;
assert_eq!(state, created["state"]);
}
}
for uri in [
"file:///etc/passwd",
"gfa://matches/../state",
"gfa://matches/x/state?seat=1",
"gfa://games/chess/info/extra",
"gfa://games/missing/info",
] {
assert!(client
.read_resource(ReadResourceRequestParams::new(uri))
.await
.is_err());
}
assert!(client
.get_prompt(GetPromptRequestParams::new("unknown"))
.await
.is_err());
assert!(client
.get_prompt(GetPromptRequestParams::new("play_game"))
.await
.is_err());
client.cancel().await?;
server.waiting().await?;
let spectator = McpServer::new(app.service.clone(), Viewer::Spectator)?;
let resource = serde_json::to_value(
spectator
.resource(&format!("gfa://matches/{id}/state"))
.await?,
)?;
let state: Value =
serde_json::from_str(resource["contents"][0]["text"].as_str().ok_or("state")?)?;
assert_eq!(state["legal_actions"], json!([]));
assert!(state["you"].is_null());
app.store.close().await;
Ok(())
}
Loading