This PR adds support for [MCP resources](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) by adding three new tools for the model: 1. `list_resources` 2. `list_resource_templates` 3. `read_resource` These 3 tools correspond to the [three primary MCP resource protocol messages](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#protocol-messages). Example of listing and reading a GitHub resource tempalte <img width="2984" height="804" alt="CleanShot 2025-10-15 at 17 31 10" src="https://github.com/user-attachments/assets/89b7f215-2e2a-41c5-90dd-b932ac84a585" /> `/mcp` with Figma configured <img width="2984" height="442" alt="CleanShot 2025-10-15 at 18 29 35" src="https://github.com/user-attachments/assets/a7578080-2ed2-4c59-b9b4-d8461f90d8ee" /> Fixes #4956
239 lines
7.7 KiB
Rust
239 lines
7.7 KiB
Rust
use std::borrow::Cow;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use rmcp::ErrorData as McpError;
|
|
use rmcp::ServiceExt;
|
|
use rmcp::handler::server::ServerHandler;
|
|
use rmcp::model::CallToolRequestParam;
|
|
use rmcp::model::CallToolResult;
|
|
use rmcp::model::JsonObject;
|
|
use rmcp::model::ListResourceTemplatesResult;
|
|
use rmcp::model::ListResourcesResult;
|
|
use rmcp::model::ListToolsResult;
|
|
use rmcp::model::PaginatedRequestParam;
|
|
use rmcp::model::RawResource;
|
|
use rmcp::model::RawResourceTemplate;
|
|
use rmcp::model::ReadResourceRequestParam;
|
|
use rmcp::model::ReadResourceResult;
|
|
use rmcp::model::Resource;
|
|
use rmcp::model::ResourceContents;
|
|
use rmcp::model::ResourceTemplate;
|
|
use rmcp::model::ServerCapabilities;
|
|
use rmcp::model::ServerInfo;
|
|
use rmcp::model::Tool;
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use tokio::task;
|
|
|
|
#[derive(Clone)]
|
|
struct TestToolServer {
|
|
tools: Arc<Vec<Tool>>,
|
|
resources: Arc<Vec<Resource>>,
|
|
resource_templates: Arc<Vec<ResourceTemplate>>,
|
|
}
|
|
|
|
const MEMO_URI: &str = "memo://codex/example-note";
|
|
const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server.";
|
|
pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) {
|
|
(tokio::io::stdin(), tokio::io::stdout())
|
|
}
|
|
impl TestToolServer {
|
|
fn new() -> Self {
|
|
let tools = vec![Self::echo_tool()];
|
|
let resources = vec![Self::memo_resource()];
|
|
let resource_templates = vec![Self::memo_template()];
|
|
Self {
|
|
tools: Arc::new(tools),
|
|
resources: Arc::new(resources),
|
|
resource_templates: Arc::new(resource_templates),
|
|
}
|
|
}
|
|
|
|
fn echo_tool() -> Tool {
|
|
#[expect(clippy::expect_used)]
|
|
let schema: JsonObject = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": { "type": "string" },
|
|
"env_var": { "type": "string" }
|
|
},
|
|
"required": ["message"],
|
|
"additionalProperties": false
|
|
}))
|
|
.expect("echo tool schema should deserialize");
|
|
|
|
Tool::new(
|
|
Cow::Borrowed("echo"),
|
|
Cow::Borrowed("Echo back the provided message and include environment data."),
|
|
Arc::new(schema),
|
|
)
|
|
}
|
|
|
|
fn memo_resource() -> Resource {
|
|
let raw = RawResource {
|
|
uri: MEMO_URI.to_string(),
|
|
name: "example-note".to_string(),
|
|
title: Some("Example Note".to_string()),
|
|
description: Some("A sample MCP resource exposed for integration tests.".to_string()),
|
|
mime_type: Some("text/plain".to_string()),
|
|
size: None,
|
|
icons: None,
|
|
};
|
|
Resource::new(raw, None)
|
|
}
|
|
|
|
fn memo_template() -> ResourceTemplate {
|
|
let raw = RawResourceTemplate {
|
|
uri_template: "memo://codex/{slug}".to_string(),
|
|
name: "codex-memo".to_string(),
|
|
title: Some("Codex Memo".to_string()),
|
|
description: Some(
|
|
"Template for memo://codex/{slug} resources used in tests.".to_string(),
|
|
),
|
|
mime_type: Some("text/plain".to_string()),
|
|
};
|
|
ResourceTemplate::new(raw, None)
|
|
}
|
|
|
|
fn memo_text() -> &'static str {
|
|
MEMO_CONTENT
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct EchoArgs {
|
|
message: String,
|
|
#[allow(dead_code)]
|
|
env_var: Option<String>,
|
|
}
|
|
|
|
impl ServerHandler for TestToolServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo {
|
|
capabilities: ServerCapabilities::builder()
|
|
.enable_tools()
|
|
.enable_tool_list_changed()
|
|
.enable_resources()
|
|
.build(),
|
|
..ServerInfo::default()
|
|
}
|
|
}
|
|
|
|
fn list_tools(
|
|
&self,
|
|
_request: Option<PaginatedRequestParam>,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
|
|
let tools = self.tools.clone();
|
|
async move {
|
|
Ok(ListToolsResult {
|
|
tools: (*tools).clone(),
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn list_resources(
|
|
&self,
|
|
_request: Option<PaginatedRequestParam>,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> impl std::future::Future<Output = Result<ListResourcesResult, McpError>> + Send + '_ {
|
|
let resources = self.resources.clone();
|
|
async move {
|
|
Ok(ListResourcesResult {
|
|
resources: (*resources).clone(),
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn list_resource_templates(
|
|
&self,
|
|
_request: Option<PaginatedRequestParam>,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<ListResourceTemplatesResult, McpError> {
|
|
Ok(ListResourceTemplatesResult {
|
|
resource_templates: (*self.resource_templates).clone(),
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
|
|
async fn read_resource(
|
|
&self,
|
|
ReadResourceRequestParam { uri }: ReadResourceRequestParam,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<ReadResourceResult, McpError> {
|
|
if uri == MEMO_URI {
|
|
Ok(ReadResourceResult {
|
|
contents: vec![ResourceContents::TextResourceContents {
|
|
uri,
|
|
mime_type: Some("text/plain".to_string()),
|
|
text: Self::memo_text().to_string(),
|
|
meta: None,
|
|
}],
|
|
})
|
|
} else {
|
|
Err(McpError::resource_not_found(
|
|
"resource_not_found",
|
|
Some(json!({ "uri": uri })),
|
|
))
|
|
}
|
|
}
|
|
|
|
async fn call_tool(
|
|
&self,
|
|
request: CallToolRequestParam,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
match request.name.as_ref() {
|
|
"echo" => {
|
|
let args: EchoArgs = match request.arguments {
|
|
Some(arguments) => serde_json::from_value(serde_json::Value::Object(
|
|
arguments.into_iter().collect(),
|
|
))
|
|
.map_err(|err| McpError::invalid_params(err.to_string(), None))?,
|
|
None => {
|
|
return Err(McpError::invalid_params(
|
|
"missing arguments for echo tool",
|
|
None,
|
|
));
|
|
}
|
|
};
|
|
|
|
let env_snapshot: HashMap<String, String> = std::env::vars().collect();
|
|
let structured_content = json!({
|
|
"echo": format!("ECHOING: {}", args.message),
|
|
"env": env_snapshot.get("MCP_TEST_VALUE"),
|
|
});
|
|
|
|
Ok(CallToolResult {
|
|
content: Vec::new(),
|
|
structured_content: Some(structured_content),
|
|
is_error: Some(false),
|
|
meta: None,
|
|
})
|
|
}
|
|
other => Err(McpError::invalid_params(
|
|
format!("unknown tool: {other}"),
|
|
None,
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
eprintln!("starting rmcp test server");
|
|
// Run the server with STDIO transport. If the client disconnects we simply
|
|
// bubble up the error so the process exits.
|
|
let service = TestToolServer::new();
|
|
let running = service.serve(stdio()).await?;
|
|
|
|
// Wait for the client to finish interacting with the server.
|
|
running.waiting().await?;
|
|
// Drain background tasks to ensure clean shutdown.
|
|
task::yield_now().await;
|
|
Ok(())
|
|
}
|