Huginn/crates/eureka-state/src/main.rs
Meta Vens c76e37280d huginn-mind: a Moved resolution freezes its campaign's root and the receiver replays the history as an import naming its source
The giver: (Campaign, Moved) joins the matrix; a Moved to this mind refuses MoveToSelf and a withdrawn Moved MoveIsFinal; any staged document rooted at a moved campaign, derived ones included, refuses CampaignMoved; Docs::closing_resolution reads every other document under the root closed by the Moved, and the image fans status_changed out so an earlier as_of still reads in force. The oracle derives the same fallback independently.

The receiver: PipelineProvenance.imported_from (optional, absent from every other receipt and from the digest) names the source receipt and the externals the batch cites. An import derives nothing, refuses its own instance, a Moved outcome, and an external this mind holds; a listed external stands in for an absent referent. mind-body persona.rs gains one outcome_words arm for the new variant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:47:43 +01:00

416 lines
18 KiB
Rust

//! `eureka-state`: Huginn's mind as six MCP tools over stdio.
//!
//! One tool per wire operation (`whoami`, `admit`, `view`, `query`,
//! `persona_get`, `persona_put`), each a `HuginnClient` call and nothing else. The declared identity is this
//! process's configuration, not a per-call argument: `EUREKA_INSTANCE` names
//! the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that holds
//! it. The server fills in `instance` and `provenance.tool`, so a caller can
//! name neither.
//!
//! Bad configuration does not stop the server: an MCP server that dies at
//! startup vanishes from Claude Code without a word. `whoami` reports it and
//! every other tool refuses with it. A refusal from the mind is an answer and
//! comes back as a normal result; a daemon that cannot be reached or that
//! rejects the envelope, or a request too large for one send, is a result with
//! `isError: true`, typed, so a caller can tell a down daemon from a permanent
//! mismatch. Arguments that do not fit a tool's input are typed the same way
//! (`InvalidInput`), as a tool error and not a protocol one.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use cultnet_rs::Selection;
use eureka_state::door::{self, Refusal};
use eureka_state::{ClientError, HuginnClient};
use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineRef, Short, Slug};
use huginn_mind::{
Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineProvenance, SemanticQuery,
};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::tool::ToolCallContext;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo};
use rmcp::service::RequestContext;
use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::Deserialize;
use serde_json::{Value, json};
/// The `provenance.tool` every admission from this server carries.
const TOOL: &str = "eureka-state";
/// How long one call may take, connection to last byte.
const TIMEOUT: Duration = Duration::from_secs(15);
/// CultLib's published shape of a selection, vendored because the selection
/// type carries no schema of its own. `selection_schema_matches_cultlib` pins
/// it to the CultLib revision `cultnet-rs` is built from.
const SELECTION_SCHEMA: &str = include_str!("../schemas/cultnet.selection.schema.json");
/// The selection's schema, self-contained: its own `$defs` move into the
/// tool's, so every `$ref` in the input schema is `#/$defs/...`.
fn selection_schema(generator: &mut SchemaGenerator) -> Schema {
let Ok(Value::Object(mut selection)) = serde_json::from_str(SELECTION_SCHEMA) else {
unreachable!("the vendored selection schema is a JSON object");
};
selection.remove("$schema");
selection.remove("$id");
if let Some(Value::Object(defs)) = selection.remove("$defs") {
generator.definitions_mut().extend(defs);
}
Schema::try_from(Value::Object(selection)).expect("an object is a schema")
}
#[derive(Deserialize, JsonSchema)]
struct AdmitInput {
/// The faculty the batch is admitted as. Attribution, not authority.
faculty: Faculty,
/// The admitting agent's name.
agent: Short,
/// The admitting session, recorded on the receipt so that several sessions
/// sharing one mind stay tellable apart.
session: Short,
/// The documents to admit as one batch. The mind admits them whole or refuses them.
/// Decoded by the text door, which refuses a key the schema does not know.
#[schemars(with = "Vec<PipelineDocument>")]
documents: Value,
}
#[derive(Deserialize, JsonSchema)]
struct PersonaPutInput {
/// The faculty the put is attributed to. Attribution, not authority.
faculty: Faculty,
/// The putting agent's name.
agent: Short,
/// The putting session, recorded on the receipt.
session: Short,
/// The whole `gamecult.persona_state.v0` document. It replaces the stored
/// one; there is no partial write.
state: Value,
/// The stored document's `updatedAt`; omit it for the first put.
expected_updated_at: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct ViewInput {
/// The document's id, as a query or an admission returned it.
id: PipelineRef,
}
#[derive(Deserialize, JsonSchema)]
struct QueryInput {
/// A CultNet typed selection over the mind's documents.
#[schemars(schema_with = "selection_schema")]
selection: Selection,
/// Optional: rank by nearness to this text instead of ordering by key.
semantic: Option<SemanticQuery>,
}
/// Why a call produced no answer. Each is a typed body with `isError: true`.
enum Trouble {
Misconfigured(String),
Client(ClientError),
Internal(String),
/// Input the schema does not describe, refused by the text door before any call.
Invalid(String),
}
impl Trouble {
fn body(&self) -> Value {
match self {
Self::Misconfigured(detail) => json!({ "error": "Misconfigured", "detail": detail }),
Self::Client(ClientError::Unavailable { endpoint, detail }) => {
json!({ "error": "Unavailable", "endpoint": format!("rudp://{endpoint}"), "detail": detail })
}
Self::Client(ClientError::Rejected { endpoint, code, detail }) => {
json!({ "error": "Rejected", "endpoint": format!("rudp://{endpoint}"), "code": code, "detail": detail })
}
Self::Client(ClientError::TooLarge { bytes, limit }) => {
json!({ "error": "TooLarge", "bytes": bytes, "limit": limit })
}
Self::Client(ClientError::Unencodable { detail }) => json!({ "error": "Unencodable", "detail": detail }),
Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }),
Self::Invalid(detail) => json!({ "error": "InvalidInput", "detail": detail }),
}
}
}
/// The two environment values as given, and the client they make when both are
/// valid.
struct Settings {
instance: Option<String>,
endpoint: Option<String>,
client: Result<HuginnClient, String>,
}
impl Settings {
fn from_env() -> Self {
let instance = variable("EUREKA_INSTANCE");
let endpoint = variable("HUGINN_ENDPOINT");
let shown = |value: &Result<Option<String>, String>| value.as_ref().ok().cloned().flatten();
let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint));
Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) }
}
}
/// An environment variable: its value, `None` when unset, or why it cannot be read.
fn variable(name: &str) -> Result<Option<String>, String> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")),
}
}
fn client(instance: Result<Option<String>, String>, endpoint: Result<Option<String>, String>) -> Result<HuginnClient, String> {
let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?;
let slug = Slug(instance);
slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?;
let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?;
let address = endpoint
.strip_prefix("rudp://")
.and_then(|address| address.parse::<SocketAddr>().ok())
.ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://<ip>:<port>: {endpoint}"))?;
Ok(HuginnClient::new(address, slug, TIMEOUT))
}
#[derive(Clone)]
struct EurekaState {
settings: Arc<Settings>,
tool_router: ToolRouter<Self>,
}
impl EurekaState {
fn new(settings: Settings) -> Self {
Self { settings: Arc::new(settings), tool_router: Self::tool_router() }
}
/// One wire call for the configured instance. The request is built from
/// that instance, never from anything the caller sent.
async fn call(
&self,
request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static,
) -> Result<HuginnMindResponse, Trouble> {
self.ask(move |client| client.call(request(client.instance().clone()))).await
}
/// One `HuginnClient` call for the configured instance, off the async
/// thread.
async fn ask(
&self,
call: impl FnOnce(&HuginnClient) -> Result<HuginnMindResponse, ClientError> + Send + 'static,
) -> Result<HuginnMindResponse, Trouble> {
let client = self.settings.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))?.clone();
tokio::task::spawn_blocking(move || call(&client))
.await
.map_err(|error| Trouble::Internal(error.to_string()))?
.map_err(Trouble::Client)
}
async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static) -> CallToolResult {
Self::replied(self.call(request).await)
}
fn replied(result: Result<HuginnMindResponse, Trouble>) -> CallToolResult {
match result {
Ok(response) => reply(payload(response), false),
Err(trouble) => reply(trouble.body(), true),
}
}
}
/// The answer as the tool's output: the operation's own payload, or the typed
/// refusal, tagged, when the mind refused the request.
fn payload(response: HuginnMindResponse) -> Value {
let value = match response {
HuginnMindResponse::Whoami(status) => serde_json::to_value(status),
HuginnMindResponse::Admit(outcome) => serde_json::to_value(outcome),
HuginnMindResponse::View(view) => serde_json::to_value(view),
HuginnMindResponse::Query(page) => serde_json::to_value(page),
HuginnMindResponse::Persona(view) => serde_json::to_value(view),
HuginnMindResponse::PersonaPut(outcome) => serde_json::to_value(outcome),
other => serde_json::to_value(other),
};
value.expect("wire types serialize")
}
fn reply(body: Value, is_error: bool) -> CallToolResult {
let content = vec![ContentBlock::text(body.to_string())];
if is_error { CallToolResult::error(content) } else { CallToolResult::success(content) }
}
#[tool_router]
impl EurekaState {
#[tool(
description = "Who this server acts for and whether the Huginn daemon answers: the configured instance and endpoint, reachable, and the mind's status when it does. Reports bad configuration instead of failing."
)]
async fn whoami(&self) -> CallToolResult {
let mut out = json!({
"instance": self.settings.instance,
"endpoint": self.settings.endpoint,
"reachable": false,
"status": null,
});
match self.call(|_| HuginnMindRequest::Whoami).await {
Ok(HuginnMindResponse::Whoami(status)) => {
out["reachable"] = json!(true);
out["status"] = serde_json::to_value(status).expect("wire types serialize");
}
Ok(other) => out["error"] = json!({ "error": "Internal", "detail": format!("unexpected answer: {other:?}") }),
Err(trouble) => out["error"] = trouble.body(),
}
reply(out, false)
}
#[tool(
description = "Admit one batch of pipeline documents into the mind, whole or not at all. Returns the outcome: committed with a receipt, already admitted, conflict, or a typed refusal. The instance is this server's configuration; do not pass one."
)]
async fn admit(&self, Parameters(input): Parameters<AdmitInput>) -> CallToolResult {
let documents = match door::documents(&input.documents.to_string()) {
Ok(documents) => documents,
Err(Refusal::Invalid(detail)) => return Self::replied(Err(Trouble::Invalid(detail))),
Err(Refusal::Internal(detail)) => return Self::replied(Err(Trouble::Internal(detail))),
};
self.answer(move |instance| {
HuginnMindRequest::Admit(PipelineAdmissionBatch {
instance,
provenance: PipelineProvenance {
faculty: input.faculty,
agent: input.agent,
session: input.session,
tool: Short(TOOL.into()),
imported_from: None,
},
documents,
})
})
.await
}
#[tool(description = "Read one document by id, with its admission facts and status. null when the mind has no such document.")]
async fn view(&self, Parameters(input): Parameters<ViewInput>) -> CallToolResult {
self.answer(move |instance| HuginnMindRequest::View { instance, id: input.id }).await
}
#[tool(
description = "Query the mind with a CultNet typed selection, optionally ranked by semantic nearness. Returns one page; pass its cursor back in the selection for the next. A semantic answer returns at most top_k members of the selection, nearest first, with scores (one cosine similarity per row, in order)."
)]
async fn query(&self, Parameters(input): Parameters<QueryInput>) -> CallToolResult {
self.answer(move |instance| HuginnMindRequest::Query {
instance,
selection: input.selection,
semantic: input.semantic,
})
.await
}
#[tool(
description = "The mind's persona document (gamecult.persona_state.v0) with its updatedAt and the receipt of the put that wrote it. null until the first put."
)]
async fn persona_get(&self) -> CallToolResult {
Self::replied(self.ask(HuginnClient::persona_get).await)
}
#[tool(
description = "Replace the mind's persona document whole. The document must pass the published schema, name this instance as personaId, carry provenance.authority canonical, and have an updatedAt later than the stored one; expected_updated_at must be the stored updatedAt (omit it for the first put). A refusal is an answer; a stale put names the stored updatedAt to retry against."
)]
async fn persona_put(&self, Parameters(input): Parameters<PersonaPutInput>) -> CallToolResult {
let provenance = PipelineProvenance {
faculty: input.faculty,
agent: input.agent,
session: input.session,
tool: Short(TOOL.into()),
imported_from: None,
};
Self::replied(
self.ask(move |client| client.persona_put(provenance, input.state, input.expected_updated_at)).await,
)
}
}
/// Every tool error is a typed JSON body. The ones this server writes already
/// are; the one rmcp writes, for arguments that do not deserialize into the
/// tool's input, is plain text, so it is put into the same shape.
fn typed_error(result: CallToolResult) -> CallToolResult {
if result.is_error != Some(true) {
return result;
}
let Some(text) = result.content.first().and_then(ContentBlock::as_text).map(|text| text.text.as_str()) else {
return result;
};
if serde_json::from_str::<Value>(text).is_ok_and(|body| body.is_object()) {
return result;
}
reply(json!({ "error": "InvalidInput", "detail": text }), true)
}
#[tool_handler]
impl ServerHandler for EurekaState {
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
Ok(typed_error(self.tool_router.call(ToolCallContext::new(self, request, context)).await?))
}
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
"Huginn's typed mind for this instance: whoami, admit, view, query, persona_get, persona_put. Refusals are answers; isError means the daemon could not be reached or rejected the request.",
)
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let server = EurekaState::new(Settings::from_env()).serve(rmcp::transport::stdio()).await?;
server.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn body_of(result: &CallToolResult) -> (bool, String) {
(result.is_error == Some(true), result.content[0].as_text().unwrap().text.clone())
}
/// Reaching `Internal` for real takes a panic in a blocking task, so its
/// body is pinned where it is built.
#[test]
fn every_trouble_has_its_typed_body() {
assert_eq!(Trouble::Internal("task panicked".into()).body(), json!({ "error": "Internal", "detail": "task panicked" }));
assert_eq!(
Trouble::Client(ClientError::TooLarge { bytes: 70_000, limit: 65_000 }).body(),
json!({ "error": "TooLarge", "bytes": 70_000, "limit": 65_000 })
);
assert_eq!(
Trouble::Client(ClientError::Unencodable { detail: "no".into() }).body(),
json!({ "error": "Unencodable", "detail": "no" })
);
assert_eq!(Trouble::Misconfigured("bad".into()).body(), json!({ "error": "Misconfigured", "detail": "bad" }));
}
/// Only the text rmcp writes is rewritten: our own typed bodies and every
/// success pass through untouched.
#[test]
fn typed_error_rewrites_only_rmcp_plain_text() {
let plain = CallToolResult::error(vec![ContentBlock::text("failed to deserialize parameters: missing field `faculty`")]);
let (is_error, text) = body_of(&typed_error(plain));
assert!(is_error);
assert_eq!(
serde_json::from_str::<Value>(&text).unwrap(),
json!({ "error": "InvalidInput", "detail": "failed to deserialize parameters: missing field `faculty`" })
);
let typed = reply(json!({ "error": "Unavailable", "detail": "down" }), true);
assert_eq!(body_of(&typed_error(typed.clone())), body_of(&typed));
let success = CallToolResult::success(vec![ContentBlock::text("not json, and fine")]);
assert_eq!(body_of(&typed_error(success)), (false, "not json, and fine".to_string()));
let quoted = CallToolResult::error(vec![ContentBlock::text("[1, 2]")]);
assert_eq!(serde_json::from_str::<Value>(&body_of(&typed_error(quoted)).1).unwrap()["error"], json!("InvalidInput"));
}
}