Skip to content

Commit 3de3f77

Browse files
committed
chore(mcp): integrate stdio and format resources
2 parents e71775c + 984e063 commit 3de3f77

12 files changed

Lines changed: 865 additions & 186 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.
@@ -25,6 +25,11 @@ Match controls: `create_match`, `get_state`, `get_legal_actions`, `make_move`, a
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.
2626

2727

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.
31+
32+
2833
`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.
2934

3035

‎crates/adapters/gfa-mcp/src/history.rs‎

Lines changed: 146 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use rmcp::{model::CallToolResult, schemars::JsonSchema};
44
use serde::{Deserialize, Serialize};
55
use serde_json::{json, Value};
66

7-
fn page_size() -> u32 { 25 }
7+
fn page_size() -> u32 {
8+
25
9+
}
810
#[derive(Deserialize, JsonSchema)]
911
#[schemars(crate = "rmcp::schemars")]
1012
#[serde(deny_unknown_fields)]
@@ -64,51 +66,165 @@ pub(crate) struct ReplayOutput {
6466
pub next: Option<u64>,
6567
}
6668
impl McpServer {
67-
pub(crate) async fn history_tool(&self, arguments: Value) -> Result<CallToolResult,ApiError> {
69+
pub(crate) async fn history_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
6870
let args: HistoryArgs = decode(arguments)?;
69-
let page = self.service.list_matches(MatchHistoryQuery{game_id:args.game_id,limit:args.limit,after:args.after,status:None}).await?;
70-
let matches = page.matches.into_iter().map(|entry|MatchSummary {
71-
match_id:entry.match_id,game_id:entry.game_id,status:match entry.status{MatchStatus::Active=>"active",MatchStatus::Finished=>"finished"}.into(),
72-
turn:entry.turn,returns:entry.returns,created_at_ms:entry.created_at_ms,
73-
}).collect::<Vec<_>>();
74-
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"));
75-
success(HistoryOutput{matches,next:page.next},text)
71+
let page = self
72+
.service
73+
.list_matches(MatchHistoryQuery {
74+
game_id: args.game_id,
75+
limit: args.limit,
76+
after: args.after,
77+
status: None,
78+
})
79+
.await?;
80+
let matches = page
81+
.matches
82+
.into_iter()
83+
.map(|entry| MatchSummary {
84+
match_id: entry.match_id,
85+
game_id: entry.game_id,
86+
status: match entry.status {
87+
MatchStatus::Active => "active",
88+
MatchStatus::Finished => "finished",
89+
}
90+
.into(),
91+
turn: entry.turn,
92+
returns: entry.returns,
93+
created_at_ms: entry.created_at_ms,
94+
})
95+
.collect::<Vec<_>>();
96+
let text = format!(
97+
"{}\nNext cursor: {}",
98+
matches
99+
.iter()
100+
.map(|entry| format!(
101+
"{}: {} — {}, {} moves, returns {:?}",
102+
entry.match_id, entry.game_id, entry.status, entry.turn, entry.returns
103+
))
104+
.collect::<Vec<_>>()
105+
.join("\n"),
106+
page.next.as_deref().unwrap_or("none")
107+
);
108+
success(
109+
HistoryOutput {
110+
matches,
111+
next: page.next,
112+
},
113+
text,
114+
)
76115
}
77-
pub(crate) async fn replay_tool(&self, arguments: Value) -> Result<CallToolResult,ApiError> {
116+
pub(crate) async fn replay_tool(&self, arguments: Value) -> Result<CallToolResult, ApiError> {
78117
let args: ReplayArgs = decode(arguments)?;
79-
let page = self.service.get_events(&args.match_id,EventsQuery{since:args.since,limit:args.limit,seat:None},self.viewer).await?;
118+
let page = self
119+
.service
120+
.get_events(
121+
&args.match_id,
122+
EventsQuery {
123+
since: args.since,
124+
limit: args.limit,
125+
seat: None,
126+
},
127+
self.viewer,
128+
)
129+
.await?;
80130
let mut events = Vec::with_capacity(page.events.len());
81131
for entry in page.events {
82-
let mut output = ReplayEntry{sequence:entry.sequence,kind:String::new(),turn:None,seat:None,action:None,reasoning:None,details:Value::Null};
132+
let mut output = ReplayEntry {
133+
sequence: entry.sequence,
134+
kind: String::new(),
135+
turn: None,
136+
seat: None,
137+
action: None,
138+
reasoning: None,
139+
details: Value::Null,
140+
};
83141
match entry.event {
84-
EventData::Created {game_id,engine_version,config,assists,at_ms,..} => {
142+
EventData::Created {
143+
game_id,
144+
engine_version,
145+
config,
146+
assists,
147+
at_ms,
148+
..
149+
} => {
85150
output.kind = "created".into();
86151
output.details = json!({"game_id":game_id,"engine_version":engine_version,"config":config,"assists":assists,"at_ms":at_ms});
87152
}
88-
EventData::Action {turn,seat,action,reasoning,opponent_info,events,at_ms} => {
89-
output.kind = "action".into();output.turn=Some(turn);output.seat=Some(seat);
90-
output.action=action.map(|action|action.string);output.reasoning=reasoning;
91-
output.details=json!({"opponent_info":opponent_info,"events":events,"at_ms":at_ms});
153+
EventData::Action {
154+
turn,
155+
seat,
156+
action,
157+
reasoning,
158+
opponent_info,
159+
events,
160+
at_ms,
161+
} => {
162+
output.kind = "action".into();
163+
output.turn = Some(turn);
164+
output.seat = Some(seat);
165+
output.action = action.map(|action| action.string);
166+
output.reasoning = reasoning;
167+
output.details =
168+
json!({"opponent_info":opponent_info,"events":events,"at_ms":at_ms});
92169
}
93-
EventData::ForkedFrom {source} => {
94-
output.kind="forked_from".into();output.details=serde_json::to_value(source).map_err(|_|internal())?;
170+
EventData::ForkedFrom { source } => {
171+
output.kind = "forked_from".into();
172+
output.details = serde_json::to_value(source).map_err(|_| internal())?;
95173
}
96-
EventData::Resigned {turn,seat,at_ms} => {
97-
output.kind="resigned".into();output.turn=Some(turn);output.seat=Some(seat);output.details=json!({"at_ms":at_ms});
174+
EventData::Resigned { turn, seat, at_ms } => {
175+
output.kind = "resigned".into();
176+
output.turn = Some(turn);
177+
output.seat = Some(seat);
178+
output.details = json!({"at_ms":at_ms});
98179
}
99-
EventData::DrawOffered {turn,seat,at_ms} => {
100-
output.kind="draw_offered".into();output.turn=Some(turn);output.seat=Some(seat);output.details=json!({"at_ms":at_ms});
180+
EventData::DrawOffered { turn, seat, at_ms } => {
181+
output.kind = "draw_offered".into();
182+
output.turn = Some(turn);
183+
output.seat = Some(seat);
184+
output.details = json!({"at_ms":at_ms});
101185
}
102-
EventData::Finished {turn,returns,terminated,truncated} => {
103-
output.kind="finished".into();output.turn=Some(turn);
104-
output.details=json!({"returns":returns,"terminated":terminated,"truncated":truncated});
186+
EventData::Finished {
187+
turn,
188+
returns,
189+
terminated,
190+
truncated,
191+
} => {
192+
output.kind = "finished".into();
193+
output.turn = Some(turn);
194+
output.details =
195+
json!({"returns":returns,"terminated":terminated,"truncated":truncated});
105196
}
106197
}
107198
events.push(output);
108199
}
109-
let text = format!("Match: {}. Revision: {}. Next sequence: {:?}.\n{}",args.match_id,page.revision,page.next,
110-
events.iter().map(|entry|format!("#{} {} turn={:?} seat={:?} action={} reasoning={} details={}",entry.sequence,entry.kind,entry.turn,entry.seat,
111-
entry.action.as_deref().unwrap_or("none/private"),entry.reasoning.as_deref().unwrap_or("none/private"),entry.details)).collect::<Vec<_>>().join("\n"));
112-
success(ReplayOutput{match_id:args.match_id,revision:page.revision,events,next:page.next},text)
200+
let text = format!(
201+
"Match: {}. Revision: {}. Next sequence: {:?}.\n{}",
202+
args.match_id,
203+
page.revision,
204+
page.next,
205+
events
206+
.iter()
207+
.map(|entry| format!(
208+
"#{} {} turn={:?} seat={:?} action={} reasoning={} details={}",
209+
entry.sequence,
210+
entry.kind,
211+
entry.turn,
212+
entry.seat,
213+
entry.action.as_deref().unwrap_or("none/private"),
214+
entry.reasoning.as_deref().unwrap_or("none/private"),
215+
entry.details
216+
))
217+
.collect::<Vec<_>>()
218+
.join("\n")
219+
);
220+
success(
221+
ReplayOutput {
222+
match_id: args.match_id,
223+
revision: page.revision,
224+
events,
225+
next: page.next,
226+
},
227+
text,
228+
)
113229
}
114230
}

0 commit comments

Comments
 (0)