catenary-mcp 1.3.4

A high-performance multiplexing bridge between MCP (Model Context Protocol) and LSP (Language Server Protocol). Enables LLMs to access IDE-grade code intelligence across multiple languages simultaneously with smart routing and UTF-8 accuracy.
Documentation
# Architecture

## Workspace Roots

Catenary accepts multiple workspace roots via the `-r`/`--root` flag:

```bash
catenary -r ./frontend -r ./backend serve
```

If no roots are specified, the current directory is used. Roots can also be
provided dynamically by the MCP client via the `roots/list` protocol.

### One Server Per Language, All Roots

Catenary spawns **one LSP server per language** and passes all roots as
`workspaceFolders` in the LSP `initialize` request. This mirrors how VS Code
and other multi-root editors work — the LSP specification added
`workspaceFolders` and `workspace/didChangeWorkspaceFolders` specifically for
this use case.

When roots change at runtime (via MCP `roots/list_changed`), Catenary sends a
single `workspace/didChangeWorkspaceFolders` notification to each active server
with the added and removed folders.

### Why Not One Server Per Root?

A natural question is whether each root should get its own LSP server instance
to avoid symbol conflicts. Catenary deliberately does **not** do this:

- **LSP servers handle multi-root internally.** Mature servers like
  rust-analyzer, gopls, and pyright discover independent project configurations
  (`Cargo.toml`, `go.mod`, `tsconfig.json`) within each workspace folder and
  treat them as separate compilation units. A `Config` struct in root A and a
  `Config` struct in root B are tracked as distinct types — find-references,
  go-to-definition, and rename all respect project boundaries.

- **Cross-project navigation would break.** Monorepos and library-plus-consumer
  setups rely on a single server seeing all roots to resolve cross-project
  imports and references.

- **Catenary is a transport bridge, not a language engine.** It does not
  understand language semantics and cannot correctly scope results. Imposing its
  own boundaries would conflict with the server's semantic model.

### Where This Can Break Down

- **Weak multi-root support:** Not all LSP servers handle `workspaceFolders`
  well. Some treat the first root as primary and partially ignore the rest.
  This is a server quality issue, not a Catenary limitation.

- **Agent confusion:** An AI agent receiving search results that span two
  unrelated projects might not realize the results come from different
  codebases. File paths in results carry this information, but the agent must
  interpret them correctly.

If two projects are truly unrelated, running them in separate Catenary sessions
is the cleanest solution.

## Path Security

All file operations pass through a `PathValidator` that enforces workspace root
boundaries. A path must be a descendant of at least one root to be accessed.
Symlinks are resolved (canonicalized) before validation, preventing escapes via
symlink traversal.

Catenary's own configuration files (`.catenary.toml`,
`~/.config/catenary/config.toml`) are additionally protected from write access,
preventing agents from modifying their own tool configuration.

## LSP Multiplexing

Catenary routes MCP tool calls to the correct LSP server based on file
extension. The agent never needs to know which server handles which language —
a `hover` request on a `.rs` file routes to rust-analyzer, while the same
request on a `.py` file routes to pyright.

Servers are started eagerly at launch for languages detected in the workspace.
If a request arrives for a language whose server is not yet running, Catenary
spawns it on demand. Dead servers are automatically restarted on the next
request.

## Diagnostics Consistency

LSP has two interaction models. Request/response operations — hover,
go-to-definition, document symbols — return consistent results directly: the
server computes the answer on demand and sends it back. Diagnostics work
differently. Servers push them asynchronously via `textDocument/publishDiagnostics`
whenever analysis completes, and Catenary caches whatever arrived last.

This creates a consistency problem. After a file change is sent to the server,
there is a window where the diagnostics cache still holds results from before
the change. If the result is returned during this window, the agent receives
stale diagnostics and may proceed unaware of errors it just introduced.

Catenary buffers this eventually consistent gap to ensure diagnostics are
current before returning them. Each URI has a generation counter that
increments every time `publishDiagnostics` arrives for it. Before sending a
change notification (`didOpen`/`didChange`) to the server, Catenary snapshots
the counter. After sending, it waits for the server to publish diagnostics for
that URI — advancing the counter past the snapshot — before reading the cache
and returning results. Because the snapshot is taken before the change is sent,
there is no race window: any publication that arrives after the snapshot
necessarily reflects the change or something newer.

After the counter advances, Catenary continues to observe the server's
notification stream and progress state. A polling loop checks every 100 ms
for new notifications or active progress tokens (e.g., flycheck running
`cargo check`). If the server sends any further notifications or has
background work in progress, the quiet timer resets. Only when the server
has been completely silent for 2 seconds with no active progress tokens does
Catenary read the cache and return. This catches servers like rust-analyzer
that publish diagnostics in multiple rounds — fast warnings from native
analysis followed by slower type-checking errors from flycheck.

This mechanism applies to the paths that return diagnostics after a change:
the `catenary notify` hook (for post-edit diagnostics) and the `diagnostics`
tool. Request/response tools like `hover` and `document_symbols` do not need
it — their results come directly from the server response, not from the cache.

## Root Synchronization

When the MCP client sends a `notifications/roots/list_changed` notification,
Catenary:

1. Sends a `roots/list` request to the client to fetch the current roots.
2. Diffs the new roots against the current set.
3. Updates the `PathValidator` security boundary.
4. Sends a batched `workspace/didChangeWorkspaceFolders` notification to each
   active LSP server.
5. Spawns any newly needed LSP servers for languages detected in the added
   roots.