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
18 changes: 18 additions & 0 deletions crates/tinymcp-bus/src/registry/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions crates/tinymcp-bus/src/registry/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,17 @@ pub struct ConnectedServerOverview {
/// hint a host can show.
#[serde(default)]
pub description: Option<String>,
/// 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<String>,
/// The tools the server advertises.
///
/// Kept in full so a host can fall back to a tool count when a server has
Expand Down
26 changes: 26 additions & 0 deletions crates/tinymcp/src/registry/connections/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<McpServerToolResult> {
match self {
Expand All @@ -137,6 +151,11 @@ struct Connection {
qualified_name: String,
display_name: String,
description: Option<String>,
/// 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<String>,
}

impl Connection {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}
Expand Down