Skip to content

feat: answer the MCP handshake immediately and index in the background - #5

Open
Kevinrob wants to merge 4 commits into
ThinkyMiner:mainfrom
qoqa:feat/background-indexing
Open

Kevinrob wants to merge 4 commits into
ThinkyMiner:mainfrom
qoqa:feat/background-indexing

Conversation

@Kevinrob

@Kevinrob Kevinrob commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #4. Only the last commit is new; review feat: answer the MCP handshake immediately….

Summary

All indexing ran inside create_server(), before mcp.run() could answer the MCP handshake. On a large repository a cold build still took longer than the client's startup timeout, so the server never came up. Indexing and the graph build now run in a background thread. On a ~9 500-file repository the handshake completes in about 1 s (previously more than 25 s), and single-file tools answer within ~1.5 s while indexing continues.

Changes

  • New src/codetree/index_state.py. IndexState owns the lifecycle (discovery → indexing with the skeleton cache → graph build) and exposes files_ready, index_ready and graph_ready events.
  • Single-file tools (get_file_skeleton, get_symbol, get_imports, get_skeletons, get_symbols, get_complexity, analyze_dataflow flow/taint) answer immediately by parsing the requested files on demand. Only files that discovery would index are served.
  • Repo-wide and graph tools wait up to 20 s (CODETREE_WAIT_TIMEOUT to change it), then return a "still building … (indexing N/M files)" message.
  • index_status never blocks.
    • New keys: status, files_discovered, files_indexed, index_ready, graph_ready, startup_seconds, error. Existing keys are unchanged.
    • During a rebuild, it reports the last committed graph.
  • Failures release every waiter. A graph failure rolls back (new GraphStore.rollback()) and leaves structural tools working.
  • create_server(root, background=False): synchronous by default, re-raising build errors (tests, embedding). The codetree CLI uses background=True.
  • Cache:
    • atomic writes with a unique temp file, so concurrent servers on one repository cannot collide;
    • the file mode is kept;
    • a failed save never fails indexing;
    • has_errors is now cached, so syntax warnings survive warm starts. Older cache entries are re-parsed once.
  • Indexer: index_file(), build(files=, progress=), and a locked build-then-publish lazy call graph for concurrent tool calls.
  • fastmcp>=3.0.0: 3.0 is the first release that runs sync tools in a thread pool. On 2.x, a tool waiting for the index would block the event loop. The tests already rely on the 3.x local_provider API.

Behaviour changes

  • During a cold start, repo-wide and graph tools may return a "still building" message. Retry, or poll index_status.
  • index_status returns additional keys.
  • The minimum fastmcp version is now 3.0.0.

Test plan

  • Ran pytest: all tests pass (1206)
  • Added tests/test_async_startup.py:
    • non-blocking startup, progress reporting, and on-demand single-file tools that respect .gitignore;
    • "still building" messages, and a repo-wide wait that succeeds when the index arrives in time;
    • failure paths (indexing, graph, failing rollback);
    • synchronous re-raise, cache save failure, the atexit registration, and committed stats during a rebuild;
    • has_errors across warm starts, and the env timeout.
  • Added tests for cache concurrency and file mode, and for the concurrent call graph.
  • Tested with a real MCP client over stdio (initialize, tools/list, single-file call during indexing, polling index_status), cold and warm.

Related issues

Third of three stacked PRs: #3 → #4 → #5 (this one).

🤖 Generated with Claude Code

Kevinrob and others added 4 commits October 1, 2026 15:02
delete_edges_for_file used `source_qn LIKE 'file::%' OR target_qn LIKE
'file::%'`. LIKE folds ASCII case and treats `_` as a wildcard, so
deleting the edges of `my_file.py` also deleted those of `myXfile.py`
and `My_File.py`. The OR also prevented SQLite from using the edge
indexes, so every call scanned the whole edges table, which made graph
builds quadratic in the number of files.

