-
Notifications
You must be signed in to change notification settings - Fork 3.6k
perf(tui): compile an MCP resource template once instead of per call (#6213 T7) #6266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1236,14 +1236,15 @@ pub struct McpResourceTemplate { | |
| /// simple (`{id}`), and reserved (`{+path}`) expansions cover the common MCP | ||
| /// resource templates. More elaborate operators remain listable but are not | ||
| /// callable until their expansion semantics are implemented exactly. | ||
| fn resource_uri_matches_template(uri: &str, template: &str) -> bool { | ||
| /// | ||
| /// `None` is the fail-closed answer: a template this subset cannot express | ||
| /// matches nothing. | ||
| fn resource_template_pattern(template: &str) -> Option<String> { | ||
| let mut pattern = String::from("^"); | ||
| let mut rest = template; | ||
| while let Some(start) = rest.find('{') { | ||
| pattern.push_str(®ex::escape(&rest[..start])); | ||
| let Some(end) = rest[start + 1..].find('}') else { | ||
| return false; | ||
| }; | ||
| let end = rest[start + 1..].find('}')?; | ||
| let expression = &rest[start + 1..start + 1 + end]; | ||
| let (reserved, variables) = match expression.strip_prefix('+') { | ||
| Some(variables) => (true, variables), | ||
|
|
@@ -1257,7 +1258,7 @@ fn resource_uri_matches_template(uri: &str, template: &str) -> bool { | |
| .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) | ||
| }) | ||
| { | ||
| return false; | ||
| return None; | ||
| } | ||
| let atom = if reserved { ".+" } else { "[^/?#]+" }; | ||
| for (index, _) in variables.split(',').enumerate() { | ||
|
|
@@ -1269,11 +1270,38 @@ fn resource_uri_matches_template(uri: &str, template: &str) -> bool { | |
| rest = &rest[start + end + 2..]; | ||
| } | ||
| if rest.contains('}') { | ||
| return false; | ||
| return None; | ||
| } | ||
| pattern.push_str(®ex::escape(rest)); | ||
| pattern.push('$'); | ||
| regex::Regex::new(&pattern).is_ok_and(|regex| regex.is_match(uri)) | ||
| Some(pattern) | ||
| } | ||
|
|
||
| /// `template`'s anchored pattern, compiled once and reused. | ||
| /// | ||
| /// This runs per URI per advertised template, while the template itself is | ||
| /// fixed by the server's listing, so compiling it on every call was pure | ||
| /// repetition. `None` still means "matches nothing" (#6213 T7). | ||
| fn compiled_resource_template(template: &str) -> Option<Arc<regex::Regex>> { | ||
| static CACHE: std::sync::OnceLock< | ||
| std::sync::Mutex<HashMap<String, Option<Arc<regex::Regex>>>>, | ||
| > = std::sync::OnceLock::new(); | ||
| let cache = CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); | ||
| let mut cache = cache | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| cache | ||
| .entry(template.to_string()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [INFO] Every cache lookup allocates a String key and takes the global mutex
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider a |
||
| .or_insert_with(|| { | ||
| resource_template_pattern(template) | ||
| .and_then(|pattern| regex::Regex::new(&pattern).ok()) | ||
| .map(Arc::new) | ||
| }) | ||
| .clone() | ||
| } | ||
|
Comment on lines
+1285
to
+1301
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This crate already has the exact solved pattern for "small cache of things derived from untrusted/remote input" in |
||
|
|
||
| fn resource_uri_matches_template(uri: &str, template: &str) -> bool { | ||
| compiled_resource_template(template).is_some_and(|regex| regex.is_match(uri)) | ||
| } | ||
|
|
||
| /// Prompt discovered from an MCP server | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[WARNING] Global template-regex cache is never evicted (unbounded memory retention)
compiled_resource_templateinserts every distinct template string into astaticHashMapthat is never pruned, keeping both the keyStringand either a compiledRegexor a negativeNone. The key is server-supplied (uriTemplatefromresources/templates/list), and listings can be re-fetched or a server reconnected with different templates, so a long-running TUI session retains one entry per template string ever observed — including templates belonging to servers that are no longer connected — for the life of the process. Triggering path: repeated listing refresh where the server returns varyinguriTemplatevalues, or a server that advertises many templates; the map grows monotonically and itsRegexvalues are retained forever. Impact: memory growth in a long-lived interactive process, with no bound and no way to release it. Smallest useful fix: bound or scope the cache (an LRU/capacity-limited structure, invalidation when a server's template listing is replaced/disconnected), instead of a process-global static keyed by arbitrary input.