diff --git a/README.md b/README.md index 42dc7ac..ae4bbb4 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ structs-universe/ │ ├── system.rs # structs_system tool (health, logs, self-tuning) │ ├── players.rs # structs_players tool (virtual players: create/list/roster/state/act) │ ├── policy.rs # Standing order management +│ ├── comms.rs # structs_comms tool (headless Matrix guild chat over the in-app client) │ └── format.rs # Shared formatting utilities ├── .mcp.json # Claude Code MCP server config ├── .github/workflows/ # CI/CD @@ -139,7 +140,7 @@ structs-universe/ ## MCP Server -The app runs an MCP server on `localhost:8420` with bearer token authentication (plus an unauthenticated `GET /health` liveness probe for external monitors). AI agents interact with the game through 13 tools, built-in prompts, and compendium resources. +The app runs an MCP server on `localhost:8420` with bearer token authentication (plus an unauthenticated `GET /health` liveness probe for external monitors). AI agents interact with the game through 14 tools, built-in prompts, and compendium resources. ### Tools @@ -158,6 +159,7 @@ The app runs an MCP server on `localhost:8420` with bearer token authentication | `structs_map` | Render a planet map to PNG/GIF using the game's own renderer | | `structs_doctrine` | Standing rules of engagement + per-tick executor (advise/auto autonomy) | | `structs_strike` | Coordinated team attack + kill-chain (strip blockers → kill → raid window) | +| `structs_comms` | Headless guild chat over Matrix, as the player already signed in: `status`, `connect`, `rooms`, `browse`, `timeline`/`backfill`, `send`, `join`/`leave`, `dm`, `react`, `people`. Sign-in is the in-app wallet signature, so no key or token ever leaves the app | ### Prompts @@ -203,6 +205,26 @@ The bearer token is generated on first launch and stored in `~/Library/Applicati The MCP server requires a bearer token on every request. Without it, requests return `400 Bad Request`. This prevents unauthorized access from other processes or websites on the same machine. +### Guild Comms (Matrix) + +The `structs_comms` tool re-exposes the app's built-in Matrix client (`src-tauri/src/matrix/`) over MCP, so an agent can read and post to guild chat rooms **as the player it is already signed in as** — the same identity a human sees in the Comms window. It never opens a browser and never touches a private key. + +**How sign-in works.** `structs_comms {action:"connect"}` runs the full guild → Matrix login chain headlessly: + +1. Resolve the guild's declared homeserver and OAuth metadata (`matrix_url`, issuer). +2. Register an ephemeral OAuth client and start an authorization-code + PKCE request. +3. Prove identity to the guild: fetch the guild's server timestamp, ask the in-app signer to sign the `LOGIN_GUILD{guild}…{timestamp}` message with the player's key (via `vplayer_bridge`, which **only** signs that fixed login string — never an arbitrary payload), and POST it to `{guild_api}/auth/login`. +4. Resume the parked OAuth request and auto-post any MAS consent form(s) back to the issuer, following the redirect chain until it yields an authorization code. MAS may interpose more than one interstitial form in a row; the consent handler posts each in turn, always only to the issuer the homeserver named. +5. Redeem the code for a Matrix access token and persist the session. + +The resulting identity is `@:` (e.g. `@1-471:matrix.beta.playstructs.com`). All arguments default `guild_id` to the active guild, so the single-guild case needs none. + +**Actions.** `status` (login state, networks, profile) · `rooms` / `browse` (directory, with optional `query`) · `timeline` / `backfill` (`{room_id, limit?}`) · `send` (`{room_id, body, mentions?, reply_to?}`) · `join` / `leave` (`{room_id}`) · `dm` (`{player_id}`) · `react` (`{room_id, event_id, key, on?}`) · `people` · `disconnect`. Arguments are passed nested under an `args` object. + +**Safety.** Only the fixed guild-login message is ever signed, so the bridge can never be turned into a generic signing oracle. Consent forms are only posted back to the issuer the homeserver named, so a hijacked page cannot turn the flow into a blind form submitter. + +> **Provenance.** This `structs_comms` tool, the consent-handler fix, and these docs were AI-generated (Claude) and tested end-to-end against a live guild account — connected as a real player, joined a guild room, and read/posted messages over Matrix before submission. + ## GPU Hashing The app replaces the webapp's JavaScript WebWorker hasher with a Rust-native implementation: diff --git a/src-tauri/src/matrix/auth.rs b/src-tauri/src/matrix/auth.rs index 5dd0925..a0e2bc6 100644 --- a/src-tauri/src/matrix/auth.rs +++ b/src-tauri/src/matrix/auth.rs @@ -722,43 +722,54 @@ async fn consent( body: String, ) -> Result { let issuer = reqwest::Url::parse(&meta.issuer).map_err(|e| e.to_string())?; - if url.host_str() != issuer.host_str() { - return Err(format!( - "the authorization chain stopped at {}, which is neither the callback nor the auth service", - redact(&url) - )); - } - let (action, fields) = parse_form(&body, &url) - .ok_or_else(|| format!("{} needs a browser to continue", redact(&url)))?; - if action.host_str() != issuer.host_str() { - return Err(format!("consent form targets {}, not the auth service", redact(&action))); - } - let resp = http - .post(action.clone()) - .form(&fields) - .send() - .await - .map_err(|e| format!("consent: {}", e))?; - let next = if resp.status().is_redirection() { - let loc = resp - .headers() - .get(reqwest::header::LOCATION) - .and_then(|v| v.to_str().ok()) - .ok_or("consent redirected without a Location")?; - action.join(loc).map_err(|e| e.to_string())? - } else { - return Err(format!( - "consent returned {} instead of continuing", - resp.status().as_u16() - )); - }; - match follow(http, next).await? { - Landing::Callback(u) => Ok(u), - Landing::Page { url, .. } => Err(format!( - "the authorization chain stopped at {} instead of returning a code", - redact(&url) - )), + // MAS may interpose more than one form in a row (e.g. a consent screen + // followed by a device/scope confirmation). Post each in turn — always + // only to the issuer the homeserver named — until we reach the callback. + let (mut cur_url, mut cur_body) = (url, body); + for _ in 0..6 { + if cur_url.host_str() != issuer.host_str() { + return Err(format!( + "the authorization chain stopped at {}, which is neither the callback nor the auth service", + redact(&cur_url) + )); + } + let (action, fields) = parse_form(&cur_body, &cur_url) + .ok_or_else(|| format!("{} needs a browser to continue", redact(&cur_url)))?; + if action.host_str() != issuer.host_str() { + return Err(format!("consent form targets {}, not the auth service", redact(&action))); + } + let resp = http + .post(action.clone()) + .form(&fields) + .send() + .await + .map_err(|e| format!("consent: {}", e))?; + let next = if resp.status().is_redirection() { + let loc = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or("consent redirected without a Location")?; + action.join(loc).map_err(|e| e.to_string())? + } else { + return Err(format!( + "consent returned {} instead of continuing", + resp.status().as_u16() + )); + }; + match follow(http, next).await? { + Landing::Callback(u) => return Ok(u), + // Another interstitial form — post it too on the next pass. + Landing::Page { url, body } => { + cur_url = url; + cur_body = body; + } + } } + Err(format!( + "the authorization chain stopped at {} instead of returning a code (too many consent hops)", + redact(&cur_url) + )) } async fn exchange_code( diff --git a/src-tauri/src/matrix/mod.rs b/src-tauri/src/matrix/mod.rs index 522a57a..6af41a3 100644 --- a/src-tauri/src/matrix/mod.rs +++ b/src-tauri/src/matrix/mod.rs @@ -109,6 +109,19 @@ fn selected_guild() -> Option { .and_then(|n| n.get("guild_id").and_then(|g| g.as_str()).map(String::from)) } +/// Best guess at the guild a headless caller (the MCP `structs_comms` tool) +/// means when it does not name one: the UI's current selection first, then a +/// connected homeserver, then the guild the game itself is signed in to. Lets +/// the tool be called with no `guild_id` in the common single-guild case. +pub fn default_guild() -> Option { + selected_guild().or_else(|| { + crate::game_state::GAME_STATE + .read() + .ok() + .and_then(|gs| gs.guild_id.clone()) + }) +} + /// The HUD's own numbers, formatted by the game's OWN unit ladders. /// /// These are pre-rendered here rather than in JS on purpose. `gs.alpha` is in diff --git a/src-tauri/src/mcp/handler.rs b/src-tauri/src/mcp/handler.rs index a89212f..5584b0b 100644 --- a/src-tauri/src/mcp/handler.rs +++ b/src-tauri/src/mcp/handler.rs @@ -261,6 +261,24 @@ impl StructsMcpHandler { "required": ["command"] })), ), + Tool::new( + "structs_comms", + "Guild chat over Matrix, as the signed-in player (headless — the app signs in with the in-app wallet, no key or token leaves the app). Actions: 'status' (networks + which guild is connected + your profile); 'connect' (sign in to the guild homeserver — run this first); 'disconnect'; 'rooms' (joined rooms with unread counts); 'browse' {query?} (discoverable rooms in the guild directory); 'people' {query?} (players you can DM); 'timeline' {room_id, limit?} (recent messages, newest last); 'backfill' {room_id, limit?} (older history, one page up); 'send' {room_id, body, msgtype?, mentions?, reply_to?} (post a message — msgtype '/me'-style emote via msgtype:\"m.emote\"; mentions:[{name,user_id}]; reply_to:{event_id,sender,body}); 'join' {room_id}; 'leave' {room_id}; 'dm' {player_id} (open/return a direct room with a player id like '1-42'); 'react' {room_id, event_id, key, on?}. guild_id defaults to the active guild; pass it only to target another.", + schema(serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["status", "connect", "disconnect", "rooms", "browse", "people", "timeline", "backfill", "send", "join", "leave", "dm", "react"] + }, + "args": { + "type": "object", + "description": "Action args. guild_id? (defaults to active guild). rooms/status/connect/disconnect: none. browse/people: {query?}. timeline/backfill: {room_id, limit?}. send: {room_id, body, msgtype?, mentions?:[{name,user_id}], reply_to?:{event_id,sender,body}}. join/leave: {room_id}. dm: {player_id}. react: {room_id, event_id, key, on?}." + } + }, + "required": ["action"] + })), + ), ] } } @@ -484,6 +502,13 @@ impl ServerHandler for StructsMcpHandler { })?; tools::system::execute(params).await } + "structs_comms" => { + let params: tools::comms::CommsParams = + serde_json::from_value(args).map_err(|e| { + McpError::invalid_params(format!("Invalid params: {}", e), None) + })?; + tools::comms::execute(&self.app_handle, params).await + } // Retired tools, absorbed elsewhere — point old prompts/workflows home. "structs_query" => vec![Content::text( "structs_query was merged into structs_intel: use structs_intel {query:\"query\", args:{type, id?, filter?, pagination_key?, limit?, page?}} (same shapes).", diff --git a/src-tauri/src/mcp/tools/comms.rs b/src-tauri/src/mcp/tools/comms.rs new file mode 100644 index 0000000..ef8e767 --- /dev/null +++ b/src-tauri/src/mcp/tools/comms.rs @@ -0,0 +1,158 @@ +//! `structs_comms` — headless Matrix (guild chat) for the agent. +//! +//! The desktop app already ships a full Matrix client (`crate::matrix`), but +//! its commands are wired only to the comms window. This tool re-exposes the +//! same functions over MCP so an agent can read and post to guild rooms as the +//! player it is already signed in as — sign-in is the in-app wallet signature, +//! so no key or token ever leaves the app. +//! +//! Every action defaults `guild_id` to `matrix::default_guild()`, so the +//! single-guild case needs no argument. + +use rmcp::model::Content; +use serde::Deserialize; +use serde_json::Value; + +use crate::matrix; + +#[derive(Debug, Deserialize)] +pub struct CommsParams { + /// status, connect, disconnect, rooms, browse, timeline, backfill, send, + /// join, leave, dm, react, people. + pub action: String, + /// Action-specific arguments. + #[serde(default)] + pub args: Value, +} + +/// Uniform JSON envelope for a matrix command result. +fn out(result: Result) -> Vec { + match result { + Ok(v) => vec![Content::text( + serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string()), + )], + Err(e) => vec![Content::text(format!("Error: {}", e))], + } +} + +fn str_arg(args: &Value, key: &str) -> Option { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) +} + +fn u32_arg(args: &Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_u64()).map(|n| n as u32) +} + +/// Resolve the guild: explicit `args.guild_id` wins, else the app default. +fn guild_of(args: &Value) -> Result { + str_arg(args, "guild_id") + .or_else(matrix::default_guild) + .ok_or_else(|| "no guild selected and none could be inferred".to_string()) +} + +fn missing(field: &str) -> Vec { + vec![Content::text(format!("Error: '{}' is required", field))] +} + +pub async fn execute(app_handle: &tauri::AppHandle, params: CommsParams) -> Vec { + let args = ¶ms.args; + + // `status` is the one action that never needs a guild — it reports every + // network the app knows and which one is connected. + if params.action == "status" { + return out(matrix::matrix_status().await); + } + + let guild = match guild_of(args) { + Ok(g) => g, + Err(e) => return vec![Content::text(format!("Error: {}", e))], + }; + + match params.action.as_str() { + "connect" => out(matrix::matrix_connect(app_handle.clone(), guild).await), + "disconnect" => out(matrix::matrix_disconnect(app_handle.clone(), guild).await), + "rooms" => out(matrix::matrix_rooms(guild).await), + "browse" => out(matrix::matrix_browse(guild, str_arg(args, "query")).await), + "people" => out(matrix::matrix_people(guild, str_arg(args, "query")).await), + "timeline" => { + let Some(room_id) = str_arg(args, "room_id") else { + return missing("room_id"); + }; + out(matrix::matrix_timeline(guild, room_id, u32_arg(args, "limit")).await) + } + "backfill" => { + let Some(room_id) = str_arg(args, "room_id") else { + return missing("room_id"); + }; + out(matrix::matrix_backfill(guild, room_id, u32_arg(args, "limit")).await) + } + "join" => { + let Some(room_id) = str_arg(args, "room_id") else { + return missing("room_id"); + }; + out(matrix::matrix_join(app_handle.clone(), guild, room_id).await) + } + "leave" => { + let Some(room_id) = str_arg(args, "room_id") else { + return missing("room_id"); + }; + out(matrix::matrix_leave(app_handle.clone(), guild, room_id).await) + } + "dm" => { + let Some(player_id) = str_arg(args, "player_id") else { + return missing("player_id"); + }; + out(matrix::matrix_dm(app_handle.clone(), guild, player_id).await) + } + "send" => { + let Some(room_id) = str_arg(args, "room_id") else { + return missing("room_id"); + }; + let Some(body) = str_arg(args, "body") else { + return missing("body"); + }; + let mentions = args + .get("mentions") + .and_then(|v| v.as_array()) + .map(|a| a.to_vec()); + let reply_to = match args.get("reply_to") { + Some(v) if !v.is_null() => match serde_json::from_value(v.clone()) { + Ok(r) => Some(r), + Err(e) => { + return vec![Content::text(format!("Error: bad reply_to: {}", e))] + } + }, + _ => None, + }; + out(matrix::matrix_send( + guild, + room_id, + body, + str_arg(args, "msgtype"), + mentions, + reply_to, + ) + .await) + } + "react" => { + let (Some(room_id), Some(event_id), Some(key)) = ( + str_arg(args, "room_id"), + str_arg(args, "event_id"), + str_arg(args, "key"), + ) else { + return vec![Content::text( + "Error: 'room_id', 'event_id' and 'key' are required".to_string(), + )]; + }; + let on = args.get("on").and_then(|v| v.as_bool()).unwrap_or(true); + out(matrix::matrix_react(guild, room_id, event_id, key, on).await) + } + other => vec![Content::text(format!( + "Unknown comms action '{}'. Available: status, connect, disconnect, rooms, browse, people, timeline, backfill, join, leave, dm, send, react.", + other + ))], + } +} diff --git a/src-tauri/src/mcp/tools/mod.rs b/src-tauri/src/mcp/tools/mod.rs index f28796a..cb74fd6 100644 --- a/src-tauri/src/mcp/tools/mod.rs +++ b/src-tauri/src/mcp/tools/mod.rs @@ -1,6 +1,7 @@ pub mod action; pub mod board; pub mod board_pages; +pub mod comms; pub mod dashboard; pub mod doctrine; pub mod events;