use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, Content, Implementation, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router,
transport::stdio,
};
use serde::Deserialize;
use crate::compact::{self, CompactOptions, CompactReport};
use crate::embed::{self, EmbedOptions, EmbeddingBackendFactory, OllamaBackendFactory};
use crate::entities::{
self, EntityListOptions, EntityListReport, EntityNeighborsOptions, EntityNeighborsReport,
EntitySessionNeighborsOptions, EntitySessionNeighborsReport,
};
use crate::feedback::{self, FeedbackReport};
use crate::forget;
use crate::ingest;
use crate::inspect;
use crate::query_success::{self, QuerySuccessReport};
use crate::search::{self, SearchOptions, SemanticOptions};
use crate::sessions::{
self, RelatedSessionsOptions, RelatedSessionsReport, TemporallyRelatedSessionsOptions,
TemporallyRelatedSessionsReport,
};
use crate::show;
use crate::store::Store;
#[derive(Clone)]
pub struct LanternServer {
store_dir: PathBuf,
embed_factory: Arc<dyn EmbeddingBackendFactory>,
#[allow(dead_code)]
tool_router: ToolRouter<LanternServer>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct IngestArgs {
pub path: String,
#[serde(default)]
pub store: Option<String>,
#[serde(default)]
pub no_ignore: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchArgs {
pub query: String,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub instruction: Option<String>,
#[serde(default)]
pub ollama_url: Option<String>,
#[serde(default)]
pub min_confidence: Option<f64>,
#[serde(default)]
pub session_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ShowArgs {
pub id: String,
#[serde(default)]
pub show_entities: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ForgetArgs {
pub pattern: String,
#[serde(default)]
pub apply: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct InspectArgs {
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EmbedArgs {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub ollama_url: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum FeedbackVote {
Up,
Down,
}
impl FeedbackVote {
fn as_feedback(self) -> feedback::Feedback {
match self {
FeedbackVote::Up => feedback::Feedback::Up,
FeedbackVote::Down => feedback::Feedback::Down,
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FeedbackArgs {
pub chunk_id: String,
pub vote: FeedbackVote,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct QuerySuccessArgs {
pub chunk_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EntitiesArgs {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub value_contains: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub show_chunks: Option<usize>,
#[serde(default)]
pub show_sessions: Option<usize>,
#[serde(default)]
pub show_projects: Option<usize>,
#[serde(default)]
pub show_users: Option<usize>,
#[serde(default)]
pub show_topics: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EntityNeighborsArgs {
pub entity_id: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub show_chunks: Option<usize>,
#[serde(default)]
pub show_sessions: Option<usize>,
#[serde(default)]
pub show_projects: Option<usize>,
#[serde(default)]
pub show_users: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EntitySessionNeighborsArgs {
pub entity_id: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub show_sessions: Option<usize>,
#[serde(default)]
pub show_projects: Option<usize>,
#[serde(default)]
pub show_users: Option<usize>,
#[serde(default)]
pub show_chunks: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RelatedSessionsArgs {
pub session_id: String,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub show_entities: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TemporalSessionsArgs {
pub session_id: String,
#[serde(default)]
pub window_secs: Option<i64>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CompactArgs {
#[serde(default)]
pub half_life_secs: Option<u64>,
#[serde(default)]
pub minimum_age_secs: Option<u64>,
#[serde(default)]
pub dry_run: Option<bool>,
}
#[tool_router]
impl LanternServer {
pub fn new(store_dir: PathBuf) -> Self {
Self::with_factory(store_dir, Arc::new(OllamaBackendFactory))
}
pub fn with_factory(
store_dir: PathBuf,
embed_factory: Arc<dyn EmbeddingBackendFactory>,
) -> Self {
Self {
store_dir,
embed_factory,
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Ingest a file or directory into the Lantern store. Supports text, markdown, JSONL transcripts, and common source code extensions. Re-ingesting unchanged content is a no-op."
)]
async fn lantern_ingest(
&self,
Parameters(args): Parameters<IngestArgs>,
) -> Result<CallToolResult, McpError> {
let store_dir = args
.store
.map(PathBuf::from)
.unwrap_or_else(|| self.store_dir.clone());
let path = PathBuf::from(args.path);
let no_ignore = args.no_ignore.unwrap_or(false);
run_blocking(move || {
let mut store = Store::open(&store_dir)?;
ingest::ingest_path_with(
&mut store,
&path,
&ingest::IngestOptions {
no_ignore,
include_exts: Vec::new(),
},
)
})
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "Keyword (FTS5), semantic (cosine over stored embeddings), or hybrid search over ingested chunks. Returns ranked hits with full provenance."
)]
async fn lantern_search(
&self,
Parameters(args): Parameters<SearchArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.search_sync(args))
.await
.and_then(|v| json_content(&v))
}
#[tool(
description = "Show full provenance and all chunk text for a single source. Accepts the full source id or any unambiguous prefix. Pass show_entities=N to include up to N extracted entities per chunk."
)]
async fn lantern_show(
&self,
Parameters(args): Parameters<ShowArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.show_sync(args))
.await
.and_then(|s| json_content(&s))
}
#[tool(
description = "Remove indexed sources (and their chunks) whose path or URI contains the given substring. Requires at least 3 characters. Defaults to dry-run; pass apply=true to actually delete."
)]
async fn lantern_forget(
&self,
Parameters(args): Parameters<ForgetArgs>,
) -> Result<CallToolResult, McpError> {
let store_dir = self.store_dir.clone();
let pattern = args.pattern;
let apply = args.apply.unwrap_or(false);
run_blocking(move || {
let mut store = Store::open(&store_dir)?;
forget::forget(&mut store, &pattern, apply)
})
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "Report store status: schema version, on-disk size, source/chunk/embedding counts, and the most recently ingested sources."
)]
async fn lantern_inspect(
&self,
Parameters(args): Parameters<InspectArgs>,
) -> Result<CallToolResult, McpError> {
let store_dir = self.store_dir.clone();
let recent_limit = args.limit.unwrap_or(10);
run_blocking(move || {
let store = Store::open(&store_dir)?;
inspect::inspect(&store, inspect::InspectOptions { recent_limit })
})
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "Embed every chunk that doesn't yet have a vector for the requested model. Requires a running local Ollama daemon."
)]
async fn lantern_embed(
&self,
Parameters(args): Parameters<EmbedArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.embed_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "Record thumbs-up or thumbs-down feedback for a chunk and return the updated net score. Feedback is additive across repeated calls."
)]
async fn lantern_feedback(
&self,
Parameters(args): Parameters<FeedbackArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.feedback_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "Record that a chunk helped answer a query successfully and return the updated success count. This is a positive-only signal distinct from explicit feedback."
)]
async fn lantern_query_success(
&self,
Parameters(args): Parameters<QuerySuccessArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.query_success_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "List entities (URLs, repository slugs, domains, emails, file paths, @-mentions, #hashtags) extracted from ingested chunks. Ordered by chunk-reference count, then value. Optional kind / value_contains / limit filters mirror the `lantern entities` CLI."
)]
async fn lantern_entities(
&self,
Parameters(args): Parameters<EntitiesArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.entities_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "List entities that co-occur with a given entity id, ranked by shared-chunk count. This exposes a lightweight graph traversal over Lantern's extracted entity layer."
)]
async fn lantern_entity_neighbors(
&self,
Parameters(args): Parameters<EntityNeighborsArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.entity_neighbors_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "List entities that appear in at least one of the same sessions as a given entity id, ranked by shared-session count. This is the MCP twin of `lantern entity-session-neighbors`."
)]
async fn lantern_entity_session_neighbors(
&self,
Parameters(args): Parameters<EntitySessionNeighborsArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.entity_session_neighbors_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "List sessions related to a source session by shared extracted entities, ranked by shared-entity count. This is the MCP twin of `lantern related-sessions`."
)]
async fn lantern_related_sessions(
&self,
Parameters(args): Parameters<RelatedSessionsArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.related_sessions_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "List sessions whose timestamp ranges are closest to a source session. Optional window_secs filters by maximum gap, and the results are ranked by gap then session id."
)]
async fn lantern_temporal_sessions(
&self,
Parameters(args): Parameters<TemporalSessionsArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.temporal_sessions_sync(args))
.await
.and_then(|r| json_content(&r))
}
#[tool(
description = "Compact stale access metadata: decay access_count for chunks whose last touch is older than minimum_age_secs, on a half_life_secs curve. Pass dry_run=true to preview the effect without mutating the store."
)]
async fn lantern_compact(
&self,
Parameters(args): Parameters<CompactArgs>,
) -> Result<CallToolResult, McpError> {
let srv = self.clone();
run_blocking(move || srv.compact_sync(args))
.await
.and_then(|r| json_content(&r))
}
pub fn search_sync(&self, args: SearchArgs) -> Result<serde_json::Value> {
let store = Store::open(&self.store_dir)?;
let mode = args
.mode
.as_deref()
.unwrap_or("keyword")
.to_ascii_lowercase();
let limit = args.limit.unwrap_or(10);
let model = args
.model
.unwrap_or_else(|| embed::DEFAULT_EMBED_MODEL.to_string());
let ollama_url = args
.ollama_url
.unwrap_or_else(|| embed::DEFAULT_OLLAMA_URL.to_string());
let instruction = args.instruction;
let kind = args.kind;
let path_contains = args.path;
let query = args.query;
let min_confidence = args.min_confidence;
let session_id = args.session_id;
let hits = match mode.as_str() {
"semantic" => {
let backend = self.embed_factory.build(&model, &ollama_url)?;
search::semantic_search_with(
&store,
&query,
&SemanticOptions {
limit,
kind,
path_contains,
session_id,
model,
ollama_url,
instruction,
min_confidence,
},
&*backend,
)?
}
"hybrid" => {
let backend = self.embed_factory.build(&model, &ollama_url)?;
search::hybrid_search_with(
&store,
&query,
&SemanticOptions {
limit,
kind,
path_contains,
session_id,
model,
ollama_url,
instruction,
min_confidence,
},
&*backend,
)?
}
"keyword" | "" => search::search(
&store,
&query,
SearchOptions {
limit,
kind,
path_contains,
session_id,
min_confidence,
},
)?,
other => {
anyhow::bail!("unknown mode {other:?}; expected keyword, semantic, or hybrid")
}
};
Ok(serde_json::json!({ "query": query, "results": hits }))
}
pub fn show_sync(&self, args: ShowArgs) -> Result<crate::export::ExportedSource> {
let store = Store::open(&self.store_dir)?;
let opts = show::ShowOptions {
with_entities: args.show_entities.filter(|n| *n > 0),
};
show::show_with_options(&store, &args.id, opts)
}
pub fn embed_sync(&self, args: EmbedArgs) -> Result<embed::EmbedReport> {
let mut store = Store::open(&self.store_dir)?;
let model = args
.model
.unwrap_or_else(|| embed::DEFAULT_EMBED_MODEL.to_string());
let ollama_url = args
.ollama_url
.unwrap_or_else(|| embed::DEFAULT_OLLAMA_URL.to_string());
let backend = self.embed_factory.build(&model, &ollama_url)?;
embed::embed_missing_with(
&mut store,
&EmbedOptions {
model,
ollama_url,
limit: args.limit,
},
&*backend,
)
}
pub fn feedback_sync(&self, args: FeedbackArgs) -> Result<FeedbackReport> {
let store = Store::open(&self.store_dir)?;
feedback::apply_feedback(&store, &args.chunk_id, args.vote.as_feedback())
}
pub fn query_success_sync(&self, args: QuerySuccessArgs) -> Result<QuerySuccessReport> {
let store = Store::open(&self.store_dir)?;
query_success::apply_query_success(&store, &args.chunk_id)
}
pub fn entities_sync(&self, args: EntitiesArgs) -> Result<EntityListReport> {
let store = Store::open(&self.store_dir)?;
let kind = args
.kind
.as_deref()
.map(entities::entity_kind_from_str)
.transpose()?;
let opts = EntityListOptions {
kind,
value_contains: args.value_contains,
limit: Some(args.limit.unwrap_or(50)),
with_chunks: args.show_chunks.filter(|n| *n > 0),
session_id: args.session_id,
with_sessions: args.show_sessions.filter(|n| *n > 0),
with_projects: args.show_projects.filter(|n| *n > 0),
with_users: args.show_users.filter(|n| *n > 0),
with_topics: args.show_topics.filter(|n| *n > 0),
};
entities::list_entities(store.conn(), &opts)
}
pub fn entity_neighbors_sync(
&self,
args: EntityNeighborsArgs,
) -> Result<EntityNeighborsReport> {
let store = Store::open(&self.store_dir)?;
let kind = args
.kind
.as_deref()
.map(entities::entity_kind_from_str)
.transpose()?;
let opts = EntityNeighborsOptions {
kind,
limit: Some(args.limit.unwrap_or(50)),
with_chunks: args.show_chunks.filter(|n| *n > 0),
session_id: args.session_id,
with_sessions: args.show_sessions.filter(|n| *n > 0),
with_projects: args.show_projects.filter(|n| *n > 0),
with_users: args.show_users.filter(|n| *n > 0),
};
entities::entity_neighbors(store.conn(), &args.entity_id, &opts)
}
pub fn entity_session_neighbors_sync(
&self,
args: EntitySessionNeighborsArgs,
) -> Result<EntitySessionNeighborsReport> {
let store = Store::open(&self.store_dir)?;
let kind = args
.kind
.as_deref()
.map(entities::entity_kind_from_str)
.transpose()?;
let opts = EntitySessionNeighborsOptions {
kind,
limit: Some(args.limit.unwrap_or(50)),
session_id: args.session_id,
with_chunks: args.show_chunks.filter(|n| *n > 0),
with_sessions: args.show_sessions.filter(|n| *n > 0),
with_projects: args.show_projects.filter(|n| *n > 0),
with_users: args.show_users.filter(|n| *n > 0),
};
entities::entity_session_neighbors(store.conn(), &args.entity_id, &opts)
}
pub fn related_sessions_sync(
&self,
args: RelatedSessionsArgs,
) -> Result<RelatedSessionsReport> {
let store = Store::open(&self.store_dir)?;
let opts = RelatedSessionsOptions {
limit: Some(args.limit.unwrap_or(50)),
with_entities: args.show_entities.filter(|n| *n > 0),
};
sessions::related_sessions(store.conn(), &args.session_id, &opts)
}
pub fn temporal_sessions_sync(
&self,
args: TemporalSessionsArgs,
) -> Result<TemporallyRelatedSessionsReport> {
let store = Store::open(&self.store_dir)?;
let opts = TemporallyRelatedSessionsOptions {
limit: Some(args.limit.unwrap_or(50)),
window_secs: args.window_secs,
};
sessions::temporally_related_sessions(store.conn(), &args.session_id, &opts)
}
pub fn compact_sync(&self, args: CompactArgs) -> Result<CompactReport> {
let defaults = CompactOptions::default();
let opts = CompactOptions {
half_life_secs: args.half_life_secs.unwrap_or(defaults.half_life_secs),
minimum_age_secs: args.minimum_age_secs.unwrap_or(defaults.minimum_age_secs),
dry_run: args.dry_run.unwrap_or(defaults.dry_run),
};
let mut store = Store::open(&self.store_dir)?;
compact::compact_access_metadata(&mut store, opts)
}
}
#[tool_handler]
impl ServerHandler for LanternServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::from_build_env())
.with_instructions(String::from(
"Lantern MCP server: provenance-aware local memory for agents. \
Tools: lantern_ingest, lantern_search, lantern_show, \
lantern_forget, lantern_inspect, lantern_embed, \
lantern_feedback, lantern_query_success, lantern_entities, \
lantern_entity_neighbors, lantern_entity_session_neighbors, \
lantern_related_sessions, lantern_temporal_sessions, \
lantern_compact.",
))
}
}
pub fn run(store_dir: PathBuf, port: Option<u16>) -> Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async move {
let server = LanternServer::new(store_dir);
match port {
None => {
let service = server.serve(stdio()).await?;
service.waiting().await?;
}
Some(p) => {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", p)).await?;
eprintln!("lantern mcp listening on 127.0.0.1:{p}");
let (stream, peer) = listener.accept().await?;
eprintln!("lantern mcp client connected from {peer}");
let service = server.serve(stream).await?;
service.waiting().await?;
}
}
Ok::<_, anyhow::Error>(())
})
}
fn json_content<T: serde::Serialize>(value: &T) -> Result<CallToolResult, McpError> {
let text = serde_json::to_string_pretty(value)
.map_err(|e| McpError::internal_error(format!("serialize result: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(text)]))
}
async fn run_blocking<F, T>(f: F) -> Result<T, McpError>
where
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|e| McpError::internal_error(format!("spawn_blocking: {e}"), None))?
.map_err(|e| McpError::internal_error(format!("{e:#}"), None))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn entities_sync_show_sessions_is_opt_in_and_honors_session_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-entities-sessions",
Some("application/jsonl"),
br##"{"session_id":"s2","role":"assistant","content":"@topic"}
{"session_id":"s1","role":"user","content":"@topic"}
{"session_id":"s3","role":"assistant","content":"@topic"}
"##,
)
.expect("ingest entities corpus");
let server = LanternServer::new(store_dir);
let default_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("default entity report");
assert_eq!(default_report.entries.len(), 1);
assert!(
default_report.entries[0].session_ids.is_none(),
"session evidence must be omitted by default"
);
let zeroed_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: Some(0),
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("zeroed entity report");
assert!(
zeroed_report.entries[0].session_ids.is_none(),
"show_sessions=0 must collapse to the cheap default path"
);
let opted_in = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: Some(2),
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("entity report with evidence");
assert_eq!(
opted_in.entries[0]
.session_ids
.as_ref()
.expect("opt-in session evidence should be populated"),
&vec!["s1".to_string(), "s2".to_string()]
);
let scoped = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: Some("s2".into()),
limit: Some(5),
show_chunks: None,
show_sessions: Some(3),
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("session-scoped entity report");
assert_eq!(scoped.session_id.as_deref(), Some("s2"));
assert_eq!(scoped.entries[0].session_count, 1);
assert_eq!(
scoped.entries[0]
.session_ids
.as_ref()
.expect("session-scoped evidence should be populated"),
&vec!["s2".to_string()]
);
}
#[test]
fn entities_sync_show_projects_is_opt_in_and_honors_session_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-entities-projects",
Some("application/jsonl"),
br##"{"session_id":"s1","project":"beta","role":"assistant","content":"@topic"}
{"session_id":"s1","project":"alpha","role":"user","content":"@topic"}
{"session_id":"s2","project":"gamma","role":"assistant","content":"@topic"}
"##,
)
.expect("ingest entities corpus");
let server = LanternServer::new(store_dir);
let default_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("default entity report");
assert!(
default_report.entries[0].projects.is_none(),
"project evidence must be omitted by default"
);
let zeroed_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: Some(0),
show_users: None,
show_topics: None,
})
.expect("zeroed entity report");
assert!(
zeroed_report.entries[0].projects.is_none(),
"show_projects=0 must collapse to the cheap default path"
);
let opted_in = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: Some(2),
show_users: None,
show_topics: None,
})
.expect("entity report with project evidence");
assert_eq!(
opted_in.entries[0]
.projects
.as_ref()
.expect("opt-in project evidence should be populated"),
&vec!["alpha".to_string(), "beta".to_string()]
);
let scoped = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: Some(5),
show_users: None,
show_topics: None,
})
.expect("session-scoped entity report");
assert_eq!(scoped.session_id.as_deref(), Some("s1"));
assert_eq!(
scoped.entries[0]
.projects
.as_ref()
.expect("session-scoped project evidence should be populated"),
&vec!["alpha".to_string(), "beta".to_string()]
);
}
#[test]
fn entities_sync_show_users_is_opt_in_and_honors_session_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-entities-users",
Some("application/jsonl"),
br##"{"session_id":"s1","user":"bob","role":"assistant","content":"@topic"}
{"session_id":"s1","user":"alice","role":"user","content":"@topic"}
{"session_id":"s2","user":"carol","role":"assistant","content":"@topic"}
"##,
)
.expect("ingest entities corpus");
let server = LanternServer::new(store_dir);
let default_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("default entity report");
assert!(
default_report.entries[0].users.is_none(),
"user evidence must be omitted by default"
);
let zeroed_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: Some(0),
show_topics: None,
})
.expect("zeroed entity report");
assert!(
zeroed_report.entries[0].users.is_none(),
"show_users=0 must collapse to the cheap default path"
);
let opted_in = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: Some(2),
show_topics: None,
})
.expect("entity report with user evidence");
assert_eq!(
opted_in.entries[0]
.users
.as_ref()
.expect("opt-in user evidence should be populated"),
&vec!["alice".to_string(), "bob".to_string()]
);
let scoped = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: Some(5),
show_topics: None,
})
.expect("session-scoped entity report");
assert_eq!(scoped.session_id.as_deref(), Some("s1"));
assert_eq!(
scoped.entries[0]
.users
.as_ref()
.expect("session-scoped user evidence should be populated"),
&vec!["alice".to_string(), "bob".to_string()]
);
}
#[test]
fn entities_sync_show_topics_is_opt_in_and_honors_session_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-entities-topic-evidence",
Some("application/jsonl"),
br##"{"session_id":"s1","topic":"beta","role":"assistant","content":"@topic"}
{"session_id":"s1","topic":"alpha","role":"user","content":"@topic"}
{"session_id":"s2","topic":"gamma","role":"assistant","content":"@topic"}
"##,
)
.expect("ingest entities corpus");
let server = LanternServer::new(store_dir);
let default_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("default entity report");
assert!(
default_report.entries[0].topics.is_none(),
"topic evidence must be omitted by default"
);
let zeroed_report = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: Some(0),
})
.expect("zeroed entity report");
assert!(
zeroed_report.entries[0].topics.is_none(),
"show_topics=0 must collapse to the cheap default path"
);
let opted_in = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: Some(2),
})
.expect("entity report with topic evidence");
assert_eq!(
opted_in.entries[0]
.topics
.as_ref()
.expect("opt-in topic evidence should be populated"),
&vec!["alpha".to_string(), "beta".to_string()]
);
let scoped = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@topic".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: Some(5),
})
.expect("session-scoped entity report");
assert_eq!(scoped.session_id.as_deref(), Some("s1"));
assert_eq!(
scoped.entries[0]
.topics
.as_ref()
.expect("session-scoped topic evidence should be populated"),
&vec!["alpha".to_string(), "beta".to_string()]
);
let json = serde_json::to_value(&scoped).expect("serialize report");
assert_eq!(json["entries"][0]["topics"][0], "alpha");
assert_eq!(json["entries"][0]["topics"][1], "beta");
}
#[test]
fn entities_sync_surfaces_topic_count_through_wire() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-entities-topics",
Some("application/jsonl"),
br##"{"session_id":"s1","topic":"alpha","role":"user","content":"@shared"}
{"session_id":"s1","topic":"alpha","role":"assistant","content":"@shared"}
{"session_id":"s1","topic":"beta","role":"user","content":"@shared"}
{"session_id":"s2","role":"assistant","content":"@shared"}
"##,
)
.expect("ingest entities topic corpus");
let server = LanternServer::new(store_dir);
let global = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@shared".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("global entity report");
assert_eq!(global.entries.len(), 1);
assert_eq!(global.entries[0].value, "@shared");
assert_eq!(global.entries[0].chunk_count, 4);
assert_eq!(global.entries[0].topic_count, 2);
let scoped = server
.entities_sync(EntitiesArgs {
kind: Some("mention".into()),
value_contains: Some("@shared".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("session-scoped entity report");
assert_eq!(scoped.entries.len(), 1);
assert_eq!(scoped.entries[0].session_count, 1);
assert_eq!(scoped.entries[0].topic_count, 2);
let json = serde_json::to_value(&scoped).expect("serialize report");
assert_eq!(json["entries"][0]["topic_count"], 2);
}
#[test]
fn entity_neighbors_sync_returns_ranked_neighbors() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-neighbors-1",
Some("text/plain"),
b"#topic @frequent @sometimes https://one.test\n",
)
.expect("ingest first chunk");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-neighbors-2",
Some("text/plain"),
b"#topic @frequent https://two.test\n",
)
.expect("ingest second chunk");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let report = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: Some(1),
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("neighbor report");
assert_eq!(report.source_value, "#topic");
assert_eq!(report.total_neighbors, 2);
assert_eq!(report.neighbors.len(), 2);
assert_eq!(report.neighbors[0].value, "@frequent");
assert_eq!(report.neighbors[0].shared_chunks, 2);
assert_eq!(
report.neighbors[0].edge_kind,
entities::EdgeKind::CoOccursWith
);
let refs = report.neighbors[0]
.chunks
.as_ref()
.expect("mcp neighbor chunks should be included");
assert_eq!(refs.len(), 1);
assert!(
["stdin://mcp-neighbors-1", "stdin://mcp-neighbors-2"]
.contains(&refs[0].source_uri.as_str())
);
assert!(refs[0].snippet.contains("@frequent"));
assert_eq!(report.neighbors[1].value, "@sometimes");
assert_eq!(report.neighbors[1].shared_chunks, 1);
assert!(report.neighbors.iter().all(|n| n.session_count == 0));
}
#[test]
fn entity_neighbors_sync_surfaces_project_count_through_wire() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-neighbors-projects",
Some("application/jsonl"),
br##"{"session_id":"s1","project":"alpha","role":"user","content":"#topic @shared"}
{"session_id":"s1","project":"beta","role":"assistant","content":"#topic @shared"}
{"session_id":"s2","project":"gamma","role":"user","content":"#topic @shared"}
"##,
)
.expect("ingest entity-neighbor project corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let global = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("global neighbor report");
assert_eq!(global.neighbors.len(), 1);
assert_eq!(global.neighbors[0].value, "@shared");
assert_eq!(global.neighbors[0].project_count, 3);
assert_eq!(global.neighbors[0].session_count, 2);
let scoped = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("session-scoped neighbor report");
assert_eq!(scoped.neighbors.len(), 1);
assert_eq!(scoped.neighbors[0].session_count, 1);
assert_eq!(scoped.neighbors[0].project_count, 2);
let json = serde_json::to_value(&scoped).expect("serialize report");
assert_eq!(json["neighbors"][0]["project_count"], 2);
}
#[test]
fn entity_neighbors_sync_surfaces_user_count_through_wire() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-neighbors-users",
Some("application/jsonl"),
br##"{"session_id":"s1","user":"alice","role":"user","content":"#topic @shared"}
{"session_id":"s1","user":"bob","role":"assistant","content":"#topic @shared"}
{"session_id":"s2","user":"carol","role":"user","content":"#topic @shared"}
"##,
)
.expect("ingest entity-neighbor user corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let global = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("global neighbor report");
assert_eq!(global.neighbors.len(), 1);
assert_eq!(global.neighbors[0].value, "@shared");
assert_eq!(global.neighbors[0].user_count, 3);
assert_eq!(global.neighbors[0].session_count, 2);
let scoped = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("session-scoped neighbor report");
assert_eq!(scoped.neighbors.len(), 1);
assert_eq!(scoped.neighbors[0].session_count, 1);
assert_eq!(scoped.neighbors[0].user_count, 2);
let json = serde_json::to_value(&scoped).expect("serialize report");
assert_eq!(json["neighbors"][0]["user_count"], 2);
}
#[test]
fn entity_neighbors_sync_show_sessions_is_opt_in_and_honors_session_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-neighbors-sessions",
Some("application/jsonl"),
br##"{"session_id":"s3","role":"user","content":"#topic @shared"}
{"session_id":"s1","role":"assistant","content":"#topic @shared"}
{"session_id":"s2","role":"assistant","content":"#topic @shared @only-s2"}
{"session_id":"s4","role":"assistant","content":"@shared"}
"##,
)
.expect("ingest entity-neighbor session corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let default_report = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("default neighbor report");
assert!(
default_report
.neighbors
.iter()
.all(|n| n.shared_session_ids.is_none())
);
let zeroed = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: Some(0),
show_projects: None,
show_users: None,
})
.expect("zeroed neighbor report");
assert!(
zeroed
.neighbors
.iter()
.all(|n| n.shared_session_ids.is_none())
);
let report = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: Some(2),
show_projects: None,
show_users: None,
})
.expect("neighbor report with session evidence");
assert_eq!(report.neighbors.len(), 2);
assert_eq!(report.neighbors[0].value, "@shared");
assert_eq!(
report.neighbors[0].shared_session_ids.as_ref(),
Some(&vec!["s1".to_string(), "s2".to_string()]),
);
assert_eq!(report.neighbors[1].value, "@only-s2");
assert_eq!(
report.neighbors[1].shared_session_ids.as_ref(),
Some(&vec!["s2".to_string()]),
);
let scoped = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s2".into()),
limit: Some(5),
show_chunks: None,
show_sessions: Some(3),
show_projects: None,
show_users: None,
})
.expect("session-scoped neighbor report");
assert_eq!(scoped.session_id.as_deref(), Some("s2"));
assert_eq!(scoped.neighbors.len(), 2);
for neighbor in &scoped.neighbors {
assert_eq!(
neighbor.shared_session_ids.as_ref(),
Some(&vec!["s2".to_string()])
);
}
}
#[test]
fn entity_neighbors_sync_show_projects_is_opt_in_and_honors_session_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-neighbors-projects",
Some("application/jsonl"),
br##"{"session_id":"s2","project":"gamma","role":"user","content":"#topic @shared"}
{"session_id":"s1","project":"alpha","role":"assistant","content":"#topic @shared"}
{"session_id":"s1","project":"beta","role":"assistant","content":"#topic @shared"}
"##,
)
.expect("ingest entity-neighbor project corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let default_report = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
})
.expect("default neighbor report");
assert!(
default_report
.neighbors
.iter()
.all(|n| n.shared_projects.is_none())
);
let zeroed = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: Some(0),
show_users: None,
})
.expect("zeroed neighbor report");
assert!(zeroed.neighbors.iter().all(|n| n.shared_projects.is_none()));
let report = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: Some(2),
show_users: None,
})
.expect("neighbor report with project evidence");
assert_eq!(report.neighbors.len(), 1);
assert_eq!(report.neighbors[0].value, "@shared");
assert_eq!(
report.neighbors[0].shared_projects.as_ref(),
Some(&vec!["alpha".to_string(), "beta".to_string()]),
);
let scoped = server
.entity_neighbors_sync(EntityNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: Some(5),
show_users: None,
})
.expect("session-scoped neighbor report");
assert_eq!(scoped.session_id.as_deref(), Some("s1"));
assert_eq!(scoped.neighbors.len(), 1);
assert_eq!(
scoped.neighbors[0].shared_projects.as_ref(),
Some(&vec!["alpha".to_string(), "beta".to_string()]),
);
}
#[test]
fn entity_session_neighbors_sync_honors_session_id_scope() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-session-neighbors",
Some("application/jsonl"),
br##"{"session_id":"s1","role":"user","content":"#topic @only-s1"}
{"session_id":"s2","role":"user","content":"#topic @shared"}
{"session_id":"s2","role":"assistant","content":"@shared @only-s2"}
"##,
)
.expect("ingest session-neighbor corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let report = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: Some("s2".into()),
limit: Some(5),
show_sessions: None,
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("session-neighbor report");
assert_eq!(report.session_id.as_deref(), Some("s2"));
assert_eq!(report.source_session_count, 1);
assert_eq!(report.total_neighbors, 2);
let values: Vec<&str> = report.neighbors.iter().map(|n| n.value.as_str()).collect();
assert_eq!(values, vec!["@only-s2", "@shared"]);
assert!(report.neighbors.iter().all(|n| n.shared_sessions == 1));
assert!(report.neighbors.iter().all(|n| n.session_count == 1));
assert!(
report
.neighbors
.iter()
.all(|n| n.shared_session_ids.is_none()),
"evidence must be omitted by default"
);
let scoped = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s2".into()),
limit: Some(5),
show_sessions: Some(3),
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("session-neighbor report with evidence");
for n in &scoped.neighbors {
let evidence = n
.shared_session_ids
.as_ref()
.expect("opt-in evidence should be populated");
assert_eq!(evidence, &vec!["s2".to_string()]);
}
}
#[test]
fn entity_session_neighbors_sync_surfaces_project_count_through_wire() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-session-neighbors-projects",
Some("application/jsonl"),
br##"{"session_id":"s1","project":"alpha","role":"user","content":"#topic"}
{"session_id":"s1","project":"alpha","role":"assistant","content":"@shared"}
{"session_id":"s1","project":"beta","role":"user","content":"#topic"}
{"session_id":"s1","project":"beta","role":"assistant","content":"@shared"}
{"session_id":"s2","project":"gamma","role":"user","content":"#topic"}
{"session_id":"s2","project":"gamma","role":"assistant","content":"@shared"}
"##,
)
.expect("ingest session-neighbor project corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let global = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_sessions: None,
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("global session-neighbor report");
assert_eq!(global.neighbors.len(), 1);
assert_eq!(global.neighbors[0].value, "@shared");
assert_eq!(global.neighbors[0].session_count, 2);
assert_eq!(global.neighbors[0].project_count, 3);
let scoped = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_sessions: None,
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("session-scoped session-neighbor report");
assert_eq!(scoped.neighbors.len(), 1);
assert_eq!(scoped.neighbors[0].session_count, 1);
assert_eq!(scoped.neighbors[0].project_count, 2);
let json = serde_json::to_value(&scoped).expect("serialize report");
assert_eq!(json["neighbors"][0]["project_count"], 2);
}
#[test]
fn entity_session_neighbors_sync_surfaces_user_count_through_wire() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-session-neighbors-users",
Some("application/jsonl"),
br##"{"session_id":"s1","user":"alice","role":"user","content":"#topic"}
{"session_id":"s1","user":"alice","role":"assistant","content":"@shared"}
{"session_id":"s1","user":"bob","role":"user","content":"#topic"}
{"session_id":"s1","user":"bob","role":"assistant","content":"@shared"}
{"session_id":"s2","user":"carol","role":"user","content":"#topic"}
{"session_id":"s2","user":"carol","role":"assistant","content":"@shared"}
"##,
)
.expect("ingest session-neighbor user corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let global = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_sessions: None,
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("global session-neighbor report");
assert_eq!(global.neighbors.len(), 1);
assert_eq!(global.neighbors[0].value, "@shared");
assert_eq!(global.neighbors[0].session_count, 2);
assert_eq!(global.neighbors[0].user_count, 3);
let scoped = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: Some("s1".into()),
limit: Some(5),
show_sessions: None,
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("session-scoped session-neighbor report");
assert_eq!(scoped.neighbors.len(), 1);
assert_eq!(scoped.neighbors[0].session_count, 1);
assert_eq!(scoped.neighbors[0].user_count, 2);
let json = serde_json::to_value(&scoped).expect("serialize report");
assert_eq!(json["neighbors"][0]["user_count"], 2);
}
#[test]
fn entity_session_neighbors_sync_threads_show_projects_parity() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-session-neighbor-project-evidence",
Some("application/jsonl"),
br##"{"session_id":"s1","project":"alpha","role":"user","content":"#topic"}
{"session_id":"s1","project":"beta","role":"assistant","content":"@shared"}
{"session_id":"s2","project":"gamma","role":"user","content":"#topic"}
{"session_id":"s2","project":"delta","role":"assistant","content":"@shared"}
"##,
)
.expect("ingest session-neighbor project evidence corpus");
let server = LanternServer::new(store_dir);
let entities = server
.entities_sync(EntitiesArgs {
kind: Some("hashtag".into()),
value_contains: Some("#topic".into()),
session_id: None,
limit: Some(5),
show_chunks: None,
show_sessions: None,
show_projects: None,
show_users: None,
show_topics: None,
})
.expect("list entities");
let topic_id = entities.entries[0].id.clone();
let omitted = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_sessions: None,
show_projects: None,
show_users: None,
show_chunks: None,
})
.expect("session-neighbor report without project evidence");
assert_eq!(omitted.neighbors.len(), 1);
assert!(omitted.neighbors[0].shared_projects.is_none());
let zeroed = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id.clone(),
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_sessions: None,
show_projects: Some(0),
show_users: None,
show_chunks: None,
})
.expect("session-neighbor report with zero project evidence");
assert!(zeroed.neighbors[0].shared_projects.is_none());
let opt_in = server
.entity_session_neighbors_sync(EntitySessionNeighborsArgs {
entity_id: topic_id,
kind: Some("mention".into()),
session_id: None,
limit: Some(5),
show_sessions: None,
show_projects: Some(2),
show_users: None,
show_chunks: None,
})
.expect("session-neighbor report with project evidence");
assert_eq!(
opt_in.neighbors[0].shared_projects.as_ref(),
Some(&vec!["alpha".to_string(), "beta".to_string()]),
);
}
#[test]
fn related_sessions_sync_returns_ranked_sessions() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-related-sessions-1",
Some("application/jsonl"),
br##"{"session_id":"sess-source","role":"user","content":"#alpha #beta","timestamp":100}
{"session_id":"sess-source","role":"assistant","content":"#alpha only","timestamp":150}
{"session_id":"sess-b","role":"assistant","content":"#alpha #gamma","timestamp":200}
{"session_id":"sess-c","role":"assistant","content":"#beta","timestamp":300}
{"session_id":"sess-z","role":"assistant","content":"#delta","timestamp":400}
"##,
)
.expect("ingest session corpus");
let server = LanternServer::new(store_dir);
let report = server
.related_sessions_sync(RelatedSessionsArgs {
session_id: "sess-source".into(),
limit: Some(5),
show_entities: Some(2),
})
.expect("related sessions report");
assert_eq!(report.source_session_id, "sess-source");
assert_eq!(report.source_chunk_count, 2);
assert_eq!(report.source_entity_count, 2);
assert_eq!(report.total_related, 2);
assert_eq!(report.sessions.len(), 2);
assert_eq!(report.sessions[0].session_id, "sess-b");
assert_eq!(report.sessions[0].shared_entity_count, 1);
assert_eq!(report.sessions[0].shared_chunk_count, 1);
assert_eq!(report.sessions[0].chunk_count, 1);
assert_eq!(report.sessions[0].first_timestamp_unix, Some(200));
assert_eq!(report.sessions[0].last_timestamp_unix, Some(200));
let shared_entities = report.sessions[0]
.shared_entities
.as_ref()
.expect("show_entities should include shared entity evidence");
assert_eq!(shared_entities.len(), 1);
assert_eq!(shared_entities[0].kind, "hashtag");
assert_eq!(shared_entities[0].value, "#alpha");
assert_eq!(report.sessions[1].session_id, "sess-c");
assert_eq!(report.sessions[1].shared_entity_count, 1);
assert_eq!(report.sessions[1].shared_chunk_count, 1);
assert_eq!(report.sessions[1].chunk_count, 1);
let shared_entities = report.sessions[1]
.shared_entities
.as_ref()
.expect("show_entities should include shared entity evidence");
assert_eq!(shared_entities.len(), 1);
assert_eq!(shared_entities[0].kind, "hashtag");
assert_eq!(shared_entities[0].value, "#beta");
}
#[test]
fn temporal_sessions_sync_returns_ranked_sessions_and_honors_window() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
ingest::ingest_stdin(
&mut store,
"stdin://mcp-temporal-sessions-1",
Some("application/jsonl"),
br##"{"session_id":"sess-source","role":"user","content":"source","timestamp":100}
{"session_id":"sess-source","role":"assistant","content":"source","timestamp":150}
{"session_id":"sess-overlap","role":"assistant","content":"overlap","timestamp":140}
{"session_id":"sess-overlap","role":"assistant","content":"overlap","timestamp":180}
{"session_id":"sess-near","role":"assistant","content":"near","timestamp":210}
{"session_id":"sess-far","role":"assistant","content":"far","timestamp":1000}
"##,
)
.expect("ingest temporal corpus");
let server = LanternServer::new(store_dir);
let report = server
.temporal_sessions_sync(TemporalSessionsArgs {
session_id: "sess-source".into(),
window_secs: None,
limit: Some(5),
})
.expect("temporal sessions report");
assert_eq!(report.source_session_id, "sess-source");
assert_eq!(report.source_first_timestamp_unix, 100);
assert_eq!(report.source_last_timestamp_unix, 150);
assert_eq!(report.total_related, 3);
assert_eq!(report.sessions.len(), 3);
assert_eq!(report.sessions[0].session_id, "sess-overlap");
assert_eq!(report.sessions[0].gap_secs, 0);
assert_eq!(report.sessions[0].overlap_secs, 10);
assert_eq!(report.sessions[1].session_id, "sess-near");
assert_eq!(report.sessions[1].gap_secs, 60);
assert_eq!(report.sessions[1].overlap_secs, 0);
assert_eq!(report.sessions[2].session_id, "sess-far");
assert_eq!(report.sessions[2].gap_secs, 850);
assert_eq!(report.sessions[2].overlap_secs, 0);
let windowed = server
.temporal_sessions_sync(TemporalSessionsArgs {
session_id: "sess-source".into(),
window_secs: Some(100),
limit: Some(5),
})
.expect("windowed temporal sessions report");
assert_eq!(windowed.total_related, 2);
let windowed_ids: Vec<&str> = windowed
.sessions
.iter()
.map(|s| s.session_id.as_str())
.collect();
assert_eq!(windowed_ids, vec!["sess-overlap", "sess-near"]);
}
#[test]
fn show_sync_includes_chunk_entities_when_show_entities_set() {
let root = tempdir().expect("tempdir");
let store_dir = root.path().join("store");
let mut store = Store::initialize(&store_dir).expect("init store");
let report = ingest::ingest_stdin(
&mut store,
"stdin://mcp-show-entities",
Some("text/plain"),
b"ping @alice at alice@example.com about `src/main.rs` via https://example.com/docs #memory\n",
)
.expect("ingest entity-rich chunk");
let source_id = report.ingested[0].source_id.clone();
let server = LanternServer::new(store_dir);
let plain = server
.show_sync(ShowArgs {
id: source_id.clone(),
show_entities: None,
})
.expect("default show");
assert_eq!(plain.source_id, source_id);
assert!(!plain.chunks.is_empty());
assert!(
plain.chunks.iter().all(|c| c.entities.is_none()),
"default show_sync must omit entities to preserve cheap path"
);
let zero = server
.show_sync(ShowArgs {
id: source_id.clone(),
show_entities: Some(0),
})
.expect("show with explicit zero limit");
assert!(
zero.chunks.iter().all(|c| c.entities.is_none()),
"show_entities=0 must collapse to the cheap default path"
);
let with_entities = server
.show_sync(ShowArgs {
id: source_id,
show_entities: Some(2),
})
.expect("show with entity limit");
let chunk = &with_entities.chunks[0];
let entities = chunk
.entities
.as_ref()
.expect("show_entities should populate the entities field");
assert_eq!(entities.len(), 2);
assert_eq!(entities[0].kind.as_str(), "domain");
assert_eq!(entities[0].value, "example.com");
assert_eq!(entities[1].kind.as_str(), "email");
assert_eq!(entities[1].value, "alice@example.com");
}
}