pub(crate) mod schemas;
pub(crate) mod tools;
use std::sync::Arc;
use std::sync::Mutex;
use rmcp::{
ServerHandler,
handler::server::router::tool::ToolRouter,
model::{ProtocolVersion, ServerCapabilities, ServerInfo},
};
use crate::db::DbPool;
use crate::db::models::AuthUser;
use crate::realtime::{RealtimeEvent, RealtimeHub};
static MCP_HANDLER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
static MCP_REQUEST_USER: Mutex<Option<AuthUser>> = Mutex::new(None);
static MCP_REQUEST_OPERATOR: Mutex<bool> = Mutex::new(false);
pub async fn with_request_user<F, Fut, R>(user: Option<AuthUser>, f: F) -> R
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
{
with_request_identity(user, false, f).await
}
pub async fn with_request_identity<F, Fut, R>(user: Option<AuthUser>, is_operator: bool, f: F) -> R
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
{
let _guard = MCP_HANDLER_LOCK.lock().await;
let actor = crate::actor::ActorCtx {
user_id: user.as_ref().map(|u| u.id),
transport: crate::actor::Transport::Mcp,
};
*MCP_REQUEST_USER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = user;
*MCP_REQUEST_OPERATOR
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = is_operator;
let result = crate::actor::scope(actor, f()).await;
*MCP_REQUEST_USER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
*MCP_REQUEST_OPERATOR
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = false;
result
}
pub(crate) fn current_auth_user() -> Option<AuthUser> {
MCP_REQUEST_USER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub(crate) fn current_is_operator() -> bool {
*MCP_REQUEST_OPERATOR
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
const SERVER_INSTRUCTIONS: &str = "Lific is a local-first issue tracker. Use list_resources(type='project') to discover projects. \
Use list_issues to browse issues with filters. Use get_issue with an identifier like 'PRO-42' \
for details. Use workable=true to find issues ready to work on (no unresolved blockers). \
Use search to find anything by text across issues and pages. \
Conventions: when you finish work on an issue, mark it done (status='done'). \
Organize issues into modules; keep each issue a self-contained work item. \
Prefer edit_issue/edit_page (exact string replacement) over update_issue/update_page for small changes. \
Use plans (create_plan/get_plan) for multi-step or multi-session work; steps can mirror issues and stay in sync. On resume, check for existing plans first: list_resources(type='plan', project='X'), then get_plan to see where you left off. \
Use pages for documentation and design notes.";
#[derive(Clone)]
pub struct LificMcp {
db: Arc<DbPool>,
realtime: RealtimeHub,
tool_router: ToolRouter<Self>,
}
impl LificMcp {
pub fn new(db: DbPool) -> Self {
Self::with_realtime(db, RealtimeHub::new())
}
pub fn with_realtime(db: DbPool, realtime: RealtimeHub) -> Self {
Self {
db: Arc::new(db),
realtime,
tool_router: Self::create_tool_router(),
}
}
fn emit(&self, event: RealtimeEvent) {
self.realtime.send(event);
}
fn read<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(&rusqlite::Connection) -> Result<T, crate::error::LificError>,
{
let conn = self.db.read().map_err(|e| e.to_string())?;
f(&conn).map_err(|e| e.to_string())
}
fn write<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(&rusqlite::Connection) -> Result<T, crate::error::LificError>,
{
let conn = self.db.write().map_err(|e| e.to_string())?;
let user = current_auth_user();
crate::actor::stamp(
&conn,
&crate::actor::ActorCtx {
user_id: user.map(|u| u.id),
transport: crate::actor::Transport::Mcp,
},
);
f(&conn).map_err(|e| e.to_string())
}
}
#[cfg(test)]
impl LificMcp {
pub(crate) fn list_tool_names(&self) -> Vec<String> {
self.tool_router
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect()
}
}
impl ServerHandler for LificMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(ProtocolVersion::V_2025_03_26)
.with_server_info(rmcp::model::Implementation::new(
"lific",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(SERVER_INSTRUCTIONS)
}
fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<rmcp::model::ListToolsResult, rmcp::ErrorData>>
+ rmcp::service::MaybeSendFuture
+ '_ {
std::future::ready(Ok(rmcp::model::ListToolsResult {
tools: self.tool_router.list_all(),
..Default::default()
}))
}
fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<rmcp::model::CallToolResult, rmcp::ErrorData>>
+ rmcp::service::MaybeSendFuture
+ '_ {
let tool_context =
rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tool_context)
}
fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
self.tool_router.get(name).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
Extension, Router,
body::Body,
http::{Request, StatusCode},
middleware,
routing::get,
};
use http_body_util::BodyExt;
use rusqlite::params;
use tower::ServiceExt;
fn test_hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn insert_oauth_token(pool: &DbPool, suffix: &str, user_id: Option<i64>) -> String {
use sha2::{Digest, Sha256};
let token = format!("lific_at_test-{suffix}");
let hash = test_hex_encode(&Sha256::digest(token.as_bytes()));
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let client_id = format!("client-{suffix}");
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?1, 'Test', '[\"http://localhost\"]')",
params![client_id],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id) VALUES (?1, ?2, ?3, 'mcp', ?4)",
params![hash, client_id, expires, user_id],
)
.unwrap();
token
}
fn mcp_echo_app(auth_state: crate::auth::AuthState) -> Router {
async fn echo(Extension(auth_user): Extension<Option<AuthUser>>) -> String {
crate::mcp::with_request_user(auth_user, || async {
match crate::mcp::current_auth_user() {
Some(u) => format!("user:{}:{}:{}", u.id, u.username, u.is_admin),
None => "none".to_string(),
}
})
.await
}
Router::new()
.route("/mcp-echo", get(echo))
.layer(middleware::from_fn_with_state(
auth_state,
crate::auth::require_api_key,
))
}
#[tokio::test]
async fn oauth_token_backed_mcp_session_resolves_current_auth_user() {
let pool = crate::db::open_memory().expect("test db");
let user_id = {
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "mcp-token-user".into(),
email: "mcp-token-user@test.com".into(),
password: "testpassword1".into(),
display_name: Some("MCP Token User".into()),
is_admin: false,
is_bot: false,
},
)
.unwrap()
.id
};
let token = insert_oauth_token(&pool, "mcp", Some(user_id));
let auth_state = crate::auth::AuthState {
db: pool.clone(),
manager: crate::auth::create_key_manager().unwrap(),
public_url: "https://example.com".into(),
required: true,
};
let resp = mcp_echo_app(auth_state)
.oneshot(
Request::builder()
.uri("/mcp-echo")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
bytes.as_ref(),
format!("user:{user_id}:mcp-token-user:false").as_bytes(),
"OAuth-token-backed MCP session must resolve current_auth_user() to the bound user"
);
assert!(current_auth_user().is_none());
}
#[tokio::test]
async fn with_request_identity_exposes_and_clears_operator_flag() {
assert!(!current_is_operator());
let seen = with_request_identity(None, true, || async { current_is_operator() }).await;
assert!(
seen,
"operator flag must be visible inside the request scope"
);
assert!(
!current_is_operator(),
"operator flag must be cleared after the request"
);
}
#[tokio::test]
async fn with_request_user_defaults_operator_false() {
let seen = with_request_user(None, || async { current_is_operator() }).await;
assert!(
!seen,
"with_request_user (non-unbound-key callers) must never set the operator flag"
);
}
#[tokio::test]
async fn mcp_operator_key_passes_gate_but_legacy_oauth_does_not() {
use axum::extract::State;
use axum::response::IntoResponse;
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let unbound_key = crate::auth::create_api_key(&pool, &manager, "mcp-operator").unwrap();
let project = {
let conn = pool.write().unwrap();
crate::db::queries::settings::update(
&conn,
crate::db::queries::settings::InstanceSettingsPatch {
authz_enforced: Some(true),
..Default::default()
},
)
.unwrap();
crate::db::queries::create_project(
&conn,
&crate::db::models::CreateProject {
name: "MCP Gate".into(),
identifier: "MGT".into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap()
.id
};
let oauth_token = insert_oauth_token(&pool, "mcp-legacy-unbound", None);
async fn gate(
State((pool, project_id)): State<(DbPool, i64)>,
axum::Extension(auth_user): axum::Extension<Option<AuthUser>>,
request: axum::extract::Request,
) -> axum::response::Response {
let is_operator = request
.extensions()
.get::<crate::auth::OperatorCredential>()
.is_some();
crate::mcp::with_request_identity(auth_user, is_operator, || async {
let db = std::sync::Arc::new(pool);
match crate::authz::require_role(
&db,
&crate::mcp::current_auth_user(),
project_id,
crate::db::models::Role::Viewer,
) {
Ok(()) => (StatusCode::OK, "allowed").into_response(),
Err(e) => e.into_response(),
}
})
.await
}
let auth_state = crate::auth::AuthState {
db: pool.clone(),
manager,
public_url: "https://example.com".into(),
required: true,
};
let app = Router::new()
.route("/mcp-gate", get(gate))
.with_state((pool.clone(), project))
.layer(middleware::from_fn_with_state(
auth_state,
crate::auth::require_api_key,
));
let status = |key: String, app: Router| async move {
app.oneshot(
Request::builder()
.uri("/mcp-gate")
.header("authorization", format!("Bearer {key}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
};
assert_eq!(
status(unbound_key, app.clone()).await,
StatusCode::OK,
"operator-trusted unbound API key must pass the enforced MCP Viewer gate"
);
assert_eq!(
status(oauth_token, app).await,
StatusCode::FORBIDDEN,
"legacy unbound OAuth token must NOT gain operator power on the MCP surface"
);
}
#[test]
fn get_info_instructions_include_conventions() {
let pool = crate::db::open_memory().expect("test db");
let mcp = LificMcp::new(pool);
let info = mcp.get_info();
let instructions = info
.instructions
.expect("server info must carry instructions");
assert!(instructions.contains("list_resources(type='project')"));
assert!(instructions.contains("workable=true"));
assert!(
instructions.contains("done"),
"instructions must tell agents to mark finished issues done"
);
assert!(
instructions.contains("edit_issue"),
"instructions must steer agents to edit_issue for small changes"
);
assert!(instructions.contains("edit_page"));
assert!(instructions.contains("modules"));
assert!(instructions.contains("create_plan"));
assert!(instructions.contains("check for existing plans"));
assert!(instructions.contains("list_resources(type='plan', project='X')"));
assert!(instructions.contains("then get_plan to see where you left off"));
assert!(instructions.contains("pages for documentation"));
}
#[test]
fn get_info_identifies_as_lific() {
let pool = crate::db::open_memory().expect("test db");
let mcp = LificMcp::new(pool);
let info = mcp.get_info();
assert_eq!(info.server_info.name, "lific");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn server_instructions_stay_compact() {
let base = "Lific is a local-first issue tracker. Use list_resources(type='project') to discover projects. \
Use list_issues to browse issues with filters. Use get_issue with an identifier like 'PRO-42' \
for details. Use workable=true to find issues ready to work on (no unresolved blockers). \
Use search to find anything by text across issues and pages. ";
let addition = SERVER_INSTRUCTIONS.len() - base.len();
assert!(
addition <= 700,
"convention addition grew to {addition} chars; keep it tight"
);
}
}