Bring in mcp-* crates from goose as a starting point

This commit is contained in:
Alex Hancock 2025-02-20 13:15:20 -05:00
commit 4554644a3a
9 changed files with 3926 additions and 0 deletions

View file

@ -0,0 +1,18 @@
[package]
name = "mcp-core"
version = "0.1.0"
edition = "2021"
[dependencies]
async-trait = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
schemars = "0.8"
anyhow = "1.0"
chrono = { version = "0.4.38", features = ["serde"] }
url = "2.5"
base64 = "0.21"
[dev-dependencies]
tempfile = "3.8"

View file

@ -0,0 +1,310 @@
/// Content sent around agents, extensions, and LLMs
/// The various content types can be display to humans but also understood by models
/// They include optional annotations used to help inform agent usage
use super::role::Role;
use crate::resource::ResourceContents;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Annotations {
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<Vec<Role>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<DateTime<Utc>>,
}
impl Annotations {
/// Creates a new Annotations instance specifically for resources
/// optional priority, and a timestamp (defaults to now if None)
pub fn for_resource(priority: f32, timestamp: DateTime<Utc>) -> Self {
assert!(
(0.0..=1.0).contains(&priority),
"Priority {priority} must be between 0.0 and 1.0"
);
Annotations {
priority: Some(priority),
timestamp: Some(timestamp),
audience: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageContent {
pub data: String,
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbeddedResource {
pub resource: ResourceContents,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl EmbeddedResource {
pub fn get_text(&self) -> String {
match &self.resource {
ResourceContents::TextResourceContents { text, .. } => text.clone(),
_ => String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Content {
Text(TextContent),
Image(ImageContent),
Resource(EmbeddedResource),
}
impl Content {
pub fn text<S: Into<String>>(text: S) -> Self {
Content::Text(TextContent {
text: text.into(),
annotations: None,
})
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
Content::Image(ImageContent {
data: data.into(),
mime_type: mime_type.into(),
annotations: None,
})
}
pub fn resource(resource: ResourceContents) -> Self {
Content::Resource(EmbeddedResource {
resource,
annotations: None,
})
}
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
Content::Resource(EmbeddedResource {
resource: ResourceContents::TextResourceContents {
uri: uri.into(),
mime_type: Some("text".to_string()),
text: content.into(),
},
annotations: None,
})
}
/// Get the text content if this is a TextContent variant
pub fn as_text(&self) -> Option<&str> {
match self {
Content::Text(text) => Some(&text.text),
_ => None,
}
}
/// Get the image content if this is an ImageContent variant
pub fn as_image(&self) -> Option<(&str, &str)> {
match self {
Content::Image(image) => Some((&image.data, &image.mime_type)),
_ => None,
}
}
/// Set the audience for the content
pub fn with_audience(mut self, audience: Vec<Role>) -> Self {
let annotations = match &mut self {
Content::Text(text) => &mut text.annotations,
Content::Image(image) => &mut image.annotations,
Content::Resource(resource) => &mut resource.annotations,
};
*annotations = Some(match annotations.take() {
Some(mut a) => {
a.audience = Some(audience);
a
}
None => Annotations {
audience: Some(audience),
priority: None,
timestamp: None,
},
});
self
}
/// Set the priority for the content
/// # Panics
/// Panics if priority is not between 0.0 and 1.0 inclusive
pub fn with_priority(mut self, priority: f32) -> Self {
if !(0.0..=1.0).contains(&priority) {
panic!("Priority must be between 0.0 and 1.0");
}
let annotations = match &mut self {
Content::Text(text) => &mut text.annotations,
Content::Image(image) => &mut image.annotations,
Content::Resource(resource) => &mut resource.annotations,
};
*annotations = Some(match annotations.take() {
Some(mut a) => {
a.priority = Some(priority);
a
}
None => Annotations {
audience: None,
priority: Some(priority),
timestamp: None,
},
});
self
}
/// Get the audience if set
pub fn audience(&self) -> Option<&Vec<Role>> {
match self {
Content::Text(text) => text.annotations.as_ref().and_then(|a| a.audience.as_ref()),
Content::Image(image) => image.annotations.as_ref().and_then(|a| a.audience.as_ref()),
Content::Resource(resource) => resource
.annotations
.as_ref()
.and_then(|a| a.audience.as_ref()),
}
}
/// Get the priority if set
pub fn priority(&self) -> Option<f32> {
match self {
Content::Text(text) => text.annotations.as_ref().and_then(|a| a.priority),
Content::Image(image) => image.annotations.as_ref().and_then(|a| a.priority),
Content::Resource(resource) => resource.annotations.as_ref().and_then(|a| a.priority),
}
}
pub fn unannotated(&self) -> Self {
match self {
Content::Text(text) => Content::text(text.text.clone()),
Content::Image(image) => Content::image(image.data.clone(), image.mime_type.clone()),
Content::Resource(resource) => Content::resource(resource.resource.clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_text() {
let content = Content::text("hello");
assert_eq!(content.as_text(), Some("hello"));
assert_eq!(content.as_image(), None);
}
#[test]
fn test_content_image() {
let content = Content::image("data", "image/png");
assert_eq!(content.as_text(), None);
assert_eq!(content.as_image(), Some(("data", "image/png")));
}
#[test]
fn test_content_annotations_basic() {
let content = Content::text("hello")
.with_audience(vec![Role::User])
.with_priority(0.5);
assert_eq!(content.audience(), Some(&vec![Role::User]));
assert_eq!(content.priority(), Some(0.5));
}
#[test]
fn test_content_annotations_order_independence() {
let content1 = Content::text("hello")
.with_audience(vec![Role::User])
.with_priority(0.5);
let content2 = Content::text("hello")
.with_priority(0.5)
.with_audience(vec![Role::User]);
assert_eq!(content1.audience(), content2.audience());
assert_eq!(content1.priority(), content2.priority());
}
#[test]
fn test_content_annotations_overwrite() {
let content = Content::text("hello")
.with_audience(vec![Role::User])
.with_priority(0.5)
.with_audience(vec![Role::Assistant])
.with_priority(0.8);
assert_eq!(content.audience(), Some(&vec![Role::Assistant]));
assert_eq!(content.priority(), Some(0.8));
}
#[test]
fn test_content_annotations_image() {
let content = Content::image("data", "image/png")
.with_audience(vec![Role::User])
.with_priority(0.5);
assert_eq!(content.audience(), Some(&vec![Role::User]));
assert_eq!(content.priority(), Some(0.5));
}
#[test]
fn test_content_annotations_preservation() {
let text_content = Content::text("hello")
.with_audience(vec![Role::User])
.with_priority(0.5);
match &text_content {
Content::Text(TextContent { annotations, .. }) => {
assert!(annotations.is_some());
let ann = annotations.as_ref().unwrap();
assert_eq!(ann.audience, Some(vec![Role::User]));
assert_eq!(ann.priority, Some(0.5));
}
_ => panic!("Expected Text content"),
}
}
#[test]
#[should_panic(expected = "Priority must be between 0.0 and 1.0")]
fn test_invalid_priority() {
Content::text("hello").with_priority(1.5);
}
#[test]
fn test_unannotated() {
let content = Content::text("hello")
.with_audience(vec![Role::User])
.with_priority(0.5);
let unannotated = content.unannotated();
assert_eq!(unannotated.audience(), None);
assert_eq!(unannotated.priority(), None);
}
#[test]
fn test_partial_annotations() {
let content = Content::text("hello").with_priority(0.5);
assert_eq!(content.audience(), None);
assert_eq!(content.priority(), Some(0.5));
let content = Content::text("hello").with_audience(vec![Role::User]);
assert_eq!(content.audience(), Some(&vec![Role::User]));
assert_eq!(content.priority(), None);
}
}

View file

@ -0,0 +1,73 @@
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
#[non_exhaustive]
#[derive(Error, Debug, Clone, Deserialize, Serialize, PartialEq)]
pub enum ToolError {
#[error("Invalid parameters: {0}")]
InvalidParameters(String),
#[error("Execution failed: {0}")]
ExecutionError(String),
#[error("Schema error: {0}")]
SchemaError(String),
#[error("Tool not found: {0}")]
NotFound(String),
}
pub type ToolResult<T> = std::result::Result<T, ToolError>;
#[derive(Error, Debug)]
pub enum ResourceError {
#[error("Execution failed: {0}")]
ExecutionError(String),
#[error("Resource not found: {0}")]
NotFound(String),
}
#[derive(Error, Debug)]
pub enum PromptError {
#[error("Invalid parameters: {0}")]
InvalidParameters(String),
#[error("Internal error: {0}")]
InternalError(String),
#[error("Prompt not found: {0}")]
NotFound(String),
}
/// Trait for implementing MCP tools
#[async_trait]
pub trait ToolHandler: Send + Sync + 'static {
/// The name of the tool
fn name(&self) -> &'static str;
/// A description of what the tool does
fn description(&self) -> &'static str;
/// JSON schema describing the tool's parameters
fn schema(&self) -> Value;
/// Execute the tool with the given parameters
async fn call(&self, params: Value) -> ToolResult<Value>;
}
/// Trait for implementing MCP resources
#[async_trait]
pub trait ResourceTemplateHandler: Send + Sync + 'static {
/// The URL template for this resource
fn template() -> &'static str;
/// JSON schema describing the resource parameters
fn schema() -> Value;
/// Get the resource value
async fn get(&self, params: Value) -> ToolResult<String>;
}
/// Helper function to generate JSON schema for a type
pub fn generate_schema<T: JsonSchema>() -> ToolResult<Value> {
let schema = schemars::schema_for!(T);
serde_json::to_value(schema).map_err(|e| ToolError::SchemaError(e.to_string()))
}

View file

@ -0,0 +1,12 @@
pub mod content;
pub use content::{Annotations, Content, ImageContent, TextContent};
pub mod handler;
pub mod role;
pub use role::Role;
pub mod tool;
pub use tool::{Tool, ToolCall};
pub mod resource;
pub use resource::{Resource, ResourceContents};
pub mod protocol;
pub use handler::{ToolError, ToolResult};
pub mod prompt;

View file

@ -0,0 +1,156 @@
use crate::content::{Annotations, EmbeddedResource, ImageContent};
use crate::handler::PromptError;
use crate::resource::ResourceContents;
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
use serde::{Deserialize, Serialize};
/// A prompt that can be used to generate text from a model
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Prompt {
/// The name of the prompt
pub name: String,
/// A description of what the prompt does
pub description: String,
/// The arguments that can be passed to customize the prompt
pub arguments: Vec<PromptArgument>,
}
impl Prompt {
/// Create a new prompt with the given name, description and arguments
pub fn new<N, D>(name: N, description: D, arguments: Vec<PromptArgument>) -> Self
where
N: Into<String>,
D: Into<String>,
{
Prompt {
name: name.into(),
description: description.into(),
arguments,
}
}
}
/// Represents a prompt argument that can be passed to customize the prompt
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptArgument {
/// The name of the argument
pub name: String,
/// A description of what the argument is used for
pub description: String,
/// Whether this argument is required
pub required: bool,
}
/// Represents the role of a message sender in a prompt conversation
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromptMessageRole {
User,
Assistant,
}
/// Content types that can be included in prompt messages
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PromptMessageContent {
/// Plain text content
Text { text: String },
/// Image content with base64-encoded data
Image { image: ImageContent },
/// Embedded server-side resource
Resource { resource: EmbeddedResource },
}
/// A message in a prompt conversation
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptMessage {
/// The role of the message sender
pub role: PromptMessageRole,
/// The content of the message
pub content: PromptMessageContent,
}
impl PromptMessage {
/// Create a new text message with the given role and text content
pub fn new_text<S: Into<String>>(role: PromptMessageRole, text: S) -> Self {
Self {
role,
content: PromptMessageContent::Text { text: text.into() },
}
}
pub fn new_image<S: Into<String>>(
role: PromptMessageRole,
data: S,
mime_type: S,
annotations: Option<Annotations>,
) -> Result<Self, PromptError> {
let data = data.into();
let mime_type = mime_type.into();
// Validate base64 data
BASE64_STANDARD.decode(&data).map_err(|_| {
PromptError::InvalidParameters("Image data must be valid base64".to_string())
})?;
// Validate mime type
if !mime_type.starts_with("image/") {
return Err(PromptError::InvalidParameters(
"MIME type must be a valid image type (e.g. image/jpeg)".to_string(),
));
}
Ok(Self {
role,
content: PromptMessageContent::Image {
image: ImageContent {
data,
mime_type,
annotations,
},
},
})
}
/// Create a new resource message
pub fn new_resource(
role: PromptMessageRole,
uri: String,
mime_type: String,
text: Option<String>,
annotations: Option<Annotations>,
) -> Self {
let resource_contents = ResourceContents::TextResourceContents {
uri,
mime_type: Some(mime_type),
text: text.unwrap_or_default(),
};
Self {
role,
content: PromptMessageContent::Resource {
resource: EmbeddedResource {
resource: resource_contents,
annotations,
},
},
}
}
}
/// A template for a prompt
#[derive(Debug, Serialize, Deserialize)]
pub struct PromptTemplate {
pub id: String,
pub template: String,
pub arguments: Vec<PromptArgumentTemplate>,
}
/// A template for a prompt argument, this should be identical to PromptArgument
#[derive(Debug, Serialize, Deserialize)]
pub struct PromptArgumentTemplate {
pub name: String,
pub description: String,
pub required: bool,
}

View file

@ -0,0 +1,289 @@
/// The protocol messages exchanged between client and server
use crate::{
content::Content,
prompt::{Prompt, PromptMessage},
resource::Resource,
resource::ResourceContents,
tool::Tool,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorData>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct JsonRpcNotification {
pub jsonrpc: String,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct JsonRpcError {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
pub error: ErrorData,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged, try_from = "JsonRpcRaw")]
pub enum JsonRpcMessage {
Request(JsonRpcRequest),
Response(JsonRpcResponse),
Notification(JsonRpcNotification),
Error(JsonRpcError),
Nil, // used to respond to notifications
}
#[derive(Debug, Serialize, Deserialize)]
struct JsonRpcRaw {
jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ErrorData>,
}
impl TryFrom<JsonRpcRaw> for JsonRpcMessage {
type Error = String;
fn try_from(raw: JsonRpcRaw) -> Result<Self, <Self as TryFrom<JsonRpcRaw>>::Error> {
// If it has an error field, it's an error response
if raw.error.is_some() {
return Ok(JsonRpcMessage::Error(JsonRpcError {
jsonrpc: raw.jsonrpc,
id: raw.id,
error: raw.error.unwrap(),
}));
}
// If it has a result field, it's a response
if raw.result.is_some() {
return Ok(JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: raw.jsonrpc,
id: raw.id,
result: raw.result,
error: None,
}));
}
// If we have a method, it's either a notification or request
if let Some(method) = raw.method {
if raw.id.is_none() {
return Ok(JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: raw.jsonrpc,
method,
params: raw.params,
}));
}
return Ok(JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: raw.jsonrpc,
id: raw.id,
method,
params: raw.params,
}));
}
// If we have no method and no result/error, it's a nil response
if raw.id.is_none() && raw.result.is_none() && raw.error.is_none() {
return Ok(JsonRpcMessage::Nil);
}
// If we get here, something is wrong with the message
Err(format!(
"Invalid JSON-RPC message format: id={:?}, method={:?}, result={:?}, error={:?}",
raw.id, raw.method, raw.result, raw.error
))
}
}
// Standard JSON-RPC error codes
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
/// Error information for JSON-RPC error responses.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct ErrorData {
/// The error type that occurred.
pub code: i32,
/// A short description of the error. The message SHOULD be limited to a concise single sentence.
pub message: String,
/// Additional information about the error. The value of this member is defined by the
/// sender (e.g. detailed error information, nested errors etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
pub protocol_version: String,
pub capabilities: ServerCapabilities,
pub server_info: Implementation,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct Implementation {
pub name: String,
pub version: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct ServerCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompts: Option<PromptsCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourcesCapability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsCapability>,
// Add other capabilities as needed
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PromptsCapability {
pub list_changed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResourcesCapability {
pub subscribe: Option<bool>,
pub list_changed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ToolsCapability {
pub list_changed: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ListResourcesResult {
pub resources: Vec<Resource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct ReadResourceResult {
pub contents: Vec<ResourceContents>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ListToolsResult {
pub tools: Vec<Tool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CallToolResult {
pub content: Vec<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct ListPromptsResult {
pub prompts: Vec<Prompt>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GetPromptResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub messages: Vec<PromptMessage>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmptyResult {}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_notification_conversion() {
let raw = JsonRpcRaw {
jsonrpc: "2.0".to_string(),
id: None,
method: Some("notify".to_string()),
params: Some(json!({"key": "value"})),
result: None,
error: None,
};
let message = JsonRpcMessage::try_from(raw).unwrap();
match message {
JsonRpcMessage::Notification(n) => {
assert_eq!(n.jsonrpc, "2.0");
assert_eq!(n.method, "notify");
assert_eq!(n.params.unwrap(), json!({"key": "value"}));
}
_ => panic!("Expected Notification"),
}
}
#[test]
fn test_request_conversion() {
let raw = JsonRpcRaw {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: Some("request".to_string()),
params: Some(json!({"key": "value"})),
result: None,
error: None,
};
let message = JsonRpcMessage::try_from(raw).unwrap();
match message {
JsonRpcMessage::Request(r) => {
assert_eq!(r.jsonrpc, "2.0");
assert_eq!(r.id, Some(1));
assert_eq!(r.method, "request");
assert_eq!(r.params.unwrap(), json!({"key": "value"}));
}
_ => panic!("Expected Request"),
}
}
}

View file

@ -0,0 +1,260 @@
/// Resources that servers provide to clients
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::content::Annotations;
const EPSILON: f32 = 1e-6; // Tolerance for floating point comparison
/// Represents a resource in the extension with metadata
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Resource {
/// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content")
pub uri: String,
/// Name of the resource
pub name: String,
/// Optional description of the resource
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// MIME type of the resource content ("text" or "blob")
#[serde(default = "default_mime_type")]
pub mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase", untagged)]
pub enum ResourceContents {
TextResourceContents {
uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
text: String,
},
BlobResourceContents {
uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
blob: String,
},
}
fn default_mime_type() -> String {
"text".to_string()
}
impl Resource {
/// Creates a new Resource from a URI with explicit mime type
pub fn new<S: AsRef<str>>(
uri: S,
mime_type: Option<String>,
name: Option<String>,
) -> Result<Self> {
let uri = uri.as_ref();
let url = Url::parse(uri).map_err(|e| anyhow!("Invalid URI: {}", e))?;
// Extract name from the path component of the URI
// Use provided name if available, otherwise extract from URI
let name = match name {
Some(n) => n,
None => url
.path_segments()
.and_then(|segments| segments.last())
.unwrap_or("unnamed")
.to_string(),
};
// Use provided mime_type or default
let mime_type = match mime_type {
Some(t) if t == "text" || t == "blob" => t,
_ => default_mime_type(),
};
Ok(Self {
uri: uri.to_string(),
name,
description: None,
mime_type,
annotations: Some(Annotations::for_resource(0.0, Utc::now())),
})
}
/// Creates a new Resource with explicit URI, name, and priority
pub fn with_uri<S: Into<String>>(
uri: S,
name: S,
priority: f32,
mime_type: Option<String>,
) -> Result<Self> {
let uri_string = uri.into();
Url::parse(&uri_string).map_err(|e| anyhow!("Invalid URI: {}", e))?;
// Use provided mime_type or default
let mime_type = match mime_type {
Some(t) if t == "text" || t == "blob" => t,
_ => default_mime_type(),
};
Ok(Self {
uri: uri_string,
name: name.into(),
description: None,
mime_type,
annotations: Some(Annotations::for_resource(priority, Utc::now())),
})
}
/// Updates the resource's timestamp to the current time
pub fn update_timestamp(&mut self) {
self.annotations.as_mut().unwrap().timestamp = Some(Utc::now());
}
/// Sets the priority of the resource and returns self for method chaining
pub fn with_priority(mut self, priority: f32) -> Self {
self.annotations.as_mut().unwrap().priority = Some(priority);
self
}
/// Mark the resource as active, i.e. set its priority to 1.0
pub fn mark_active(self) -> Self {
self.with_priority(1.0)
}
// Check if the resource is active
pub fn is_active(&self) -> bool {
if let Some(priority) = self.priority() {
(priority - 1.0).abs() < EPSILON
} else {
false
}
}
/// Returns the priority of the resource, if set
pub fn priority(&self) -> Option<f32> {
self.annotations.as_ref().and_then(|a| a.priority)
}
/// Returns the timestamp of the resource, if set
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
self.annotations.as_ref().and_then(|a| a.timestamp)
}
/// Returns the scheme of the URI
pub fn scheme(&self) -> Result<String> {
let url = Url::parse(&self.uri)?;
Ok(url.scheme().to_string())
}
/// Sets the description of the resource
pub fn with_description<S: Into<String>>(mut self, description: S) -> Self {
self.description = Some(description.into());
self
}
/// Sets the MIME type of the resource
pub fn with_mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
let mime_type = mime_type.into();
match mime_type.as_str() {
"text" | "blob" => self.mime_type = mime_type,
_ => self.mime_type = default_mime_type(),
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_new_resource_with_file_uri() -> Result<()> {
let mut temp_file = NamedTempFile::new()?;
writeln!(temp_file, "test content")?;
let uri = Url::from_file_path(temp_file.path())
.map_err(|_| anyhow!("Invalid file path"))?
.to_string();
let resource = Resource::new(&uri, Some("text".to_string()), None)?;
assert!(resource.uri.starts_with("file:///"));
assert_eq!(resource.priority(), Some(0.0));
assert_eq!(resource.mime_type, "text");
assert_eq!(resource.scheme()?, "file");
Ok(())
}
#[test]
fn test_resource_with_str_uri() -> Result<()> {
let test_content = "Hello, world!";
let uri = format!("str:///{}", test_content);
let resource = Resource::with_uri(
uri.clone(),
"test.txt".to_string(),
0.5,
Some("text".to_string()),
)?;
assert_eq!(resource.uri, uri);
assert_eq!(resource.name, "test.txt");
assert_eq!(resource.priority(), Some(0.5));
assert_eq!(resource.mime_type, "text");
assert_eq!(resource.scheme()?, "str");
Ok(())
}
#[test]
fn test_mime_type_validation() -> Result<()> {
// Test valid mime types
let resource = Resource::new("file:///test.txt", Some("text".to_string()), None)?;
assert_eq!(resource.mime_type, "text");
let resource = Resource::new("file:///test.bin", Some("blob".to_string()), None)?;
assert_eq!(resource.mime_type, "blob");
// Test invalid mime type defaults to "text"
let resource = Resource::new("file:///test.txt", Some("invalid".to_string()), None)?;
assert_eq!(resource.mime_type, "text");
// Test None defaults to "text"
let resource = Resource::new("file:///test.txt", None, None)?;
assert_eq!(resource.mime_type, "text");
Ok(())
}
#[test]
fn test_with_description() -> Result<()> {
let resource = Resource::with_uri("file:///test.txt", "test.txt", 0.0, None)?
.with_description("A test resource");
assert_eq!(resource.description, Some("A test resource".to_string()));
Ok(())
}
#[test]
fn test_with_mime_type() -> Result<()> {
let resource =
Resource::with_uri("file:///test.txt", "test.txt", 0.0, None)?.with_mime_type("blob");
assert_eq!(resource.mime_type, "blob");
// Test invalid mime type defaults to "text"
let resource = resource.with_mime_type("invalid");
assert_eq!(resource.mime_type, "text");
Ok(())
}
#[test]
fn test_invalid_uri() {
let result = Resource::new("not-a-uri", None, None);
assert!(result.is_err());
}
}

View file

@ -0,0 +1,9 @@
/// Roles to describe the origin/ownership of content
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}

View file

@ -0,0 +1,51 @@
/// Tools represent a routine that a server can execute
/// Tool calls represent requests from the client to execute one
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A tool that can be used by a model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
/// The name of the tool
pub name: String,
/// A description of what the tool does
pub description: String,
/// A JSON Schema object defining the expected parameters for the tool
pub input_schema: Value,
}
impl Tool {
/// Create a new tool with the given name and description
pub fn new<N, D>(name: N, description: D, input_schema: Value) -> Self
where
N: Into<String>,
D: Into<String>,
{
Tool {
name: name.into(),
description: description.into(),
input_schema,
}
}
}
/// A tool call request that an extension can execute
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCall {
/// The name of the tool to execute
pub name: String,
/// The parameters for the execution
pub arguments: Value,
}
impl ToolCall {
/// Create a new ToolUse with the given name and parameters
pub fn new<S: Into<String>>(name: S, arguments: Value) -> Self {
Self {
name: name.into(),
arguments,
}
}
}