Skip to content

Commit 7148b41

Browse files
authored
Merge pull request #58 from CurtisTechSolutions/feat/mcp-resource-subscriptions
feat(mcp): notify clients when watched match state changes
2 parents c58f89f + e6a77c6 commit 7148b41

7 files changed

Lines changed: 519 additions & 1 deletion

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/adapters/gfa-mcp/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ gfa-service.workspace = true
1111
rmcp.workspace = true
1212
serde.workspace = true
1313
serde_json.workspace = true
14+
tokio.workspace = true
1415

1516
[lints]
1617
workspace = true

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ Compact match briefings use the same text-board and canonical-move state as `get
4343

4444

4545
`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+
48+
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/adapters/gfa-mcp/src/lib.rs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Official-SDK MCP adapter over the transport-independent lifecycle service.
22
mod briefing;
33
mod history;
4+
mod live;
45
mod planning;
56
mod play;
67
mod play_types;
@@ -33,6 +34,7 @@ pub struct McpServer {
3334
service: Arc<GameService>,
3435
viewer: Viewer,
3536
tools: Arc<Vec<Tool>>,
37+
watches: Arc<live::Watches>,
3638
}
3739
impl McpServer {
3840
/// Build the game-discovery tools. Hosts must authorize the supplied viewer first.
@@ -44,7 +46,7 @@ impl McpServer {
4446
"Select an authorized player or spectator view.",
4547
));
4648
}
47-
Ok(Self { service, viewer, tools: Arc::new(vec![
49+
Ok(Self { service, viewer, watches: Arc::new(live::Watches::default()), tools: Arc::new(vec![
4850
tool::<Empty, GamesOutput>("list_games", "Discover installed games before choosing one. Example: list_games({}).")?,
4951
tool::<InfoArgs, BriefingOutput>("get_game_info", "Read rules and the API loop before playing; use match_id for an existing match. Example: get_game_info({\"game_id\":\"chess\",\"detail\":\"compact\"}).")?,
5052
tool::<GameArgs, OpponentsOutput>("list_opponents", "Find installed opponents and their measured levels before creating a match. Example: list_opponents({\"game_id\":\"chess\"}).")?,
@@ -210,6 +212,7 @@ impl ServerHandler for McpServer {
210212
ServerCapabilities::builder()
211213
.enable_tools()
212214
.enable_resources()
215+
.enable_resources_subscribe()
213216
.enable_prompts()
214217
.build(),
215218
)
@@ -258,6 +261,29 @@ impl ServerHandler for McpServer {
258261
)
259262
.map(Into::into)
260263
}
264+
fn accepted_subscription_filter(
265+
&self,
266+
requested: &rmcp::model::SubscriptionFilter,
267+
) -> Option<rmcp::model::SubscriptionFilter> {
268+
Some(self.accepted_watches(requested))
269+
}
270+
async fn listen(&self, context: rmcp::service::SubscriptionContext) -> Result<(), ErrorData> {
271+
self.listen_states(context).await
272+
}
273+
async fn subscribe(
274+
&self,
275+
request: rmcp::model::SubscribeRequestParams,
276+
context: RequestContext<RoleServer>,
277+
) -> Result<(), ErrorData> {
278+
self.subscribe_state(request.uri, context).await
279+
}
280+
async fn unsubscribe(
281+
&self,
282+
request: rmcp::model::UnsubscribeRequestParams,
283+
context: RequestContext<RoleServer>,
284+
) -> Result<(), ErrorData> {
285+
self.unsubscribe_state(&request.uri, context).await
286+
}
261287
fn get_tool(&self, name: &str) -> Option<Tool> {
262288
self.tools.iter().find(|tool| tool.name == name).cloned()
263289
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use crate::{play_types::StateOutput, McpServer};
2+
use gfa_core::Viewer;
3+
use gfa_service::GameService;
4+
use rmcp::{
5+
model::{ClientConfig, ResourceUpdatedNotificationParam, SubscriptionFilter},
6+
service::{Peer, RequestContext, SubscriptionContext},
7+
ErrorData, RoleServer,
8+
};
9+
use std::{sync::Arc, time::Duration};
10+
use tokio::{
11+
sync::{Mutex, Semaphore},
12+
task::JoinHandle,
13+
};
14+
15+
const MAX_URIS: usize = 8;
16+
const POLL: Duration = Duration::from_secs(2);
17+
const IO_TIMEOUT: Duration = Duration::from_secs(3);
18+
struct LegacyWatch {
19+
// The SDK returns clones of this connection's stable Arc. Pointer identity
20+
// distinguishes clients with identical names without trusting client IDs.
21+
identity: Arc<ClientConfig>,
22+
uri: String,
23+
task: JoinHandle<()>,
24+
}
25+
impl Drop for LegacyWatch {
26+
fn drop(&mut self) {
27+
self.task.abort();
28+
}
29+
}
30+
pub(crate) struct Watches {
31+
capacity: Arc<Semaphore>,
32+
legacy: Mutex<Vec<LegacyWatch>>,
33+
}
34+
impl Default for Watches {
35+
fn default() -> Self {
36+
Self {
37+
capacity: Arc::new(Semaphore::new(16)),
38+
legacy: Mutex::new(Vec::new()),
39+
}
40+
}
41+
}
42+
fn state_id(uri: &str) -> Option<&str> {
43+
let id = uri.strip_prefix("gfa://matches/")?.strip_suffix("/state")?;
44+
(!id.is_empty()
45+
&& id.len() <= 128
46+
&& id
47+
.bytes()
48+
.all(|byte| byte.is_ascii_alphanumeric() || b"_-".contains(&byte)))
49+
.then_some(id)
50+
}
51+
fn unavailable() -> ErrorData {
52+
ErrorData::internal_error("Resource subscription unavailable", None)
53+
}
54+
async fn snapshot(service: &GameService, viewer: Viewer, uri: &str) -> Result<String, ErrorData> {
55+
let id = state_id(uri).ok_or_else(|| {
56+
ErrorData::invalid_params("Only gfa://matches/{match_id}/state can be watched", None)
57+
})?;
58+
let state = tokio::time::timeout(IO_TIMEOUT, service.get_state(id, viewer))
59+
.await
60+
.map_err(|_| unavailable())?
61+
.map_err(|error| ErrorData::invalid_params(error.message, None))?;
62+
let state = StateOutput::from_state(state, viewer).map_err(|_| unavailable())?;
63+
let encoded = serde_json::to_string(&state).map_err(|_| unavailable())?;
64+
if encoded.len() > 64 * 1024 {
65+
return Err(ErrorData::invalid_params(
66+
"State exceeds the live watch size limit",
67+
None,
68+
));
69+
}
70+
Ok(encoded)
71+
}
72+
impl McpServer {
73+
pub(crate) fn accepted_watches(&self, requested: &SubscriptionFilter) -> SubscriptionFilter {
74+
let mut uris = requested
75+
.resource_subscriptions
76+
.as_ref()
77+
.into_iter()
78+
.flatten()
79+
.filter(|uri| state_id(uri).is_some())
80+
.take(MAX_URIS)
81+
.cloned()
82+
.collect::<Vec<_>>();
83+
uris.sort();
84+
uris.dedup();
85+
SubscriptionFilter::builder()
86+
.resource_subscriptions(uris)
87+
.build()
88+
}
89+
pub(crate) async fn listen_states(
90+
&self,
91+
context: SubscriptionContext,
92+
) -> Result<(), ErrorData> {
93+
let _permit = self
94+
.watches
95+
.capacity
96+
.clone()
97+
.try_acquire_owned()
98+
.map_err(|_| unavailable())?;
99+
let uris = context
100+
.accepted()
101+
.resource_subscriptions
102+
.clone()
103+
.unwrap_or_default();
104+
if uris.is_empty() {
105+
return Ok(());
106+
}
107+
let mut previous = vec![None; uris.len()];
108+
let mut tick = tokio::time::interval(POLL);
109+
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
110+
loop {
111+
tokio::select! {
112+
_=context.cancelled()=>return Ok(()),
113+
_=tick.tick()=>{}
114+
}
115+
for (index, uri) in uris.iter().enumerate() {
116+
let current = tokio::select! {
117+
_=context.cancelled()=>return Ok(()),
118+
state=snapshot(&self.service,self.viewer,uri)=>state?,
119+
};
120+
if previous[index].as_ref() != Some(&current) {
121+
tokio::select! {
122+
_=context.cancelled()=>return Ok(()),
123+
sent=tokio::time::timeout(IO_TIMEOUT,context.sink().notify_resource_updated(uri))=> {
124+
sent.map_err(|_|unavailable())?.map_err(|_|unavailable())?;
125+
}
126+
}
127+
previous[index] = Some(current);
128+
}
129+
}
130+
}
131+
}
132+
pub(crate) async fn subscribe_state(
133+
&self,
134+
uri: String,
135+
context: RequestContext<RoleServer>,
136+
) -> Result<(), ErrorData> {
137+
let identity = context.peer.peer_info().ok_or_else(unavailable)?;
138+
let current = snapshot(&self.service, self.viewer, &uri).await?;
139+
let mut watches = self.watches.legacy.lock().await;
140+
watches.retain(|watch| !watch.task.is_finished());
141+
if watches
142+
.iter()
143+
.any(|watch| Arc::ptr_eq(&watch.identity, &identity) && watch.uri == uri)
144+
{
145+
return Ok(());
146+
}
147+
let permit = self
148+
.watches
149+
.capacity
150+
.clone()
151+
.try_acquire_owned()
152+
.map_err(|_| unavailable())?;
153+
let service = self.service.clone();
154+
let viewer = self.viewer;
155+
let watched_uri = uri.clone();
156+
let task = tokio::spawn(async move {
157+
let _permit = permit;
158+
legacy_loop(service, viewer, watched_uri, context.peer, current).await;
159+
});
160+
watches.push(LegacyWatch {
161+
identity,
162+
uri,
163+
task,
164+
});
165+
Ok(())
166+
}
167+
pub(crate) async fn unsubscribe_state(
168+
&self,
169+
uri: &str,
170+
context: RequestContext<RoleServer>,
171+
) -> Result<(), ErrorData> {
172+
let identity = context.peer.peer_info().ok_or_else(unavailable)?;
173+
self.watches.legacy.lock().await.retain(|watch| {
174+
!watch.task.is_finished()
175+
&& !(watch.uri == uri && Arc::ptr_eq(&watch.identity, &identity))
176+
});
177+
Ok(())
178+
}
179+
}
180+
async fn legacy_loop(
181+
service: Arc<GameService>,
182+
viewer: Viewer,
183+
uri: String,
184+
peer: Peer<RoleServer>,
185+
mut previous: String,
186+
) {
187+
loop {
188+
tokio::time::sleep(POLL).await;
189+
if peer.is_transport_closed() {
190+
return;
191+
}
192+
let Ok(current) = snapshot(&service, viewer, &uri).await else {
193+
return;
194+
};
195+
if current != previous {
196+
let sent = tokio::time::timeout(
197+
IO_TIMEOUT,
198+
peer.notify_resource_updated(ResourceUpdatedNotificationParam::new(&uri)),
199+
)
200+
.await;
201+
if !matches!(sent, Ok(Ok(()))) {
202+
return;
203+
}
204+
previous = current;
205+
}
206+
}
207+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,6 @@ mod mcp_resource_tests;
283283

284284
#[cfg(test)]
285285
mod mcp_plan_tests;
286+
287+
#[cfg(test)]
288+
mod mcp_live_tests;

0 commit comments

Comments
 (0)