feat: add Model Context Protocol (MCP) SSE endpoint

Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
This commit is contained in:
Andrew Phillips
2025-08-23 12:57:00 -03:00
parent f2eabd65b0
commit 925c978bbc
7 changed files with 622 additions and 1 deletions

View File

@@ -0,0 +1,4 @@
pub mod server;
pub mod tools;
pub use server::KeepMcpServer;

View File

@@ -0,0 +1,186 @@
use anyhow::Result;
use rmcp::{
handler::server::ServerHandler,
protocol::{
InitializeParams, InitializeResult, ServerCapabilities, ToolsCapability,
CallToolParams, CallToolResult, ListToolsParams, ListToolsResult,
Tool, TextContent, Content,
},
};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use log::{debug, warn};
use crate::modes::server::common::AppState;
use crate::db;
use super::tools::{KeepTools, ToolError};
#[derive(Clone)]
pub struct KeepMcpServer {
state: AppState,
}
impl KeepMcpServer {
pub fn new(state: AppState) -> Self {
Self { state }
}
}
#[rmcp::async_trait]
impl ServerHandler for KeepMcpServer {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
debug!("MCP: Initializing Keep MCP server with client info: {:?}", params.client_info);
Ok(InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
..Default::default()
},
server_info: rmcp::protocol::ServerInfo {
name: "keep".to_string(),
version: "0.1.0".to_string(),
},
})
}
async fn list_tools(&self, _params: ListToolsParams) -> Result<ListToolsResult> {
debug!("MCP: Listing available tools");
let tools = vec![
Tool {
name: "save_item".to_string(),
description: Some("Save content as a new item with optional tags and metadata".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The content to save"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags to associate with the item"
},
"metadata": {
"type": "object",
"additionalProperties": {"type": "string"},
"description": "Optional metadata key-value pairs"
}
},
"required": ["content"]
}),
},
Tool {
name: "get_item".to_string(),
description: Some("Retrieve an item by ID".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "The ID of the item to retrieve"
}
},
"required": ["id"]
}),
},
Tool {
name: "get_latest_item".to_string(),
description: Some("Retrieve the most recently saved item, optionally filtered by tags".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags to filter by - returns latest item with ALL specified tags"
}
}
}),
},
Tool {
name: "list_items".to_string(),
description: Some("List stored items with optional filtering and pagination".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags to filter by"
},
"limit": {
"type": "integer",
"description": "Maximum number of items to return (default: 10)"
},
"offset": {
"type": "integer",
"description": "Number of items to skip (default: 0)"
}
}
}),
},
Tool {
name: "search_items".to_string(),
description: Some("Search items by tags and metadata".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags that items must have (AND operation)"
},
"metadata": {
"type": "object",
"additionalProperties": {"type": "string"},
"description": "Metadata key-value pairs that items must match"
}
}
}),
},
];
Ok(ListToolsResult { tools })
}
async fn call_tool(&self, params: CallToolParams) -> Result<CallToolResult> {
debug!("MCP: Calling tool '{}' with arguments: {:?}", params.name, params.arguments);
let tools = KeepTools::new(self.state.clone());
let result = match params.name.as_str() {
"save_item" => tools.save_item(params.arguments).await,
"get_item" => tools.get_item(params.arguments).await,
"get_latest_item" => tools.get_latest_item(params.arguments).await,
"list_items" => tools.list_items(params.arguments).await,
"search_items" => tools.search_items(params.arguments).await,
_ => Err(ToolError::UnknownTool(params.name.clone())),
};
match result {
Ok(content) => Ok(CallToolResult {
content: vec![Content::Text(TextContent {
text: content,
})],
is_error: Some(false),
}),
Err(e) => {
warn!("MCP: Tool execution failed: {}", e);
Ok(CallToolResult {
content: vec![Content::Text(TextContent {
text: format!("Error: {}", e),
})],
is_error: Some(true),
})
}
}
}
}

View File

