Skip to content

feat(tui): cache MCP tool schemas for instant advertisement - #2

Open
undivisible wants to merge 1 commit into
mainfrom
jcode/mcp-tool-cache
Open

feat(tui): cache MCP tool schemas for instant advertisement#2
undivisible wants to merge 1 commit into
mainfrom
jcode/mcp-tool-cache

Conversation

@undivisible

@undivisible undivisible commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Motivation

MCP discovery currently blocks tool advertisement on the network: every startup probes all configured servers (10s per-server budget), and the registry is swapped only after discovery finishes. Two costs follow:

  • Startup latency. The first turn either misses MCP tools or waits on slow/dead servers.
  • Prompt-cache instability. The registry swap after discovery changes the tool prefix the model sees, and the codebase already documents that replacing a registry mid-session is cache-hostile (build_tool_registry_with_profile comment).

This adopts the idea behind jcode's MCP pre-advertisement: remember what each server advertised last time and advertise it again immediately, before any connection exists.

What changed

  • New module ui/tui/src/mcp_cache.rs — disk cache at ~/.telekinesis/mcp-cache.json storing each server's tool list (name, description, input schema) plus a config fingerprint. The hash is a pinned FNV-1a over name/transport/command/args/url/headers (not DefaultHasher, which is not stable across Rust releases), so editing a server's config invalidates its entry. Corrupt or unreadable cache files load as empty — never fatal.
  • Pre-advertisement at startup — servers with a valid cache entry register their tools synchronously before the event loop starts; the tool set (and prompt-cache prefix) is stable from the first turn. Cached tools sit behind a new lazy McpToolClient (tokio OnceCell): they connect on first call, and a failed connect degrades to a tool error and retries on the next call.
  • Background refresh — discovery still probes all servers concurrently. For cached servers it seeds the lazy connection, rewrites the cache entry for the next startup, prunes entries for servers no longer configured, and surfaces a system message (new AppEvent::Notice) when the live tool list drifts from the advertised set. It deliberately does not hot-swap the registry mid-session.
  • Unchanged path for uncached servers — live discovery before registration, then one all-or-nothing registry swap, then a cache write; connect failures still degrade to system messages.

Testing

From ui/tui (matching .github/workflows/ci.yml):

  • cargo fmt --check — clean
  • cargo clippy --locked --all-features -- -D warnings — clean
  • cargo test --locked — 135 passed, 0 failed (8 new: serde/disk roundtrip, corrupt-file tolerance, config-hash stability and per-field sensitivity, hash-mismatch lookup rejection, cached-spec registration without a live server, config-change invalidation, empty-refresh no-op, lazy-client soft failure on unreachable server)
  • cargo build --locked — clean

🤖 Generated with Claude Code


Note

Medium Risk
Changes MCP tool registration timing and session-stable tool advertisement; wrong or stale cache could expose outdated schemas until refresh/restart, but config hashing and no mid-session hot-swap limit blast radius.

Overview
Adds MCP pre-advertisement so the model’s tool set (and prompt-cache prefix) can be stable from the first turn instead of waiting on concurrent server probes.

A new mcp_cache module persists per-server tool schemas at ~/.telekinesis/mcp-cache.json, keyed by server name and a stable config hash (invalidates on command/args/url/headers changes). Corrupt cache loads as empty. At startup, valid entries register synchronously; McpToolClient wraps connections in a lazy OnceCell so cached tools connect on first call and failed connects retry later.

Background refresh_mcp_tools replaces discover_mcp_tools: it still probes servers in parallel, updates the cache for the next run, seeds connections for cached servers, and only swaps the tool registry for servers that had no cache (fully cached sessions skip the swap). If live tools drift from what was advertised, a new AppEvent::Notice system message tells the user to restart rather than hot-swapping mid-session.

Uncached servers keep the prior all-or-nothing discovery + registry swap path. Tests cover cache roundtrip, hash invalidation, registration without a live server, and lazy client soft failure.

Reviewed by Cursor Bugbot for commit dd366f1. Configure here.

