Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 `@<player-id>:<homeserver>` (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:
Expand Down
83 changes: 47 additions & 36 deletions src-tauri/src/matrix/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -722,43 +722,54 @@ async fn consent(
body: String,
) -> Result<reqwest::Url, String> {
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(
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/matrix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ fn selected_guild() -> Option<String> {
.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<String> {
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
Expand Down
25 changes: 25 additions & 0 deletions src-tauri/src/mcp/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
})),
),
]
}
}
Expand Down Expand Up @@ -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).",
Expand Down
158 changes: 158 additions & 0 deletions src-tauri/src/mcp/tools/comms.rs
Original file line number Diff line number Diff line change
@@ -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<Value, String>) -> Vec<Content> {
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<String> {
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<u32> {
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<String, String> {
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<Content> {
vec![Content::text(format!("Error: '{}' is required", field))]
}

pub async fn execute(app_handle: &tauri::AppHandle, params: CommsParams) -> Vec<Content> {
let args = &params.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
))],
}
}
1 change: 1 addition & 0 deletions src-tauri/src/mcp/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down