feat: add poem MCP server as optional service endpoint

This commit is contained in:
rustmailer
2026-05-18 07:56:23 +08:00
parent 6e984f376c
commit 602bc223d9
28 changed files with 477 additions and 6 deletions
+1 -4
View File
@@ -13,10 +13,7 @@ use fjall::{
CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions,
};
use mail_parser::MessageParser;
use tantivy::{
indexer::{LogMergePolicy, NoMergePolicy},
Index, IndexWriter, TantivyDocument,
};
use tantivy::{indexer::NoMergePolicy, Index, IndexWriter, TantivyDocument};
use uuid::Uuid;
use crate::{
+8
View File
@@ -250,6 +250,14 @@ pub struct Settings {
)]
pub bichon_enable_smtp: bool,
#[clap(
long,
env,
default_value = "false",
help = "Enable the MCP (Model Context Protocol) server for AI assistant integration"
)]
pub bichon_enable_mcp: bool,
#[clap(
long,
env,
+2
View File
@@ -54,6 +54,7 @@ pub struct SystemConfigurations {
pub bichon_index_dir: Option<String>,
pub bichon_data_dir: Option<String>,
pub bichon_enable_mcp: bool,
pub bichon_enable_smtp: bool,
pub bichon_smtp_port: u16,
pub bichon_smtp_encryption: String,
@@ -88,6 +89,7 @@ impl From<&Settings> for SystemConfigurations {
bichon_base_url: s.bichon_base_url.clone(),
bichon_index_dir: s.bichon_index_dir.clone(),
bichon_data_dir: s.bichon_data_dir.clone(),
bichon_enable_mcp: s.bichon_enable_mcp,
bichon_enable_smtp: s.bichon_enable_smtp,
bichon_smtp_port: s.bichon_smtp_port,
bichon_smtp_encryption: s.bichon_smtp_encryption.to_string(),
+2
View File
@@ -30,6 +30,8 @@ tokio.workspace = true
http.workspace = true
urlencoding.workspace = true
mimalloc.workspace = true
poem-mcpserver = { version = "0.3", features = ["streamable-http"] }
schemars = "1.0"
[dev-dependencies]
poem = { version = "3.1.12", features = ["test"] }
+1
View File
@@ -46,6 +46,7 @@ use crate::rest::start_http_server;
pub mod common;
pub mod error;
pub mod mcp;
pub mod rest;
#[global_allocator]
+43
View File
@@ -0,0 +1,43 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use bichon_core::common::auth::ClientContext;
use poem::Request;
use poem_mcpserver::{streamable_http, McpServer};
mod tools;
use tools::BichonMcpTools;
/// Create a Poem endpoint that handles MCP Streamable HTTP requests.
///
/// The endpoint requires authentication via `ApiGuard` middleware (applied at
/// the route level in `rest/mod.rs`). The guard stores a `ClientContext` in
/// request extensions, which the server factory reads to configure per-session
/// tool authorization.
pub fn mcp_endpoint() -> impl poem::IntoEndpoint {
streamable_http::endpoint(|req: &Request| {
let ctx = req
.extensions()
.get::<ClientContext>()
.expect("ApiGuard middleware must provide ClientContext in request extensions")
.clone();
McpServer::new()
.with_server_info("bichon-mcp", env!("CARGO_PKG_VERSION"))
.tools(BichonMcpTools::new(ctx))
})
}
+318
View File
@@ -0,0 +1,318 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashSet;
use bichon_core::{
account::migration::AccountModel,
common::auth::ClientContext,
dashboard::DashboardStats,
message::search::{
search_attachment_impl, search_messages_impl, AttachmentSearchFilter, AttachmentSearchRequest,
EmailSearchFilter, EmailSearchRequest, SortBy,
},
message::{content::retrieve_email_content, list::get_thread_messages},
users::permissions::Permission,
};
use poem_mcpserver::{content::Text, Tools};
/// Tools for interacting with the Bichon email archive via MCP.
///
/// Provides email search, content retrieval, thread viewing, attachment search,
/// dashboard statistics, and account management capabilities.
pub struct BichonMcpTools {
context: ClientContext,
}
impl BichonMcpTools {
pub fn new(context: ClientContext) -> Self {
Self { context }
}
/// Derive the set of account IDs that the current user is authorized to access.
/// Returns None if the user has global access (DATA_READ_ALL or ACCOUNT_MANAGE_ALL),
/// meaning "all accounts are accessible".
fn authorized_accounts(&self) -> Option<HashSet<u64>> {
if self.context.has_permission(None, Permission::DATA_READ_ALL)
|| self.context.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
{
None
} else {
Some(
self.context
.user
.account_access_map
.keys()
.copied()
.collect(),
)
}
}
/// Restrict the given account_ids to only those the user is authorized to access.
/// If the user has global access, the input is returned as-is.
fn restrict_account_ids(
&self,
account_ids: Option<Vec<u64>>,
) -> Option<HashSet<u64>> {
let global = self.authorized_accounts();
match (global, account_ids) {
// User has global access → return input as HashSet (or None)
(None, Some(ids)) => Some(ids.into_iter().collect()),
(None, None) => None,
// User is scoped → intersect with authorized set
(Some(authorized), Some(ids)) => {
let filtered: HashSet<u64> =
ids.into_iter().filter(|id| authorized.contains(id)).collect();
if filtered.is_empty() {
Some(filtered)
} else {
Some(filtered)
}
}
(Some(authorized), None) => Some(authorized),
}
}
}
#[Tools]
impl BichonMcpTools {
/// Search archived emails using full-text and structured filters.
async fn search_emails(
&self,
/// Full-text search across all message fields (subject, body, from, to, etc.)
text: Option<String>,
/// Filter by email subject line
subject: Option<String>,
/// Filter by sender email address
from: Option<String>,
/// Filter by recipient email address
to: Option<String>,
/// Start date as Unix timestamp in milliseconds
since: Option<i64>,
/// End date as Unix timestamp in milliseconds
before: Option<i64>,
/// Filter by specific account IDs
account_ids: Option<Vec<u64>>,
/// Filter by specific mailbox/folder IDs
mailbox_ids: Option<Vec<u64>>,
/// Only return messages that have attachments
has_attachment: Option<bool>,
/// Filter by attachment filename
attachment_name: Option<String>,
/// Filter by tags/labels
tags: Option<Vec<String>>,
/// Page number (1-based, default 1)
page: Option<u64>,
/// Results per page (default 50, max 500)
page_size: Option<u64>,
) -> Result<Text<String>, String> {
let authorized_ids = self.restrict_account_ids(account_ids);
let filter = EmailSearchFilter {
text,
subject,
from,
to,
since,
before,
account_ids: authorized_ids.clone(),
mailbox_ids: mailbox_ids.map(|v| v.into_iter().collect()),
has_attachment,
attachment_name,
tags: tags.map(|v| v.into_iter().collect()),
..Default::default()
};
let request = EmailSearchRequest {
filter,
page: page.unwrap_or(1),
page_size: page_size.unwrap_or(50).min(500),
sort_by: Some(SortBy::DATE),
desc: Some(true),
};
search_messages_impl(authorized_ids, request)
.map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default()))
.map_err(|e| format!("Search failed: {:#}", e))
}
/// Retrieve the full content of a specific email including plain text, HTML body,
/// and attachment metadata (filenames, sizes, content hashes).
async fn get_email_content(
&self,
/// The ID of the email account
account_id: u64,
/// The envelope ID of the email message
envelope_id: String,
) -> Result<Text<String>, String> {
self.context
.require_permission(Some(account_id), Permission::DATA_READ)
.map_err(|e| format!("Permission denied: {:#}", e))?;
retrieve_email_content(account_id, envelope_id)
.map(|content| Text(serde_json::to_string_pretty(&content).unwrap_or_default()))
.map_err(|e| format!("Failed to retrieve email content: {:#}", e))
}
/// Retrieve all messages in a specific email thread/conversation.
async fn get_thread(
&self,
/// The ID of the email account
account_id: u64,
/// The thread ID (from the thread_id field of an envelope)
thread_id: String,
/// Page number (1-based, default 1)
page: Option<u64>,
/// Results per page (default 50, max 500)
page_size: Option<u64>,
) -> Result<Text<String>, String> {
self.context
.require_permission(Some(account_id), Permission::DATA_READ)
.map_err(|e| format!("Permission denied: {:#}", e))?;
get_thread_messages(
account_id,
&thread_id,
page.unwrap_or(1),
page_size.unwrap_or(50).min(500),
)
.map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default()))
.map_err(|e| format!("Failed to retrieve thread: {:#}", e))
}
/// Search email attachments using filters like filename, extension, content type,
/// category, sender, and date range.
async fn search_attachments(
&self,
/// Full-text search in attachment content and metadata
text: Option<String>,
/// Filter by attachment filename
attachment_name: Option<String>,
/// Filter by file extension (e.g., pdf, docx, jpg)
attachment_extension: Option<String>,
/// Filter by category (document, image, spreadsheet, etc.)
attachment_category: Option<String>,
/// Filter by MIME content type (e.g., application/pdf)
attachment_content_type: Option<String>,
/// Filter by sender email address
from: Option<String>,
/// Start date as Unix timestamp in milliseconds
since: Option<i64>,
/// End date as Unix timestamp in milliseconds
before: Option<i64>,
/// Filter by specific account IDs
account_ids: Option<Vec<u64>>,
/// Minimum attachment size in bytes
min_size: Option<u64>,
/// Maximum attachment size in bytes
max_size: Option<u64>,
/// Page number (1-based, default 1)
page: Option<u64>,
/// Results per page (default 50, max 500)
page_size: Option<u64>,
) -> Result<Text<String>, String> {
let authorized_ids = self.restrict_account_ids(account_ids);
let filter = AttachmentSearchFilter {
text,
attachment_name,
attachment_extension,
attachment_category,
attachment_content_type,
from,
since,
before,
account_ids: authorized_ids.clone(),
min_size,
max_size,
..Default::default()
};
let request: AttachmentSearchRequest =
serde_json::from_value(serde_json::json!({
"filter": filter,
"page": page.unwrap_or(1),
"page_size": page_size.unwrap_or(50).min(500),
"sort_by": "DATE",
"desc": true,
}))
.map_err(|e| format!("Invalid request: {e}"))?;
search_attachment_impl(authorized_ids, request)
.map(|result| Text(serde_json::to_string_pretty(&result).unwrap_or_default()))
.map_err(|e| format!("Attachment search failed: {:#}", e))
}
/// Retrieve summary statistics about the email archive including total emails,
/// attachments, storage usage, top senders, and recent activity.
async fn get_dashboard_stats(&self) -> Result<Text<String>, String> {
if !self.context.has_permission(None, Permission::SYSTEM_ACCESS) {
return Err("Permission denied: requires system:access".into());
}
DashboardStats::get(self.context.clone())
.await
.map(|stats| Text(serde_json::to_string_pretty(&stats).unwrap_or_default()))
.map_err(|e| format!("Failed to retrieve dashboard stats: {:#}", e))
}
/// List all email accounts the current user has access to.
/// Returns minimal account information (ID and email address).
async fn list_accounts(&self) -> Result<Text<String>, String> {
let accounts = AccountModel::minimal_list(false)
.map_err(|e| format!("Failed to list accounts: {:#}", e))?;
let global_access = self
.context
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL);
let visible: Vec<_> = if global_access {
accounts
} else {
let authorized: HashSet<u64> =
self.context.user.account_access_map.keys().copied().collect();
accounts
.into_iter()
.filter(|a| authorized.contains(&a.id))
.collect()
};
Ok(Text(
serde_json::to_string_pretty(&visible).unwrap_or_default(),
))
}
/// Retrieve detailed configuration and status of a specific email account.
async fn get_account(
&self,
/// The ID of the email account
account_id: u64,
) -> Result<Text<String>, String> {
self.context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.map_err(|e| format!("Permission denied: {:#}", e))?;
let account = AccountModel::get(account_id)
.map_err(|e| format!("Account not found: {:#}", e))?;
Ok(Text(
serde_json::to_string_pretty(&account).unwrap_or_default(),
))
}
}
+15 -1
View File
@@ -30,6 +30,7 @@ use bichon_core::settings::cli::SETTINGS;
use super::error::ApiErrorResponse;
use crate::common::auth::ApiGuard;
use crate::common::timeout::{Timeout, TIMEOUT_HEADER};
use crate::mcp::mcp_endpoint;
use api::create_openapi_service;
use assets::FrontEndAssets;
use bichon_core::raise_error;
@@ -123,7 +124,20 @@ pub async fn start_http_server() -> BichonResult<()> {
.nest("/api-docs/spec.yaml", spec_yaml)
.nest("/oauth2/callback", get(oauth2_callback))
.nest("/api/status", get(get_status))
.nest("/api/login", post(login))
.nest("/api/login", post(login));
let app_logic = if SETTINGS.bichon_enable_mcp {
app_logic.nest_no_strip(
"/mcp",
mcp_endpoint()
.with(ApiGuard)
.with(Timeout),
)
} else {
app_logic
};
let app_logic = app_logic
.nest_no_strip("/api/v1", open_api_route)
.nest_no_strip(
"/assets",
+2
View File
@@ -138,6 +138,8 @@ export type ServerConfigurations = {
bichon_smtp_auth_required: boolean
bichon_smtp_tls_key_path?: string | null
bichon_smtp_tls_cert_path?: string | null
bichon_enable_mcp: boolean
}
export const get_dashboard_stats = async () => {
@@ -29,7 +29,8 @@ import {
Activity,
InfoIcon,
Mail,
Zap
Zap,
Bot
} from "lucide-react"
import { get_system_configurations } from "@/api/system/api"
import { useQuery } from "@tanstack/react-query"
@@ -180,6 +181,17 @@ export default function ServerConfigurationsPage() {
<SettingRow label="BICHON_SMTP_AUTH_REQUIRED" value={<BooleanBadge value={data!.bichon_smtp_auth_required} />} />
</SettingsCard>
<SettingsCard
icon={Bot}
title={t("systemConfig.sections.integration.title")}
description={t("systemConfig.sections.integration.desc")}
>
<SettingRow
label="BICHON_ENABLE_MCP"
value={<BooleanBadge value={data!.bichon_enable_mcp} />}
/>
</SettingsCard>
<SettingsCard
icon={Zap}
title={t("systemConfig.sections.performance.title")}
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "قواعد الوصول عبر الأصول",
"title": "CORS"
},
"integration": {
"desc": "تكامل بروتوكول المساعد الذكي.",
"title": "التكامل الخارجي"
},
"logging": {
"desc": "سلوك إخراج السجلات",
"title": "التسجيل"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Adgangsregler for cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Integration af AI-assistentprotokol.",
"title": "Ekstern integration"
},
"logging": {
"desc": "Log-output adfærd",
"title": "Logning"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Cross-Origin-Zugriffsregeln",
"title": "CORS"
},
"integration": {
"desc": "Integration des KI-Assistenten-Protokolls.",
"title": "Externe Integration"
},
"logging": {
"desc": "Verhalten der Log-Ausgabe",
"title": "Protokollierung"
+4
View File
@@ -1318,6 +1318,10 @@
"desc": "Cross-origin access rules",
"title": "CORS"
},
"integration": {
"desc": "AI assistant protocol integration.",
"title": "External Integration"
},
"logging": {
"desc": "Log output behavior",
"title": "Logging"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Reglas de acceso de origen cruzado",
"title": "CORS"
},
"integration": {
"desc": "Integración del protocolo del asistente de IA.",
"title": "Integración externa"
},
"logging": {
"desc": "Comportamiento de salida de logs",
"title": "Registro"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Cross-origin-käyttösäännöt",
"title": "CORS"
},
"integration": {
"desc": "Tekoälyavustajan protokollaintegraatio.",
"title": "Ulkoinen integraatio"
},
"logging": {
"desc": "Lokien tulostuskäyttäytyminen",
"title": "Lokitukset"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Règles d'accès cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Intégration du protocole d'assistant IA.",
"title": "Intégration externe"
},
"logging": {
"desc": "Comportement de sortie des logs",
"title": "Journalisation"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Regole di accesso cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Integrazione del protocollo dell'assistente IA.",
"title": "Integrazione esterna"
},
"logging": {
"desc": "Comportamento dell'output dei log",
"title": "Logging"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "クロスオリジンアクセスのルール",
"title": "CORS"
},
"integration": {
"desc": "AIアシスタントプロトコルの統合。",
"title": "外部統合"
},
"logging": {
"desc": "ログ出力の動作",
"title": "ロギング"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "교차 출처 액세스 규칙",
"title": "CORS"
},
"integration": {
"desc": "AI 어시스턴트 프로토콜 연동.",
"title": "외부 연동"
},
"logging": {
"desc": "로그 출력 동작",
"title": "로깅"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Cross-origin toegangsregels",
"title": "CORS"
},
"integration": {
"desc": "Integratie van AI-assistentprotocol.",
"title": "Externe integratie"
},
"logging": {
"desc": "Gedrag van log-output",
"title": "Logging"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Tilgangsregler for cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Integrasjon av AI-assistentprotokoll.",
"title": "Ekstern integrasjon"
},
"logging": {
"desc": "Loggutdata-oppførsel",
"title": "Logging"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Zasady dostępu cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Integracja protokołu asystenta AI.",
"title": "Zewnętrzna integracja"
},
"logging": {
"desc": "Zachowanie wyjściowe logów",
"title": "Logowanie"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Regras de acesso cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Integração do protocolo do assistente de IA.",
"title": "Integração externa"
},
"logging": {
"desc": "Comportamento de saída de logs",
"title": "Logging"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Правила кросс-доменного доступа",
"title": "CORS"
},
"integration": {
"desc": "Интеграция протокола ИИ-ассистента.",
"title": "Внешняя интеграция"
},
"logging": {
"desc": "Поведение вывода логов",
"title": "Логирование"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "Åtkomstregler för cross-origin",
"title": "CORS"
},
"integration": {
"desc": "Integrering av AI-assistentprotokoll.",
"title": "Extern integration"
},
"logging": {
"desc": "Loggutmatning",
"title": "Loggning"
+4
View File
@@ -1317,6 +1317,10 @@
"desc": "跨來源資源共用規則",
"title": "CORS"
},
"integration": {
"desc": "AI 助手協定集成。",
"title": "外部集成"
},
"logging": {
"desc": "日誌輸出行為",
"title": "日誌"
+4
View File
@@ -1318,6 +1318,10 @@
"desc": "跨域访问规则",
"title": "CORS"
},
"integration": {
"desc": "AI 助手协议集成。",
"title": "外部集成"
},
"logging": {
"desc": "日志输出行为",
"title": "日志"