mod entities;
mod recall;
mod relations;
mod schemas;
mod search;
mod template_library;
use http::request::Parts;
use rmcp::ErrorData;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo};
use rmcp::{ServerHandler, tool_handler};
use sqlx::PgConnection;
use sqlx::pool::PoolConnection;
use yorishiro_core::YorishiroError;
use yorishiro_core::services::auth::{self, ApiKeyScope, AuthContext};
use crate::state::AppState;
#[derive(Clone)]
pub struct YorishiroMcpServer {
state: AppState,
tool_router: ToolRouter<Self>,
}
impl YorishiroMcpServer {
pub fn new(state: AppState) -> Self {
Self {
state,
tool_router: Self::tool_router_entities()
+ Self::tool_router_recall()
+ Self::tool_router_relations()
+ Self::tool_router_search()
+ Self::tool_router_schemas()
+ Self::tool_router_template_library(),
}
}
}
#[tool_handler(router = self.tool_router.clone())]
impl ServerHandler for YorishiroMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
"Yorishiro is a multi-tenant knowledge store with user-defined schemas. \
Every tool call requires authentication via an `Authorization: Bearer <api-key>` \
header, and the tools available depend on the API key's scope \
(read/write/schema, where higher scopes include the permissions of lower ones).",
)
}
}
pub(super) struct Authorized {
pub(super) ctx: AuthContext,
conn: PoolConnection<sqlx::Postgres>,
}
impl Authorized {
pub(super) fn conn(&mut self) -> &mut PgConnection {
&mut self.conn
}
}
pub(super) enum AuthzOutcome {
Authorized(Authorized),
ScopeDenied(CallToolResult),
}
fn extract_bearer_key(parts: &Parts) -> Result<&str, ErrorData> {
parts
.headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| {
ErrorData::invalid_request("missing or malformed Authorization header", None)
})
}
pub(super) async fn authorize(
state: &AppState,
parts: &Parts,
required: ApiKeyScope,
) -> Result<AuthzOutcome, ErrorData> {
let presented_key = extract_bearer_key(parts)?;
match auth::authorize(&state.tenant_db, presented_key, required).await {
Ok((ctx, conn)) => Ok(AuthzOutcome::Authorized(Authorized { ctx, conn })),
Err(err @ YorishiroError::ScopeInsufficient { .. }) => {
Ok(AuthzOutcome::ScopeDenied(err_to_tool_result(err)))
}
Err(YorishiroError::Unauthenticated) => {
Err(ErrorData::invalid_request("authentication failed", None))
}
Err(err) => Err(ErrorData::internal_error(err.to_string(), None)),
}
}
pub(super) enum ScopeOutcome {
Verified(AuthContext),
ScopeDenied(CallToolResult),
}
pub(super) async fn authorize_scope_only(
state: &AppState,
parts: &Parts,
required: ApiKeyScope,
) -> Result<ScopeOutcome, ErrorData> {
let presented_key = extract_bearer_key(parts)?;
match auth::authorize_scope(&state.tenant_db, presented_key, required).await {
Ok(ctx) => Ok(ScopeOutcome::Verified(ctx)),
Err(err @ YorishiroError::ScopeInsufficient { .. }) => {
Ok(ScopeOutcome::ScopeDenied(err_to_tool_result(err)))
}
Err(YorishiroError::Unauthenticated) => {
Err(ErrorData::invalid_request("authentication failed", None))
}
Err(err) => Err(ErrorData::internal_error(err.to_string(), None)),
}
}
pub(super) fn err_to_tool_result(err: YorishiroError) -> CallToolResult {
let message = match err {
YorishiroError::Internal(err) => {
tracing::error!(error = %err, "internal error in mcp tool handler");
"internal server error".to_string()
}
YorishiroError::ValidationFailed {
message,
details,
hint,
} => {
let mut msg = message;
if !details.is_empty() {
let detail_lines: Vec<String> = details
.iter()
.map(|d| format!("{}: {}", d.field, d.problem))
.collect();
msg = format!("{msg}\n {}", detail_lines.join("\n "));
}
if !hint.is_empty() {
msg = format!("{msg}\nhint: {hint}");
}
msg
}
other => other.to_string(),
};
CallToolResult::error(vec![ContentBlock::text(message)])
}
pub(super) fn ok_json(value: impl serde::Serialize) -> Result<CallToolResult, ErrorData> {
let text = serde_json::to_string(&value)
.map_err(|err| ErrorData::internal_error(err.to_string(), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
}
macro_rules! authorized {
($state:expr, $parts:expr, $scope:expr) => {
match $crate::http::mcp::authorize($state, $parts, $scope).await? {
$crate::http::mcp::AuthzOutcome::Authorized(authorized) => authorized,
$crate::http::mcp::AuthzOutcome::ScopeDenied(result) => {
return ::core::result::Result::Ok(result);
}
}
};
}
pub(crate) use authorized;
macro_rules! verified {
($state:expr, $parts:expr, $scope:expr) => {
match $crate::http::mcp::authorize_scope_only($state, $parts, $scope).await? {
$crate::http::mcp::ScopeOutcome::Verified(ctx) => ctx,
$crate::http::mcp::ScopeOutcome::ScopeDenied(result) => {
return ::core::result::Result::Ok(result);
}
}
};
}
pub(crate) use verified;
macro_rules! mcp_try {
($expr:expr) => {
match $expr {
::core::result::Result::Ok(val) => val,
::core::result::Result::Err(err) => {
return ::core::result::Result::Ok($crate::http::mcp::err_to_tool_result(err));
}
}
};
}
pub(crate) use mcp_try;