| |
| |
| |
| |
| |
| use schemars::JsonSchema; |
| use serde::Deserialize; |
| use serde::Serialize; |
| use std::collections::HashMap; |
| use ts_rs::TS; |
|
|
| |
| #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(rename_all = "camelCase")] |
| #[ts(rename_all = "camelCase")] |
| pub enum McpServerConnectionStatus { |
| NotStarted, |
| Starting, |
| Connected, |
| AuthenticationRequired, |
| Failed, |
| Cancelled, |
| Disabled, |
| } |
|
|
| |
| pub const OPENAI_ELICITATION_EXTENSION_ID: &str = "openai/elicitation"; |
| |
| pub const OPENAI_FORM_EXTENSION_ID: &str = "openai/form"; |
| |
| pub const OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID: &str = "openai/standard-form-input"; |
| |
| pub const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; |
| |
| pub const CONFIRMATION_POLICIES_META_KEY: &str = "openai/confirmation_policies"; |
|
|
| |
| pub fn is_node_repl_backed_server(server: &str) -> bool { |
| matches!(server, "node_repl" | "cua_repl") |
| } |
|
|
| |
| |
| pub fn is_node_repl_backed_tool(name: &str, namespace: Option<&str>) -> bool { |
| if let Some(namespace) = namespace { |
| let namespace = namespace.strip_prefix("mcp__").unwrap_or(namespace); |
| return is_node_repl_backed_server(namespace.strip_suffix("__").unwrap_or(namespace)); |
| } |
|
|
| let name = name.strip_prefix("mcp__").unwrap_or(name); |
| name.split_once("__") |
| .is_some_and(|(server, _)| is_node_repl_backed_server(server)) |
| } |
|
|
| |
| #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
| pub struct McpResourceOriginCheckpoint { |
| pub origins: Vec<McpResourceOrigin>, |
| pub turns: Vec<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub current_turn_id: Option<String>, |
| } |
|
|
| |
| #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
| pub struct McpResourceOrigin { |
| pub call_id: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub turn_id: Option<String>, |
| pub tool: String, |
| pub connector_id: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| pub link_id: Option<String>, |
| pub uri: String, |
| #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
| pub ambiguous_account: bool, |
| } |
|
|
| |
| const MCP_CLIENT_ONLY_EXTENSION_IDS: [&str; 1] = [OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID]; |
|
|
| |
| #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| pub struct ClientMcpExtensions { |
| extensions: HashMap<String, serde_json::Value>, |
| } |
|
|
| impl ClientMcpExtensions { |
| |
| pub fn new(extensions: impl IntoIterator<Item = (String, serde_json::Value)>) -> Self { |
| Self { |
| extensions: extensions.into_iter().collect(), |
| } |
| } |
|
|
| |
| pub fn contains(&self, extension_id: &str) -> bool { |
| self.extensions.contains_key(extension_id) |
| } |
|
|
| |
| pub fn get(&self, extension_id: &str) -> Option<&serde_json::Value> { |
| self.extensions.get(extension_id) |
| } |
|
|
| |
| pub fn for_mcp_servers(&self) -> Self { |
| Self::new( |
| self.extensions |
| .iter() |
| .filter(|(id, _)| !MCP_CLIENT_ONLY_EXTENSION_IDS.contains(&id.as_str())) |
| .map(|(id, settings)| (id.clone(), settings.clone())), |
| ) |
| } |
|
|
| |
| pub fn iter(&self) -> impl Iterator<Item = (&str, &serde_json::Value)> { |
| self.extensions |
| .iter() |
| .map(|(id, settings)| (id.as_str(), settings)) |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(untagged)] |
| pub enum RequestId { |
| String(String), |
| #[ts(type = "number")] |
| Integer(i64), |
| } |
|
|
| impl std::fmt::Display for RequestId { |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| match self { |
| RequestId::String(s) => f.write_str(s), |
| RequestId::Integer(i) => i.fmt(f), |
| } |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(rename_all = "camelCase")] |
| pub struct McpServerInfo { |
| pub name: String, |
| pub title: Option<String>, |
| pub version: String, |
| pub description: Option<String>, |
| pub icons: Option<Vec<serde_json::Value>>, |
| pub website_url: Option<String>, |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(rename_all = "camelCase")] |
| pub struct Tool { |
| pub name: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub title: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub description: Option<String>, |
| pub input_schema: serde_json::Value, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub output_schema: Option<serde_json::Value>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub annotations: Option<serde_json::Value>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub icons: Option<Vec<serde_json::Value>>, |
| #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub meta: Option<serde_json::Value>, |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(rename_all = "camelCase")] |
| pub struct Resource { |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub annotations: Option<serde_json::Value>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub description: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub mime_type: Option<String>, |
| pub name: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| #[ts(type = "number")] |
| pub size: Option<i64>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub title: Option<String>, |
| pub uri: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub icons: Option<Vec<serde_json::Value>>, |
| #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub meta: Option<serde_json::Value>, |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(untagged)] |
| pub enum ResourceContent { |
| #[serde(rename_all = "camelCase")] |
| #[ts(rename_all = "camelCase")] |
| Text { |
| |
| uri: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| mime_type: Option<String>, |
| text: String, |
| #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| meta: Option<serde_json::Value>, |
| }, |
| #[serde(rename_all = "camelCase")] |
| #[ts(rename_all = "camelCase")] |
| Blob { |
| |
| uri: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| mime_type: Option<String>, |
| blob: String, |
| #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| meta: Option<serde_json::Value>, |
| }, |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(rename_all = "camelCase")] |
| pub struct ResourceTemplate { |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub annotations: Option<serde_json::Value>, |
| pub uri_template: String, |
| pub name: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub title: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub description: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub mime_type: Option<String>, |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] |
| #[serde(rename_all = "camelCase")] |
| pub struct CallToolResult { |
| pub content: Vec<serde_json::Value>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub structured_content: Option<serde_json::Value>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub is_error: Option<bool>, |
| #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub meta: Option<serde_json::Value>, |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| fn deserialize_lossy_opt_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error> |
| where |
| D: serde::Deserializer<'de>, |
| { |
| match Option::<serde_json::Number>::deserialize(deserializer)? { |
| Some(number) => { |
| if let Some(v) = number.as_i64() { |
| Ok(Some(v)) |
| } else if let Some(v) = number.as_u64() { |
| Ok(i64::try_from(v).ok()) |
| } else { |
| Ok(None) |
| } |
| } |
| None => Ok(None), |
| } |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[serde(rename_all = "camelCase")] |
| struct ToolSerde { |
| name: String, |
| #[serde(default)] |
| title: Option<String>, |
| #[serde(default)] |
| description: Option<String>, |
| #[serde(default, rename = "inputSchema", alias = "input_schema")] |
| input_schema: serde_json::Value, |
| #[serde(default, rename = "outputSchema", alias = "output_schema")] |
| output_schema: Option<serde_json::Value>, |
| #[serde(default)] |
| annotations: Option<serde_json::Value>, |
| #[serde(default)] |
| icons: Option<Vec<serde_json::Value>>, |
| #[serde(rename = "_meta", default)] |
| meta: Option<serde_json::Value>, |
| } |
|
|
| impl From<ToolSerde> for Tool { |
| fn from(value: ToolSerde) -> Self { |
| let ToolSerde { |
| name, |
| title, |
| description, |
| input_schema, |
| output_schema, |
| annotations, |
| icons, |
| meta, |
| } = value; |
| Self { |
| name, |
| title, |
| description, |
| input_schema, |
| output_schema, |
| annotations, |
| icons, |
| meta, |
| } |
| } |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[serde(rename_all = "camelCase")] |
| struct ResourceSerde { |
| #[serde(default)] |
| annotations: Option<serde_json::Value>, |
| #[serde(default)] |
| description: Option<String>, |
| #[serde(rename = "mimeType", alias = "mime_type", default)] |
| mime_type: Option<String>, |
| name: String, |
| #[serde(default, deserialize_with = "deserialize_lossy_opt_i64")] |
| size: Option<i64>, |
| #[serde(default)] |
| title: Option<String>, |
| uri: String, |
| #[serde(default)] |
| icons: Option<Vec<serde_json::Value>>, |
| #[serde(rename = "_meta", default)] |
| meta: Option<serde_json::Value>, |
| } |
|
|
| impl From<ResourceSerde> for Resource { |
| fn from(value: ResourceSerde) -> Self { |
| let ResourceSerde { |
| annotations, |
| description, |
| mime_type, |
| name, |
| size, |
| title, |
| uri, |
| icons, |
| meta, |
| } = value; |
| Self { |
| annotations, |
| description, |
| mime_type, |
| name, |
| size, |
| title, |
| uri, |
| icons, |
| meta, |
| } |
| } |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| #[serde(rename_all = "camelCase")] |
| struct ResourceTemplateSerde { |
| #[serde(default)] |
| annotations: Option<serde_json::Value>, |
| #[serde(rename = "uriTemplate", alias = "uri_template")] |
| uri_template: String, |
| name: String, |
| #[serde(default)] |
| title: Option<String>, |
| #[serde(default)] |
| description: Option<String>, |
| #[serde(rename = "mimeType", alias = "mime_type", default)] |
| mime_type: Option<String>, |
| } |
|
|
| impl From<ResourceTemplateSerde> for ResourceTemplate { |
| fn from(value: ResourceTemplateSerde) -> Self { |
| let ResourceTemplateSerde { |
| annotations, |
| uri_template, |
| name, |
| title, |
| description, |
| mime_type, |
| } = value; |
| Self { |
| annotations, |
| uri_template, |
| name, |
| title, |
| description, |
| mime_type, |
| } |
| } |
| } |
|
|
| impl Tool { |
| pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> { |
| Ok(serde_json::from_value::<ToolSerde>(value)?.into()) |
| } |
| } |
|
|
| impl Resource { |
| pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> { |
| Ok(serde_json::from_value::<ResourceSerde>(value)?.into()) |
| } |
| } |
|
|
| impl ResourceTemplate { |
| pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> { |
| Ok(serde_json::from_value::<ResourceTemplateSerde>(value)?.into()) |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| #[test] |
| fn node_repl_backed_tool_recognizes_namespaces_and_legacy_names() { |
| for (name, namespace, expected) in [ |
| ("read_file", Some("mcp__node_repl"), true), |
| ("read_file", Some("mcp__node_repl__"), true), |
| ("read_file", Some("node_repl"), true), |
| ("read_file", Some("node_repl__"), true), |
| ("read_file", Some("mcp__cua_repl"), true), |
| ("read_file", Some("mcp__cua_repl__"), true), |
| ("read_file", Some("cua_repl"), true), |
| ("read_file", Some("cua_repl__"), true), |
| ("read_file", Some("node_repl_"), false), |
| ("read_file", Some("cua_repl_"), false), |
| ("read_file", Some("mcp__node_repl____"), false), |
| ("read_file", Some("mcp__cua_repl____"), false), |
| ("mcp__node_repl__js", None, true), |
| ("node_repl__js", None, true), |
| ("mcp__cua_repl__js", None, true), |
| ("cua_repl__js", None, true), |
| ("mcp__node_repl__js", Some("other"), false), |
| ("mcp__cua_repl__js", Some("other"), false), |
| ("mcp__node_repl_other__js", None, false), |
| ("mcp__cua_repl_other__js", None, false), |
| ] { |
| assert_eq!( |
| is_node_repl_backed_tool(name, namespace), |
| expected, |
| "tool {name} in namespace {namespace:?}" |
| ); |
| } |
| } |
|
|
| #[test] |
| fn client_mcp_extensions_for_mcp_servers_excludes_client_only_extensions() { |
| let openai_form_settings = serde_json::json!({ "version": 1 }); |
| let app_ui_settings = serde_json::json!({ "mimeTypes": ["text/html"] }); |
| let future_server_extension_settings = serde_json::json!({ "version": 2 }); |
| let extensions = ClientMcpExtensions::new([ |
| ( |
| OPENAI_FORM_EXTENSION_ID.to_string(), |
| openai_form_settings.clone(), |
| ), |
| (MCP_APP_UI_EXTENSION_ID.to_string(), app_ui_settings.clone()), |
| ( |
| OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID.to_string(), |
| serde_json::json!({}), |
| ), |
| ( |
| "example/future-server-extension".to_string(), |
| future_server_extension_settings.clone(), |
| ), |
| ]); |
|
|
| assert_eq!( |
| extensions.for_mcp_servers(), |
| ClientMcpExtensions::new([ |
| (OPENAI_FORM_EXTENSION_ID.to_string(), openai_form_settings), |
| (MCP_APP_UI_EXTENSION_ID.to_string(), app_ui_settings), |
| ( |
| "example/future-server-extension".to_string(), |
| future_server_extension_settings, |
| ), |
| ]) |
| ); |
| } |
|
|
| #[test] |
| fn resource_size_deserializes_without_narrowing() { |
| let resource = serde_json::json!({ |
| "name": "big", |
| "uri": "file:///tmp/big", |
| "size": 5_000_000_000u64, |
| }); |
|
|
| let parsed = Resource::from_mcp_value(resource).expect("should deserialize"); |
| assert_eq!(parsed.size, Some(5_000_000_000)); |
|
|
| let resource = serde_json::json!({ |
| "name": "negative", |
| "uri": "file:///tmp/negative", |
| "size": -1, |
| }); |
|
|
| let parsed = Resource::from_mcp_value(resource).expect("should deserialize"); |
| assert_eq!(parsed.size, Some(-1)); |
|
|
| let resource = serde_json::json!({ |
| "name": "too_big_for_i64", |
| "uri": "file:///tmp/too_big_for_i64", |
| "size": 18446744073709551615u64, |
| }); |
|
|
| let parsed = Resource::from_mcp_value(resource).expect("should deserialize"); |
| assert_eq!(parsed.size, None); |
| } |
| } |
|
|