Skip to content

Commit e6a77c6

Browse files
committed
chore(mcp): format subscriptions and integrate HTTP transport
2 parents a297e8b + c58f89f commit e6a77c6

11 files changed

Lines changed: 397 additions & 14 deletions

File tree

‎Cargo.lock‎

Lines changed: 74 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/adapters/gfa-http/src/lib.rs‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,32 @@ pub fn local_router_with_updates(
5757
build_router(service, address, Some(updates))
5858
}
5959

60-
fn build_router(
61-
service: Arc<GameService>,
62-
address: SocketAddr,
63-
updates: Option<Arc<LiveUpdates>>,
64-
) -> Result<Router, ApiError> {
60+
/// Apply local peer, Host, Origin, and response-header policy to host-composed routes.
61+
/// Additional transports remain responsible for enforcing their own request-body limit.
62+
pub fn protect_local_routes(router: Router, address: SocketAddr) -> Result<Router, ApiError> {
63+
validate_local_address(address)?;
64+
Ok(router.layer(middleware::from_fn_with_state(
65+
access::LocalAccess::new(address),
66+
access::guard,
67+
)))
68+
}
69+
fn validate_local_address(address: SocketAddr) -> Result<(), ApiError> {
6570
if !address.ip().is_loopback() || address.port() == 0 {
6671
return Err(ApiError::new(
6772
"INVALID_CONFIG",
6873
"A bound loopback listener is required",
6974
"Bind 127.0.0.1 or ::1, then pass listener.local_addr().",
7075
));
7176
}
77+
Ok(())
78+
}
79+
80+
fn build_router(
81+
service: Arc<GameService>,
82+
address: SocketAddr,
83+
updates: Option<Arc<LiveUpdates>>,
84+
) -> Result<Router, ApiError> {
85+
validate_local_address(address)?;
7286
let document = openapi_document(updates.is_some()).to_json().map_err(|_| {
7387
ApiError::new(
7488
"INVALID_CONFIG",

‎crates/adapters/gfa-mcp/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,7 @@ The resource catalog lists each installed game's compact rules. URI templates ex
4242
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.
4343

4444

45+
`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.
46+
47+
4548
Live state watches support current `subscriptions/listen` and legacy stdio `resources/subscribe`/`unsubscribe`. A shared adapter permits at most 16 concurrent watches/streams, with at most 8 URIs in a modern stream and 64 KiB per projected snapshot. Only match state URIs are accepted. Polling every two seconds catches changes committed outside MCP, including same-turn controls. Modern streams emit an initial refresh notification; unchanged snapshots stay quiet. Slow writes, disconnects, explicit cancellation, and dropped handlers release capacity. Legacy watches are keyed by connection identity so identical client names cannot unsubscribe one another. The stateless HTTP host supports modern listen streams; older HTTP clients can poll state tools. Tokio supplies bounded tasks, synchronization, cancellation points, and timers for this transport concern.

‎crates/apps/gfa-cli/src/main.rs‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
use gfa_server::{Config, Database, ServerError};
33
use std::path::PathBuf;
44

5-
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.";
5+
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.";
66

77
fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Config>, String> {
88
parse_with_env(args, |name| std::env::var(name).ok())
@@ -24,6 +24,7 @@ fn parse_with_env(
2424
let mut database_seen = false;
2525
let mut port_seen = false;
2626
let mut stockfish_seen = false;
27+
let mut mcp_viewer_seen = false;
2728
while let Some(option) = args.next() {
2829
match option.as_str() {
2930
"--help" | "-h" => return Ok(None),
@@ -58,6 +59,19 @@ fn parse_with_env(
5859
"--postgres-env" => {
5960
return Err("PostgreSQL requires a build with --features postgres".into())
6061
}
62+
"--mcp-seat" if !mcp_viewer_seen => {
63+
config.mcp_seat = Some(
64+
args.next()
65+
.ok_or("--mcp-seat requires a seat")?
66+
.parse()
67+
.map_err(|_| "MCP seat must be an integer from 0 to 255")?,
68+
);
69+
mcp_viewer_seen = true;
70+
}
71+
"--mcp-spectator" if !mcp_viewer_seen => {
72+
config.mcp_seat = None;
73+
mcp_viewer_seen = true;
74+
}
6175
"--stockfish" if !stockfish_seen => {
6276
let path = PathBuf::from(
6377
args.next()
@@ -114,6 +128,9 @@ fn command(args: impl IntoIterator<Item = String>) -> Result<Option<Command>, St
114128
viewer_seen = true;
115129
}
116130
"--seat" | "--spectator" => return Err("Choose --seat or --spectator once".into()),
131+
"--mcp-seat" | "--mcp-spectator" => {
132+
return Err("Use --seat or --spectator for mcp stdio".into())
133+
}
117134
"--port" => return Err("mcp uses stdin/stdout and does not accept --port".into()),
118135
// Preserve values even when a filesystem path equals another option.
119136
"--sqlite" | "--postgres-env" | "--stockfish" => {
@@ -195,6 +212,7 @@ mod tests {
195212
database: Database::Sqlite("matches.sqlite".into()),
196213
port: 0,
197214
stockfish: None,
215+
mcp_seat: Some(0),
198216
})
199217
);
200218
let configured =
@@ -339,3 +357,30 @@ mod mcp_command_tests {
339357
Ok(())
340358
}
341359
}
360+
361+
#[cfg(test)]
362+
mod http_mcp_options_tests {
363+
use super::*;
364+
#[test]
365+
fn validates_http_mcp_viewer_options() -> Result<(), String> {
366+
let config = parse(["serve", "--mcp-seat", "1"].map(str::to_owned))?.ok_or("config")?;
367+
assert_eq!(config.mcp_seat, Some(1));
368+
assert_eq!(
369+
parse(["serve", "--mcp-spectator"].map(str::to_owned))?
370+
.ok_or("config")?
371+
.mcp_seat,
372+
None
373+
);
374+
for args in [
375+
vec!["serve", "--mcp-seat"],
376+
vec!["serve", "--mcp-seat", "256"],
377+
vec!["serve", "--mcp-seat", "0", "--mcp-spectator"],
378+
vec!["serve", "--mcp-spectator", "--mcp-seat", "0"],
379+
vec!["mcp", "--mcp-seat", "1"],
380+
vec!["mcp", "--mcp-spectator"],
381+
] {
382+
assert!(command(args.into_iter().map(str::to_owned)).is_err());
383+
}
384+
Ok(())
385+
}
386+
}

‎crates/apps/gfa-server/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ postgres = ["gfa-store/postgres"]
1010

1111
[dependencies]
1212
gfa-mcp.workspace = true
13-
rmcp.workspace = true
13+
rmcp = { workspace = true, features = ["transport-streamable-http-server"] }
1414
gfa-engine-uci.workspace = true
1515
gfa-core.workspace = true
1616
gfa-opponents.workspace = true

‎crates/apps/gfa-server/src/lib.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub struct Config {
2929
pub port: u16,
3030
/// Optional installed Stockfish engine and sandbox policy.
3131
pub stockfish: Option<StockfishConfig>,
32+
/// MCP viewer selected by the trusted local launcher. None selects spectator.
33+
pub mcp_seat: Option<u8>,
3234
}
3335

3436
impl Default for Config {
@@ -37,6 +39,7 @@ impl Default for Config {
3739
database: Database::Sqlite("gfa.sqlite".into()),
3840
port: 8080,
3941
stockfish: None,
42+
mcp_seat: Some(0),
4043
}
4144
}
4245
}
@@ -119,6 +122,7 @@ impl MatchIds for Host {
119122
}
120123

121124
struct Application {
125+
mcp: mcp_http::McpHttp,
122126
router: axum::Router,
123127
store: Store,
124128
updates: Arc<gfa_http::LiveUpdates>,
@@ -155,7 +159,10 @@ async fn application(config: &Config, address: SocketAddr) -> Result<Application
155159
service,
156160
} = components(config).await?;
157161
let router = gfa_http::local_router_with_updates(service.clone(), address, updates.clone())?;
162+
let (mcp_router, mcp) = mcp_http::router(service.clone(), address, config.mcp_seat)?;
163+
let router = router.merge(mcp_router);
158164
Ok(Application {
165+
mcp,
159166
router,
160167
store,
161168
updates,
@@ -187,17 +194,20 @@ async fn serve_application(
187194
let (stop_runner, stopped) = tokio::sync::watch::channel(false);
188195
let runner_task = tokio::spawn(runner::run(app.service.clone(), stopped));
189196
let stop_on_shutdown = stop_runner.clone();
197+
let stop_mcp = app.mcp.clone();
190198
let result = axum::serve(
191199
listener,
192200
app.router
193201
.into_make_service_with_connect_info::<SocketAddr>(),
194202
)
195203
.with_graceful_shutdown(async move {
196204
shutdown.await;
205+
stop_mcp.stop();
197206
let _ = stop_on_shutdown.send(true);
198207
updates.close();
199208
})
200209
.await;
210+
app.mcp.stop();
201211
let _ = stop_runner.send(true);
202212
let runner_result = runner_task.await;
203213
app.updates.wait_closed().await;
@@ -261,6 +271,10 @@ mod mcp_play_tests;
261271
mod mcp_host;
262272
pub use mcp_host::serve_stdio;
263273

274+
mod mcp_http;
275+
#[cfg(test)]
276+
mod mcp_http_tests;
277+
264278
#[cfg(test)]
265279
mod mcp_history_tests;
266280

0 commit comments

Comments
 (0)