Skip to content
Draft
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
2 changes: 2 additions & 0 deletions crates/rmcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ features = [
"server",
"server-side-http",
"tower",
"tool-search",
"transport-async-rw",
"transport-child-process",
"transport-io",
Expand Down Expand Up @@ -138,6 +139,7 @@ libc = "0.2"
default = ["base64", "macros", "server"]
local = ["rmcp-macros?/local"]
client = ["dep:tokio-stream"]
tool-search = []
server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"]
macros = ["dep:rmcp-macros", "dep:pastey"]
elicitation = ["dep:url"]
Expand Down
49 changes: 49 additions & 0 deletions crates/rmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ For **getting started**, **usage guides**, and **full MCP feature documentation*
| `auth-enterprise-managed` | EMA/XAA refresh-token and ID-JAG exchanges for registered public and confidential clients (includes `auth`) | |
| `elicitation` | Elicitation support | |
| `skills-fs` | Serve SEP-2640 skills from a directory tree (`SkillCatalog`) | |
| `tool-search` | Dependency-free BM25 tool search and optional server discovery wrapper | |

### Transport features

Expand Down Expand Up @@ -67,6 +68,54 @@ Any type that implements the [`Transport`](crate::transport::Transport) trait ca
3. A [`Worker`](crate::transport::worker::Worker) implementation
4. A [`Transport`](crate::transport::Transport) implementation directly

## Tool search

Enable `tool-search` for a standalone, lazily built BM25 Okapi index:

```rust,ignore
use rmcp::tool_search::ToolSearchIndex;

let mut index = ToolSearchIndex::new(tools);
let hits = index.search("create a github issue", 5);
// After an upstream tools/list_changed notification, fetch the new catalog:
index.replace_tools(updated_tools);
```

Names, titles, descriptions, and input-schema parameter names/descriptions are
searched case-insensitively. Use `with_parameter_indexing(tools, false)` to omit
parameters. Hits include full `Tool` definitions and scores; unmatched queries
return no hits.

With `server` enabled, wrap your existing `ServerHandler` (including handlers
using `#[tool_handler]` / `ToolRouter`):

```rust,ignore
use rmcp::tool_search::{ToolSearchConfig, ToolSearchServer};

let mut config = ToolSearchConfig::default();
config.max_results = 5;
config.always_visible = vec!["health_check".into()];
let server = ToolSearchServer::with_config(my_handler, config)?;
// Serve `server` as usual.
```

`tools/list` exposes `search_tools` and `call_tool`, plus `always_visible` tools
which are excluded from search. Search arguments are
`{"query": "create an issue", "max_results": 3}`; the optional limit defaults to
the configured maximum (five by default) and cannot exceed it. Search returns
`{"tools": [...]}` as both structured and text content, including input schemas.
Dispatch arguments are `{"name": "create_issue", "arguments": {...}}`.
Original tools remain directly callable, and dispatch preserves request metadata
and retry state. Search controls discovery, **not authorization**: underlying
handlers must still enforce access checks.

Use `search_tool_name` / `call_tool_name` to avoid catalog collisions. Invalid
settings are rejected at construction, and catalog collisions fail listing/search.
The wrapper fetches every catalog page on each listing/search and lazily rebuilds
its index only when definitions change, so dynamic or request-specific catalogs
do not require a notification invalidation hook. Other handler methods and
notifications are delegated unchanged. Regex and embedding search are not included.

## License

This project is licensed under the terms specified in the repository's [LICENSE](../../LICENSE) file.
70 changes: 50 additions & 20 deletions crates/rmcp/src/handler/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,31 @@ pub trait ServerHandler: Sized + 'static {

macro_rules! impl_server_handler_for_wrapper {
($wrapper:ident) => {
impl_server_handler_for_wrapper!($wrapper, {
fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, McpError>> + MaybeSendFuture + '_ {
(**self).call_tool(request, context)
}

fn list_tools(
&self,
request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, McpError>> + MaybeSendFuture + '_ {
(**self).list_tools(request, context)
}

fn get_tool(&self, name: &str) -> Option<Tool> {
(**self).get_tool(name)
}
});
};
($wrapper:ident, { $($tool_methods:tt)* }) => {
impl<T: ServerHandler> ServerHandler for $wrapper<T> {
$($tool_methods)*
fn ping(
&self,
context: RequestContext<RoleServer>,
Expand Down Expand Up @@ -869,26 +893,6 @@ macro_rules! impl_server_handler_for_wrapper {
(**self).unsubscribe(request, context)
}

fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, McpError>> + MaybeSendFuture + '_ {
(**self).call_tool(request, context)
}

fn list_tools(
&self,
request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, McpError>> + MaybeSendFuture + '_ {
(**self).list_tools(request, context)
}

fn get_tool(&self, name: &str) -> Option<Tool> {
(**self).get_tool(name)
}

fn on_custom_request(
&self,
request: CustomRequest,
Expand Down Expand Up @@ -995,3 +999,29 @@ macro_rules! impl_server_handler_for_wrapper {

impl_server_handler_for_wrapper!(Box);
impl_server_handler_for_wrapper!(Arc);

#[cfg(feature = "tool-search")]
use crate::tool_search::ToolSearchServer;

#[cfg(feature = "tool-search")]
impl_server_handler_for_wrapper!(ToolSearchServer, {
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
self.search_call_tool(request, context).await
}

async fn list_tools(
&self,
request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
self.search_list_tools(request, context).await
}

fn get_tool(&self, name: &str) -> Option<Tool> {
self.search_get_tool(name)
}
});
3 changes: 3 additions & 0 deletions crates/rmcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub use service::{RoleServer, serve_server};
pub mod handler;
#[cfg(feature = "server")]
pub mod task_manager;
/// Dependency-free BM25 tool discovery, with an optional server wrapper.
#[cfg(feature = "tool-search")]
pub mod tool_search;
#[cfg(any(feature = "client", feature = "server"))]
pub mod transport;

Expand Down
185 changes: 185 additions & 0 deletions crates/rmcp/src/tool_search.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! Opt-in, in-memory BM25 Okapi search over tool definitions.
//!
//! Matching is case-insensitive, splitting on punctuation (including underscores).
//! Scores use `k1 = 1.5` and `b = 0.75`; only positive matches are returned.
//! Equal scores retain catalog order. This is discovery, not access control.

use std::{
collections::{BTreeSet, HashMap},
sync::OnceLock,
};

use crate::model::Tool;

#[cfg(feature = "server")]
mod server;
#[cfg(feature = "server")]
pub use server::{ToolSearchConfig, ToolSearchServer};

/// A matching tool, including its full definition and BM25 score.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolMatch {
/// The complete matching definition, including schemas and metadata.
pub tool: Tool,
/// Positive BM25 Okapi relevance score.
pub score: f64,
}

/// A lazily built search index usable by servers, clients, or gateways.
#[derive(Debug)]
pub struct ToolSearchIndex {
tools: Vec<Tool>,
index_parameters: bool,
index: OnceLock<Bm25>,
}

#[derive(Debug)]
struct Bm25 {
documents: Vec<HashMap<String, usize>>,
lengths: Vec<usize>,
frequencies: HashMap<String, usize>,
average_length: f64,
}

impl ToolSearchIndex {
/// Index names, titles, descriptions, and parameter names/descriptions.
pub fn new(tools: Vec<Tool>) -> Self {
Self::with_parameter_indexing(tools, true)
}

/// Choose whether to include input-schema parameter names/descriptions.
pub fn with_parameter_indexing(tools: Vec<Tool>, index_parameters: bool) -> Self {
Self {
tools,
index_parameters,
index: OnceLock::new(),
}
}

/// Replace the catalog and invalidate the index, e.g. after `tools/list_changed`.
pub fn replace_tools(&mut self, tools: Vec<Tool>) {
self.tools = tools;
self.index.take();
}

/// The full catalog backing this index.
pub fn tools(&self) -> &[Tool] {
&self.tools
}

/// Return up to `max_results` ranked tools. Empty/unmatched queries return no hits.
pub fn search(&self, query: &str, max_results: usize) -> Vec<ToolMatch> {
let terms: BTreeSet<_> = tokenize(query).collect();
if terms.is_empty() || max_results == 0 || self.tools.is_empty() {
return Vec::new();
}
let index = self.index.get_or_init(|| self.build());
let count = self.tools.len() as f64;
let mut scores: Vec<_> = index
.documents
.iter()
.enumerate()
.filter_map(|(i, document)| {
let mut score = 0.0;
for term in &terms {
if let Some(&frequency) = document.get(term) {
let df = index.frequencies[term] as f64;
let idf = (1.0 + (count - df + 0.5) / (df + 0.5)).ln();
let tf = frequency as f64;
let length = index.lengths[i] as f64 / index.average_length;
score += idf * tf * 2.5 / (tf + 1.5 * (0.25 + 0.75 * length));
}
}
(score > 0.0).then_some((i, score))
})
.collect();
scores.sort_by(|(a, sa), (b, sb)| sb.total_cmp(sa).then_with(|| a.cmp(b)));
scores
.into_iter()
.take(max_results)
.map(|(i, score)| ToolMatch {
tool: self.tools[i].clone(),
score,
})
.collect()
}

fn build(&self) -> Bm25 {
let mut documents = Vec::with_capacity(self.tools.len());
let mut lengths = Vec::with_capacity(self.tools.len());
let mut frequencies = HashMap::new();
for tool in &self.tools {
let mut text = tool.name.to_string();
for field in [tool.title.as_deref(), tool.description.as_deref()]
.into_iter()
.flatten()
{
text.push(' ');
text.push_str(field);
}
if self.index_parameters {
// Iterative traversal handles nested schemas without recursive stack growth.
let mut pending: Vec<&serde_json::Value> = tool.input_schema.values().collect();
if let Some(properties) = tool
.input_schema
.get("properties")
.and_then(|v| v.as_object())
{
for name in properties.keys() {
text.push(' ');
text.push_str(name);
}
}
while let Some(value) = pending.pop() {
match value {
serde_json::Value::Object(object) => {
if let Some(description) =
object.get("description").and_then(|v| v.as_str())
{
text.push(' ');
text.push_str(description);
}
if let Some(properties) =
object.get("properties").and_then(|v| v.as_object())
{
for name in properties.keys() {
text.push(' ');
text.push_str(name);
}
}
pending.extend(object.values());
}
serde_json::Value::Array(array) => pending.extend(array),
_ => {}
}
}
}
let mut document = HashMap::new();
let mut length = 0;
for term in tokenize(&text) {
*document.entry(term).or_insert(0) += 1;
length += 1;
}
for term in document.keys() {
*frequencies.entry(term.clone()).or_insert(0) += 1;
}
documents.push(document);
lengths.push(length);
}
let average_length =
(lengths.iter().sum::<usize>() as f64 / self.tools.len().max(1) as f64).max(1.0);
Bm25 {
documents,
lengths,
frequencies,
average_length,
}
}
}

fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
text.split(|c: char| !c.is_alphanumeric())
.filter(|term| !term.is_empty())
.map(str::to_lowercase)
}
Loading