Skip to content

Commit a9618e1

Browse files
committed
perf: add tool timing metrics and speed up balanced fetch
1 parent e634940 commit a9618e1

13 files changed

Lines changed: 391 additions & 34 deletions

File tree

.github/copilot-instructions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ Unified web content tool via `mode`:
4545
- `mode="crawl"`: site crawl from a root URL.
4646

4747
Common behavior:
48+
- Default path is non-proxy. Use proxies only after confirmed blocking/rate-limit symptoms or when you know your IP reputation is poor.
4849
- Supports token-efficient extraction (`clean_json` in single mode).
4950
- Supports proxy retry (`use_proxy=true`).
5051
- Supports relevance filtering and JS rendering fallback.
52+
- Responses now include total timing in `_tool_metrics`; fetch/screenshot-style JSON responses may also include per-phase timing details.
5153

5254
### `extract_fields`
5355
Primary structured extraction tool.
@@ -103,6 +105,7 @@ These remain callable for backward compatibility, but agents should prefer the u
103105
- Read long docs: `web_fetch(url, output_format="clean_json", strict_relevance=true, query="...")`
104106
- Structured extraction: `extract_fields(url, schema=[...])`
105107
- Memory-first: `memory_search(query)` before live fetch
108+
- Performance inspection: check `_tool_metrics.total_duration_ms`; for `web_fetch`/`visual_scout`, inspect `metrics.phases` to see where time was spent.
106109

107110
---
108111

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ Policy:
44
- Keep changes under **Unreleased** during normal development.
55
- `bash scripts/release.sh` automatically promotes `## Unreleased``## vX.Y.Z (YYYY-MM-DD)` and commits the changelog before tagging.
66

7+
## Unreleased
8+
9+
### Changed
10+
- Updated MCP tool descriptions and agent guidance to clarify that proxy use is optional by default, balanced fetches stay on the non-proxy/native path unless blocking signals appear, and all tool responses now expose timing information.
11+
- `web_fetch` balanced-mode strategy now prefers the fast native HTTP path on normal server-rendered pages such as GitHub, only escalating into CDP earlier for proxy mode, high/aggressive mode, or known JS-heavy/problematic hosts.
12+
13+
### Added
14+
- Added `_tool_metrics` total-duration reporting to all Cortex Scout MCP tool responses through the shared HTTP/stdio dispatch layer.
15+
- Added structured phase timing metrics to `web_fetch`/scrape responses and `visual_scout` responses so slow steps can be identified without external profiling.
16+
17+
### Verified
18+
- Re-tested the live MCP runtime after rebuild/reload and confirmed timing metrics now appear in `visual_scout`, `extract_fields`, and `proxy_control` responses.
19+
- Re-tested the GitHub discussion fetch path through MCP and confirmed the same content path now completes in about 1.7 seconds instead of hanging for tens of seconds on the prior CDP-first behavior.
20+
721
## v3.3.5 (2026-04-09)
822

923
### Changed

mcp-server/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mcp-server/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cortex-scout"
3-
version = "3.3.5"
3+
version = "3.3.6"
44
edition = "2021"
55
description = "God-Tier stealth intelligence engine for AI agents — zero-dependency parallel meta-search (Google/Bing/DDG/Brave), universal anti-bot bypass, native Chromium CDP (zero-Docker standalone), semantic research memory, and HITL nuclear option. Self-hosted, 100% private, MCP-native."
66
repository = "https://github.com/cortex-works/cortex-scout"

mcp-server/src/core/types.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,31 @@ pub struct ScrapeResponse {
135135
/// Final URL after any server-side redirects, when detectable.
136136
#[serde(default, skip_serializing_if = "Option::is_none")]
137137
pub final_url: Option<String>,
138+
139+
/// Execution timing metrics for the scrape pipeline.
140+
#[serde(default, skip_serializing_if = "Option::is_none")]
141+
pub metrics: Option<ToolExecutionMetrics>,
142+
}
143+
144+
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
145+
pub struct ToolExecutionMetrics {
146+
pub total_duration_ms: u64,
147+
pub total_duration_seconds: f64,
148+
#[serde(default, skip_serializing_if = "Option::is_none")]
149+
pub strategy: Option<String>,
150+
#[serde(default)]
151+
pub cache_hit: bool,
152+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
153+
pub phases: Vec<ToolExecutionPhase>,
154+
}
155+
156+
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
157+
pub struct ToolExecutionPhase {
158+
pub name: String,
159+
pub duration_ms: u64,
160+
pub duration_seconds: f64,
161+
#[serde(default, skip_serializing_if = "Option::is_none")]
162+
pub detail: Option<String>,
138163
}
139164

140165
// ───────────────────────────────────────────────────────────────────────────

mcp-server/src/features/visual_scout.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
/// screenshot tool avoids burning text-extraction budget on pages whose auth state
1212
/// we only need to visually confirm.
1313
use crate::scraping::browser_manager;
14+
use crate::types::{ToolExecutionMetrics, ToolExecutionPhase};
1415
use anyhow::{anyhow, Result};
1516
use futures::StreamExt;
1617
use serde::{Deserialize, Serialize};
17-
use std::time::Duration;
18+
use std::time::{Duration, Instant};
1819
use tracing::info;
1920

