[MCP] Add support for resources (#5239)
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
This commit is contained in:
@@ -57,6 +57,7 @@ urlencoding = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
escargot = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -8,8 +8,17 @@ 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;
|
||||
@@ -20,15 +29,24 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +69,36 @@ impl TestToolServer {
|
||||
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)]
|
||||
@@ -66,6 +114,7 @@ impl ServerHandler for TestToolServer {
|
||||
capabilities: ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_tool_list_changed()
|
||||
.enable_resources()
|
||||
.build(),
|
||||
..ServerInfo::default()
|
||||
}
|
||||
@@ -85,6 +134,53 @@ impl ServerHandler for TestToolServer {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -18,8 +18,17 @@ 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;
|
||||
@@ -33,13 +42,22 @@ 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.";
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +80,36 @@ impl TestToolServer {
|
||||
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)]
|
||||
@@ -77,6 +125,7 @@ impl ServerHandler for TestToolServer {
|
||||
capabilities: ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_tool_list_changed()
|
||||
.enable_resources()
|
||||
.build(),
|
||||
..ServerInfo::default()
|
||||
}
|
||||
@@ -96,6 +145,53 @@ impl ServerHandler for TestToolServer {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -13,12 +13,19 @@ use mcp_types::CallToolRequestParams;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::InitializeRequestParams;
|
||||
use mcp_types::InitializeResult;
|
||||
use mcp_types::ListResourceTemplatesRequestParams;
|
||||
use mcp_types::ListResourceTemplatesResult;
|
||||
use mcp_types::ListResourcesRequestParams;
|
||||
use mcp_types::ListResourcesResult;
|
||||
use mcp_types::ListToolsRequestParams;
|
||||
use mcp_types::ListToolsResult;
|
||||
use mcp_types::ReadResourceRequestParams;
|
||||
use mcp_types::ReadResourceResult;
|
||||
use reqwest::header::HeaderMap;
|
||||
use rmcp::model::CallToolRequestParam;
|
||||
use rmcp::model::InitializeRequestParam;
|
||||
use rmcp::model::PaginatedRequestParam;
|
||||
use rmcp::model::ReadResourceRequestParam;
|
||||
use rmcp::service::RoleClient;
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::service::{self};
|
||||
@@ -264,6 +271,54 @@ impl RmcpClient {
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
pub async fn list_resources(
|
||||
&self,
|
||||
params: Option<ListResourcesRequestParams>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ListResourcesResult> {
|
||||
let service = self.service().await?;
|
||||
let rmcp_params = params
|
||||
.map(convert_to_rmcp::<_, PaginatedRequestParam>)
|
||||
.transpose()?;
|
||||
|
||||
let fut = service.list_resources(rmcp_params);
|
||||
let result = run_with_timeout(fut, timeout, "resources/list").await?;
|
||||
let converted = convert_to_mcp(result)?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
pub async fn list_resource_templates(
|
||||
&self,
|
||||
params: Option<ListResourceTemplatesRequestParams>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ListResourceTemplatesResult> {
|
||||
let service = self.service().await?;
|
||||
let rmcp_params = params
|
||||
.map(convert_to_rmcp::<_, PaginatedRequestParam>)
|
||||
.transpose()?;
|
||||
|
||||
let fut = service.list_resource_templates(rmcp_params);
|
||||
let result = run_with_timeout(fut, timeout, "resources/templates/list").await?;
|
||||
let converted = convert_to_mcp(result)?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
pub async fn read_resource(
|
||||
&self,
|
||||
params: ReadResourceRequestParams,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ReadResourceResult> {
|
||||
let service = self.service().await?;
|
||||
let rmcp_params: ReadResourceRequestParam = convert_to_rmcp(params)?;
|
||||
let fut = service.read_resource(rmcp_params);
|
||||
let result = run_with_timeout(fut, timeout, "resources/read").await?;
|
||||
let converted = convert_to_mcp(result)?;
|
||||
self.persist_oauth_tokens().await;
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -299,6 +354,8 @@ impl RmcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// This should be called after every tool call so that if a given tool call triggered
|
||||
/// a refresh of the OAuth tokens, they are persisted.
|
||||
async fn persist_oauth_tokens(&self) {
|
||||
if let Some(runtime) = self.oauth_persistor().await
|
||||
&& let Err(error) = runtime.persist_if_needed().await
|
||||
|
||||
124
codex-rs/rmcp-client/tests/resources.rs
Normal file
124
codex-rs/rmcp-client/tests/resources.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use escargot::CargoBuild;
|
||||
use mcp_types::ClientCapabilities;
|
||||
use mcp_types::Implementation;
|
||||
use mcp_types::InitializeRequestParams;
|
||||
use mcp_types::ListResourceTemplatesResult;
|
||||
use mcp_types::ReadResourceRequestParams;
|
||||
use mcp_types::ReadResourceResultContents;
|
||||
use mcp_types::Resource;
|
||||
use mcp_types::ResourceTemplate;
|
||||
use mcp_types::TextResourceContents;
|
||||
use serde_json::json;
|
||||
|
||||
const RESOURCE_URI: &str = "memo://codex/example-note";
|
||||
|
||||
fn stdio_server_bin() -> anyhow::Result<PathBuf> {
|
||||
let build = CargoBuild::new()
|
||||
.package("codex-rmcp-client")
|
||||
.bin("test_stdio_server")
|
||||
.run()?;
|
||||
Ok(build.path().to_path_buf())
|
||||
}
|
||||
|
||||
fn init_params() -> InitializeRequestParams {
|
||||
InitializeRequestParams {
|
||||
capabilities: ClientCapabilities {
|
||||
experimental: None,
|
||||
roots: None,
|
||||
sampling: None,
|
||||
elicitation: Some(json!({})),
|
||||
},
|
||||
client_info: Implementation {
|
||||
name: "codex-test".into(),
|
||||
version: "0.0.0-test".into(),
|
||||
title: Some("Codex rmcp resource test".into()),
|
||||
user_agent: None,
|
||||
},
|
||||
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> {
|
||||
let client = RmcpClient::new_stdio_client(
|
||||
stdio_server_bin()?.into(),
|
||||
Vec::<OsString>::new(),
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.initialize(init_params(), Some(Duration::from_secs(5)))
|
||||
.await?;
|
||||
|
||||
let list = client
|
||||
.list_resources(None, Some(Duration::from_secs(5)))
|
||||
.await?;
|
||||
let memo = list
|
||||
.resources
|
||||
.iter()
|
||||
.find(|resource| resource.uri == RESOURCE_URI)
|
||||
.expect("memo resource present");
|
||||
assert_eq!(
|
||||
memo,
|
||||
&Resource {
|
||||
annotations: None,
|
||||
description: Some("A sample MCP resource exposed for integration tests.".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
name: "example-note".to_string(),
|
||||
size: None,
|
||||
title: Some("Example Note".to_string()),
|
||||
uri: RESOURCE_URI.to_string(),
|
||||
}
|
||||
);
|
||||
let templates = client
|
||||
.list_resource_templates(None, Some(Duration::from_secs(5)))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
templates,
|
||||
ListResourceTemplatesResult {
|
||||
next_cursor: None,
|
||||
resource_templates: vec![ResourceTemplate {
|
||||
annotations: None,
|
||||
description: Some(
|
||||
"Template for memo://codex/{slug} resources used in tests.".to_string()
|
||||
),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
name: "codex-memo".to_string(),
|
||||
title: Some("Codex Memo".to_string()),
|
||||
uri_template: "memo://codex/{slug}".to_string(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
let read = client
|
||||
.read_resource(
|
||||
ReadResourceRequestParams {
|
||||
uri: RESOURCE_URI.to_string(),
|
||||
},
|
||||
Some(Duration::from_secs(5)),
|
||||
)
|
||||
.await?;
|
||||
let ReadResourceResultContents::TextResourceContents(text) =
|
||||
read.contents.first().expect("resource contents present")
|
||||
else {
|
||||
panic!("expected text resource");
|
||||
};
|
||||
assert_eq!(
|
||||
text,
|
||||
&TextResourceContents {
|
||||
text: "This is a sample MCP resource served by the rmcp test server.".to_string(),
|
||||
uri: RESOURCE_URI.to_string(),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user