diff --git a/crates/tinymcp-bus/src/registry/test.rs b/crates/tinymcp-bus/src/registry/test.rs index 5cbb8c8..a7707f1 100644 --- a/crates/tinymcp-bus/src/registry/test.rs +++ b/crates/tinymcp-bus/src/registry/test.rs @@ -230,6 +230,7 @@ fn an_overview_round_trips_with_its_tools() { qualified_name: "@test/server".into(), display_name: "Test".into(), description: Some("does things".into()), + instructions: Some("call list_accounts first".into()), tools: vec![McpTool::new("a"), McpTool::new("b")], }; let encoded = serde_json::to_value(&overview).unwrap(); @@ -239,6 +240,23 @@ fn an_overview_round_trips_with_its_tools() { ); } +#[test] +fn an_overview_written_before_instructions_existed_still_decodes() { + // `#[serde(default)]`, so a payload from an older peer — or a stored + // snapshot — decodes as "no instructions" rather than failing the whole + // overview and taking the server listing with it. + let overview: ConnectedServerOverview = serde_json::from_value(json!({ + "server_id": "uuid-1", + "qualified_name": "@test/server", + "display_name": "Test", + "description": "does things", + "tools": [{ "name": "a" }] + })) + .unwrap(); + assert_eq!(overview.instructions, None); + assert_eq!(overview.tools.len(), 1); +} + // --------------------------------------------------------------------------- // ServerStatus and McpAuthHint // --------------------------------------------------------------------------- diff --git a/crates/tinymcp-bus/src/registry/types.rs b/crates/tinymcp-bus/src/registry/types.rs index c85980c..62762b7 100644 --- a/crates/tinymcp-bus/src/registry/types.rs +++ b/crates/tinymcp-bus/src/registry/types.rs @@ -255,6 +255,17 @@ pub struct ConnectedServerOverview { /// hint a host can show. #[serde(default)] pub description: Option, + /// The server's own `initialize` instructions, when it sent any. + /// + /// A hand-added server has no registry entry and therefore no + /// [`description`](Self::description) — the handshake's `instructions` is + /// then the only thing that can say what the server is *for*, and without it + /// a host has nothing to show but a name and a tool count. + /// + /// Untrusted remote free-form text, on the same footing as `description`: + /// sanitize before placing it in an LLM's context. + #[serde(default)] + pub instructions: Option, /// The tools the server advertises. /// /// Kept in full so a host can fall back to a tool count when a server has diff --git a/crates/tinymcp/src/registry/connections/types.rs b/crates/tinymcp/src/registry/connections/types.rs index 88aef3b..a6a519e 100644 --- a/crates/tinymcp/src/registry/connections/types.rs +++ b/crates/tinymcp/src/registry/connections/types.rs @@ -111,6 +111,20 @@ impl ActiveClient { } } + /// The `instructions` the server sent in its handshake, if any. + /// + /// Both transports cache the initialize result, so this reads the completed + /// handshake rather than performing one. `None` on a transport error for the + /// same reason it is `None` for a server that sent nothing: the caller is + /// describing a server, and a missing description is not worth failing over. + async fn instructions(&self) -> Option { + let initialized = match self { + Self::Stdio(client) => client.initialize().await, + Self::Http(client) => client.initialize().await, + }; + initialized.ok().and_then(|result| result.instructions) + } + /// Calls a tool. async fn call_tool(&self, name: &str, arguments: Value) -> Result { match self { @@ -137,6 +151,11 @@ struct Connection { qualified_name: String, display_name: String, description: Option, + /// The handshake's `instructions`, captured once at connect time. + /// + /// Stored rather than re-read per overview: the value cannot change without + /// a reconnect, which rebuilds this whole record anyway. + instructions: Option, } impl Connection { @@ -263,12 +282,18 @@ impl Connections { }) .collect(); + // Read before the client is moved into the record. The handshake is + // already complete by here — `list_tools` above cannot have succeeded + // otherwise — so this reads the cached result and does not dial again. + let instructions = client.instructions().await; + let connection = Arc::new(Connection { client, tools: RwLock::new(tools.clone()), qualified_name: server.qualified_name.clone(), display_name: server.display_name.clone(), description: server.description.clone(), + instructions, }); self.live @@ -553,6 +578,7 @@ impl Connections { qualified_name: connection.qualified_name.clone(), display_name: connection.display_name.clone(), description: connection.description.clone(), + instructions: connection.instructions.clone(), tools: connection.tools_snapshot().await, }); }