@@ -0,0 +1,346 @@
use anyhow::{Result, anyhow};
use serde_json::Value;
use std::collections::HashMap;
use std::io::Write;
use std::str::FromStr;
use log::{debug, warn};
use crate::modes::server::common::AppState;
use crate::db;
use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::meta_plugin::{MetaPluginType, get_meta_plugin};
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("Unknown tool: {0}")]
UnknownTool(String),
#[error("Invalid arguments: {0}")]
InvalidArguments(String),
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Other error: {0}")]
Other(#[from] anyhow::Error),
}
pub struct KeepTools {
state: AppState,
}
impl KeepTools {
pub fn new(state: AppState) -> Self {
Self { state }
}
pub async fn save_item(&self, args: Option<Value>) -> Result<String, ToolError> {
let args = args.ok_or_else(|| ToolError::InvalidArguments("Missing arguments".to_string()))?;
let content = args.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidArguments("Missing 'content' field".to_string()))?;
let tags: Vec<String> = args.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let metadata: HashMap<String, String> = args.get("metadata")
.and_then(|v| v.as_object())
.map(|obj| obj.iter().filter_map(|(k, v)| {
v.as_str().map(|s| (k.clone(), s.to_string()))
}).collect())
.unwrap_or_default();
debug!("MCP: Saving item with {} bytes, {} tags, {} metadata entries",
content.len(), tags.len(), metadata.len());
let mut conn = self.state.db.lock().await;
// Create new item
let item = db::create_item(&mut *conn, CompressionType::LZ4)?;
let item_id = item.id.ok_or_else(|| anyhow!("Failed to get item ID"))?;
// Save content to file
let mut item_path = self.state.data_dir.clone();
item_path.push(item_id.to_string());
let compression_engine = get_compression_engine(CompressionType::LZ4)?;
let mut writer = compression_engine.create(item_path)?;
writer.write_all(content.as_bytes())?;
drop(writer); // Ensure file is closed
// Add tags
for tag in &tags {
db::add_tag(&mut *conn, item_id, tag)?;
}
// Add custom metadata
for (key, value) in &metadata {
db::add_meta(&mut *conn, item_id, key, value)?;
}
// Run metadata plugins
let meta_plugins = vec![
MetaPluginType::FileMime,
MetaPluginType::FileEncoding,
MetaPluginType::Binary,
MetaPluginType::LineCount,
MetaPluginType::WordCount,
MetaPluginType::DigestSha256,
MetaPluginType::Uid,
MetaPluginType::User,
MetaPluginType::Hostname,
];
for plugin_type in meta_plugins {
let mut plugin = get_meta_plugin(plugin_type);
if plugin.is_supported() {
if let Err(e) = plugin.initialize(&*conn, item_id) {
warn!("Failed to initialize plugin {:?}: {}", plugin_type, e);
continue;
}
let mut item_path = self.state.data_dir.clone();
item_path.push(item_id.to_string());
if let Err(e) = plugin.process_file(&*conn, item_id, &item_path) {
warn!("Failed to process file with plugin {:?}: {}", plugin_type, e);
continue;
}
if let Err(e) = plugin.finalize(&*conn) {
warn!("Failed to finalize plugin {:?}: {}", plugin_type, e);
}
}
}
Ok(format!("Successfully saved item with ID: {}", item_id))
}
pub async fn get_item(&self, args: Option<Value>) -> Result<String, ToolError> {
let args = args.ok_or_else(|| ToolError::InvalidArguments("Missing arguments".to_string()))?;
let item_id = args.get("id")
.and_then(|v| v.as_i64())
.ok_or_else(|| ToolError::InvalidArguments("Missing or invalid 'id' field".to_string()))?;
let mut conn = self.state.db.lock().await;
let item = db::get_item(&mut *conn, item_id)?
.ok_or_else(|| ToolError::InvalidArguments(format!("Item {} not found", item_id)))?;
// Get content
let mut item_path = self.state.data_dir.clone();
item_path.push(item_id.to_string());
let compression_type = crate::compression_engine::CompressionType::from_str(&item.compression)?;
let compression_engine = get_compression_engine(compression_type)?;
let mut reader = compression_engine.open(item_path)?;
let mut content = String::new();
std::io::Read::read_to_string(&mut reader, &mut content)?;
// Get metadata and tags
let tags = db::get_item_tags(&mut *conn, &item)?;
let metadata = db::get_item_meta(&mut *conn, &item)?;
let response = serde_json::json!({
"id": item_id,
"content": content,
"timestamp": item.ts.to_rfc3339(),
"size": item.size,
"compression": item.compression,
"tags": tags.iter().map(|t| &t.name).collect::<Vec<_>>(),
"metadata": metadata.iter().map(|m| (&m.name, &m.value)).collect::<HashMap<_, _>>()
});
Ok(serde_json::to_string_pretty(&response)?)
}
pub async fn get_latest_item(&self, args: Option<Value>) -> Result<String, ToolError> {
let tags: Vec<String> = args
.and_then(|v| v.get("tags"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let mut conn = self.state.db.lock().await;
let item = if tags.is_empty() {
db::get_item_last(&mut *conn)?
} else {
db::get_item_matching(&mut *conn, &tags, &HashMap::new())?
};
let item = item.ok_or_else(|| ToolError::InvalidArguments("No items found".to_string()))?;
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
// Get content
let mut item_path = self.state.data_dir.clone();
item_path.push(item_id.to_string());
let compression_type = crate::compression_engine::CompressionType::from_str(&item.compression)?;
let compression_engine = get_compression_engine(compression_type)?;
let mut reader = compression_engine.open(item_path)?;
let mut content = String::new();
std::io::Read::read_to_string(&mut reader, &mut content)?;
// Get metadata and tags
let tags = db::get_item_tags(&mut *conn, &item)?;
let metadata = db::get_item_meta(&mut *conn, &item)?;
let response = serde_json::json!({
"id": item_id,
"content": content,
"timestamp": item.ts.to_rfc3339(),
"size": item.size,
"compression": item.compression,
"tags": tags.iter().map(|t| &t.name).collect::<Vec<_>>(),
"metadata": metadata.iter().map(|m| (&m.name, &m.value)).collect::<HashMap<_, _>>()
});
Ok(serde_json::to_string_pretty(&response)?)
}
pub async fn list_items(&self, args: Option<Value>) -> Result<String, ToolError> {
let tags: Vec<String> = args
.as_ref()
.and_then(|v| v.get("tags"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let limit = args
.as_ref()
.and_then(|v| v.get("limit"))
.and_then(|v| v.as_u64())
.unwrap_or(10) as usize;
let offset = args
.as_ref()
.and_then(|v| v.get("offset"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let mut conn = self.state.db.lock().await;
let items = if tags.is_empty() {
db::get_items(&mut *conn)?
} else {
db::get_items_matching(&mut *conn, &tags, &HashMap::new())?
};
// Sort by timestamp (newest first) and apply pagination
let mut items = items;
items.sort_by(|a, b| b.ts.cmp(&a.ts));
let items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
// Get item IDs for batch queries
let item_ids: Vec<i64> = items.iter().filter_map(|item| item.id).collect();
// Get tags and metadata for all items
let tags_map = db::get_tags_for_items(&mut *conn, &item_ids)?;
let meta_map = db::get_meta_for_items(&mut *conn, &item_ids)?;
let items_info: Vec<_> = items
.into_iter()
.map(|item| {
let item_id = item.id.unwrap_or(0);
let item_tags = tags_map.get(&item_id)
.map(|tags| tags.iter().map(|t| &t.name).collect::<Vec<_>>())
.unwrap_or_default();
let item_meta = meta_map.get(&item_id)
.map(|meta| meta.iter().map(|m| (&m.name, &m.value)).collect::<HashMap<_, _>>())
.unwrap_or_default();
serde_json::json!({
"id": item_id,
"timestamp": item.ts.to_rfc3339(),
"size": item.size,
"compression": item.compression,
"tags": item_tags,
"metadata": item_meta
})
})
.collect();
let response = serde_json::json!({
"items": items_info,
"count": items_info.len(),
"offset": offset,
"limit": limit
});
Ok(serde_json::to_string_pretty(&response)?)
}
pub async fn search_items(&self, args: Option<Value>) -> Result<String, ToolError> {
let tags: Vec<String> = args
.as_ref()
.and_then(|v| v.get("tags"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let metadata: HashMap<String, String> = args
.as_ref()
.and_then(|v| v.get("metadata"))
.and_then(|v| v.as_object())
.map(|obj| obj.iter().filter_map(|(k, v)| {
v.as_str().map(|s| (k.clone(), s.to_string()))
}).collect())
.unwrap_or_default();
let mut conn = self.state.db.lock().await;
let items = db::get_items_matching(&mut *conn, &tags, &metadata)?;
// Sort by timestamp (newest first)
let mut items = items;
items.sort_by(|a, b| b.ts.cmp(&a.ts));
// Get item IDs for batch queries
let item_ids: Vec<i64> = items.iter().filter_map(|item| item.id).collect();
// Get tags and metadata for all items
let tags_map = db::get_tags_for_items(&mut *conn, &item_ids)?;
let meta_map = db::get_meta_for_items(&mut *conn, &item_ids)?;
let items_info: Vec<_> = items
.into_iter()
.map(|item| {
let item_id = item.id.unwrap_or(0);
let item_tags = tags_map.get(&item_id)
.map(|tags| tags.iter().map(|t| &t.name).collect::<Vec<_>>())
.unwrap_or_default();
let item_meta = meta_map.get(&item_id)
.map(|meta| meta.iter().map(|m| (&m.name, &m.value)).collect::<HashMap<_, _>>())
.unwrap_or_default();
serde_json::json!({
"id": item_id,
"timestamp": item.ts.to_rfc3339(),
"size": item.size,
"compression": item.compression,
"tags": item_tags,
"metadata": item_meta
})
})
.collect();
let response = serde_json::json!({
"items": items_info,
"count": items_info.len(),
"search_criteria": {
"tags": tags,
"metadata": metadata
}
});
Ok(serde_json::to_string_pretty(&response)?)
}
}