Skip to main content

systemprompt_api/services/proxy/audit/
mod.rs

1//! Per-tool audit for external MCP servers served over the HTTP gateway.
2//!
3//! A client-mediated `tools/call` to an external provider has no backend
4//! process to record it, so the gateway taps the forwarded request/response and
5//! writes one `mcp_tool_executions` row under the calling user. `record`
6//! composes the tap over the upstream body; the tap owns an [`McpAudit`] and
7//! finalizes it (once) on stream EOF or drop.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12pub mod jsonrpc;
13pub mod tap;
14
15use std::sync::Arc;
16
17use chrono::{DateTime, Utc};
18use serde_json::Value;
19use systemprompt_mcp::models::{ExecutionStatus, ToolExecutionRequest, ToolExecutionResult};
20use systemprompt_mcp::repository::ToolUsageRepository;
21use systemprompt_models::RequestContext;
22
23pub(crate) use jsonrpc::parse_tool_call;
24pub(crate) use tap::record;
25
26use jsonrpc::{ToolCallInvocation, ToolCallOutcome};
27
28#[derive(Debug)]
29pub struct McpAudit {
30    repo: Arc<ToolUsageRepository>,
31    context: RequestContext,
32    server_name: String,
33    invocation: ToolCallInvocation,
34    started_at: DateTime<Utc>,
35}
36
37impl McpAudit {
38    pub fn new(
39        repo: Arc<ToolUsageRepository>,
40        context: RequestContext,
41        server_name: String,
42        invocation: ToolCallInvocation,
43    ) -> Self {
44        Self {
45            repo,
46            context,
47            server_name,
48            invocation,
49            started_at: Utc::now(),
50        }
51    }
52
53    const fn request_id(&self) -> &Value {
54        &self.invocation.id
55    }
56
57    fn finalize(self, outcome: Option<ToolCallOutcome>) {
58        let (output, error_message) = match outcome {
59            Some(o) => (o.output, o.error_message),
60            None => (
61                None,
62                Some("external MCP tool call produced no parseable result".to_owned()),
63            ),
64        };
65
66        let request = ToolExecutionRequest {
67            tool_name: self.invocation.tool_name,
68            server_name: self.server_name.clone(),
69            input: self.invocation.arguments,
70            started_at: self.started_at,
71            context: self.context,
72            request_method: Some("mcp".to_owned()),
73            request_source: Some(self.server_name),
74            ai_tool_call_id: None,
75        };
76        let result = ToolExecutionResult {
77            status: ExecutionStatus::from_error(error_message.is_some()).to_string(),
78            error_message,
79            output,
80            output_schema: None,
81            started_at: self.started_at,
82            completed_at: Utc::now(),
83        };
84
85        let repo = self.repo;
86        tokio::spawn(async move {
87            if let Err(e) = repo.log_execution_sync(&request, &result).await {
88                tracing::warn!(
89                    tool = %request.tool_name,
90                    server = %request.server_name,
91                    error = %e,
92                    "Failed to record external MCP tool execution"
93                );
94            }
95        });
96    }
97}