AST-Navigator-MCP
o
/ \
o o A S T N A V I G A T O R M C P
/ \ \ =================================
o o o syntax-tree navigation for LLM agents
An MCP server that lets LLM agents navigate JavaScript and TypeScript repositories through their syntax tree - file outlines, symbol definitions and dependency graphs - instead of reading files end to end.
It is built for inputs it cannot trust. Filesystem access is sandboxed to the roots you choose, and the sandbox does not even leak which files exist outside it. The stdio transport stays within bounded memory when a client floods it with malformed JSON.
Cheaper context. A compact outline costs a fraction of the tokens of the file it describes, with line ranges so the agent reads only what it needs.
Precise answers. Matching runs on parsed declarations, not on text, so call sites, comments and strings never produce false hits.
Safe by construction. Read-only tools, strict Zod contracts, a canonical-path sandbox, single-handle file reads and a transport with backpressure.
Contents
Related MCP server: arcscope
Features
Tree-sitter at the core
Every answer comes from a real parse, not from regular expressions:
Native Tree-sitter 0.25 with the JavaScript (including JSX), TypeScript and TSX grammars. Supported files:
.js .mjs .cjs .jsx .ts .mts .cts .tsx, including.d.ts.Error tolerant. Tree-sitter still builds a tree for code that does not compile. Syntax errors are reported with their line numbers, and the rest of the file is still analysed.
Lazy parsers. One parser per language is created on first use and then reused; parsing is synchronous, so a shared instance is never used concurrently.
No toolchain required. The grammars ship prebuilt binaries for Windows, macOS and Linux on x64 and arm64.
Two output formats. Every tool returns a token-cheap Markdown view and typed
structuredContentvalidated against a publishedoutputSchema.
Three read-only tools
Tool | Question it answers | Required input |
What is in this file? |
| |
Where is this symbol declared? |
| |
Which local files does this file load? |
|
A typical agent loop: find_definition → get_file_structure on the hit → analyze_dependencies to follow the graph → read only the relevant lines.
get_file_structure
Outlines one file:
Imports: ESM, CommonJS
requireand re-exports.Classes: properties and methods, with visibility,
static,async,abstract, getters and setters.Top-level functions: declarations, arrow functions, function expressions and CommonJS exports.
TypeScript types: interfaces, type aliases and enums.
Every entry carries 1-based line ranges, and signatures appear exactly as written.
Parameter | Type | Default |
| string | - |
|
|
|
# /repo/src/services/UserService.ts
typescript | 42 lines | 3 imports | 1 class | 1 function | 1 type
## Imports
- L1 import 'node:crypto': { randomUUID }
- L2 import type '../models/user.js': { CreateUserInput, User }
- L3 import '../db/Repository.js': { Repository }
## Classes
- L10-36 export class UserService extends Repository<User> implements Disposable
- properties: private readonly cache; static instances
- L14-17 constructor(private readonly options: UserServiceOptions)
- L19-23 async create(input: CreateUserInput): Promise<User>
- L25-27 findById(id: string): User | undefined
- L29-31 get size(): number
- L33-35 [Symbol.dispose](): void
## Functions
- L38-42 export function createUserService(options: UserServiceOptions): UserService
## Types
- L5-7 export interface UserServiceOptionsfind_definition
Finds where classes, functions, methods, interfaces, type aliases and enums are declared, recursively under a directory. It also accepts qualified names such as UserService.create.
Parameter | Type | Default |
| string | - |
| string | first allowed root |
|
|
|
| boolean |
|
|
| all |
| pagination, |
|
|
|
|
Results are ranked by relevance (exact match first), then by path and line.
Text pre-filter: a file is parsed only if it contains the searched name, so most files are never parsed at all.
Bounded reads: at most 16 files are read concurrently.
Skipped while scanning: dependency and build directories (
node_modules,dist,build,out,coverage,vendor…), dot-directories, symlinks and minified bundles.
# Definitions of 'create' (prefix) in /repo
Showing 1-4 of 4 | 7 files scanned, 4 files parsed
- [method] Repository.create(input: unknown): Promise<T>
/repo/src/db/Repository.ts:L4 (exported)
- [method] UserService.create(input: CreateUserInput): Promise<User>
/repo/src/services/UserService.ts:L19-23 (exported)
- [method] Calculator.create()
/repo/src/utils/math.js:L14-16 (exported)
- [function] createUserService(options: UserServiceOptions): UserService
/repo/src/services/UserService.ts:L38-42 (exported)analyze_dependencies
Maps one file to the local files it depends on. Packages from node_modules, node: built-ins and path aliases are counted but not listed.
Detection walks the whole tree, not just the top level, because a require inside a function or a lazy import() loads a module all the same. It recognises:
ES imports, including side-effect imports and
import type;re-exports (
export ... from);require(), including TypeScript'simport x = require();dynamic
import().
Resolution mirrors TypeScript and Node.js for local specifiers:
The exact file.
The TypeScript source behind a
.jsspecifier (NodeNext style):./util.js→util.ts,.mjs→.mts,.cjs→.cts,.jsx→.tsx.Extension probing:
.ts .tsx .d.ts .mts .cts .js .jsx .mjs .cjs .json.Directory index files:
./lib→lib/index.ts.
The probing order depends on the importer. A TypeScript file prefers the .ts source over a compiled .js sibling; a JavaScript file prefers what Node.js would actually load. Bundler query suffixes (./icon.svg?raw) and file: URLs are also handled.
Several spellings of the same import (./util and ./util.js) are merged into one entry. A file counts as type-only only if every reference to it is type-only.
Parameter | Type | Default |
| string | - |
| boolean: include |
|
|
|
|
A JavaScript barrel file whose .js specifiers point at TypeScript sources, one of which is missing:
# Dependencies of /repo/src/index.mjs
javascript | 2 internal dependencies | 1 unresolved | 0 external specifiers ignored
## Internal
- /repo/src/services/UserService.ts
"./services/UserService.js" [re-export] L1
- /repo/src/models/user.ts
"./models/user.js" [re-export] L2
## Unresolved
- "./polyfills.js": not_found [import] L3Unresolved specifiers carry a reason: not_found, invalid, or outside_roots, which never reveals where the specifier points. The JSON output also counts the require() and import() calls whose argument is computed, since those cannot be followed statically.
Architecture
flowchart TD
client["MCP client<br/>Claude Code · Claude Desktop"] -- "JSON-RPC over stdio" --> transport
subgraph server ["ast-navigator-mcp-server"]
transport["ResilientStdioServerTransport<br/>framing · backpressure · flood control"] --> mcp["McpServer<br/>Zod input validation"]
mcp --> tools["Tools<br/>get_file_structure · find_definition · analyze_dependencies"]
tools --> policy["PathPolicy<br/>lexical + canonical sandbox"]
policy --> parser["Parser<br/>single-handle reads · Tree-sitter"]
parser --> formatters["Formatters<br/>Markdown + structuredContent"]
end
formatters --> transportsrc/
├── index.ts Entry point: process guards + startup
├── constants.ts Every operational limit, in one auditable place
├── errors.ts AstNavigatorError (code + hint) and type guards
├── server/
│ ├── AstNavigatorServer.ts Composition root: McpServer, tools, lifecycle
│ ├── ResilientStdioServerTransport.ts Hardened stdio transport (bounded memory)
│ ├── config.ts CLI (allowed roots, --help, --version), validated with Zod
│ ├── logger.ts Structured stderr-only logger; console redirected
│ └── processGuards.ts uncaughtException, unhandledRejection, EPIPE, signals
├── parser/
│ ├── index.ts Public API: extract_file_structure, extract_module_references
│ ├── model.ts Data contract: Zod schemas → DeepReadonly types
│ ├── languages.ts Extension → Tree-sitter grammar registry
│ ├── treeSitterParser.ts Lazy per-language Parser instances
│ ├── fileReader.ts Single-handle reads, O_NOFOLLOW, typed fs errors
│ ├── structureExtractor.ts Syntax tree → file outline
│ ├── definitions.ts Outline → flat list of definitions
│ └── dependencies.ts Syntax tree → module references (whole-tree walk)
└── tools/
├── index.ts registerTools()
├── schemas.ts Zod input/output contracts + z.infer types
├── pathPolicy.ts The sandbox: the only authority on paths
├── getFileStructure.ts Tool: get_file_structure
├── findDefinition.ts Tool: find_definition (search, ranking, pagination)
├── analyzeDependencies.ts Tool: analyze_dependencies
├── dependencyResolver.ts Node/TypeScript-style resolution through PathPolicy
├── directoryWalker.ts Deterministic traversal that never follows links
├── formatters.ts Markdown renderers
├── responses.ts Uniform results, character limit, error mapping
└── context.ts Dependencies injected into every toolDesign principles
stdout carries JSON-RPC and nothing else. Every log line goes to stderr. The global
consoleis also replaced with a stderr-bound one, so a strayconsole.log(ours or a dependency's) cannot corrupt the protocol stream.Zod is the single source of truth. Inputs are
z.strictObjects (unknown fields are rejected), handlers are typed withz.infer, and output schemas are published asoutputSchema. The CLI arguments and the log-level variable are validated too.Actionable errors. Expected failures come back as
isErrorresults that readError [CODE]: … Hint: …. Unexpected ones are logged with their stack trace and reported generically. Either way, the session survives.Stateless and read-only. Every call re-reads the disk; no cache can go stale and no state can be corrupted.
Every limit lives in
constants.ts.
Security & Resilience
Threat model. The server has two untrusted inputs:
Tool arguments, chosen by a model that may have been prompt-injected by the very repository it is reading.
The raw byte stream on stdin.
The guarantees: a sandboxed server never reads, lists or reveals the existence of anything outside its allowed roots, and no input makes its memory grow without bound.
1. Path traversal, without existence oracles
Blocking ../../../etc/passwd is the easy part. The harder part is not leaking information through how a request is rejected. If a missing file outside the sandbox answers FILE_NOT_FOUND while an existing one answers PATH_OUTSIDE_ROOTS, the error code becomes a filesystem scanner. The defence is layered:
Shape validation (Zod). Control characters (including NUL) and Windows device-namespace prefixes (
\\?\,\\.\) are rejected, since those prefixes bypass Win32 path normalisation. Traversal is deliberately not decided here: the schema cannot know the roots.PathPolicyis the only authority. A path must be inside a root canonically (afterrealpath), so a symlink or NTFS junction inside a root cannot lead out of it. A root that is itself reached through a link (/tmp→/private/tmpon macOS) keeps working.Uniform answers outside the sandbox. A path that is not lexically inside a root is either served, because it resolves canonically inside one, or rejected with
PATH_OUTSIDE_ROOTS. It never getsFILE_NOT_FOUNDorPERMISSION_DENIED, whether or not it exists.No probing through links. When an in-root path cannot be resolved,
PathPolicycanonicalises its deepest existing ancestor. If that ancestor lies outside the roots, the answer isPATH_OUTSIDE_ROOTS, so a link pointing out of the sandbox cannot be used to map its target.Defence in depth.
PathPolicyre-checks its own input (INVALID_PATH) instead of trusting callers. The directory walker never follows links. The dependency resolver routes every candidate path throughPathPolicybeforestat, and stops at the first candidate outside the roots.
Probe against a sandboxed server | Naive | AST Navigator |
Outside the root, file exists |
|
|
Outside the root, file missing |
|
|
Through an in-root link that points out, file missing |
|
|
Inside the root, file missing |
|
|
2. TOCTOU-resistant reads
One handle per read. Files are opened once; the type and size checks then run on that handle (
fstat) and the content is read from the same handle. There is nostat(path)followed byreadFile(path), so the path cannot be re-pointed between the check and the read.O_NOFOLLOWwhen sandboxed. A sandboxed path is already canonical, so a symlink appearing at its last component can only be a swap made after validation. The open refuses it.Size re-checked after reading. The 2 MiB limit is enforced again on the bytes actually decoded, so a file that grows mid-read is rejected rather than silently exceeding the limit.
The window between realpath and open is narrowed, not eliminated: Node.js exposes no openat2(RESOLVE_BENEATH). See Limitations.
3. A stdio transport that cannot be flooded
The SDK's StdioServerTransport closes the connection when a message is too large, which ends the session. ResilientStdioServerTransport speaks the same wire format (newline-delimited JSON-RPC 2.0) but rejects bad frames and keeps serving:
Input | Response |
Invalid JSON | JSON-RPC |
Valid JSON but not JSON-RPC |
|
Frame larger than 10 MiB | Discarded up to the next newline, then |
A handler that throws | Reported through |
"Keep serving" must not turn into "keep allocating", so everything the transport holds is bounded:
Inbound. A partial frame is capped at 10 MiB and kept as a list of chunks, so a split frame is copied once on reassembly rather than once per chunk.
Outbound. A single-writer queue keeps exactly one
drainlistener at a time and is capped at 32 MiB. Beyond that,send()fails fast instead of buffering without end.Backpressure. stdin is paused while more than 4 MiB is queued for stdout and resumed below 1 MiB, so a slow client slows the producer instead of growing the heap.
Flood control. A run of invalid frames gets at most 64 error replies; the budget resets at the next valid message. A flood of short malformed lines therefore cannot be amplified into a flood of larger responses and log lines.
No reflection. Request ids longer than 256 characters are dropped instead of being echoed back.
Clean shutdown.
close()detaches every listener and rejects every queued send.
Measured with a 200,000-line burst of malformed JSON while the client had stopped reading:
Before hardening | After | |
Heap retained after GC | 172.3 MiB | 0.5-1.0 MiB |
| 200,000 (and 0 removed by | 1 (0 after |
Error replies / log lines | 200,000 | 64 + one flood notice |
Reply to a malformed request with a 100,000-char | the whole | 141 bytes |
4. Verified by mutation testing
Every mitigation above is pinned by a regression test. To prove the tests catch real bugs, and not just pass, the vulnerable code was restored and the suite run against it:
First round: transport and sandbox. 12 tests failed against the original code, each reporting the bug's own numbers:
172.3 MiB retained and 200,000
drainlisteners;20,000 error replies to a 20,000-line flood, against a budget of 64;
0 of 5,000 sends refused by a stalled client;
the oracle itself:
[PATH_OUTSIDE_ROOTS, FILE_NOT_FOUND, FILE_NOT_FOUND].
Second round:
analyze_dependencies. Removing the ancestor check made 4 tests fail. Letting the resolver probe the disk without going throughPathPolicymade 5 fail, one of them reporting the leaked out-of-root path verbatim.
The exercise also exposed a test that hung instead of failing against the broken code. The suite now enforces a 60-second timeout per test and a 2-second settle guard on promises, so a regression always fails and never stalls CI.
Installation & Usage
Requirements
Node.js 22 or later.
Windows, macOS or Linux, on x64 or arm64. Tree-sitter ships prebuilt binaries for all six targets, so no C++ toolchain is needed.
Build
git clone https://github.com/vpa2007vpa-web/AST-Navigator-MCP.git
cd AST-Navigator-MCP
npm install # installs dependencies and compiles to ./dist (prepare script)
npm test # rebuilds and runs the full test suiteThe server takes its allowed roots as positional arguments. Always pass at least one in normal use: with no roots the server can read anything the OS user can, and find_definition then needs an explicit directory. Relative tool paths resolve against the first root.
Claude Code
claude mcp add --transport stdio ast-navigator -- node /abs/path/AST-Navigator-MCP/dist/index.js /abs/path/to/your/repoEverything after -- is passed to the server unchanged. To make the server available in every project, add --scope user. To share the configuration with your team through a committed .mcp.json, add --scope project instead. Environment variables go before the server name:
claude mcp add --env AST_NAVIGATOR_LOG_LEVEL=debug --transport stdio ast-navigator -- node /abs/path/AST-Navigator-MCP/dist/index.js /abs/path/to/your/repoOn Windows (PowerShell or cmd), the same command takes native paths:
claude mcp add --transport stdio ast-navigator -- node C:\tools\AST-Navigator-MCP\dist\index.js C:\code\my-appCheck the connection with claude mcp list, or with /mcp inside a Claude Code session.
Claude Desktop
Open Settings → Developer → Edit Config, or edit the file directly:
macOS:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"ast-navigator": {
"command": "node",
"args": [
"/abs/path/AST-Navigator-MCP/dist/index.js",
"/abs/path/to/your/repo"
],
"env": { "AST_NAVIGATOR_LOG_LEVEL": "info" }
}
}
}On Windows, escape backslashes in JSON ("C:\\code\\my-app") or use forward slashes ("C:/code/my-app"). Restart Claude Desktop after saving.
Any other MCP client
The server speaks standard MCP over stdio, so any client works. To explore it interactively with the MCP Inspector:
npx @modelcontextprotocol/inspector node dist/index.js /abs/path/to/your/repoConfiguration reference
Setting | Values |
Positional arguments | Allowed roots; zero or more directories |
| Printed to stderr; stdout stays protocol-only |
|
|
Limit ( | Value |
Source file size | 2 MiB |
Text returned per call ( | 25,000 characters |
Files per directory scan / directory depth | 20,000 / 32 |
Concurrent file reads | 16 |
JSON-RPC frame / outbound queue | 10 MiB / 32 MiB |
Error replies per run of invalid frames | 64 |
Distinct local specifiers resolved per | 500 |
Development
Script | Action |
| Compile |
| Run |
| Incremental compile in watch mode |
| Type-check without emitting |
| Build, then run every suite with |
| Delete |
The compiler runs in its strictest configuration: strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride and verbatimModuleSyntax. Line endings are pinned to LF through .gitattributes, so source files are byte-identical on every platform.
Testing
71 tests across 13 suites. The end-to-end suites spawn the compiled server and drive it over real stdio, exactly as an MCP client would.
Suite | Tests | Covers |
| 17 | The tools over stdio, input validation, a sandboxed and an unrestricted server, malformed payloads |
| 22 |
|
| 14 | Framing edge cases, memory bounds, backpressure, a malformed flood against the real process |
| 18 | Reference extraction, Node/TypeScript resolution, the sandbox contract, the bundled fixture |
On Windows, one test is skipped, because O_NOFOLLOW only exists on POSIX systems; a Windows run therefore reports 70 passed and 1 skipped. Directory-link tests use NTFS junctions on Windows, which need no administrator rights.
A note on dependencies
The runtime stack is deliberately small: @modelcontextprotocol/sdk, zod, tree-sitter, tree-sitter-javascript and tree-sitter-typescript, plus typescript and @types/node for development.
tree-sitter-typescript@0.23.2 (the latest release) declares an outdated peer dependency (tree-sitter ^0.21), which makes npm install fail next to tree-sitter@0.25. The overrides block in package.json resolves this without adding a dependency. It is safe: that grammar targets ABI 14, which the 0.25 core supports (ABI 13-15), and the TypeScript/TSX tests confirm it.
Limitations
Definitions, not usages.
find_definitionlocates declarations, not references.analyze_dependencieslists what one file imports, not which files import it, and it does not build a repository-wide graph.Module resolution. tsconfig
paths, package.jsonexportsand#importsare not read; such specifiers are counted as external. Percent-encoded ESM specifiers are not decoded.Outline scope. The outline covers the module's top level. Wrapped functions (
const X = memo(() => …)) are not reported as functions, and TypeScriptnamespaceblocks are not traversed.Scanning.
.gitignoreis not read; a fixed list of directories is skipped instead. Files over 2 MiB are skipped, and a scan stops at 20,000 files (scanTruncated: true).Platform security limits.
O_NOFOLLOWdoes not exist on Windows; there, the canonical-path check still applies, but the post-validation swap protection does not. A hardlink inside a root pointing to a file outside it is indistinguishable at the OS level and cannot be detected.Spec deviation kept for compatibility. Error replies to frames whose request id cannot be determined omit
idinstead of sending"id": nullas JSON-RPC 2.0 specifies.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Stateless TS/JS compiler facts for agents: references, imports, impact. No repo index or OAuth.
Code intelligence for coding agents: semantic, AST, graph, and full-text search. 279+ languages.
Codebase graphs, caller impact analysis, and recorded project context for AI coding agents.
Code intelligence platform for AI agents. 20 tools for architecture, security & impact analysis.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceAST-aware TypeScript/JavaScript codebase exploration for AI agents, providing high-precision symbol resolution, reference finding, and structural analysis via MCP tools.228 npmMIT
- AlicenseNot gradedqualityCmaintenanceA local MCP server that gives AI coding agents symbol definitions, dependency graphs, and a live architecture vocabulary for TypeScript/JavaScript repos, with no network or embeddings.16 npmMIT
- AlicenseNot gradedqualityDmaintenanceExposes type-aware code navigation and fast file search to AI agents via language servers, enabling definitions, references, symbols, and file lookup without reading entire codebases.3,050 npmMIT
- AlicenseAqualityFmaintenanceA deterministic structural code map server for AI agents, giving them the shape of a codebase (imports, exports, classes, functions, signatures, comments, TODO-markers) without reading whole files into context. Powered by tree-sitter WASM grammars, it runs anywhere Node 18+ works.77 npm1AGPL 3.0