Skip to content

Commit c60e87e

Browse files
authored
Merge pull request #52 from CurtisTechSolutions/feat/mcp-stdio-host
feat(cli): launch local MCP over stdio
2 parents 4a925aa + da2ef26 commit c60e87e

7 files changed

Lines changed: 319 additions & 14 deletions

File tree

‎.github/scripts/mcp-stdio-smoke.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Exercise the actual MCP CLI, clean stdout, game loop, EOF, signal, and restart."""
2+
import json
3+
from pathlib import Path
4+
import queue
5+
import subprocess
6+
import tempfile
7+
import threading
8+
import time
9+
10+
binary = Path("target/debug/gfa").resolve()
11+
12+
13+
class Client:
14+
def __init__(self, database, *options):
15+
self.errors = tempfile.TemporaryFile(mode="w+t")
16+
self.process = subprocess.Popen(
17+
[str(binary), "mcp", "--sqlite", str(database), *options],
18+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.errors,
19+
text=True, bufsize=1,
20+
)
21+
self.lines = queue.Queue()
22+
self.sequence = 0
23+
self.reader = threading.Thread(target=self.read, daemon=True)
24+
self.reader.start()
25+
26+
def read(self):
27+
for line in self.process.stdout:
28+
self.lines.put(line)
29+
self.lines.put(None)
30+
31+
def send(self, message):
32+
self.process.stdin.write(json.dumps(message) + "\n")
33+
self.process.stdin.flush()
34+
35+
def request(self, method, params=None):
36+
self.sequence += 1
37+
request_id = self.sequence
38+
self.send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}})
39+
while True:
40+
line = self.lines.get(timeout=30)
41+
if line is None:
42+
raise RuntimeError("MCP stdout closed before a response")
43+
message = json.loads(line) # Startup/log banners contaminating stdout fail here.
44+
if message.get("id") == request_id:
45+
if "error" in message:
46+
raise RuntimeError(message["error"])
47+
return message["result"]
48+
49+
def initialize(self):
50+
result = self.request("initialize", {
51+
"protocolVersion": "2025-11-25", "capabilities": {},
52+
"clientInfo": {"name": "gfa-smoke", "version": "1"},
53+
})
54+
assert result["protocolVersion"] == "2025-11-25"
55+
self.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
56+
return result
57+
58+
def tool(self, name, arguments, failed=False):
59+
result = self.request("tools/call", {"name": name, "arguments": arguments})
60+
assert result.get("isError", False) == failed, result
61+
assert result["content"] and result["content"][0]["type"] == "text"
62+
return result["structuredContent"]["error" if failed else "data"]
63+
64+
def close(self, signal=False):
65+
if self.process.poll() is None:
66+
if signal:
67+
self.process.terminate() # Keep stdin open to exercise blocking-reader cleanup.
68+
else:
69+
self.process.stdin.close()
70+
try:
71+
self.process.wait(timeout=10)
72+
except subprocess.TimeoutExpired:
73+
self.process.kill()
74+
self.process.wait()
75+
raise
76+
if not self.process.stdin.closed:
77+
self.process.stdin.close()
78+
self.reader.join(timeout=2)
79+
self.errors.seek(0)
80+
diagnostics = self.errors.read()
81+
self.errors.close()
82+
assert self.process.returncode == 0, diagnostics
83+
assert not self.reader.is_alive()
84+
85+
86+
with tempfile.TemporaryDirectory() as directory:
87+
database = Path(directory) / "mcp.sqlite"
88+
client = Client(database)
89+
try:
90+
client.initialize()
91+
names = {tool["name"] for tool in client.request("tools/list")["tools"]}
92+
assert {"list_games", "create_match", "make_move", "get_state"} <= names
93+
created = client.tool("create_match", {"game_id": "tictactoe", "opponent": "random", "seed": 23})
94+
match_id = created["state"]["match_id"]
95+
action = created["state"]["legal_actions"][0]
96+
error = client.tool("make_move", {"match_id": match_id, "action": "invalid"}, failed=True)
97+
assert error["details"]["legal_actions"]
98+
moved = client.tool("make_move", {"match_id": match_id, "action": action, "reasoning": "smoke"})
99+
assert moved["state"]["turn"] == 2 and len(moved["opponent_actions"]) == 1
100+
finally:
101+
client.close()
102+
restarted = Client(database)
103+
try:
104+
restarted.initialize()
105+
assert restarted.tool("get_state", {"match_id": match_id}) == moved["state"]
106+
finally:
107+
restarted.close(signal=True)
108+
spectator = Client(database, "--spectator")
109+
try:
110+
spectator.initialize()
111+
view = spectator.tool("get_state", {"match_id": match_id})
112+
assert view["you"] is None and view["legal_actions"] == []
113+
assert spectator.tool("resign", {"match_id": match_id}, failed=True)["code"] == "FORBIDDEN"
114+
finally:
115+
spectator.close()
116+
uninitialized = Client(database)
117+
# Allow signal handler installation, then leave stdin open during shutdown.
118+
time.sleep(1)
119+
uninitialized.close(signal=True)
120+
121+
print("MCP stdio game loop, clean stdout, persistence, EOF, and signal shutdown passed.")

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jobs:
3333
- run: cargo test -p gfa-cli --locked
3434
- run: cargo build -p gfa-cli --locked
3535
- run: python3 .github/scripts/local-server-smoke.py
36+
- run: python3 .github/scripts/mcp-stdio-smoke.py
3637
- name: Export generated OpenAPI
3738
run: cargo run -p gfa-http --example export-openapi --locked > target/openapi.json
3839
- uses: actions/upload-artifact@v4

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ protocol errors; domain and input errors are ordinary isError tool results.
1313
MCP DTOs use the SDK's JSON Schema 2020-12 generator. The existing game-plugin
1414
schema dependency remains unchanged. Official-client protocol tests and output
1515
schema validation run at the application layer, where concrete games may be
16-
named. Stdio/HTTP composition and playing tools are separate implementation units.
16+
named. The CLI supplies stdio composition; streamable HTTP is a separate implementation unit.
1717

1818
Dependency justification: rmcp supplies maintained protocol negotiation, wire
1919
types and transports rather than a project-specific JSON-RPC implementation.
@@ -23,3 +23,8 @@ Reference: https://github.com/modelcontextprotocol/rust-sdk
2323
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.
2424

2525
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.
26+
27+
28+
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.
29+
30+
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.

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

Lines changed: 116 additions & 11 deletions
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 serve [--sqlite PATH | --postgres-env VARIABLE] [--port PORT] [--stockfish PATH]\n\nStarts 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]\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.";
66

77
fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Config>, String> {
88
parse_with_env(args, |name| std::env::var(name).ok())
@@ -83,6 +83,53 @@ fn parse_with_env(
8383
Ok(Some(config))
8484
}
8585

86+
#[derive(Debug, PartialEq, Eq)]
87+
enum Command {
88+
Serve(Config),
89+
Mcp { config: Config, seat: Option<u8> },
90+
}
91+
fn command(args: impl IntoIterator<Item = String>) -> Result<Option<Command>, String> {
92+
let args = args.into_iter().collect::<Vec<_>>();
93+
if args.first().map(String::as_str) != Some("mcp") {
94+
return parse(args).map(|config| config.map(Command::Serve));
95+
}
96+
let mut seat = Some(0);
97+
let mut viewer_seen = false;
98+
let mut forwarded = vec!["serve".to_string()];
99+
let mut arguments = args.into_iter().skip(1);
100+
while let Some(argument) = arguments.next() {
101+
match argument.as_str() {
102+
"--seat" if !viewer_seen => {
103+
seat = Some(
104+
arguments
105+
.next()
106+
.ok_or("--seat requires a zero-based seat")?
107+
.parse()
108+
.map_err(|_| "Seat must be an integer from 0 to 255")?,
109+
);
110+
viewer_seen = true;
111+
}
112+
"--spectator" if !viewer_seen => {
113+
seat = None;
114+
viewer_seen = true;
115+
}
116+
"--seat" | "--spectator" => return Err("Choose --seat or --spectator once".into()),
117+
"--port" => return Err("mcp uses stdin/stdout and does not accept --port".into()),
118+
// Preserve values even when a filesystem path equals another option.
119+
"--sqlite" | "--postgres-env" | "--stockfish" => {
120+
forwarded.push(argument);
121+
forwarded.push(
122+
arguments
123+
.next()
124+
.ok_or("Storage and engine options require a value")?,
125+
);
126+
}
127+
_ => forwarded.push(argument),
128+
}
129+
}
130+
parse(forwarded).map(|config| config.map(|config| Command::Mcp { config, seat }))
131+
}
132+
86133
async fn shutdown() -> Result<(), std::io::Error> {
87134
#[cfg(unix)]
88135
{
@@ -98,16 +145,17 @@ async fn shutdown() -> Result<(), std::io::Error> {
98145
Ok(())
99146
}
100147

101-
#[tokio::main]
102-
async fn main() -> Result<(), ServerError> {
103-
match parse(std::env::args().skip(1)) {
104-
Ok(Some(config)) => {
105-
gfa_server::serve(config, async {
106-
if let Err(error) = shutdown().await {
107-
eprintln!("Shutdown signal handler failed: {error}");
108-
}
109-
})
110-
.await
148+
async fn shutdown_signal() {
149+
if let Err(error) = shutdown().await {
150+
eprintln!("Shutdown signal handler failed: {error}");
151+
}
152+
}
153+
154+
async fn run() -> Result<(), ServerError> {
155+
match command(std::env::args().skip(1)) {
156+
Ok(Some(Command::Serve(config))) => gfa_server::serve(config, shutdown_signal()).await,
157+
Ok(Some(Command::Mcp { config, seat })) => {
158+
gfa_server::serve_stdio(config, seat, shutdown_signal()).await
111159
}
112160
Ok(None) => {
113161
println!("{USAGE}");
@@ -119,6 +167,16 @@ async fn main() -> Result<(), ServerError> {
119167
}
120168
}
121169
}
170+
fn main() -> Result<(), ServerError> {
171+
let runtime = tokio::runtime::Builder::new_multi_thread()
172+
.enable_all()
173+
.build()?;
174+
let result = runtime.block_on(run());
175+
// Tokio's stdin reader uses an uncancellable blocking read. A launcher may
176+
// leave stdin open when sending SIGTERM; do not wait forever for that reader.
177+
runtime.shutdown_timeout(std::time::Duration::from_secs(1));
178+
result
179+
}
122180

123181
#[cfg(test)]
124182
mod tests {
@@ -234,3 +292,50 @@ mod tests {
234292
}
235293
}
236294
}
295+
296+
#[cfg(test)]
297+
mod mcp_command_tests {
298+
use super::*;
299+
fn parsed(args: &[&str]) -> Result<Option<Command>, String> {
300+
command(args.iter().map(|arg| (*arg).to_string()))
301+
}
302+
#[test]
303+
fn parses_stdio_and_rejects_ambiguous_viewers() -> Result<(), String> {
304+
assert_eq!(
305+
parsed(&["mcp"])?,
306+
Some(Command::Mcp {
307+
config: Config::default(),
308+
seat: Some(0)
309+
})
310+
);
311+
assert_eq!(
312+
parsed(&["mcp", "--seat", "1"])?,
313+
Some(Command::Mcp {
314+
config: Config::default(),
315+
seat: Some(1)
316+
})
317+
);
318+
assert_eq!(
319+
parsed(&["mcp", "--spectator"])?,
320+
Some(Command::Mcp {
321+
config: Config::default(),
322+
seat: None
323+
})
324+
);
325+
assert!(parsed(&["mcp", "--help"])?.is_none());
326+
for args in [
327+
vec!["mcp", "--seat"],
328+
vec!["mcp", "--seat", "256"],
329+
vec!["mcp", "--seat", "-1"],
330+
vec!["mcp", "--seat", "0", "--spectator"],
331+
vec!["mcp", "--spectator", "--seat", "0"],
332+
vec!["mcp", "--spectator", "--spectator"],
333+
vec!["mcp", "--port", "0"],
334+
vec!["serve", "--seat", "1"],
335+
vec!["mcp", "--sqlite"],
336+
] {
337+
assert!(parsed(&args).is_err(), "{args:?}");
338+
}
339+
Ok(())
340+
}
341+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ default = []
99
postgres = ["gfa-store/postgres"]
1010

1111
[dependencies]
12+
gfa-mcp.workspace = true
13+
rmcp.workspace = true
1214
gfa-engine-uci.workspace = true
1315
gfa-core.workspace = true
1416
gfa-opponents.workspace = true
@@ -22,7 +24,6 @@ tokio.workspace = true
2224
uuid.workspace = true
2325

2426
[dev-dependencies]
25-
gfa-mcp.workspace = true
2627
rmcp = { workspace = true, features = ["client"] }
2728
gfa-game-chess.workspace = true
2829
jsonschema.workspace = true

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,12 @@ struct Application {
125125
service: Arc<GameService>,
126126
}
127127

128-
async fn application(config: &Config, address: SocketAddr) -> Result<Application, ServerError> {
128+
struct Components {
129+
store: Store,
130+
updates: Arc<gfa_http::LiveUpdates>,
131+
service: Arc<GameService>,
132+
}
133+
async fn components(config: &Config) -> Result<Components, ServerError> {
129134
let registry = gfa_games::registry()?;
130135
let opponents = stockfish::factory(config.stockfish.as_ref(), &registry).await?;
131136
let store = Store::open(&config.database).await?;
@@ -136,6 +141,19 @@ async fn application(config: &Config, address: SocketAddr) -> Result<Application
136141
.with_observer(updates.clone())
137142
.with_opponents(opponents, Arc::new(workers::Workers::new(4))),
138143
);
144+
Ok(Components {
145+
store,
146+
updates,
147+
service,
148+
})
149+
}
150+
151+
async fn application(config: &Config, address: SocketAddr) -> Result<Application, ServerError> {
152+
let Components {
153+
store,
154+
updates,
155+
service,
156+
} = components(config).await?;
139157
let router = gfa_http::local_router_with_updates(service.clone(), address, updates.clone())?;
140158
Ok(Application {
141159
router,
@@ -239,3 +257,6 @@ mod mcp_tests;
239257

240258
#[cfg(test)]
241259
mod mcp_play_tests;
260+
261+
mod mcp_host;
262+
pub use mcp_host::serve_stdio;

0 commit comments

Comments
 (0)