use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::ScopedMcpServer;
pub use everruns_contracts::runtime::mcp_server::{
USER_MCP_CAPABILITY_ID, USER_MCP_CONNECT_SETTING,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UserMcpServerEntry {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(flatten)]
pub server: ScopedMcpServer,
}
fn default_enabled() -> bool {
true
}
impl UserMcpServerEntry {
pub fn enabled(server: ScopedMcpServer) -> Self {
Self {
enabled: true,
server,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UserMcpLoginStatus {
Connected,
NotConnected,
NotNeeded,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UserMcpServerSummary {
pub name: String,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
pub login: UserMcpLoginStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skipped: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub chat_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "kind", content = "message", rename_all = "snake_case")]
pub enum UserMcpStoreError {
#[error("No MCP server named '{0}' in your list")]
NotFound(String),
#[error("{0}")]
Invalid(String),
#[error("{0}")]
Unavailable(String),
#[error("{0}")]
Internal(String),
}
pub type UserMcpStoreResult<T> = std::result::Result<T, UserMcpStoreError>;
#[async_trait]
pub trait UserMcpStore: Send + Sync {
async fn list(&self) -> UserMcpStoreResult<Vec<UserMcpServerSummary>>;
async fn upsert(
&self,
name: &str,
entry: UserMcpServerEntry,
) -> UserMcpStoreResult<UserMcpServerSummary>;
async fn remove(&self, name: &str) -> UserMcpStoreResult<bool>;
async fn add_to_chat(
&self,
name: &str,
server: ScopedMcpServer,
) -> UserMcpStoreResult<UserMcpServerSummary> {
let _ = (name, server);
Err(UserMcpStoreError::Unavailable(
"Adding an MCP server for this conversation only is not available here.".into(),
))
}
async fn set_enabled(
&self,
name: &str,
enabled: bool,
) -> UserMcpStoreResult<UserMcpServerSummary>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum McpLogin {
NotNeeded,
AlreadyConnected,
Completed,
Pending {
provider: String,
setup_url: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
for_agent: bool,
#[serde(default, skip_serializing_if = "crate::McpConnectInChat::is_ask")]
connect_in_chat: crate::McpConnectInChat,
},
}
#[async_trait]
pub trait McpLoginPrompter: Send + Sync {
async fn start_login(&self, name: &str) -> UserMcpStoreResult<McpLogin>;
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum UserMcpStoreCall {
List,
Upsert {
name: String,
entry: Box<UserMcpServerEntry>,
},
AddToChat {
name: String,
server: Box<ScopedMcpServer>,
},
Remove {
name: String,
},
SetEnabled {
name: String,
enabled: bool,
},
StartLogin {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "reply", rename_all = "snake_case")]
pub enum UserMcpStoreReply {
Servers {
servers: Vec<UserMcpServerSummary>,
},
Server {
server: UserMcpServerSummary,
},
Removed {
removed: bool,
},
Login {
login: McpLogin,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entries_default_to_enabled_and_flatten_the_server() {
let entry: UserMcpServerEntry =
serde_json::from_value(serde_json::json!({"url": "https://mcp.example.com/mcp"}))
.unwrap();
assert!(entry.enabled);
assert_eq!(entry.server.url, "https://mcp.example.com/mcp");
let wire = serde_json::to_value(&entry).unwrap();
assert_eq!(wire["enabled"], true);
assert_eq!(wire["url"], "https://mcp.example.com/mcp");
}
#[test]
fn calls_and_errors_round_trip() {
let call = UserMcpStoreCall::SetEnabled {
name: "linear".into(),
enabled: false,
};
let wire = serde_json::to_string(&call).unwrap();
assert_eq!(
serde_json::from_str::<UserMcpStoreCall>(&wire).unwrap(),
call
);
let error = UserMcpStoreError::Unavailable("no person".into());
let wire = serde_json::to_string(&error).unwrap();
assert_eq!(
serde_json::from_str::<UserMcpStoreError>(&wire).unwrap(),
error
);
let login = McpLogin::Pending {
provider: "mcp_oauth_1".into(),
setup_url: "/settings".into(),
for_agent: false,
connect_in_chat: crate::McpConnectInChat::Ask,
};
let wire = serde_json::to_value(&login).unwrap();
assert_eq!(wire["status"], "pending");
assert!(wire.get("for_agent").is_none());
assert!(wire.get("connect_in_chat").is_none());
let agent = McpLogin::Pending {
provider: "mcp_oauth_1".into(),
setup_url: "/agents/agent_1?tab=mcp".into(),
for_agent: true,
connect_in_chat: crate::McpConnectInChat::Never,
};
let wire = serde_json::to_string(&agent).unwrap();
assert!(wire.contains(r#""connect_in_chat":"never""#), "{wire}");
assert_eq!(serde_json::from_str::<McpLogin>(&wire).unwrap(), agent);
}
}