Skip to content
Open
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
4 changes: 4 additions & 0 deletions cursor-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json`
"mcpAllowlist": [
"tracedecay:tracedecay_active_project",
"tracedecay:tracedecay_affected",
"tracedecay:tracedecay_automation_run_artifact_view",
"tracedecay:tracedecay_body",
"tracedecay:tracedecay_branch_diff",
"tracedecay:tracedecay_branch_list",
Expand Down Expand Up @@ -89,6 +90,7 @@ per-call review, add the snippet below to `~/.cursor/permissions.json`
"tracedecay:tracedecay_gini",
"tracedecay:tracedecay_god_class",
"tracedecay:tracedecay_health",
"tracedecay:tracedecay_hermes_skill_bridge",
"tracedecay:tracedecay_hotspots",
"tracedecay:tracedecay_impact",
"tracedecay:tracedecay_implementations",
Expand Down Expand Up @@ -123,6 +125,8 @@ per-call review, add the snippet below to `~/.cursor/permissions.json`
"tracedecay:tracedecay_signature_search",
"tracedecay:tracedecay_similar",
"tracedecay:tracedecay_simplify_scan",
"tracedecay:tracedecay_skill_list",
"tracedecay:tracedecay_skill_view",
"tracedecay:tracedecay_status",
"tracedecay:tracedecay_storage_status",
"tracedecay:tracedecay_test_map",
Expand Down
46 changes: 30 additions & 16 deletions src/automation/skill_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::tracedecay::current_timestamp;
mod analytics;
mod recommendations;

pub(crate) use analytics::analytics_import_key_for_request;
pub use analytics::{ingest_analytics_events, ingest_project_analytics_events};
pub use recommendations::{skill_improvement_recommendations, stale_skill_recommendations};

Expand Down Expand Up @@ -236,28 +237,41 @@ pub async fn record_skill_usage(
_actor: impl Into<String>,
targets: Vec<String>,
target: Option<String>,
_metadata: Option<serde_json::Value>,
metadata: Option<serde_json::Value>,
) -> Result<SkillUsageRecord> {
let skill_id = skill.metadata.id.clone();
let timestamp = current_timestamp();
let mut ledger = load_skill_usage_ledger(profile_root).await?;
let record = ledger
.records
.entry(skill_id.clone())
.or_insert_with(|| SkillUsageRecord::new(skill_id, timestamp));
record.merge_skill_metadata(skill);
record.record(&SkillUsageEvent {
skill_name: skill.metadata.id.clone(),
action,
timestamp,
target,
});
for target in targets {
if let Some(target) = normalize_target(&target) {
insert_sorted_unique(&mut record.targets, target);
let updated = {
let record = ledger
.records
.entry(skill_id.clone())
.or_insert_with(|| SkillUsageRecord::new(skill_id, timestamp));
record.merge_skill_metadata(skill);
record.record(&SkillUsageEvent {
skill_name: skill.metadata.id.clone(),
action,
timestamp,
target,
});
for target in targets {
if let Some(target) = normalize_target(&target) {
insert_sorted_unique(&mut record.targets, target);
}
}
record.clone()
};
if let Some(import_key) = metadata
.as_ref()
.and_then(|metadata| metadata.get("imported_analytics_event_key"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|key| !key.is_empty())
{
ledger
.imported_analytics_events
.insert(import_key.to_string());
}
let updated = record.clone();
save_skill_usage_ledger(profile_root, &ledger).await?;
Ok(updated)
}
Expand Down
32 changes: 26 additions & 6 deletions src/automation/skill_usage/analytics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,12 @@ fn analytics_import_key(
action: SkillUsageAction,
) -> String {
if let Some(request_id) = analytics_request_id(event) {
return format!(
"{}:{}:request:{request_id}:{}:{:?}",
event.project_id, event.provider, skill_id, action
return analytics_import_key_for_request(
&event.project_id,
&event.provider,
&request_id,
skill_id,
action,
);
}
format!(
Expand All @@ -114,6 +117,16 @@ fn analytics_import_key(
)
}

pub(crate) fn analytics_import_key_for_request(
project_id: &str,
provider: &str,
request_id: &str,
skill_id: &str,
action: SkillUsageAction,
) -> String {
format!("{project_id}:{provider}:request:{request_id}:{skill_id}:{action:?}")
}

fn should_skip_analytics_event(event: &AnalyticsEventRecord) -> bool {
event.event_kind == "mcp_tool_call"
&& event
Expand All @@ -136,10 +149,9 @@ fn analytics_request_id(event: &AnalyticsEventRecord) -> Option<String> {
.or_else(|| metadata.pointer("/metadata/request_id"))
.or_else(|| metadata.pointer("/runtime/request_id"))
.or_else(|| metadata.pointer("/function/request_id"))
.and_then(|value| value.as_str())
.map(str::trim)
.and_then(request_id_value)
.map(|request_id| request_id.trim().to_string())
.filter(|request_id| !request_id.is_empty())
.map(ToOwned::to_owned)
}

fn analytics_action(event: &AnalyticsEventRecord) -> SkillUsageAction {
Expand All @@ -158,3 +170,11 @@ fn analytics_action(event: &AnalyticsEventRecord) -> SkillUsageAction {
_ => SkillUsageAction::Use,
}
}

fn request_id_value(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(value) => Some(value.clone()),
serde_json::Value::Number(value) => Some(value.to_string()),
_ => None,
}
}
93 changes: 79 additions & 14 deletions src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ struct VersionCheckState {
}

/// The MCP server wrapping a `TraceDecay` instance.
// Lock ordering: file_token_map -> tool_call_counts (never nested)
// Lock ordering: file_token_map -> method/resource/tool call counts (never nested)
pub struct McpServer {
/// The served code graph. Guarded so a mid-session `git checkout` can
/// hot-swap the instance onto the new branch's DB
Expand All @@ -416,6 +416,8 @@ pub struct McpServer {
/// each call is internally consistent.
cg: tokio::sync::RwLock<Arc<TraceDecay>>,
stats: ServerStats,
method_call_counts: std::sync::Mutex<HashMap<String, u64>>,
resource_read_counts: std::sync::Mutex<HashMap<String, u64>>,
tool_call_counts: std::sync::Mutex<HashMap<String, u64>>,
/// Approximate token count per indexed file (`file_path` -> tokens).
file_token_map: std::sync::Mutex<HashMap<String, u64>>,
Expand Down Expand Up @@ -544,6 +546,8 @@ impl McpServer {
let server = Arc::new(Self {
cg: tokio::sync::RwLock::new(Arc::new(cg)),
stats: ServerStats::new(),
method_call_counts: std::sync::Mutex::new(HashMap::new()),
resource_read_counts: std::sync::Mutex::new(HashMap::new()),
tool_call_counts: std::sync::Mutex::new(HashMap::new()),
file_token_map: std::sync::Mutex::new(file_token_map),
tokens_saved: AtomicU64::new(persisted),
Expand Down Expand Up @@ -1285,6 +1289,9 @@ impl McpServer {
"handle_request called with empty method"
);
self.stats.total_requests.fetch_add(1, Ordering::Relaxed);
if let Ok(mut counts) = self.method_call_counts.lock() {
*counts.entry(request.method.clone()).or_insert(0) += 1;
}
let id = request.id.clone()?;

let result = match request.method.as_str() {
Expand Down Expand Up @@ -1419,6 +1426,9 @@ impl McpServer {
"missing 'uri' in resources/read params".to_string(),
);
};
if let Ok(mut counts) = self.resource_read_counts.lock() {
*counts.entry(uri.to_string()).or_insert(0) += 1;
}

match uri {
"tracedecay://status" => self.read_resource_status(id).await,
Expand Down Expand Up @@ -1646,6 +1656,7 @@ impl McpServer {
};

let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
let analytics_arguments = arguments.clone();
let analytics_session_id = mcp_analytics_session_id(&arguments);

// Branch-drift hot-swap: if the working tree switched branches since
Expand Down Expand Up @@ -1675,10 +1686,19 @@ impl McpServer {
} else {
None
};
let mut handler_arguments = arguments;
if crate::analytics::is_skill_view_tool(tool_name) {
if let Some(request_id) = json_rpc_request_id_string(&id) {
if let Some(map) = handler_arguments.as_object_mut() {
map.insert("__mcp_request_id".to_string(), json!(request_id));
}
}
}

let dispatch_outcome = handle_tool_call_with_registry(
&cg,
tool_name,
arguments,
handler_arguments,
server_stats,
self.scope_prefix(),
self.registry_db.as_deref(),
Expand Down Expand Up @@ -1765,6 +1785,7 @@ impl McpServer {
net_saved_tokens,
timestamp: ts,
request_id: &request_id,
arguments: &analytics_arguments,
});
self.spawn_observed_ledger_write(async move {
gdb.record_savings(
Expand Down Expand Up @@ -1917,6 +1938,7 @@ impl McpServer {
analytics_session_id,
tool_name,
&request_id,
&analytics_arguments,
);
tool_error_response(id, tool_name, &e)
}
Expand All @@ -1929,6 +1951,7 @@ impl McpServer {
session_id: Option<String>,
tool_name: &str,
request_id: &Value,
arguments: &Value,
) {
let Some(gdb) = self.global_db.clone() else {
return;
Expand All @@ -1943,6 +1966,7 @@ impl McpServer {
net_saved_tokens: 0,
timestamp: crate::tracedecay::current_timestamp(),
request_id,
arguments,
});
self.spawn_observed_ledger_write(async move {
if let Err(e) = gdb.append_analytics_event(&event).await {
Expand All @@ -1968,18 +1992,45 @@ impl McpServer {
/// Returns the current server runtime statistics as a JSON value.
pub async fn server_stats_json(&self) -> Value {
let uptime = self.stats.started_at.elapsed();
let total_requests = self.stats.total_requests.load(Ordering::Relaxed);
let tool_calls = self.stats.tool_calls.load(Ordering::Relaxed);
let errors = self.stats.errors.load(Ordering::Relaxed);
let method_counts: Value = self
.method_call_counts
.lock()
.map(|counts| json!(*counts))
.unwrap_or(json!({}));
let resource_counts: Value = self
.resource_read_counts
.lock()
.map(|counts| json!(*counts))
.unwrap_or(json!({}));
let tool_counts: Value = self
.tool_call_counts
.lock()
.map(|counts| json!(*counts))
.unwrap_or(json!({}));
let ratio = |n: u64| {
if total_requests == 0 {
0.0
} else {
n as f64 / total_requests as f64
}
};

let mut stats = json!({
"uptime_secs": uptime.as_secs(),
"total_requests": self.stats.total_requests.load(Ordering::Relaxed),
"tool_calls": self.stats.tool_calls.load(Ordering::Relaxed),
"errors": self.stats.errors.load(Ordering::Relaxed),
"total_requests": total_requests,
"jsonrpc_messages": total_requests,
"tool_calls": tool_calls,
"errors": errors,
"method_call_counts": method_counts,
"resource_read_counts": resource_counts,
"tool_call_counts": tool_counts,
"ratios": {
"tool_calls_per_jsonrpc_message": ratio(tool_calls),
"errors_per_jsonrpc_message": ratio(errors),
},
"approx_tokens_saved": self.tokens_saved.load(Ordering::Relaxed),
});

Expand Down Expand Up @@ -2036,10 +2087,24 @@ struct McpToolAnalyticsEvent<'a> {
net_saved_tokens: u64,
timestamp: i64,
request_id: &'a Value,
arguments: &'a Value,
}

fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> AnalyticsEventInsert {
let category = crate::accounting::classifier::classify(&[input.tool_name], &[]);
let mut metadata = json!({
"request_id": input.request_id,
"before_tokens": input.raw_file_tokens,
"after_tokens": input.response_tokens,
"tokens_saved": input.net_saved_tokens,
});
if crate::analytics::is_skill_view_tool(input.tool_name) {
metadata["arguments"] = input.arguments.clone();
metadata["function"] = json!({
"name": input.tool_name,
"arguments": input.arguments,
});
}
AnalyticsEventInsert {
provider: "mcp".to_string(),
project_id: GlobalDb::canonical_project_key(input.project_root),
Expand All @@ -2053,15 +2118,15 @@ fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> AnalyticsEventI
hint_category: None,
hint_id: None,
outcome: Some(input.outcome.to_string()),
metadata_json: Some(
json!({
"request_id": input.request_id,
"before_tokens": input.raw_file_tokens,
"after_tokens": input.response_tokens,
"tokens_saved": input.net_saved_tokens,
})
.to_string(),
),
metadata_json: Some(metadata.to_string()),
}
}

fn json_rpc_request_id_string(id: &Value) -> Option<String> {
match id {
Value::String(id) => Some(id.clone()),
Value::Number(id) => Some(id.to_string()),
_ => None,
}
}

Expand Down
Loading
Loading