Use two range scans on the indexed columns instead: every qualified name
starting with "file::" sorts in ["file::", "file:;").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Compile each tree-sitter Query once per (language, pattern) with a
  bounded, thread-safe cache (`_query()` in languages/base.py). Compiling
  a query costs several milliseconds, more than parsing a whole file, and
  every plugin method compiled its queries on each call: ~98% of
  skeleton extraction time.
- `CachedParser` wraps each plugin parser and reuses the tree for
  consecutive calls on the same source (per-thread, 2 trees, keyed by
  the source bytes, so a changed file never gets a stale tree).
- GraphBuilder: lookup tables replace the O(files^2) scans in import
  resolution; the content hash uses the source already in memory.
- GraphBuilder writes symbols, edges and file rows with multi-row
  INSERT statements and resolves callees from an in-memory name map
  loaded once per build. sqlite3 releases the GIL on every row, even with
  executemany, so row-by-row writes stall behind any CPU-bound thread.

The plugin diffs are a mechanical swap (`Query(` -> `_query(`,
`Parser(...)` -> `CachedParser(Parser(...))`) with no logic change.

On a ~1 100-file repository a cold start drops from 193 s to under 7 s,
with an identical graph (symbols, edges, files). The test suite runs
about 4x faster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discovery used rglob("*"), which walked into every directory (including
.git and node_modules before filtering) and ignored .gitignore. Nested
git worktrees, such as the ones Claude Code creates under
.claude/worktrees/, were indexed as part of the repository, so every
symbol showed up once per worktree in find_references, resolve_symbol
and friends, and the index was several times larger than needed.

- In a git work tree, files come from `git ls-files`: tracked files are
  always indexed (.gitignore decides, so a tracked build/ or env/ is
  included), and untracked, non-ignored files are also checked against
  SKIP_DIRS so an un-ignored .venv or node_modules is never crawled.
  Git does not descend into nested worktrees, repositories or
  submodules.
- Without git (no repository, git missing or refusing the repository,
  root ignored by a parent repository), os.walk prunes SKIP_DIRS and
  directories whose .git is a file (worktrees, submodules) before
  descending. A root that groups several full repositories still
  indexes them.
- Paths are decoded with os.fsdecode (non-UTF-8 names), de-duplicated
  (unmerged paths are listed once per stage) and sorted. The index keeps
  that order, so results no longer depend on which files came from the
  cache.
- Only discovered files are re-injected from the skeleton cache, and the
  cache is rewritten from the index. Stale entries for deleted or newly
  ignored files never come back.

On a repository with eight Claude Code worktrees, this indexes ~9 500
files instead of ~86 000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All indexing ran inside create_server(), before mcp.run() answered the
MCP handshake. On a large repository a cold build took longer than the
client's startup timeout, so the server never came up. Indexing and the
graph build now run in a background thread owned by the new IndexState
(src/codetree/index_state.py):

- Single-file tools (get_file_skeleton, get_symbol, get_imports,
  get_skeletons, get_symbols, get_complexity, analyze_dataflow
  flow/taint) answer right away, parsing the requested files on demand
  (only files discovery would index).
- Repo-wide and graph tools wait up to 20 s (CODETREE_WAIT_TIMEOUT),
  then return a "still building" message with progress.
- index_status never blocks. It adds status, files_discovered,
  files_indexed, index_ready, graph_ready, startup_seconds and error;
  during a rebuild it reports the last committed graph.
- Failures release every waiter. A graph failure rolls back and leaves
  structural tools working.

create_server(root) stays synchronous by default and re-raises build
errors (tests, embedding); `codetree` uses create_server(root,
background=True). No tool signature changes.

Also:
- Require fastmcp>=3.0.0, the first release that runs sync tools in a
  thread pool. On 2.x a waiting tool would block the event loop.
- Cache writes are atomic, with a unique temp file per save, and keep
  the file mode. A failed save no longer fails indexing. The cache now
  stores has_errors, so syntax warnings survive warm starts.
- Indexer: index_file(), build(files=, progress=), and a locked,
  build-then-publish lazy call graph for concurrent tool calls.
- GraphStore.rollback().

On a ~9 500-file repository the handshake completes in about 1 s
(previously more than 25 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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