Original issue tinyhumansai#2536 by @justinhsu1477 on 2026-05-23T14:18:50Z
Summary
Add a persistent mcp_writes audit log table that records every successful MCP write tool invocation (memory.store, memory.note, tree.tag) so users can review what LLMs connected via MCP have written to their Memory Tree. Replaces the current tracing::info!-only audit trail with a queryable surface.
This closes out Q4 from the Phase 3 RFC (tinyhumansai#2269) — the only one of the four RFC questions that doesn't have an implementation answer yet:
Problem
After tinyhumansai#2306 / tinyhumansai#2316 / tinyhumansai#2332 landed, the MCP write surface is functionally complete but only emits ephemeral tracing::info! lines for write events. Quoting @graycyrus's tinyhumansai#2306 review verbatim:
"The tracing::info! audit log exists but only shows in logs. A UI-side notification ... would give users visibility."
"The real residual risk isn't injection or corruption (that's well-covered), it's a rogue LLM silently filling memory with noise and the user not knowing."
Concrete user-impact gaps without a queryable audit log:
- No accountability — a user can't answer "what did Claude Desktop write into my memory this week?" without
grep-ing log files.
- No compliance story — enterprise users with audit-trail requirements can't satisfy them with a tracing-only sink.
- No abuse detection — a misbehaving client filling memory at the per-hour rate limit shows up only as log spam, not a queryable signal.
- No undo path — even if a user spots a problem write in the log, there's no structured handle to address it.
Proposed solution
Add a new SQLite table next to the existing memory tree DB (same SQLite handle to avoid a second connection):
CREATE TABLE mcp_writes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp_ms INTEGER NOT NULL,
client_info TEXT NOT NULL, -- "mcp:claude-desktop" / "mcp:cursor" / fallback "mcp"
tool_name TEXT NOT NULL, -- "memory.store" / "memory.note" / "tree.tag"
args_summary TEXT, -- JSON object with non-PII identifying args (see below)
resulting_chunk_id TEXT, -- document_id returned by memory_doc_put
success INTEGER NOT NULL, -- 1 success, 0 failure
error_message TEXT -- populated only when success == 0
);
CREATE INDEX idx_mcp_writes_timestamp ON mcp_writes (timestamp_ms DESC);
CREATE INDEX idx_mcp_writes_client ON mcp_writes (client_info);
CREATE INDEX idx_mcp_writes_tool ON mcp_writes (tool_name);
What goes into args_summary
Per-tool slim JSON that captures the identifying args without duplicating the document content (which is already persisted via memory_doc_put):
| Tool |
args_summary shape |
memory.store |
{ "title": "<first 128 chars>", "namespace": "...", "tag_count": N } |
memory.note |
{ "chunk_id": "...", "note_text_length": N } |
tree.tag |
{ "chunk_id": "...", "tags": [...] } |
args_summary deliberately avoids storing raw note/store content twice — the actual content lives in the memory tree itself; the audit table just records the metadata enough to identify and join.
Write flow
Inside dispatch_write_tool in src/openhuman/mcp_server/tools.rs:
// Existing: dispatch to doc_put
match all::try_invoke_registered_rpc(rpc_method, params.clone()).await {
Some(Ok(value)) => {
let document_id = value.get("document_id").and_then(Value::as_str);
// NEW: audit record after successful write
let _ = mcp_audit::record_write(McpWriteRecord {
timestamp_ms: now_ms(),
client_info: session.source_type(), // from McpSession
tool_name: tool_name.to_string(),
args_summary: summarize_args(tool_name, params),
resulting_chunk_id: document_id.map(str::to_string),
success: true,
error_message: None,
}).await;
tracing::info!(tool = tool_name, chunk_id = document_id, "write success");
Ok(tool_success(value))
}
Some(Err(message)) => {
// NEW: audit failed writes too — abuse detection signal
let _ = mcp_audit::record_write(McpWriteRecord {
success: false,
error_message: Some(message.clone()),
...
}).await;
...
}
...
}
The audit insert uses let _ = ... .await (best-effort) — see Q2 below.
Query surface
New RPC: openhuman.mcp_audit_list (read-only):
PutMcpAuditListParams {
limit: u32, // default 50, max 500
offset: u32, // default 0
since_ms: Option<u64>, // optional time-window filter
client_filter: Option<String>, // optional "mcp:claude-desktop" filter
tool_filter: Option<String>, // optional "memory.store" filter
success_only: Option<bool>, // default None (both); useful for UI's "show failures"
}
Returns Vec<McpWriteRecord> ordered by timestamp_ms DESC.
Open design questions (need maintainer direction)
Q1 — Storage backend
A. Add the table to the existing memory tree SQLite (single handle, single migration, transactional with the write itself). Easiest deployment.
B. Separate mcp_audit.sqlite file (isolated; survives memory tree corruption / reset; can be wiped independently for privacy). Bit more plumbing.
C. Reuse one of the existing KV stores or event_bus persistence layers if there's a natural home I haven't found.
The implementation sketch assumes A, but mcp_writes is a write-heavy append-only log with very different access patterns from the chunk tree — splitting may be cleaner.
Q2 — Audit-write coupling on failure
A. Best-effort audit (write succeeds even if audit fails) — the sketch above. Audit failure is logged but not propagated. Pro: write availability never degrades; con: theoretical race where the chunk lands but the audit row doesn't, breaking the "every write is auditable" guarantee.
B. Transactional (audit-then-write inside one SQLite tx) — strict guarantee, but couples write latency to the audit table and complicates the memory_doc_put RPC boundary.
C. Write-then-audit, abort-on-audit-failure — between A and B; would require rolling back the just-completed write, which doc_put doesn't currently support cleanly.
Preference: A unless maintainers want the stronger guarantee for compliance reasons.
Q3 — Retention policy
A. No automatic prune (table grows forever). Simplest; trusts user disk space.
B. Rolling window — e.g. delete rows older than 90 days during a daily job (could piggyback on the existing memory tree scheduler).
C. Size-bounded — drop oldest rows when the table exceeds N MB.
D. Per-user opt-in retention setting (under config.mcp.audit_retention_days).
For v1, leaning A (no prune) with an explicit "retention is a follow-up" note — simpler scope, doesn't lock us into a policy before we see real usage volume.
Q4 — Query surface
A. Internal RPC only (openhuman.mcp_audit_list) — OpenHuman's own UI or CLI can read; MCP clients themselves cannot see their audit history. Tightest blast radius.
B. Also expose as an MCP tool (e.g. audit.list_writes) — lets a client like Claude Desktop reflect on its own writes, useful for "show me what you stored last session" UX. But also means an MCP client can see what other clients wrote to the same user's memory tree.
C. Hybrid — MCP tool exposes only the current client's own writes (filtered by client_info == session.source_type()), while the internal RPC sees all.
Strong preference for A in v1 (smallest surface, fewest privacy decisions). C is the right long-term shape; B is too permissive.
Acceptance criteria
Out of scope (follow-ups)
Related
Summary
Add a persistent
mcp_writesaudit log table that records every successful MCP write tool invocation (memory.store,memory.note,tree.tag) so users can review what LLMs connected via MCP have written to their Memory Tree. Replaces the currenttracing::info!-only audit trail with a queryable surface.This closes out Q4 from the Phase 3 RFC (tinyhumansai#2269) — the only one of the four RFC questions that doesn't have an implementation answer yet:
doc_put(daemon-sole-writer)source_type = "mcp:<client>"(feat(mcp): add memory.store and memory.note write tools tinyhumansai/openhuman#2306 placeholder + YOMXXX's feat(mcp): capture client provenance in stdio sessions tinyhumansai/openhuman#2332clientInfo.namecapture)SecurityPolicy→ distinctenforce_write_policy()gating onToolOperation::Act(feat(mcp): add memory.store and memory.note write tools tinyhumansai/openhuman#2306)Problem
After tinyhumansai#2306 / tinyhumansai#2316 / tinyhumansai#2332 landed, the MCP write surface is functionally complete but only emits ephemeral
tracing::info!lines for write events. Quoting @graycyrus's tinyhumansai#2306 review verbatim:Concrete user-impact gaps without a queryable audit log:
grep-ing log files.Proposed solution
Add a new SQLite table next to the existing memory tree DB (same SQLite handle to avoid a second connection):
What goes into
args_summaryPer-tool slim JSON that captures the identifying args without duplicating the document content (which is already persisted via
memory_doc_put):args_summaryshapememory.store{ "title": "<first 128 chars>", "namespace": "...", "tag_count": N }memory.note{ "chunk_id": "...", "note_text_length": N }tree.tag{ "chunk_id": "...", "tags": [...] }args_summarydeliberately avoids storing raw note/store content twice — the actual content lives in the memory tree itself; the audit table just records the metadata enough to identify and join.Write flow
Inside
dispatch_write_toolinsrc/openhuman/mcp_server/tools.rs:The audit insert uses
let _ = ... .await(best-effort) — see Q2 below.Query surface
New RPC:
openhuman.mcp_audit_list(read-only):Returns
Vec<McpWriteRecord>ordered bytimestamp_ms DESC.Open design questions (need maintainer direction)
Q1 — Storage backend
A. Add the table to the existing memory tree SQLite (single handle, single migration, transactional with the write itself). Easiest deployment.
B. Separate
mcp_audit.sqlitefile (isolated; survives memory tree corruption / reset; can be wiped independently for privacy). Bit more plumbing.C. Reuse one of the existing KV stores or event_bus persistence layers if there's a natural home I haven't found.
The implementation sketch assumes A, but
mcp_writesis a write-heavy append-only log with very different access patterns from the chunk tree — splitting may be cleaner.Q2 — Audit-write coupling on failure
A. Best-effort audit (write succeeds even if audit fails) — the sketch above. Audit failure is logged but not propagated. Pro: write availability never degrades; con: theoretical race where the chunk lands but the audit row doesn't, breaking the "every write is auditable" guarantee.
B. Transactional (audit-then-write inside one SQLite tx) — strict guarantee, but couples write latency to the audit table and complicates the
memory_doc_putRPC boundary.C. Write-then-audit, abort-on-audit-failure — between A and B; would require rolling back the just-completed write, which
doc_putdoesn't currently support cleanly.Preference: A unless maintainers want the stronger guarantee for compliance reasons.
Q3 — Retention policy
A. No automatic prune (table grows forever). Simplest; trusts user disk space.
B. Rolling window — e.g. delete rows older than 90 days during a daily job (could piggyback on the existing memory tree scheduler).
C. Size-bounded — drop oldest rows when the table exceeds N MB.
D. Per-user opt-in retention setting (under
config.mcp.audit_retention_days).For v1, leaning A (no prune) with an explicit "retention is a follow-up" note — simpler scope, doesn't lock us into a policy before we see real usage volume.
Q4 — Query surface
A. Internal RPC only (
openhuman.mcp_audit_list) — OpenHuman's own UI or CLI can read; MCP clients themselves cannot see their audit history. Tightest blast radius.B. Also expose as an MCP tool (e.g.
audit.list_writes) — lets a client like Claude Desktop reflect on its own writes, useful for "show me what you stored last session" UX. But also means an MCP client can see what other clients wrote to the same user's memory tree.C. Hybrid — MCP tool exposes only the current client's own writes (filtered by
client_info == session.source_type()), while the internal RPC sees all.Strong preference for A in v1 (smallest surface, fewest privacy decisions). C is the right long-term shape; B is too permissive.
Acceptance criteria
mcp_writestable created via SQLite migration; existing users migrate cleanly on first launch after upgrade.dispatch_write_toolcall inserts a row with the correctclient_info(fromMcpSession),tool_name,args_summary, andresulting_chunk_id.success: 0,error_messagepopulated) — abuse-detection signal.args_summarydoes not duplicate the chunk content (only identifying metadata).openhuman.mcp_audit_listRPC registered and returns records ordered bytimestamp_ms DESC, withlimit/offset/ filter support.Out of scope (follow-ups)
memory.search,tree.read_chunk, etc.) — debatable whether reads need audit; defer until we see a request.Related
memory.store/memory.note) — answered Q1, Q2 placeholder, Q3.tree.tag) — third write tool; same audit hook applies uniformly.clientInfo.namecapture) — provides theclient_infofield this audit table records.