pub(crate) mod schemas;
pub(crate) mod tools;
#[cfg(test)]
use std::cell::Cell;
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::links::IssueLinkContext;
use crate::realtime::{RealtimeEvent, RealtimeHub};
use crate::storage::AttachmentStore;
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_ISSUE_LINKS: Mutex<Option<Arc<IssueLinkContext>>> = Mutex::new(None);
#[cfg(test)]
tokio::task_local! {
static TEST_REQUEST_ISSUE_LINKS: Option<Arc<IssueLinkContext>>;
}
#[cfg(test)]
thread_local! {
static TEST_ISSUE_LINK_CONTEXT_READS: Cell<usize> = const { Cell::new(0) };
}
#[cfg_attr(not(test), allow(dead_code))]
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_context(user, None, f).await
}
pub async fn with_request_context<F, Fut, R>(
user: Option<AuthUser>,
issue_links: Option<IssueLinkContext>,
f: F,
) -> R
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
{
let _guard = MCP_HANDLER_LOCK.lock().await;
let issue_links = issue_links.map(Arc::new);
#[cfg(test)]
let test_issue_links = issue_links.clone();
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_ISSUE_LINKS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = issue_links;
let _clear = RequestGlobalGuard;
#[cfg(test)]
let result = TEST_REQUEST_ISSUE_LINKS
.scope(test_issue_links, crate::actor::scope(actor, f()))
.await;
#[cfg(not(test))]
let result = crate::actor::scope(actor, f()).await;
result
}
struct RequestGlobalGuard;
impl Drop for RequestGlobalGuard {
fn drop(&mut self) {
*MCP_REQUEST_USER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
*MCP_REQUEST_ISSUE_LINKS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
}
pub(crate) fn current_auth_user() -> Option<AuthUser> {
MCP_REQUEST_USER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub(crate) struct StdioAuth {
token: String,
manager: api_keys_simplified::ApiKeyManagerV0,
}
impl StdioAuth {
pub(crate) fn new(token: String, manager: api_keys_simplified::ApiKeyManagerV0) -> Self {
Self { token, manager }
}
fn resolve(&self, db: &DbPool) -> Result<Option<AuthUser>, String> {
crate::auth::resolve_api_key_user(db, &self.manager, &self.token)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StdioAuthFailed;
impl StdioAuthFailed {
const MESSAGE: &'static str = "This Lific session's credential (LIFIC_TOKEN) is no longer \
valid, so the tool was not run. Run `lific connect` to reconnect this tool, then \
restart the MCP server.";
fn into_tool_result(self) -> rmcp::model::CallToolResult {
rmcp::model::CallToolResult::error(vec![rmcp::model::Content::text(Self::MESSAGE)])
}
}
pub(crate) fn current_identity(
db: &crate::db::DbPool,
) -> Option<crate::resolve_caller::ResolvedIdentity> {
crate::resolve_caller::resolve_caller(db, current_auth_user(), crate::actor::Transport::Mcp)
.ok()
.flatten()
}
pub(crate) fn current_issue_link_context() -> Option<Arc<IssueLinkContext>> {
#[cfg(test)]
{
TEST_ISSUE_LINK_CONTEXT_READS.set(TEST_ISSUE_LINK_CONTEXT_READS.get() + 1);
TEST_REQUEST_ISSUE_LINKS
.try_with(Clone::clone)
.unwrap_or(None)
}
#[cfg(not(test))]
MCP_REQUEST_ISSUE_LINKS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
#[cfg(test)]
pub(crate) fn reset_issue_link_context_reads() {
TEST_ISSUE_LINK_CONTEXT_READS.set(0);
}
#[cfg(test)]
pub(crate) fn issue_link_context_reads() -> usize {
TEST_ISSUE_LINK_CONTEXT_READS.get()
}
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,
store: AttachmentStore,
tool_router: ToolRouter<Self>,
stdio_auth: Option<Arc<StdioAuth>>,
}
impl LificMcp {
#[cfg_attr(not(test), allow(dead_code))]
pub fn new(db: DbPool) -> Self {
Self::with_realtime(db, RealtimeHub::new())
}
pub fn with_realtime(db: DbPool, realtime: RealtimeHub) -> Self {
let store = AttachmentStore::from_db_path(db.path());
Self {
db: Arc::new(db),
realtime,
store,
tool_router: Self::create_tool_router(),
stdio_auth: None,
}
}
pub fn for_stdio(db: DbPool, auth: Option<StdioAuth>) -> Self {
Self {
stdio_auth: auth.map(Arc::new),
..Self::with_realtime(db, RealtimeHub::new())
}
}
async fn with_stdio_auth<F, Fut, R>(&self, f: F) -> Result<R, StdioAuthFailed>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = R>,
{
let Some(auth) = self.stdio_auth.clone() else {
return Ok(f().await);
};
let user = auth.resolve(&self.db).map_err(|reason| {
tracing::warn!(reason, "stdio LIFIC_TOKEN no longer authenticates");
StdioAuthFailed
})?;
Ok(with_request_user(user, f).await)
}
async fn dispatch_tool<F, Fut>(
&self,
f: F,
) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<rmcp::model::CallToolResult, rmcp::ErrorData>>,
{
match self.with_stdio_auth(f).await {
Ok(result) => result,
Err(StdioAuthFailed) => Ok(StdioAuthFailed.into_tool_result()),
}
}
#[cfg(test)]
pub(crate) fn with_attachment_store(mut self, store: AttachmentStore) -> Self {
self.store = store;
self
}
fn emit(&self, event: RealtimeEvent) {
self.realtime.send(event);
}
fn emit_with_seq(&self, event: RealtimeEvent, seq: i64) {
self.realtime.send_with_seq(event, seq);
}
pub(crate) fn read_conn(&self) -> Result<crate::db::ReadConn, String> {
self.db.read().map_err(sanitize_error)
}
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(sanitize_error)?;
f(&conn).map_err(sanitize_error)
}
fn stamp_request_actor(conn: &rusqlite::Connection) {
let user = current_auth_user();
crate::actor::stamp(
conn,
&crate::actor::ActorCtx {
user_id: user.map(|user| user.id),
transport: crate::actor::Transport::Mcp,
},
);
}
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(sanitize_error)?;
Self::stamp_request_actor(&conn);
f(&conn).map_err(sanitize_error)
}
fn transaction<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(&rusqlite::Connection) -> Result<T, crate::error::LificError>,
{
self.db
.transaction(|conn| {
Self::stamp_request_actor(conn);
f(conn)
})
.map_err(sanitize_error)
}
}
pub(crate) fn sanitize_error(error: crate::error::LificError) -> String {
use crate::error::LificError;
match &error {
LificError::Database(inner) => {
tracing::error!(error = %inner, "database error");
"internal server error".to_string()
}
LificError::Internal(message) => {
tracing::error!(error = %message, "internal error");
"internal server error".to_string()
}
LificError::UpdateConflict { message, current } => {
let summary = LificError::conflict_summary(current);
if summary.is_empty() {
message.clone()
} else {
format!("{message}. Current state: {summary}")
}
}
other => other.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()
}))
}
#[allow(clippy::manual_async_fn)]
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
+ '_ {
async move {
let tool_context =
rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.dispatch_tool(|| self.tool_router.call(tool_context))
.await
}
}
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;
#[test]
fn sanitize_error_hides_only_database_and_internal_detail() {
use crate::error::LificError;
assert_eq!(
sanitize_error(LificError::Database(rusqlite::Error::InvalidColumnName(
"secret_column".into()
))),
"internal server error"
);
assert_eq!(
sanitize_error(LificError::Internal("/srv/lific/lific.db is locked".into())),
"internal server error"
);
for (error, expected) in [
(
LificError::NotFound("issue LIF-1 not found".into()),
"Not found: issue LIF-1 not found",
),
(
LificError::BadRequest("invalid status 'nope'".into()),
"Bad request: invalid status 'nope'",
),
(
LificError::Forbidden(
"requires at least 'maintainer' access to this project".into(),
),
"Forbidden: requires at least 'maintainer' access to this project",
),
(
LificError::Conflict("identifier already exists".into()),
"Conflict: identifier already exists",
),
] {
assert_eq!(sanitize_error(error), expected);
}
}
fn insert_oauth_token(pool: &DbPool, suffix: &str, user_id: Option<i64>) -> String {
let token = format!("lific_at_test-{suffix}");
let hash = crate::auth::sha256_hex(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_context_scopes_issue_link_origin() {
let context = IssueLinkContext::parse("https://tracker.example/base");
let (seen, global_seen) = with_request_context(None, context, || async {
let scoped = current_issue_link_context()
.expect("request origin should be visible")
.issue_markdown("LIF-1")
.to_string();
let global = MCP_REQUEST_ISSUE_LINKS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
.expect("production request context should also be populated")
.issue_markdown("LIF-1")
.to_string();
(scoped, global)
})
.await;
assert_eq!(
seen,
"[LIF-1](https://tracker.example/base/LIF/issues/LIF-1)"
);
assert_eq!(global_seen, seen);
assert!(current_issue_link_context().is_none());
assert!(
MCP_REQUEST_ISSUE_LINKS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_none()
);
}
#[tokio::test]
async fn unbound_credentials_resolve_to_first_admin_and_pass_mcp_gate() {
use axum::extract::State;
use axum::response::IntoResponse;
let pool = crate::db::open_memory().expect("test db");
{
let conn = pool.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "admin".into(),
email: "admin@test.local".into(),
password: "adminpass123".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
}
let manager = crate::auth::create_key_manager().unwrap();
let unbound_key =
crate::auth::create_api_key(&pool, &manager, "mcp-operator", None).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>>,
) -> axum::response::Response {
crate::mcp::with_request_user(auth_user, || async {
let db = std::sync::Arc::new(pool);
match crate::authz::require_role(
&db,
&crate::mcp::current_identity(&db),
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,
"an unbound API key authenticates and resolves to the first admin, passing the enforced MCP Viewer gate"
);
assert_eq!(
status(oauth_token, app).await,
StatusCode::OK,
"a legacy unbound OAuth token also authenticates and resolves to the first admin — MCP and REST now share one resolve_caller path"
);
}
#[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"
);
}
fn seed_user(
pool: &crate::db::DbPool,
username: &str,
admin: bool,
) -> crate::db::models::AuthUser {
let conn = pool.write().expect("write conn");
let u = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: username.into(),
email: format!("{username}@local.test"),
password: "somepass123".into(),
display_name: None,
is_admin: admin,
is_bot: false,
},
)
.expect("create user");
crate::db::models::AuthUser {
id: u.id,
username: u.username,
display_name: u.display_name,
is_admin: u.is_admin,
}
}
fn connected_agent(
pool: &crate::db::DbPool,
manager: &api_keys_simplified::ApiKeyManagerV0,
) -> (crate::db::models::AuthUser, crate::db::models::User, String) {
seed_user(pool, "operator", true);
let owner = seed_user(pool, "owner", false);
let bot = {
let conn = pool.write().expect("write conn");
crate::db::queries::users::create_bot_user(
&conn,
owner.id,
"opencode-owner",
"OpenCode",
Some("opencode"),
)
.expect("create bot")
};
let token = crate::auth::create_api_key(pool, manager, "opencode-owner", Some(bot.id))
.expect("mint agent key");
(owner, bot, token)
}
fn server_for(pool: &crate::db::DbPool, auth: Option<StdioAuth>) -> LificMcp {
LificMcp::for_stdio(pool.clone(), auth)
}
async fn observed_identity(
server: &LificMcp,
pool: &crate::db::DbPool,
) -> Result<Option<crate::resolve_caller::ResolvedIdentity>, StdioAuthFailed> {
server
.with_stdio_auth(|| async { current_identity(pool) })
.await
}
type ToolBody = std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<rmcp::model::CallToolResult, rmcp::ErrorData>>
+ Send,
>,
>;
fn mutating_tool(
pool: &crate::db::DbPool,
) -> (
impl FnOnce() -> ToolBody + use<>,
std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
let ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = ran.clone();
let pool = pool.clone();
let body = move || {
Box::pin(async move {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
let conn = pool.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES ('mutation-marker', 'Tool ran', '[]')",
[],
)
.unwrap();
Ok(rmcp::model::CallToolResult::success(vec![
rmcp::model::Content::text("ran"),
]))
}) as ToolBody
};
(body, ran)
}
#[tokio::test]
async fn a_stdio_tool_call_resolves_as_the_agent_the_token_names() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let (owner, bot, token) = connected_agent(&pool, &manager);
let server = server_for(&pool, Some(StdioAuth::new(token, manager)));
let identity = observed_identity(&server, &pool)
.await
.expect("a live token authenticates")
.expect("a bound stdio session resolves");
assert_eq!(
identity.user.id, bot.id,
"the audit actor is the bot, not the operator it inherits from"
);
assert_ne!(identity.user.id, owner.id);
assert_eq!(identity.transport, crate::actor::Transport::Mcp);
}
#[tokio::test]
async fn revoking_the_token_stops_the_very_next_tool_call() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let (_owner, _bot, token) = connected_agent(&pool, &manager);
let server = server_for(&pool, Some(StdioAuth::new(token, manager)));
assert!(observed_identity(&server, &pool).await.is_ok());
crate::auth::revoke_api_key(&pool, "opencode-owner").expect("revoke");
let (body, ran) = mutating_tool(&pool);
let result = server
.dispatch_tool(body)
.await
.expect("a dead credential is a tool failure, not a protocol failure");
assert_eq!(
result.is_error,
Some(true),
"the agent must see this as a failed tool call"
);
assert!(
!ran.load(std::sync::atomic::Ordering::SeqCst),
"the tool body must not run at all, so nothing is mutated"
);
let mutations: i64 = pool
.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM oauth_clients WHERE client_id = 'mutation-marker'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(mutations, 0, "nothing was written");
let text = format!("{:?}", result.content);
assert!(text.contains("lific connect"), "{text}");
assert!(text.contains("restart"), "{text}");
for internal in ["revoked", "deactivated", "database", "expired", "hash"] {
assert!(
!text.contains(internal),
"the agent must not be told why: {text}"
);
}
}
#[tokio::test]
async fn a_live_credential_dispatches_the_tool_through_the_central_seam() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let (_owner, _bot, token) = connected_agent(&pool, &manager);
let server = server_for(&pool, Some(StdioAuth::new(token, manager)));
let (body, ran) = mutating_tool(&pool);
let result = server.dispatch_tool(body).await.expect("dispatches");
assert_eq!(result.is_error, Some(false));
assert!(ran.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn an_account_lockdown_stops_the_agents_next_tool_call() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let (owner, _bot, token) = connected_agent(&pool, &manager);
let server = server_for(&pool, Some(StdioAuth::new(token, manager)));
assert!(observed_identity(&server, &pool).await.is_ok());
{
let conn = pool.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, owner.id).unwrap();
}
assert!(observed_identity(&server, &pool).await.is_err());
}
#[tokio::test]
async fn deactivating_the_owner_stops_the_agents_next_tool_call() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let (owner, _bot, token) = connected_agent(&pool, &manager);
let server = server_for(&pool, Some(StdioAuth::new(token, manager)));
assert!(observed_identity(&server, &pool).await.is_ok());
{
let conn = pool.write().unwrap();
crate::db::queries::users::set_active(&conn, owner.id, false).unwrap();
}
assert!(
observed_identity(&server, &pool).await.is_err(),
"a bot whose owner is deactivated is a dead credential"
);
}
#[tokio::test]
async fn an_unbound_key_still_resolves_to_the_operator() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let manager = crate::auth::create_key_manager().unwrap();
let admin = seed_user(&pool, "operator", true);
let token = crate::auth::create_api_key(&pool, &manager, "default", None).unwrap();
let server = server_for(&pool, Some(StdioAuth::new(token, manager)));
let identity = observed_identity(&server, &pool)
.await
.expect("an unbound key is valid")
.expect("operator fallback resolves");
assert_eq!(identity.user.id, admin.id);
assert!(identity.user.is_admin);
}
#[tokio::test]
async fn a_tokenless_stdio_session_keeps_operator_behavior() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let admin = seed_user(&pool, "operator", true);
let server = server_for(&pool, None);
let identity = observed_identity(&server, &pool)
.await
.expect("no credential to fail")
.expect("operator fallback resolves");
assert_eq!(
identity.user.id, admin.id,
"no-token stdio session must resolve to the first admin"
);
assert_eq!(identity.transport, crate::actor::Transport::Mcp);
}
#[tokio::test]
async fn the_http_transport_seam_does_not_retake_the_handler_lock() {
let _sguard = crate::mcp::tools::acquire_test_guard();
let pool = crate::db::open_memory().expect("test db");
let server = LificMcp::new(pool.clone());
let user = seed_user(&pool, "http-caller", true);
let seen = with_request_context(Some(user.clone()), None, || async {
server
.with_stdio_auth(|| async { current_auth_user() })
.await
.expect("pass-through")
})
.await;
assert_eq!(
seen.map(|u| u.id),
Some(user.id),
"the middleware's identity survives the seam untouched"
);
}
}