use deadpool_postgres::Pool;
const MCP_DDL: &str = r"
CREATE TABLE IF NOT EXISTS mcp_events (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
person TEXT NOT NULL,
team TEXT,
project TEXT NOT NULL,
server_id TEXT NOT NULL,
method TEXT NOT NULL,
tool TEXT,
status TEXT NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0,
result_bytes BIGINT NOT NULL DEFAULT 0,
result_tokens BIGINT NOT NULL DEFAULT 0,
context_cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
reference_model TEXT
);
CREATE INDEX IF NOT EXISTS idx_mcp_events_person_ts ON mcp_events (person, ts);
CREATE INDEX IF NOT EXISTS idx_mcp_events_server_ts ON mcp_events (server_id, ts);
CREATE INDEX IF NOT EXISTS idx_mcp_events_tool_ts ON mcp_events (server_id, tool, ts);
CREATE TABLE IF NOT EXISTS mcp_tool_inventory (
server_id TEXT NOT NULL,
tool TEXT NOT NULL,
schema_sha256 TEXT NOT NULL,
previous_sha256 TEXT,
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
change_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, tool)
);
";
pub async fn init_schema(pool: &Pool) -> anyhow::Result<()> {
let client = pool.get().await?;
client.batch_execute(MCP_DDL).await?;
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct McpEvent {
pub person: String,
pub team: Option<String>,
pub project: String,
pub server_id: String,
pub method: String,
pub tool: Option<String>,
pub status: String,
pub duration_ms: i64,
pub result_bytes: i64,
pub result_tokens: i64,
pub context_cost_usd: f64,
pub reference_model: Option<String>,
}
pub async fn insert_event(client: &deadpool_postgres::Client, e: &McpEvent) -> anyhow::Result<()> {
client
.execute(
"INSERT INTO mcp_events \
(person, team, project, server_id, method, tool, status, \
duration_ms, result_bytes, result_tokens, context_cost_usd, reference_model) \
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)",
&[
&e.person,
&e.team,
&e.project,
&e.server_id,
&e.method,
&e.tool,
&e.status,
&e.duration_ms,
&e.result_bytes,
&e.result_tokens,
&e.context_cost_usd,
&e.reference_model,
],
)
.await?;
Ok(())
}
pub async fn upsert_inventory(
client: &deadpool_postgres::Client,
server_id: &str,
tools: &[super::frames::ToolDef],
) -> anyhow::Result<()> {
let stmt = client
.prepare_cached(
"INSERT INTO mcp_tool_inventory (server_id, tool, schema_sha256) \
VALUES ($1, $2, $3) \
ON CONFLICT (server_id, tool) DO UPDATE SET \
last_seen = now(), \
previous_sha256 = CASE WHEN mcp_tool_inventory.schema_sha256 <> EXCLUDED.schema_sha256 \
THEN mcp_tool_inventory.schema_sha256 \
ELSE mcp_tool_inventory.previous_sha256 END, \
change_count = mcp_tool_inventory.change_count + \
CASE WHEN mcp_tool_inventory.schema_sha256 <> EXCLUDED.schema_sha256 \
THEN 1 ELSE 0 END, \
schema_sha256 = EXCLUDED.schema_sha256",
)
.await?;
for t in tools {
client
.execute(&stmt, &[&server_id, &t.name, &t.schema_sha256])
.await?;
}
Ok(())
}
pub async fn purge_events_older_than(pool: &Pool, days: u32) -> anyhow::Result<u64> {
let client = pool.get().await?;
let purged = client
.execute(
"DELETE FROM mcp_events WHERE ts < now() - make_interval(days => $1)",
&[&i32::try_from(days).unwrap_or(i32::MAX)],
)
.await?;
Ok(purged)
}
pub async fn person_events(
pool: &Pool,
person_keys: &[String],
) -> anyhow::Result<Vec<serde_json::Value>> {
let client = pool.get().await?;
let rows = client
.query(
"SELECT to_jsonb(mcp_events) FROM mcp_events \
WHERE person = ANY($1) ORDER BY ts",
&[&person_keys],
)
.await?;
Ok(rows
.into_iter()
.map(|r| r.get::<_, serde_json::Value>(0))
.collect())
}
pub async fn delete_person_events(pool: &Pool, person_keys: &[String]) -> anyhow::Result<u64> {
let client = pool.get().await?;
let deleted = client
.execute(
"DELETE FROM mcp_events WHERE person = ANY($1)",
&[&person_keys],
)
.await?;
Ok(deleted)
}
pub const TOOL_BREAKDOWN_SQL: &str = "
SELECT server_id,
coalesce(tool, method) AS tool,
count(*) AS calls,
count(*) FILTER (WHERE status <> 'ok') AS errors,
count(DISTINCT person) AS persons,
sum(result_tokens)::BIGINT AS result_tokens,
sum(context_cost_usd) AS context_cost_usd,
max(duration_ms) AS max_duration_ms,
percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50_duration_ms
FROM mcp_events
WHERE ts >= $1 AND ts <= $2
GROUP BY server_id, coalesce(tool, method)
ORDER BY context_cost_usd DESC, tool";
pub const ME_TOOLS_SQL: &str = "
SELECT server_id,
coalesce(tool, method) AS tool,
count(*) AS calls,
sum(result_tokens)::BIGINT AS result_tokens,
sum(context_cost_usd) AS context_cost_usd
FROM mcp_events
WHERE ts >= $1 AND ts <= $2 AND person = $3
GROUP BY server_id, coalesce(tool, method)
ORDER BY context_cost_usd DESC, tool
LIMIT 50";
pub const TOTALS_SQL: &str = "
SELECT count(*) AS calls,
count(*) FILTER (WHERE status <> 'ok') AS errors,
count(DISTINCT person) AS persons,
coalesce(sum(result_tokens), 0)::BIGINT AS result_tokens,
coalesce(sum(context_cost_usd), 0) AS context_cost_usd
FROM mcp_events
WHERE ts >= $1 AND ts <= $2";
pub const INVENTORY_SQL: &str = "
SELECT server_id, tool, schema_sha256, previous_sha256,
change_count,
to_char(first_seen AT TIME ZONE 'utc', 'YYYY-MM-DD') AS first_seen,
to_char(last_seen AT TIME ZONE 'utc', 'YYYY-MM-DD') AS last_seen
FROM mcp_tool_inventory
ORDER BY server_id, tool";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ddl_is_idempotent_by_construction() {
for stmt in ["CREATE TABLE", "CREATE INDEX"] {
for (i, _) in MCP_DDL.match_indices(stmt) {
let tail = &MCP_DDL[i..(i + stmt.len() + 14).min(MCP_DDL.len())];
assert!(
tail.contains("IF NOT EXISTS"),
"non-idempotent DDL statement: {tail}"
);
}
}
}
#[test]
fn schema_carries_the_observe_columns() {
for col in [
"server_id",
"method",
"tool",
"status",
"result_tokens",
"context_cost_usd",
"schema_sha256",
"previous_sha256",
"change_count",
] {
assert!(MCP_DDL.contains(col), "column {col} missing from DDL");
}
}
#[test]
fn aggregate_sql_is_window_bounded_and_deterministically_ordered() {
for sql in [TOOL_BREAKDOWN_SQL, ME_TOOLS_SQL, TOTALS_SQL] {
assert!(
sql.contains("ts >= $1 AND ts <= $2"),
"window bounds: {sql}"
);
}
for sql in [TOOL_BREAKDOWN_SQL, ME_TOOLS_SQL, INVENTORY_SQL] {
assert!(sql.contains("ORDER BY"), "stable ordering required: {sql}");
}
}
}