Cache each MCP server's discovered tool list (names, descriptions, input
schemas) at ~/.telekinesis/mcp-cache.json, keyed by server name plus a
stable FNV-1a hash of its config entry. On startup, cached servers'
tools register before any network I/O, so the model's tool set — and
with it the prompt-cache prefix — is stable from the first turn.

Discovery still runs in the background: it seeds the lazy connections
behind cached tools, refreshes the cache for the next startup, prunes
entries for servers no longer configured, and surfaces a system message
when the live tool list drifts from the cached set. It deliberately
never hot-swaps the registry mid-session for cached servers, because
replacing the registry invalidates the model's prompt cache. Servers
without a valid cache entry keep the old behavior: live discovery, then
one all-or-nothing registry swap, then a cache write.

Editing a server's transport, command, args, url, or headers changes
the config hash and invalidates its entry. Corrupt or unreadable cache
files are treated as empty and fall back to live discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_52e9735c-d09b-460f-8e77-1b1ba383cf69)

@undivisible

Copy link
Copy Markdown
Contributor Author

Review note: substantial, well-described feature (MCP schema disk cache + lazy client + no mid-session registry swap). Tests look real. Holding merge until a closer read of ui/tui/src/main.rs (334-line change) and confirmation the cache path/fingerprint cannot advertise tools from a stale server config. The isolated cursor-agent on this repo is also reviewing.

@undivisible undivisible left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review vs current main (12 commits ahead of this branch). Do not merge: mergeable: CONFLICTING.

The cache + lazy client + no-hot-swap-for-fully-cached sessions is the right shape. Blockers after rebase:

  1. Mixed cache still hot-swaps the whole registry when any uncached server appears — the cache-hostile swap this PR exists to avoid.
  2. OnceCell::set during in-flight get_or_try_init drops the live discovery client.
  3. fs::write is not atomic; a torn mcp-cache.json loads empty and the next refresh can wipe schemas for servers that are down this run.

Missing tests: mixed cache vs swap, seed-vs-in-flight get, atomic save, empty-config prune.

Comment thread ui/tui/src/main.rs
if outcome.new_specs.is_empty() {
return;
}
specs.extend(outcome.new_specs);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L2731: 🔴 bug: mixed cache still hot-swaps whole registry. If any server was pre-advertised, skip set_tools; cache + Notice like drift. Swap only when mcp_cached_specs was empty.

Comment thread ui/tui/src/main.rs
}

fn seed(&self, client: Arc<rx4::McpClient>) {
let _ = self.client.set(client);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L3327: 🔴 bug: OnceCell::set returns InitializingError if get() is connecting; let _ = drops the live discovery client. Use Mutex/watch so seed wins, or retry set after failed init. Retry-on-err itself is fine (get_or_try_init leaves cell empty).

Comment thread ui/tui/src/mcp_cache.rs
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(cache).map_err(std::io::Error::other)?;
std::fs::write(path, json)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L68: 🔴 bug: fs::write truncates then writes. Crash/concurrent TUI → torn JSON → load-as-empty → refresh keeps only servers that connect this run, wiping down servers' schemas. Write mcp-cache.json.tmp + rename, same as session JSONL.

@undivisible

Copy link
Copy Markdown
Contributor Author

Triage: since this opened, main was refactored — monolithic ui/tui/src/main.rs was split into app.rs/tui.rs/providers.rs/exec.rs/host.rs/etc (PR #9/#10). This patch no longer applies (git apply fails @ main.rs). Feature is real and not landed on main, but needs porting onto the new module layout: [addressing here]. Left OPEN for rebase rather than blind-merge/c close.

undivisible added a commit that referenced this pull request Aug 27, 2026
Merged:
- feat(tui): paint first, connect providers in background (#5)
- feat(avo): implement a real NVIDIA AVO loop (#16)
- feat(tui): consume rotary hashline/prewalk APIs (#19)

Skipped (massive rewrites needing rebase): #2, #4, #17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant