Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/core/all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ fn build_internal_only_controllers() -> Vec<RegisteredController> {
// whatsapp_data ingest: scanner-side write path. Callable over RPC by the
// Tauri scanner but excluded from agent-facing schema discovery.
controllers.extend(crate::openhuman::whatsapp_data::all_whatsapp_data_internal_controllers());
// MCP write audit list: internal-only so the desktop UI/CLI can inspect
// local write history without exposing cross-client history as an MCP tool.
controllers.extend(crate::openhuman::mcp_audit::all_mcp_audit_internal_controllers());
controllers
}

Expand Down
20 changes: 20 additions & 0 deletions src/core/all_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,26 @@ fn schema_for_rpc_method_finds_security_policy_info() {
assert_eq!(s.function, "policy_info");
}

#[test]
fn schema_for_rpc_method_finds_internal_mcp_audit_list() {
let schema = schema_for_rpc_method("openhuman.mcp_audit_list");
assert!(
schema.is_some(),
"mcp_audit.list should be internally routable"
);
let s = schema.unwrap();
assert_eq!(s.namespace, "mcp_audit");
assert_eq!(s.function, "list");
}

#[test]
fn rpc_method_from_parts_does_not_expose_internal_mcp_audit_list() {
assert!(
rpc_method_from_parts("mcp_audit", "list").is_none(),
"internal MCP audit RPC must not appear in the public controller registry"
);
}

#[test]
fn schema_for_rpc_method_returns_none_for_unknown() {
assert!(schema_for_rpc_method("openhuman.nonexistent_method_xyz").is_none());
Expand Down
17 changes: 17 additions & 0 deletions src/openhuman/mcp_audit/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Persistent audit log for MCP write-tool calls.
//!
//! The audit table is stored in the existing memory-tree SQLite database so
//! writes and their query surface reuse the same local workspace persistence.

mod schemas;
pub mod store;
pub mod types;

pub use schemas::{
all_controller_schemas as all_mcp_audit_controller_schemas,
all_internal_controllers as all_mcp_audit_internal_controllers,
all_registered_controllers as all_mcp_audit_registered_controllers,
schemas as mcp_audit_schemas,
};
pub use store::{list_writes, record_write};
pub use types::{McpWriteListQuery, McpWriteRecord, NewMcpWriteRecord};
203 changes: 203 additions & 0 deletions src/openhuman/mcp_audit/schemas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use serde_json::{Map, Value};

use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;

use super::store;
use super::types::McpWriteListQuery;

pub fn schemas(function: &str) -> ControllerSchema {
match function {
"list" => schema(),
other => panic!("unknown mcp_audit controller schema `{other}`"),
}
}

pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schemas("list")]
}

pub fn all_registered_controllers() -> Vec<RegisteredController> {
all_internal_controllers()
}

pub fn all_internal_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: schemas("list"),
handler: handle_list,
}]
}

fn schema() -> ControllerSchema {
ControllerSchema {
namespace: "mcp_audit",
function: "list",
description: "List MCP write-tool audit records, including successful writes and rejected or failed write attempts, from local workspace persistence.",
inputs: vec![
FieldSchema {
name: "limit",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum number of rows to return (default 50, max 500).",
required: false,
},
FieldSchema {
name: "offset",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Number of rows to skip from the newest-first result set.",
required: false,
},
FieldSchema {
name: "since_ms",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Only return rows at or after this Unix timestamp in milliseconds.",
required: false,
},
FieldSchema {
name: "client_filter",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Exact client_info filter, for example `mcp:claude-desktop`.",
required: false,
},
FieldSchema {
name: "tool_filter",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Exact tool_name filter, for example `memory.store`.",
required: false,
},
FieldSchema {
name: "success_only",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "When true, only return rows where the write attempt succeeded.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "records",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("McpWriteRecord"))),
comment: "MCP write attempt audit records ordered by timestamp descending.",
required: true,
}],
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn handle_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("[mcp_audit] handle_list enter params={params:?}");
log::trace!("[mcp_audit] handle_list loading config");
let config = match config_rpc::load_config_with_timeout().await {
Ok(config) => {
log::trace!(
"[mcp_audit] handle_list config loaded workspace={}",
config.workspace_dir.display()
);
config
}
Err(err) => {
log::warn!("[mcp_audit] handle_list config load failed error={err}");
return Err(err);
}
};

let query = match serde_json::from_value::<McpWriteListQuery>(Value::Object(params)) {
Ok(query) => {
log::trace!("[mcp_audit] handle_list parsed query={query:?}");
query
}
Err(err) => {
log::warn!("[mcp_audit] handle_list invalid params error={err}");
return Err(format!("invalid params: {err}"));
}
};

log::trace!(
"[mcp_audit] handle_list querying store workspace={} query={query:?}",
config.workspace_dir.display()
);
let records = match store::list_writes(&config, &query) {
Ok(records) => {
log::trace!(
"[mcp_audit] handle_list store success records={}",
records.len()
);
records
}
Err(err) => {
log::warn!("[mcp_audit] handle_list store failed query={query:?} error={err}");
return Err(err.to_string());
}
};

let count = records.len();
let records_value = serde_json::to_value(records).map_err(|err| {
log::warn!("[mcp_audit] handle_list serialize response failed error={err}");
err.to_string()
})?;
log::debug!("[mcp_audit] handle_list exit records={count}");
Ok(serde_json::json!({ "records": records_value }))
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn internal_controller_registers_expected_rpc_name() {
let controllers = all_internal_controllers();
assert_eq!(controllers.len(), 1);
assert_eq!(controllers[0].schema.namespace, "mcp_audit");
assert_eq!(controllers[0].schema.function, "list");
assert_eq!(controllers[0].rpc_method_name(), "openhuman.mcp_audit_list");
}

#[test]
fn domain_schema_exports_match_internal_controller() {
let schemas = all_controller_schemas();
let controllers = all_registered_controllers();

assert_eq!(schemas.len(), 1);
assert_eq!(schemas[0].namespace, "mcp_audit");
assert_eq!(controllers.len(), 1);
assert_eq!(controllers[0].schema.function, schemas[0].function);
}

#[tokio::test]
async fn handle_list_returns_persisted_audit_records() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}

let config = config_rpc::load_config_with_timeout()
.await
.expect("config");
store::record_write(
&config,
crate::openhuman::mcp_audit::NewMcpWriteRecord {
timestamp_ms: 10,
client_info: "mcp:test".into(),
tool_name: "memory.store".into(),
args_summary: json!({ "title": "safe" }),
resulting_chunk_id: Some("chunk-1".into()),
success: true,
error_message: None,
},
)
.expect("record write");

let value = handle_list(Map::new()).await.expect("handle list");
let records = value["records"].as_array().expect("records array");
assert_eq!(records.len(), 1);
assert_eq!(records[0]["tool_name"], "memory.store");
assert_eq!(records[0]["client_info"], "mcp:test");

unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
}
Loading
Loading