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
80 changes: 74 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 19 additions & 5 deletions crates/adapters/gfa-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,32 @@ pub fn local_router_with_updates(
build_router(service, address, Some(updates))
}

fn build_router(
service: Arc<GameService>,
address: SocketAddr,
updates: Option<Arc<LiveUpdates>>,
) -> Result<Router, ApiError> {
/// Apply local peer, Host, Origin, and response-header policy to host-composed routes.
/// Additional transports remain responsible for enforcing their own request-body limit.
pub fn protect_local_routes(router: Router, address: SocketAddr) -> Result<Router, ApiError> {
validate_local_address(address)?;
Ok(router.layer(middleware::from_fn_with_state(
access::LocalAccess::new(address),
access::guard,
)))
}
fn validate_local_address(address: SocketAddr) -> Result<(), ApiError> {
if !address.ip().is_loopback() || address.port() == 0 {
return Err(ApiError::new(
"INVALID_CONFIG",
"A bound loopback listener is required",
"Bind 127.0.0.1 or ::1, then pass listener.local_addr().",
));
}
Ok(())
}

fn build_router(
service: Arc<GameService>,
address: SocketAddr,
updates: Option<Arc<LiveUpdates>>,
) -> Result<Router, ApiError> {
validate_local_address(address)?;
let document = openapi_document(updates.is_some()).to_json().map_err(|_| {
ApiError::new(
"INVALID_CONFIG",
Expand Down
3 changes: 3 additions & 0 deletions crates/adapters/gfa-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ The resource catalog lists each installed game's compact rules. URI templates ex


Compact match briefings use the same text-board and canonical-move state as `get_state` across creation, `get_game_info`, and the match info resource. Full-detail tool briefings retain the service's full observation for clients that explicitly request it.


`gfa serve` also serves streamable MCP at `/mcp` on the same loopback listener. `--mcp-seat 1` selects a player and `--mcp-spectator` selects read-only access; seat 0 is the default. This remains single-user local mode and shares REST's strict peer, Host, Origin, and response-header policy. SDK request bodies are limited to 64 KiB. Transport responses are stateless JSON when possible, with SDK protocol negotiation for legacy and current clients. No legacy session table is retained. Shutdown cancels SDK streams before HTTP drains. rmcp's streamable HTTP feature supplies protocol handling and SSE framing rather than a separate RPC implementation.
47 changes: 46 additions & 1 deletion crates/apps/gfa-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
use gfa_server::{Config, Database, ServerError};
use std::path::PathBuf;

const USAGE: &str = "Usage: gfa mcp [--sqlite PATH | --postgres-env VARIABLE] [--seat SEAT | --spectator] [--stockfish PATH]\n gfa serve [--sqlite PATH | --postgres-env VARIABLE] [--port PORT] [--stockfish PATH]\n\nmcp serves a trusted local client over stdin/stdout; seat defaults to 0. No TCP port is opened.\nserve starts a local API on 127.0.0.1 (default port 8080).\nSQLite defaults to ./gfa.sqlite. Port 0 selects an available port.\nPostgreSQL requires the postgres build feature and reads its URL from VARIABLE.\nStockfish requires an absolute binary path, Linux, bubblewrap and working user namespaces.";
const USAGE: &str = "Usage: gfa mcp [--sqlite PATH | --postgres-env VARIABLE] [--seat SEAT | --spectator] [--stockfish PATH]\n gfa serve [--sqlite PATH | --postgres-env VARIABLE] [--port PORT] [--stockfish PATH] [--mcp-seat SEAT | --mcp-spectator]\n\nmcp serves a trusted local client over stdin/stdout; seat defaults to 0. No TCP port is opened.\nserve starts a local API on 127.0.0.1 (default port 8080).\nHTTP /mcp defaults to seat 0; use --mcp-seat or --mcp-spectator to change it.\nSQLite defaults to ./gfa.sqlite. Port 0 selects an available port.\nPostgreSQL requires the postgres build feature and reads its URL from VARIABLE.\nStockfish requires an absolute binary path, Linux, bubblewrap and working user namespaces.";

fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Config>, String> {
parse_with_env(args, |name| std::env::var(name).ok())
Expand All @@ -24,6 +24,7 @@ fn parse_with_env(
let mut database_seen = false;
let mut port_seen = false;
let mut stockfish_seen = false;
let mut mcp_viewer_seen = false;
while let Some(option) = args.next() {
match option.as_str() {
"--help" | "-h" => return Ok(None),
Expand Down Expand Up @@ -58,6 +59,19 @@ fn parse_with_env(
"--postgres-env" => {
return Err("PostgreSQL requires a build with --features postgres".into())
}
"--mcp-seat" if !mcp_viewer_seen => {
config.mcp_seat = Some(
args.next()
.ok_or("--mcp-seat requires a seat")?
.parse()
.map_err(|_| "MCP seat must be an integer from 0 to 255")?,
);
mcp_viewer_seen = true;
}
"--mcp-spectator" if !mcp_viewer_seen => {
config.mcp_seat = None;
mcp_viewer_seen = true;
}
"--stockfish" if !stockfish_seen => {
let path = PathBuf::from(
args.next()
Expand Down Expand Up @@ -114,6 +128,9 @@ fn command(args: impl IntoIterator<Item = String>) -> Result<Option<Command>, St
viewer_seen = true;
}
"--seat" | "--spectator" => return Err("Choose --seat or --spectator once".into()),
"--mcp-seat" | "--mcp-spectator" => {
return Err("Use --seat or --spectator for mcp stdio".into())
}
"--port" => return Err("mcp uses stdin/stdout and does not accept --port".into()),
// Preserve values even when a filesystem path equals another option.
"--sqlite" | "--postgres-env" | "--stockfish" => {
Expand Down Expand Up @@ -195,6 +212,7 @@ mod tests {
database: Database::Sqlite("matches.sqlite".into()),
port: 0,
stockfish: None,
mcp_seat: Some(0),
})
);
let configured =
Expand Down Expand Up @@ -339,3 +357,30 @@ mod mcp_command_tests {
Ok(())
}
}

#[cfg(test)]
mod http_mcp_options_tests {
use super::*;
#[test]
fn validates_http_mcp_viewer_options() -> Result<(), String> {
let config = parse(["serve", "--mcp-seat", "1"].map(str::to_owned))?.ok_or("config")?;
assert_eq!(config.mcp_seat, Some(1));
assert_eq!(
parse(["serve", "--mcp-spectator"].map(str::to_owned))?
.ok_or("config")?
.mcp_seat,
None
);
for args in [
vec!["serve", "--mcp-seat"],
vec!["serve", "--mcp-seat", "256"],
vec!["serve", "--mcp-seat", "0", "--mcp-spectator"],
vec!["serve", "--mcp-spectator", "--mcp-seat", "0"],
vec!["mcp", "--mcp-seat", "1"],
vec!["mcp", "--mcp-spectator"],
] {
assert!(command(args.into_iter().map(str::to_owned)).is_err());
}
Ok(())
}
}
2 changes: 1 addition & 1 deletion crates/apps/gfa-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ postgres = ["gfa-store/postgres"]

[dependencies]
gfa-mcp.workspace = true
rmcp.workspace = true
rmcp = { workspace = true, features = ["transport-streamable-http-server"] }
gfa-engine-uci.workspace = true
gfa-core.workspace = true
gfa-opponents.workspace = true
Expand Down
14 changes: 14 additions & 0 deletions crates/apps/gfa-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub struct Config {
pub port: u16,
/// Optional installed Stockfish engine and sandbox policy.
pub stockfish: Option<StockfishConfig>,
/// MCP viewer selected by the trusted local launcher. None selects spectator.
pub mcp_seat: Option<u8>,
}

impl Default for Config {
Expand All @@ -37,6 +39,7 @@ impl Default for Config {
database: Database::Sqlite("gfa.sqlite".into()),
port: 8080,
stockfish: None,
mcp_seat: Some(0),
}
}
}
Expand Down Expand Up @@ -119,6 +122,7 @@ impl MatchIds for Host {
}

struct Application {
mcp: mcp_http::McpHttp,
router: axum::Router,
store: Store,
updates: Arc<gfa_http::LiveUpdates>,
Expand Down Expand Up @@ -155,7 +159,10 @@ async fn application(config: &Config, address: SocketAddr) -> Result<Application
service,
} = components(config).await?;
let router = gfa_http::local_router_with_updates(service.clone(), address, updates.clone())?;
let (mcp_router, mcp) = mcp_http::router(service.clone(), address, config.mcp_seat)?;
let router = router.merge(mcp_router);
Ok(Application {
mcp,
router,
store,
updates,
Expand Down Expand Up @@ -187,17 +194,20 @@ async fn serve_application(
let (stop_runner, stopped) = tokio::sync::watch::channel(false);
let runner_task = tokio::spawn(runner::run(app.service.clone(), stopped));
let stop_on_shutdown = stop_runner.clone();
let stop_mcp = app.mcp.clone();
let result = axum::serve(
listener,
app.router
.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
shutdown.await;
stop_mcp.stop();
let _ = stop_on_shutdown.send(true);
updates.close();
})
.await;
app.mcp.stop();
let _ = stop_runner.send(true);
let runner_result = runner_task.await;
app.updates.wait_closed().await;
Expand Down Expand Up @@ -261,6 +271,10 @@ mod mcp_play_tests;
mod mcp_host;
pub use mcp_host::serve_stdio;

mod mcp_http;
#[cfg(test)]
mod mcp_http_tests;

#[cfg(test)]
mod mcp_history_tests;

Expand Down
Loading
Loading