2021
/// Response returned by `take_screenshot`.
@@ -38,6 +39,8 @@ pub struct VisualScoutResult {
3839
pub captured_at: String,
3940
/// Human-readable hint for the agent.
4041
pub hint: String,
42+
/// Execution timing metrics for the screenshot pipeline.
43+
pub metrics: ToolExecutionMetrics,
4144
}
4245

4346
/// Capture a screenshot of `url` using a headless Chromium instance.
@@ -50,6 +53,8 @@ pub async fn take_screenshot(
5053
width: Option<u32>,
5154
height: Option<u32>,
5255
) -> Result<VisualScoutResult> {
56+
let total_start = Instant::now();
57+
let mut phases = Vec::new();
5358
let exe = browser_manager::find_chrome_executable().ok_or_else(|| {
5459
anyhow!(
5560
"No browser found for visual_scout. \
@@ -65,13 +70,17 @@ pub async fn take_screenshot(
6570
exe, vp_width, vp_height, url
6671
);
6772

73+
let config_start = Instant::now();
6874
let (config, vs_data_dir) = browser_manager::build_headless_config(&exe, proxy_url, vp_width, vp_height)?;
75+
push_visual_phase(&mut phases, "build_browser_config", config_start.elapsed(), None);
6976

77+
let launch_start = Instant::now();
7078
let (mut browser, mut handler) = browser_manager::launch_browser_serialized(
7179
config,
7280
&format!("visual_scout: browser launch failed ({})", exe),
7381
)
7482
.await?;
83+
push_visual_phase(&mut phases, "browser_launch", launch_start.elapsed(), None);
7584

7685
let handle = tokio::spawn(async move {
7786
while let Some(event) = handler.next().await {
@@ -85,42 +94,55 @@ pub async fn take_screenshot(
8594
});
8695

8796
// Open page, navigate, wait for load.
97+
let page_open_start = Instant::now();
8898
let page = browser
8999
.new_page("about:blank")
90100
.await
91101
.map_err(|e| anyhow!("visual_scout: new_page failed: {}", e))?;
102+
push_visual_phase(&mut phases, "new_page", page_open_start.elapsed(), None);
92103

93104
// Auto-inject stored session cookies before navigation so auth-walled pages
94105
// are captured in an authenticated state when a prior HITL session exists.
106+
let cookie_start = Instant::now();
95107
super::session_store::auto_inject(&page, url).await;
108+
push_visual_phase(&mut phases, "session_cookie_injection", cookie_start.elapsed(), None);
96109

110+
let goto_start = Instant::now();
97111
page.goto(url)
98112
.await
99113
.map_err(|e| anyhow!("visual_scout: goto({url}) failed: {}", e))?;
114+
push_visual_phase(&mut phases, "navigate", goto_start.elapsed(), None);
100115

101116
// Brief settle to let lazy-loaded elements appear (spinner removal, etc.)
117+
let settle_start = Instant::now();
102118
tokio::time::sleep(Duration::from_millis(1200)).await;
119+
push_visual_phase(&mut phases, "settle_wait", settle_start.elapsed(), None);
103120

104121
// Grab the page title for context.
122+
let title_start = Instant::now();
105123
let page_title = page
106124
.evaluate("document.title")
107125
.await
108126
.ok()
109127
.and_then(|h| h.into_value::<String>().ok())
110128
.unwrap_or_default();
129+
push_visual_phase(&mut phases, "read_title", title_start.elapsed(), None);
111130

112131
// Grab the final URL (after any client-side redirects).
132+
let final_url_start = Instant::now();
113133
let final_url = page
114134
.evaluate("location.href")
115135
.await
116136
.ok()
117137
.and_then(|h| h.into_value::<String>().ok())
118138
.unwrap_or_else(|| url.to_string());
139+
push_visual_phase(&mut phases, "read_final_url", final_url_start.elapsed(), None);
119140

120141
// Capture PNG screenshot using the high-level page.screenshot() API.
121142
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
122143
use chromiumoxide::page::ScreenshotParams;
123144

145+
let screenshot_start = Instant::now();
124146
let screenshot_bytes: Vec<u8> = page
125147
.screenshot(
126148
ScreenshotParams::builder()
@@ -129,33 +151,40 @@ pub async fn take_screenshot(
129151
)
130152
.await
131153
.map_err(|e| anyhow!("visual_scout: screenshot capture failed: {}", e))?;
154+
push_visual_phase(&mut phases, "capture_screenshot", screenshot_start.elapsed(), None);
132155

133156
let byte_len = screenshot_bytes.len();
134157

135158
// Save screenshot to a local temp file instead of returning a large base64
136159
// blob. This keeps context windows lean — the agent reads the file only
137160
// when it needs to perform visual analysis.
138161
let cache_dir = std::env::temp_dir().join(".cortex-scout-screenshots");
162+
let create_dir_start = Instant::now();
139163
std::fs::create_dir_all(&cache_dir)
140164
.map_err(|e| anyhow!("visual_scout: failed to create screenshot cache dir: {}", e))?;
165+
push_visual_phase(&mut phases, "ensure_cache_dir", create_dir_start.elapsed(), None);
141166
let host_slug = url::Url::parse(url)
142167
.ok()
143168
.and_then(|u| u.host_str().map(|h| h.replace('.', "_")))
144169
.unwrap_or_else(|| "unknown".to_string());
145170
let ts = chrono::Utc::now().timestamp_millis();
146171
let filename = format!("scout_{}_{}.png", host_slug, ts);
147172
let screenshot_path_buf = cache_dir.join(&filename);
173+
let write_start = Instant::now();
148174
std::fs::write(&screenshot_path_buf, &screenshot_bytes).map_err(|e| {
149175
anyhow!(
150176
"visual_scout: failed to write screenshot to {:?}: {}",
151177
screenshot_path_buf,
152178
e
153179
)
154180
})?;
181+
push_visual_phase(&mut phases, "write_screenshot_file", write_start.elapsed(), None);
155182
let screenshot_path = screenshot_path_buf.to_string_lossy().to_string();
156183

157184
// Shut down browser cleanly.
185+
let shutdown_start = Instant::now();
158186
browser_manager::shutdown_browser_session(&mut browser, handle, vs_data_dir, "visual_scout").await;
187+
push_visual_phase(&mut phases, "shutdown_browser", shutdown_start.elapsed(), None);
159188

160189
let auth_hint = {
161190
let tl = page_title.trim().to_lowercase();
@@ -189,5 +218,26 @@ pub async fn take_screenshot(
189218
viewport_height: vp_height,
190219
captured_at: chrono::Utc::now().to_rfc3339(),
191220
hint: auth_hint,
221+
metrics: ToolExecutionMetrics {
222+
total_duration_ms: total_start.elapsed().as_millis() as u64,
223+
total_duration_seconds: total_start.elapsed().as_secs_f64(),
224+
strategy: Some("headless_browser_capture".to_string()),
225+
cache_hit: false,
226+
phases,
227+
},
192228
})
193229
}
230+
231+
fn push_visual_phase(
232+
phases: &mut Vec<ToolExecutionPhase>,
233+
name: &str,
234+
duration: Duration,
235+
detail: Option<String>,
236+
) {
237+
phases.push(ToolExecutionPhase {
238+
name: name.to_string(),
239+
duration_ms: duration.as_millis() as u64,
240+
duration_seconds: duration.as_secs_f64(),
241+
detail,
242+
});
243+
}

mcp-server/src/mcp/http.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use crate::types::*;
33
use crate::AppState;
44
use axum::{extract::State, http::StatusCode, response::Json};
55
use serde::{Deserialize, Serialize};
6+
use serde_json::json;
67
use std::sync::Arc;
8+
use std::time::Instant;
79
use tracing::info;
810

911
#[derive(Debug, Serialize, Deserialize)]
@@ -42,6 +44,43 @@ pub struct McpContent {
4244
pub text: String,
4345
}
4446

47+
pub(crate) fn instrument_tool_response(
48+
mut response: McpCallResponse,
49+
tool_name: &str,
50+
started_at: Instant,
51+
) -> McpCallResponse {
52+
let elapsed = started_at.elapsed();
53+
let metrics_json = json!({
54+
"tool_name": tool_name,
55+
"total_duration_ms": elapsed.as_millis() as u64,
56+
"total_duration_seconds": elapsed.as_secs_f64()
57+
});
58+
59+
for item in &mut response.content {
60+
if item.content_type != "text" {
61+
continue;
62+
}
63+
64+
if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&item.text) {
65+
if let Some(obj) = value.as_object_mut() {
66+
obj.insert("_tool_metrics".to_string(), metrics_json.clone());
67+
item.text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| item.text.clone());
68+
continue;
69+
}
70+
}
71+
72+
if !item.text.contains("Tool timing:") {
73+
item.text.push_str(&format!(
74+
"\n\nTool timing: {:.3}s ({} ms)",
75+
elapsed.as_secs_f64(),
76+
elapsed.as_millis() as u64
77+
));
78+
}
79+
}
80+
81+
response
82+
}
83+
4584
pub fn list_tools_for_state(state: &AppState) -> McpToolsResponse {
4685
let tools = state
4786
.tool_registry
@@ -68,6 +107,7 @@ pub async fn call_tool_inner(
68107
state: Arc<AppState>,
69108
request: McpCallRequest,
70109
) -> Result<McpCallResponse, (StatusCode, Json<ErrorResponse>)> {
110+
let tool_start = Instant::now();
71111
info!(
72112
"MCP tool call: {} with args: {:?}",
73113
request.name, request.arguments
@@ -126,7 +166,7 @@ pub async fn call_tool_inner(
126166
)),
127167
};
128168

129-
result.map(|Json(r)| r)
169+
result.map(|Json(r)| instrument_tool_response(r, &request.name, tool_start))
130170
}
131171

132172
/// Axum route handler: `POST /mcp/call`

0 commit comments

Comments
 (0)