lean_ctx/gateway_server/mcp/
store.rs1use deadpool_postgres::Pool;
20
21const MCP_DDL: &str = r"
24CREATE TABLE IF NOT EXISTS mcp_events (
25 id BIGSERIAL PRIMARY KEY,
26 ts TIMESTAMPTZ NOT NULL DEFAULT now(),
27 person TEXT NOT NULL,
28 team TEXT,
29 project TEXT NOT NULL,
30 server_id TEXT NOT NULL,
31 method TEXT NOT NULL,
32 tool TEXT,
33 status TEXT NOT NULL,
34 duration_ms BIGINT NOT NULL DEFAULT 0,
35 result_bytes BIGINT NOT NULL DEFAULT 0,
36 result_tokens BIGINT NOT NULL DEFAULT 0,
37 context_cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
38 reference_model TEXT
39);
40CREATE INDEX IF NOT EXISTS idx_mcp_events_person_ts ON mcp_events (person, ts);
41CREATE INDEX IF NOT EXISTS idx_mcp_events_server_ts ON mcp_events (server_id, ts);
42CREATE INDEX IF NOT EXISTS idx_mcp_events_tool_ts ON mcp_events (server_id, tool, ts);
43CREATE TABLE IF NOT EXISTS mcp_tool_inventory (
44 server_id TEXT NOT NULL,
45 tool TEXT NOT NULL,
46 schema_sha256 TEXT NOT NULL,
47 previous_sha256 TEXT,
48 first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
49 last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
50 change_count BIGINT NOT NULL DEFAULT 0,
51 PRIMARY KEY (server_id, tool)
52);
53";
54
55pub async fn init_schema(pool: &Pool) -> anyhow::Result<()> {
57 let client = pool.get().await?;
58 client.batch_execute(MCP_DDL).await?;
59 Ok(())
60}
61
62#[derive(Debug, Clone, PartialEq)]
64pub struct McpEvent {
65 pub person: String,
66 pub team: Option<String>,
67 pub project: String,
68 pub server_id: String,
69 pub method: String,
71 pub tool: Option<String>,
73 pub status: String,
75 pub duration_ms: i64,
76 pub result_bytes: i64,
77 pub result_tokens: i64,
78 pub context_cost_usd: f64,
82 pub reference_model: Option<String>,
83}
84
85pub async fn insert_event(client: &deadpool_postgres::Client, e: &McpEvent) -> anyhow::Result<()> {
87 client
88 .execute(
89 "INSERT INTO mcp_events \
90 (person, team, project, server_id, method, tool, status, \
91 duration_ms, result_bytes, result_tokens, context_cost_usd, reference_model) \
92 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)",
93 &[
94 &e.person,
95 &e.team,
96 &e.project,
97 &e.server_id,
98 &e.method,
99 &e.tool,
100 &e.status,
101 &e.duration_ms,
102 &e.result_bytes,
103 &e.result_tokens,
104 &e.context_cost_usd,
105 &e.reference_model,
106 ],
107 )
108 .await?;
109 Ok(())
110}
111
112pub async fn upsert_inventory(
117 client: &deadpool_postgres::Client,
118 server_id: &str,
119 tools: &[super::frames::ToolDef],
120) -> anyhow::Result<()> {
121 let stmt = client
122 .prepare_cached(
123 "INSERT INTO mcp_tool_inventory (server_id, tool, schema_sha256) \
124 VALUES ($1, $2, $3) \
125 ON CONFLICT (server_id, tool) DO UPDATE SET \
126 last_seen = now(), \
127 previous_sha256 = CASE WHEN mcp_tool_inventory.schema_sha256 <> EXCLUDED.schema_sha256 \
128 THEN mcp_tool_inventory.schema_sha256 \
129 ELSE mcp_tool_inventory.previous_sha256 END, \
130 change_count = mcp_tool_inventory.change_count + \
131 CASE WHEN mcp_tool_inventory.schema_sha256 <> EXCLUDED.schema_sha256 \
132 THEN 1 ELSE 0 END, \
133 schema_sha256 = EXCLUDED.schema_sha256",
134 )
135 .await?;
136 for t in tools {
137 client
138 .execute(&stmt, &[&server_id, &t.name, &t.schema_sha256])
139 .await?;
140 }
141 Ok(())
142}
143
144pub async fn purge_events_older_than(pool: &Pool, days: u32) -> anyhow::Result<u64> {
148 let client = pool.get().await?;
149 let purged = client
150 .execute(
151 "DELETE FROM mcp_events WHERE ts < now() - make_interval(days => $1)",
152 &[&i32::try_from(days).unwrap_or(i32::MAX)],
153 )
154 .await?;
155 Ok(purged)
156}
157
158pub async fn person_events(
161 pool: &Pool,
162 person_keys: &[String],
163) -> anyhow::Result<Vec<serde_json::Value>> {
164 let client = pool.get().await?;
165 let rows = client
166 .query(
167 "SELECT to_jsonb(mcp_events) FROM mcp_events \
168 WHERE person = ANY($1) ORDER BY ts",
169 &[&person_keys],
170 )
171 .await?;
172 Ok(rows
173 .into_iter()
174 .map(|r| r.get::<_, serde_json::Value>(0))
175 .collect())
176}
177
178pub async fn delete_person_events(pool: &Pool, person_keys: &[String]) -> anyhow::Result<u64> {
180 let client = pool.get().await?;
181 let deleted = client
182 .execute(
183 "DELETE FROM mcp_events WHERE person = ANY($1)",
184 &[&person_keys],
185 )
186 .await?;
187 Ok(deleted)
188}
189
190pub const TOOL_BREAKDOWN_SQL: &str = "
194SELECT server_id,
195 coalesce(tool, method) AS tool,
196 count(*) AS calls,
197 count(*) FILTER (WHERE status <> 'ok') AS errors,
198 count(DISTINCT person) AS persons,
199 sum(result_tokens)::BIGINT AS result_tokens,
200 sum(context_cost_usd) AS context_cost_usd,
201 max(duration_ms) AS max_duration_ms,
202 percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50_duration_ms
203FROM mcp_events
204WHERE ts >= $1 AND ts <= $2
205GROUP BY server_id, coalesce(tool, method)
206ORDER BY context_cost_usd DESC, tool";
207
208pub const ME_TOOLS_SQL: &str = "
210SELECT server_id,
211 coalesce(tool, method) AS tool,
212 count(*) AS calls,
213 sum(result_tokens)::BIGINT AS result_tokens,
214 sum(context_cost_usd) AS context_cost_usd
215FROM mcp_events
216WHERE ts >= $1 AND ts <= $2 AND person = $3
217GROUP BY server_id, coalesce(tool, method)
218ORDER BY context_cost_usd DESC, tool
219LIMIT 50";
220
221pub const TOTALS_SQL: &str = "
223SELECT count(*) AS calls,
224 count(*) FILTER (WHERE status <> 'ok') AS errors,
225 count(DISTINCT person) AS persons,
226 coalesce(sum(result_tokens), 0)::BIGINT AS result_tokens,
227 coalesce(sum(context_cost_usd), 0) AS context_cost_usd
228FROM mcp_events
229WHERE ts >= $1 AND ts <= $2";
230
231pub const INVENTORY_SQL: &str = "
235SELECT server_id, tool, schema_sha256, previous_sha256,
236 change_count,
237 to_char(first_seen AT TIME ZONE 'utc', 'YYYY-MM-DD') AS first_seen,
238 to_char(last_seen AT TIME ZONE 'utc', 'YYYY-MM-DD') AS last_seen
239FROM mcp_tool_inventory
240ORDER BY server_id, tool";
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn ddl_is_idempotent_by_construction() {
248 for stmt in ["CREATE TABLE", "CREATE INDEX"] {
249 for (i, _) in MCP_DDL.match_indices(stmt) {
250 let tail = &MCP_DDL[i..(i + stmt.len() + 14).min(MCP_DDL.len())];
251 assert!(
252 tail.contains("IF NOT EXISTS"),
253 "non-idempotent DDL statement: {tail}"
254 );
255 }
256 }
257 }
258
259 #[test]
260 fn schema_carries_the_observe_columns() {
261 for col in [
264 "server_id",
265 "method",
266 "tool",
267 "status",
268 "result_tokens",
269 "context_cost_usd",
270 "schema_sha256",
271 "previous_sha256",
272 "change_count",
273 ] {
274 assert!(MCP_DDL.contains(col), "column {col} missing from DDL");
275 }
276 }
277
278 #[test]
279 fn aggregate_sql_is_window_bounded_and_deterministically_ordered() {
280 for sql in [TOOL_BREAKDOWN_SQL, ME_TOOLS_SQL, TOTALS_SQL] {
281 assert!(
282 sql.contains("ts >= $1 AND ts <= $2"),
283 "window bounds: {sql}"
284 );
285 }
286 for sql in [TOOL_BREAKDOWN_SQL, ME_TOOLS_SQL, INVENTORY_SQL] {
287 assert!(sql.contains("ORDER BY"), "stable ordering required: {sql}");
288 }
289 }
290}