use std::{
collections::HashSet,
sync::{Arc, Mutex},
};
use tracing::{subscriber::with_default, Subscriber};
use tracing_subscriber::{
layer::{Context, Layer, SubscriberExt},
Registry,
};
#[derive(Debug, Clone)]
struct SpanRecord {
name: String,
#[allow(dead_code)]
level: tracing::Level,
fields: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
struct EventRecord {
message: String,
level: tracing::Level,
fields: Vec<(String, String)>,
}
#[derive(Default)]
struct RecordStore {
spans: Vec<SpanRecord>,
events: Vec<EventRecord>,
}
struct CapturingLayer {
store: Arc<Mutex<RecordStore>>,
}
struct KvVisitor(Vec<(String, String)>);
impl tracing::field::Visit for KvVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, val: &dyn std::fmt::Debug) {
self.0.push((field.name().to_string(), format!("{val:?}")));
}
fn record_str(&mut self, field: &tracing::field::Field, val: &str) {
self.0.push((field.name().to_string(), val.to_string()));
}
fn record_i64(&mut self, field: &tracing::field::Field, val: i64) {
self.0.push((field.name().to_string(), val.to_string()));
}
fn record_u64(&mut self, field: &tracing::field::Field, val: u64) {
self.0.push((field.name().to_string(), val.to_string()));
}
fn record_bool(&mut self, field: &tracing::field::Field, val: bool) {
self.0.push((field.name().to_string(), val.to_string()));
}
}
impl<S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>> Layer<S>
for CapturingLayer
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
id: &tracing::span::Id,
ctx: Context<'_, S>,
) {
let mut visitor = KvVisitor(Vec::new());
attrs.record(&mut visitor);
let mut kv = visitor.0;
for field in attrs.fields() {
let n = field.name().to_string();
if !kv.iter().any(|(k, _)| k == &n) {
kv.push((n, String::new()));
}
}
let record = SpanRecord {
name: attrs.metadata().name().to_string(),
level: *attrs.metadata().level(),
fields: kv.clone(),
};
self.store.lock().unwrap().spans.push(record);
if let Some(span) = ctx.span(id) {
span.extensions_mut().insert(kv);
}
}
fn on_record(
&self,
id: &tracing::span::Id,
values: &tracing::span::Record<'_>,
ctx: Context<'_, S>,
) {
let mut visitor = KvVisitor(Vec::new());
values.record(&mut visitor);
let new_kv = visitor.0;
if let Some(span) = ctx.span(id) {
let mut ext = span.extensions_mut();
if let Some(kv) = ext.get_mut::<Vec<(String, String)>>() {
for (new_key, new_val) in &new_kv {
if let Some(existing) = kv.iter_mut().find(|(k, _)| k == new_key) {
existing.1 = new_val.clone();
} else {
kv.push((new_key.clone(), new_val.clone()));
}
}
let span_name = span.name().to_string();
drop(ext);
let mut store = self.store.lock().unwrap();
if let Some(rec) = store.spans.iter_mut().rev().find(|s| s.name == span_name) {
for (new_key, new_val) in &new_kv {
if let Some(existing) = rec.fields.iter_mut().find(|(k, _)| k == new_key) {
existing.1 = new_val.clone();
} else {
rec.fields.push((new_key.clone(), new_val.clone()));
}
}
}
}
}
}
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let mut kv: Vec<(String, String)> = Vec::new();
let mut message = String::new();
struct EventVisitor<'a> {
kv: &'a mut Vec<(String, String)>,
message: &'a mut String,
}
impl<'a> tracing::field::Visit for EventVisitor<'a> {
fn record_debug(&mut self, field: &tracing::field::Field, val: &dyn std::fmt::Debug) {
let s = format!("{val:?}");
if field.name() == "message" {
*self.message = s;
} else {
self.kv.push((field.name().to_string(), s));
}
}
fn record_str(&mut self, field: &tracing::field::Field, val: &str) {
if field.name() == "message" {
*self.message = val.to_string();
} else {
self.kv.push((field.name().to_string(), val.to_string()));
}
}
fn record_i64(&mut self, field: &tracing::field::Field, val: i64) {
self.kv.push((field.name().to_string(), val.to_string()));
}
fn record_u64(&mut self, field: &tracing::field::Field, val: u64) {
self.kv.push((field.name().to_string(), val.to_string()));
}
fn record_bool(&mut self, field: &tracing::field::Field, val: bool) {
self.kv.push((field.name().to_string(), val.to_string()));
}
}
event.record(&mut EventVisitor {
kv: &mut kv,
message: &mut message,
});
self.store.lock().unwrap().events.push(EventRecord {
message,
level: *event.metadata().level(),
fields: kv,
});
}
}
fn with_capturing<F, R>(f: F) -> (R, Arc<Mutex<RecordStore>>)
where
F: FnOnce() -> R,
{
let store = Arc::new(Mutex::new(RecordStore::default()));
let layer = CapturingLayer {
store: Arc::clone(&store),
};
let subscriber = Registry::default().with(layer);
let result = with_default(subscriber, f);
(result, store)
}
fn canonical_fields() -> HashSet<&'static str> {
[
"session_id",
"name",
"scope",
"content_size",
"batch_size",
"chunk_size",
"dimensions",
"model",
"count",
"key_count",
"branch",
"file_count",
"oid",
"token_source",
"duration_ms",
"message",
"error",
"pull_first",
"limit",
"log.target",
"log.module_path",
"log.file",
"log.line",
]
.into_iter()
.collect()
}
#[test]
fn index_add_span_has_correct_name() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::Scope;
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc01-test".to_string());
});
let spans = store.lock().unwrap();
let names: Vec<&str> = spans.spans.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"index.add"),
"expected 'index.add' span, got: {names:?}"
);
}
#[test]
fn index_remove_span_has_correct_name() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::Scope;
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc01-remove".to_string());
let _ = idx.remove(&Scope::Global, "global/tc01-remove");
});
let spans = store.lock().unwrap();
let names: Vec<&str> = spans.spans.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"index.remove"),
"expected 'index.remove' span, got: {names:?}"
);
}
#[test]
fn index_search_span_has_correct_name() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::{Scope, ScopeFilter};
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc01-search".to_string());
let _ = idx.search(&ScopeFilter::GlobalOnly, &v, 5);
});
let spans = store.lock().unwrap();
let names: Vec<&str> = spans.spans.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"index.search"),
"expected 'index.search' span, got: {names:?}"
);
}
#[test]
fn index_save_load_spans_have_correct_names() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::Scope;
let dir = tempfile::tempdir().expect("tempdir");
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc01-save".to_string());
let _ = idx.save(dir.path());
let _ = ScopedIndex::load(dir.path(), 4);
});
let spans = store.lock().unwrap();
let names: Vec<&str> = spans.spans.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"index.save"),
"expected 'index.save' span, got: {names:?}"
);
assert!(
names.contains(&"index.load"),
"expected 'index.load' span, got: {names:?}"
);
}
#[test]
fn index_spans_only_use_canonical_fields() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::{Scope, ScopeFilter};
let dir = tempfile::tempdir().expect("tempdir");
let allowed = canonical_fields();
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc04-test".to_string());
let _ = idx.remove(&Scope::Global, "global/tc04-test");
let _ = idx.search(&ScopeFilter::All, &v, 5);
let _ = idx.save(dir.path());
let _ = ScopedIndex::load(dir.path(), 4);
});
let spans = store.lock().unwrap();
let index_spans: Vec<&SpanRecord> = spans
.spans
.iter()
.filter(|s| s.name.starts_with("index."))
.collect();
assert!(
!index_spans.is_empty(),
"expected at least one index.* span"
);
for span in &index_spans {
for (field_name, _) in &span.fields {
assert!(
allowed.contains(field_name.as_str()),
"span '{}' has field '{}' not in canonical allowlist",
span.name,
field_name
);
}
}
}
#[test]
fn index_add_span_has_scope_and_dimensions_fields() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::Scope;
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(8).expect("create index");
let v = vec![0.0_f32; 8];
let _ = idx.add(&Scope::Global, &v, "global/tc06-test".to_string());
});
let spans = store.lock().unwrap();
let add_span = spans
.spans
.iter()
.find(|s| s.name == "index.add")
.expect("index.add span not found");
assert!(
add_span.fields.iter().any(|(k, _)| k == "scope"),
"index.add missing 'scope' field"
);
assert!(
add_span.fields.iter().any(|(k, _)| k == "dimensions"),
"index.add missing 'dimensions' field"
);
assert!(
add_span.fields.iter().any(|(k, _)| k == "key_count"),
"index.add missing 'key_count' field"
);
}
#[test]
fn index_search_span_has_required_fields() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::{Scope, ScopeFilter};
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc07".to_string());
let _ = idx.search(&ScopeFilter::GlobalOnly, &v, 5);
});
let spans = store.lock().unwrap();
let search_span = spans
.spans
.iter()
.find(|s| s.name == "index.search")
.expect("index.search span not found");
assert!(
search_span.fields.iter().any(|(k, _)| k == "scope"),
"index.search missing 'scope' field"
);
assert!(
search_span.fields.iter().any(|(k, _)| k == "dimensions"),
"index.search missing 'dimensions' field"
);
assert!(
search_span.fields.iter().any(|(k, _)| k == "count"),
"index.search missing 'count' field"
);
}
#[test]
fn index_save_span_has_key_count_field() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::Scope;
let dir = tempfile::tempdir().expect("tempdir");
let (_, store) = with_capturing(|| {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc09-save".to_string());
let _ = idx.save(dir.path());
});
let spans = store.lock().unwrap();
let save_span = spans
.spans
.iter()
.find(|s| s.name == "index.save")
.expect("index.save span not found");
assert!(
save_span.fields.iter().any(|(k, _)| k == "key_count"),
"index.save missing 'key_count' field"
);
}
#[test]
fn auth_resolution_never_logs_token_value() {
use memory_mcp::auth::AuthProvider;
let token_value = "ghp_super_secret_test_token_12345";
let (_, store) = with_capturing(|| {
std::env::set_var("MEMORY_MCP_GITHUB_TOKEN", token_value);
let provider = AuthProvider::new();
let _ = provider.resolve_token();
std::env::remove_var("MEMORY_MCP_GITHUB_TOKEN");
});
let store = store.lock().unwrap();
for span in &store.spans {
for (field_name, field_value) in &span.fields {
assert!(
!field_value.contains(token_value),
"span '{}' field '{}' value contains raw token: {field_value}",
span.name,
field_name
);
}
}
for event in &store.events {
for (k, v) in &event.fields {
assert!(
!v.contains(token_value),
"event field {k}={v:?} contains raw token"
);
}
assert!(
!event.message.contains(token_value),
"event message contains raw token: {:?}",
event.message
);
}
}
#[test]
fn repo_init_url_is_redacted_in_logs() {
use memory_mcp::repo::MemoryRepo;
let dir = tempfile::tempdir().expect("tempdir");
let fake_url = "https://x-access-token:ghp_faketoken@github.com/owner/repo.git";
let (_, store) = with_capturing(|| {
let _result = MemoryRepo::init_or_open(dir.path(), Some(fake_url));
});
let store = store.lock().unwrap();
for event in &store.events {
assert!(
!event.message.contains("ghp_faketoken"),
"event message contains raw token in URL: {:?}",
event.message
);
for (k, v) in &event.fields {
assert!(
!v.contains("ghp_faketoken"),
"event field {k}={v:?} contains raw token in URL"
);
}
}
for span in &store.spans {
for (field_name, field_value) in &span.fields {
assert!(
!field_value.contains("ghp_faketoken"),
"span '{}' field '{}' value contains raw token in URL: {field_value}",
span.name,
field_name
);
}
}
}
#[test]
fn auth_failure_produces_warn_event() {
use memory_mcp::auth::AuthProvider;
let real_home = std::env::var_os("HOME");
let saved_token = std::env::var_os("MEMORY_MCP_GITHUB_TOKEN");
let fake_home = tempfile::tempdir().expect("tempdir for fake HOME");
let (_, store) = with_capturing(|| {
std::env::remove_var("MEMORY_MCP_GITHUB_TOKEN");
std::env::set_var("HOME", fake_home.path());
let provider = AuthProvider::new();
let _ = provider.resolve_token();
match &real_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
match &saved_token {
Some(t) => std::env::set_var("MEMORY_MCP_GITHUB_TOKEN", t),
None => {} }
});
let store = store.lock().unwrap();
let warn_events: Vec<&EventRecord> = store
.events
.iter()
.filter(|e| e.level == tracing::Level::WARN)
.collect();
assert!(
!warn_events.is_empty(),
"expected at least one warn event from auth failure (no keyring in CI)"
);
for event in &warn_events {
for (k, v) in &event.fields {
assert!(
!v.starts_with("ghp_") && !v.starts_with("github_pat_"),
"warn event field {k}={v:?} looks like a raw GitHub token"
);
}
}
}
#[test]
fn debug_spans_are_filtered_when_only_info_enabled() {
use memory_mcp::index::ScopedIndex;
use memory_mcp::types::Scope;
let baseline_store = Arc::new(Mutex::new(RecordStore::default()));
{
let layer = CapturingLayer {
store: Arc::clone(&baseline_store),
};
let filter = tracing_subscriber::EnvFilter::new("debug");
let subscriber = Registry::default().with(layer).with(filter);
with_default(subscriber, || {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc20-baseline".to_string());
});
}
{
let spans = baseline_store.lock().unwrap();
let index_spans: Vec<&SpanRecord> = spans
.spans
.iter()
.filter(|s| s.name.starts_with("index."))
.collect();
assert!(
!index_spans.is_empty(),
"baseline: expected index.* spans to appear under DEBUG filter"
);
}
let store = Arc::new(Mutex::new(RecordStore::default()));
let layer = CapturingLayer {
store: Arc::clone(&store),
};
let filter = tracing_subscriber::EnvFilter::new("info");
let subscriber = Registry::default().with(layer).with(filter);
with_default(subscriber, || {
let idx = ScopedIndex::new(4).expect("create index");
let v = vec![1.0_f32, 0.0, 0.0, 0.0];
let _ = idx.add(&Scope::Global, &v, "global/tc20-filter".to_string());
});
let spans = store.lock().unwrap();
let debug_spans: Vec<&SpanRecord> = spans
.spans
.iter()
.filter(|s| s.name.starts_with("index."))
.collect();
assert!(
debug_spans.is_empty(),
"debug-level index.* spans should be filtered under INFO: {debug_spans:?}"
);
}
#[test]
fn repo_save_span_has_name_and_oid_fields() {
use memory_mcp::repo::MemoryRepo;
use memory_mcp::types::{Memory, MemoryMetadata, Scope};
use std::sync::Arc;
let dir = tempfile::tempdir().expect("tempdir");
let (_, store) = with_capturing(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let repo = Arc::new(MemoryRepo::init_or_open(dir.path(), None).expect("init repo"));
let meta = MemoryMetadata {
tags: vec![],
scope: Scope::Global,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
source: None,
};
let mem = Memory::new("tc08-memory".to_string(), "test content".to_string(), meta);
let _ = repo.save_memory(&mem).await;
});
});
let spans = store.lock().unwrap();
let save_span = spans
.spans
.iter()
.find(|s| s.name == "repo.save")
.expect("repo.save span not found");
assert!(
save_span.fields.iter().any(|(k, _)| k == "name"),
"repo.save missing 'name' field; fields: {:?}",
save_span.fields
);
assert!(
save_span.fields.iter().any(|(k, _)| k == "oid"),
"repo.save missing 'oid' field; fields: {:?}",
save_span.fields
);
}
#[test]
fn repo_save_does_not_log_content_text() {
use memory_mcp::repo::MemoryRepo;
use memory_mcp::types::{Memory, MemoryMetadata, Scope};
use std::sync::Arc;
let dir = tempfile::tempdir().expect("tempdir");
let secret_content = "top_secret_content_abc123";
let (_, store) = with_capturing(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let repo = Arc::new(MemoryRepo::init_or_open(dir.path(), None).expect("init repo"));
let meta = MemoryMetadata {
tags: vec![],
scope: Scope::Global,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
source: None,
};
let mem = Memory::new("tc17-memory".to_string(), secret_content.to_string(), meta);
let _ = repo.save_memory(&mem).await;
});
});
let store = store.lock().unwrap();
for span in &store.spans {
for (field_name, field_value) in &span.fields {
assert!(
!field_value.contains(secret_content),
"span '{}' field '{}' value contains content text: {field_value}",
span.name,
field_name
);
}
}
for event in &store.events {
assert!(
!event.message.contains(secret_content),
"event message contains content text: {:?}",
event.message
);
for (k, v) in &event.fields {
assert!(
!v.contains(secret_content),
"event field {k}={v:?} contains content text"
);
}
}
}