use async_trait::async_trait;
use indexmap::IndexMap;
use pmcp::{
types::{Content, ListResourcesResult, ReadResourceResult, ResourceInfo},
ResourceHandler,
};
use serde::{Deserialize, Serialize};
use crate::error::{Result, ToolkitError};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ResourceConfig {
pub uri: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_mime_type")]
pub mime_type: String,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub content_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Map<String, serde_json::Value>>,
}
fn default_mime_type() -> String {
"text/markdown".to_string()
}
impl ResourceConfig {
pub fn validate(&self) -> Result<()> {
if self.content.is_none() && self.content_file.is_none() {
return Err(ToolkitError::Synth(format!(
"Resource '{}': must specify either 'content' or 'content_file'",
self.uri
)));
}
if self.content.is_some() && self.content_file.is_some() {
return Err(ToolkitError::Synth(format!(
"Resource '{}': cannot specify both 'content' and 'content_file'",
self.uri
)));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct LoadedResource {
pub uri: String,
pub name: String,
pub description: Option<String>,
pub mime_type: String,
pub content: String,
pub meta: Option<serde_json::Map<String, serde_json::Value>>,
}
impl LoadedResource {
pub fn from_config(config: &ResourceConfig) -> Result<Self> {
let content = config.content.clone().ok_or_else(|| {
ToolkitError::Synth(format!(
"Resource '{}': inline 'content' is required (content_file not supported in Lambda)",
config.uri
))
})?;
Ok(Self {
uri: config.uri.clone(),
name: config.name.clone(),
description: config.description.clone(),
mime_type: config.mime_type.clone(),
content,
meta: config.meta.clone(),
})
}
pub fn to_resource_info(&self) -> ResourceInfo {
let mut info = ResourceInfo::new(&self.uri, &self.name).with_mime_type(&self.mime_type);
if let Some(ref desc) = self.description {
info = info.with_description(desc);
}
if let Some(ref meta) = self.meta {
info = info.with_meta(meta.clone());
}
info
}
pub fn to_content(&self) -> Content {
Content::resource_with_text(
self.uri.clone(),
self.content.clone(),
self.mime_type.clone(),
)
}
}
pub struct StaticResourceHandler {
resources: IndexMap<String, LoadedResource>,
}
impl StaticResourceHandler {
pub fn new(resources: IndexMap<String, LoadedResource>) -> Self {
Self { resources }
}
pub fn from_configs(configs: &[ResourceConfig]) -> Result<Self> {
let mut resources = IndexMap::with_capacity(configs.len());
for config in configs {
let loaded = LoadedResource::from_config(config)?;
resources.insert(loaded.uri.clone(), loaded);
}
Ok(Self { resources })
}
pub fn empty() -> Self {
Self {
resources: IndexMap::new(),
}
}
pub fn is_empty(&self) -> bool {
self.resources.is_empty()
}
pub fn len(&self) -> usize {
self.resources.len()
}
pub fn get(&self, uri: &str) -> Option<&LoadedResource> {
self.resources.get(uri)
}
pub fn uris(&self) -> impl Iterator<Item = &str> {
self.resources.keys().map(String::as_str)
}
}
impl From<&crate::config::ServerConfig> for StaticResourceHandler {
fn from(cfg: &crate::config::ServerConfig) -> Self {
let mut resources = IndexMap::with_capacity(cfg.resources.len());
for r in &cfg.resources {
let mime = r.mime_type.clone().unwrap_or_else(default_mime_type);
let loaded = LoadedResource {
uri: r.uri.clone(),
name: r.name.clone().unwrap_or_else(|| r.uri.clone()),
description: r.description.clone(),
mime_type: mime,
content: r.content.clone().unwrap_or_default(),
meta: None,
};
resources.insert(r.uri.clone(), loaded);
}
Self { resources }
}
}
#[async_trait]
impl ResourceHandler for StaticResourceHandler {
async fn list(
&self,
_cursor: Option<String>,
_extra: pmcp::RequestHandlerExtra,
) -> pmcp::Result<ListResourcesResult> {
let resources: Vec<ResourceInfo> = self
.resources
.values()
.map(LoadedResource::to_resource_info)
.collect();
Ok(ListResourcesResult::new(resources))
}
async fn read(
&self,
uri: &str,
_extra: pmcp::RequestHandlerExtra,
) -> pmcp::Result<ReadResourceResult> {
match self.resources.get(uri) {
Some(resource) => Ok(ReadResourceResult::new(vec![resource.to_content()])),
None => Err(pmcp::Error::protocol(
pmcp::ErrorCode::METHOD_NOT_FOUND,
format!("Resource not found: {}", uri),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pmcp::RequestHandlerExtra;
fn mk_extra() -> RequestHandlerExtra {
RequestHandlerExtra::default()
}
fn cfg(uri: &str, mime: &str, body: &str) -> ResourceConfig {
ResourceConfig {
uri: uri.to_string(),
name: uri.to_string(),
description: None,
mime_type: mime.to_string(),
content: Some(body.to_string()),
content_file: None,
meta: None,
}
}
#[test]
fn resource_config_validation() {
let c = cfg("docs://test", "text/plain", "hello");
assert!(c.validate().is_ok());
let mut c = cfg("docs://test", "text/plain", "");
c.content = None;
assert!(c.validate().is_err());
let mut c = cfg("docs://test", "text/plain", "hello");
c.content_file = Some("file.md".to_string());
assert!(c.validate().is_err());
}
#[test]
fn loaded_resource_from_config() {
let c = cfg("docs://test", "text/markdown", "# Hello\nWorld");
let loaded = LoadedResource::from_config(&c).unwrap();
assert_eq!(loaded.uri, "docs://test");
assert_eq!(loaded.mime_type, "text/markdown");
assert_eq!(loaded.content, "# Hello\nWorld");
}
#[tokio::test]
async fn read_returns_resource_with_text_and_correct_mime() {
let handler = StaticResourceHandler::from_configs(&[cfg(
"schema://main",
"application/graphql",
"type Query { hello: String }",
)])
.unwrap();
let result = handler.read("schema://main", mk_extra()).await.unwrap();
assert_eq!(result.contents.len(), 1);
match &result.contents[0] {
Content::Resource {
uri,
text,
mime_type,
..
} => {
assert_eq!(uri, "schema://main");
assert_eq!(text.as_deref(), Some("type Query { hello: String }"));
assert_eq!(mime_type.as_deref(), Some("application/graphql"));
},
other => panic!("expected Content::Resource, got {:?}", other),
}
}
#[tokio::test]
async fn read_missing_uri_returns_err() {
let handler = StaticResourceHandler::empty();
let result = handler.read("docs://nope", mk_extra()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn list_returns_deterministic_order() {
let handler = StaticResourceHandler::from_configs(&[
cfg("docs://a", "text/plain", "A"),
cfg("docs://b", "text/plain", "B"),
cfg("docs://c", "text/plain", "C"),
])
.unwrap();
let first = handler.list(None, mk_extra()).await.unwrap();
let second = handler.list(None, mk_extra()).await.unwrap();
let uris1: Vec<&str> = first.resources.iter().map(|r| r.uri.as_str()).collect();
let uris2: Vec<&str> = second.resources.iter().map(|r| r.uri.as_str()).collect();
assert_eq!(uris1, vec!["docs://a", "docs://b", "docs://c"]);
assert_eq!(uris1, uris2);
}
#[test]
fn handler_len_and_empty() {
let handler = StaticResourceHandler::from_configs(&[
cfg("docs://one", "text/plain", "Content one"),
cfg("docs://two", "text/plain", "Content two"),
])
.unwrap();
assert_eq!(handler.len(), 2);
assert!(!handler.is_empty());
assert!(handler.get("docs://one").is_some());
assert!(handler.get("docs://three").is_none());
let uris: Vec<&str> = handler.uris().collect();
assert_eq!(uris, vec!["docs://one", "docs://two"]);
}
}