An MCP server that gives AI agents structural understanding of a TypeScript / JavaScript codebase — so an agent can query code structure instead of reading whole files into its context window.
ast-lens-mcp parses your project with the Babel AST toolchain and exposes twelve focused, read-only tools over the Model Context Protocol. Point it at a project root and an LLM client (Claude Desktop, Cursor, or anything that speaks MCP) can ask precise structural questions: what symbols are exported here? where is this function called? which functions are too complex? what does this module import? what exports or files look dead? what does this package expose? — all without paging entire files through the model.
It runs entirely on your local files. No API keys, no network calls, no credentials.
- Built with: TypeScript (strict), the official
@modelcontextprotocol/sdk,@babel/parser/@babel/traverse/@babel/typesfor AST analysis,zodfor tool input schemas, andvitestfor tests. - Transport: stdio. Runtime: Node 18+. Module system: ESM.
- Languages analyzed:
.ts,.tsx,.js,.jsx,.mts,.cts,.mjs,.cjs.node_modulesand build output are ignored automatically.
Coding agents waste a lot of context re-reading files just to answer structural questions ("does this symbol exist?", "where is it used?", "what's the shape of this class?"). Those questions have exact answers that come from the AST, not from an approximate read. ast-lens-mcp turns them into cheap, deterministic tool calls that return small structured JSON — leaving more of the model's context for actual reasoning.
It is deliberately syntactic, not type-aware: it parses, it does not type-check. That keeps it fast, dependency-light, and able to analyze files without a whole-program build. Some graph tools read simple tsconfig path aliases for better module resolution, but name/reference tools stay name-based. find_references is therefore a precise name-based search classified by syntactic role, not a type-resolved rename index — see the note on that tool below.
# from npm (once published)
npm install -g ast-lens-mcp
# or run without installing
npx ast-lens-mcp /path/to/your/projectFrom source:
git clone <your-repo-url> ast-lens-mcp
cd ast-lens-mcp
npm install
npm run build
node dist/index.js /path/to/your/project # boots on stdioThe project root that tools analyze is resolved in this order:
- a positional CLI arg or
--root <path>flag, - the
AST_LENS_PROJECT_ROOTenvironment variable, - the current working directory.
Add an entry to claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"ast-lens": {
"command": "npx",
"args": ["-y", "ast-lens-mcp", "/absolute/path/to/your/project"]
}
}
}Or, if installed globally / from source:
{
"mcpServers": {
"ast-lens": {
"command": "node",
"args": ["/absolute/path/to/ast-lens-mcp/dist/index.js"],
"env": {
"AST_LENS_PROJECT_ROOT": "/absolute/path/to/your/project"
}
}
}
}{
"mcpServers": {
"ast-lens": {
"command": "npx",
"args": ["-y", "ast-lens-mcp", "${workspaceFolder}"]
}
}
}All paths passed to tools are interpreted relative to the project root (or absolute, as long as they stay inside it). Directory traversal outside the root is refused, and so are symlinks that resolve to a location outside the root — the server verifies the real (symlink-resolved) path before reading any file.
Every tool is read-only (readOnlyHint: true, openWorldHint: false), validates input with a strict zod schema, returns both human-readable text and machine-readable structuredContent, supports response_format: "json" | "markdown", and never throws on bad input — parse failures come back as structured parseErrors.
| # | Tool | Use it for |
|---|---|---|
| 1 | list_symbols |
Top-level and exported symbols across files, folders, or globs |
| 2 | get_file_outline |
One-file class/interface/enum structure |
| 3 | find_references |
Name-based identifier references with syntactic context |
| 4 | search_ast |
Structural AST queries and common code smells |
| 5 | analyze_complexity |
Per-function cyclomatic complexity and LOC |
| 6 | summarize_module |
A file's imports, exports, and dependencies |
| 7 | find_unused_exports |
Exported symbols that appear unused outside their defining file |
| 8 | call_graph |
Function-to-function call relationships |
| 9 | import_graph |
Resolved module import, re-export, and dynamic-import edges |
| 10 | detect_circular_deps |
Circular dependencies in the module graph |
| 11 | find_dead_files |
Source files not reachable from package or index entry points |
| 12 | api_surface |
Public symbols reachable from a package or entry file |
| 13 | compare_implementations |
Two candidate solutions to one task, compared on structural signals (preference-ranking / rubric review support) |
All top-level / exported symbols (function, class, interface, type, enum, const/let/var) across a file, directory, or glob.
Options: kinds (filter), exportedOnly, ignore (extra globs), response_format.
A hierarchical outline of one file: classes with their methods/properties, interfaces with their members, enums with their members.
// input
{ "file": "src/models.ts" }
// output (abridged)
{
"file": "src/models.ts",
"symbolCount": 8,
"outline": [
{ "name": "User", "kind": "interface", "exported": true,
"children": [
{ "name": "id", "kind": "property", "span": { "start": {"line":4,"column":3}, ... } },
{ "name": "getDisplayName", "kind": "method", "span": { ... } }
] },
{ "name": "UserService", "kind": "class", "exported": true,
"children": [
{ "name": "create", "kind": "method", "static": true, "span": { ... } },
{ "name": "count", "kind": "getter", "span": { ... } },
{ "name": "findById", "kind": "method", "async": true, "span": { ... } }
] }
]
}Every reference to an identifier name across the project, with locations, a source snippet, and a syntactic-context classification (call / import / declaration / type / jsx / reference).
// input
{ "name": "UserService", "target": "src" }
// output (abridged)
{
"name": "UserService",
"total": 6,
"count": 6,
"references": [
{ "file": "src/models.ts", "context": "declaration",
"snippet": "export class UserService {", "span": { "start": {"line":26,"column":14}, ... } },
{ "file": "src/models.ts", "context": "call",
"snippet": "return new UserService();", "span": { ... } },
{ "file": "src/widget.tsx", "context": "import",
"snippet": "import { UserService } from \"./models\";", "span": { ... } }
],
"byContext": { "declaration": 1, "type": 1, "call": 1, "reference": 2, "import": 1 },
"parseErrors": [ { "file": "src/broken.ts", "message": "Unexpected keyword 'const'. (3:2)", "position": {"line":3,"column":3} } ]
}Note: this is a fast, name-based search (no full type resolution), so identical names from different scopes/modules are all reported. Use the
snippetandcontextto disambiguate, and thebyContexthistogram to, e.g., count only call sites. Options:target(defaults to the whole project),includeDeclarations,ignore,limit,response_format.
Structural queries over the AST — far more precise than text search. Ships a curated set of named queries plus a generic node-type escape hatch:
| query | finds |
|---|---|
calls_to |
calls to a specific callee (set callee, e.g. "console.log" or "fetch") |
console_usage |
any console.* call |
any_usage |
TypeScript any type annotations |
non_null_assertion |
TypeScript ! non-null assertions |
await_in_loop |
await inside for/while loops (a sequential-await perf smell) |
empty_catch |
catch clauses with an empty body (swallowed errors) |
todo_fixme |
TODO / FIXME / HACK / XXX markers in comments |
ts_ignore |
@ts-ignore / @ts-expect-error / @ts-nocheck suppression comments |
node_type |
generic: match any Babel node type (set nodeType, e.g. "TryStatement") |
// input
{ "query": "await_in_loop", "target": "src/smelly.ts" }
// output
{
"query": "await_in_loop",
"total": 1,
"matches": [
{ "file": "src/smelly.ts", "nodeType": "AwaitExpression",
"snippet": "const result = await fetchValue(item); // await in loop",
"span": { "start": {"line":8,"column":20}, "end": {"line":8,"column":42} } }
],
"parseErrors": []
}Per-function cyclomatic complexity (McCabe: 1 + decision points — if, loops, each non-default case, catch, ternary, &&/||/??, and optional chaining) plus lines-of-code, with a threshold flag. Nested functions are measured independently.
// input
{ "target": "src/smelly.ts", "threshold": 5, "flaggedOnly": true }
// output
{
"threshold": 5,
"totalFunctions": 4,
"flaggedCount": 1,
"maxComplexity": 6,
"averageComplexity": 2.75,
"files": [
{ "file": "src/smelly.ts",
"functions": [
{ "name": "classify", "kind": "function", "complexity": 6, "loc": 16, "params": 1,
"overThreshold": true, "span": { "start": {"line":23,"column":8}, ... } }
] }
]
}Options: threshold, flaggedOnly, sortBy (complexity | loc | location), ignore, limit, response_format.
One file's imports, exports, and dependencies — including static imports, re-exports, dynamic import() calls, and require() calls. Dependencies are split into local (relative/absolute paths) vs external (bare package specifiers).
// input
{ "file": "src/barrel.ts" }
// output (abridged)
{
"file": "src/barrel.ts",
"imports": [
{ "source": "node:fs/promises", "typeOnly": false, "bindings": [ { "local": "readFile", "imported": "readFile" } ] },
{ "source": "./models", "typeOnly": true, "bindings": [ { "local": "User", "imported": "User" } ] },
{ "source": "./models", "typeOnly": false, "bindings": [ { "local": "models", "imported": "*" } ] },
{ "source": "./models", "typeOnly": false, "bindings": [ { "local": "defaultService", "imported": "default" } ] }
],
"exports": [
{ "name": "Role", "kind": "named", "source": "./models", "typeOnly": false },
{ "name": "*", "kind": "named", "source": "./widget", "typeOnly": false },
{ "name": "loadConfig", "kind": "named", "typeOnly": false }
],
"dependencies": ["./dynamic-mod", "./legacy-cjs", "./models", "./widget", "node:fs/promises"],
"localDependencies": ["./dynamic-mod", "./legacy-cjs", "./models", "./widget"],
"externalDependencies": ["node:fs/promises"],
"counts": { "imports": 4, "exports": 6, "dependencies": 5 }
}Candidate dead public surface: exported symbols that are never referenced from another file in the scanned scope. Package entry points and index barrels are treated as intentional public API by default.
// input
{ "target": "src", "entryPoints": ["src/index.ts"] }
// output (abridged)
{
"scanned": 27,
"totalExports": 71,
"total": 4,
"unused": [
{ "file": "src/internal.ts", "name": "debugOnly", "exportKind": "named", "reexport": false }
],
"entryPoints": ["src/index.ts"],
"parseErrors": []
}This is name-based, not type-resolved. It is meant to produce a review list, not an automatic delete list.
Function-to-function call relationships for a file, folder, or glob. Nodes are function-like definitions; edges are call sites from the enclosing function to the resolved callee when there is a clear name-based match.
// input
{ "target": "src/core", "includeExternalCalls": false }
// output (abridged)
{
"nodeCount": 42,
"edgeCount": 67,
"unresolvedCallees": 12,
"nodes": [
{ "id": "src/core/parser.ts#parseFile@28", "name": "parseFile", "file": "src/core/parser.ts", "kind": "function" }
],
"edges": [
{ "from": "src/core/parser.ts#parseFile@28", "callee": "parse", "to": "src/core/parser.ts#parse@11", "file": "src/core/parser.ts" }
],
"parseErrors": []
}Set includeExternalCalls: true when you want calls to libraries, built-ins, or unresolved methods kept in the edge list with to: null.
Resolved module-level imports, re-exports, dynamic imports, and require() calls. Relative paths, directory indexes, TS-ESM .js specifiers, and simple tsconfig path aliases are mapped to concrete in-scope files when possible.
// input
{ "target": "src", "includeExternal": true }
// output (abridged)
{
"nodeCount": 27,
"edgeCount": 97,
"internalEdges": 97,
"externalCount": 11,
"unresolvedEdges": 0,
"nodes": [{ "id": "src/index.ts", "entry": true }],
"edges": [
{ "from": "src/server.ts", "specifier": "./tools/listSymbols.js", "to": "src/tools/listSymbols.ts", "kind": "import", "resolution": "internal", "symbols": ["registerListSymbols"] }
],
"parseErrors": []
}Scope matters: if the imported file is outside target, the edge is reported as unresolved.
Circular import dependencies in the module graph, including self-imports. It is path-based and works from the same resolved graph model as import_graph.
// input
{ "target": "src", "includeDynamic": true, "includeTypeOnly": true }
// output (abridged)
{
"scanned": 27,
"edgeCount": 97,
"cycleCount": 0,
"hasCycles": false,
"cycles": [],
"unresolvedLocal": [],
"parseErrors": []
}Use includeTypeOnly: false when type-only cycles are noise for the question you are asking.
Candidate orphan modules: source files that are not reachable from package.json entries or index-style entry points. Results include why a file is suspicious and which unreachable files still import it.
// input
{ "target": "src", "entryPoints": ["src/index.ts"] }
// output (abridged)
{
"scanned": 27,
"entryFileCount": 1,
"reachableCount": 27,
"total": 0,
"deadFiles": [],
"entryPoints": ["src/index.ts"],
"parseErrors": []
}Dynamic imports, test-only entry points, generated files, and assets can all change the interpretation, so treat the result as a triage list.
The public API reachable from a package directory or a single entry file. It follows re-exports transitively and returns each public symbol with a best-effort source signature.
// input
{ "target": "src/server.ts", "includeMembers": true }
// output (abridged)
{
"target": "src/server.ts",
"packageEntryPoints": [],
"entries": [
{
"entry": "src/server.ts",
"symbols": [
{ "name": "createServer", "kind": "function", "exportKind": "named", "signature": "function createServer(opts: CreateServerOptions): McpServer", "declaredIn": "src/server.ts" }
],
"unresolved": []
}
],
"totalSymbols": 1,
"parseErrors": []
}Pass target: "." for a package root, or an explicit file such as src/server.ts when you want one entry file only.
Compares two implementations of the same functionality — for example, two candidate solutions to one task — on objective, AST-derived structural signals, and returns a side-by-side table plus a transparent preference recommendation. Each signal is a deterministic count (cyclomatic complexity, empty catch blocks, console.* calls, TS any annotations, non-null assertions, TODO/FIXME markers, parameter counts, error-handling). The recommendation is an auditable per-dimension tally with the weights included in the output.
This is decision support for a "which solution is better, and why" judgment, not a correctness verdict — the output's caveats say so explicitly, and the metrics do not detect logic bugs, missing requirements, performance, or security issues. Run the candidates' own tests for ground truth.
// input
{ "left": "solutions/a.ts", "right": "solutions/b.ts", "leftLabel": "a", "rightLabel": "b" }
// output (abridged)
{
"left": { "label": "a", "metrics": { "averageComplexity": 1.67, "emptyCatch": 0, "anyAnnotations": 0, "maxParams": 2 } },
"right": { "label": "b", "metrics": { "averageComplexity": 8, "emptyCatch": 1, "anyAnnotations": 6, "maxParams": 5 } },
"dimensions": [ { "name": "averageComplexity", "weight": 2, "leftValue": 1.67, "rightValue": 8, "winner": "left" } ],
"score": { "left": 10, "right": 0 },
"recommendation": "prefer_left",
"rationale": [ "a has lower average complexity (1.67 vs 8).", "a has fewer empty catch blocks (0 vs 1)." ],
"caveats": [ "These are objective STRUCTURAL signals, not a correctness or behavior judgment — run the candidates' own tests for ground truth." ]
}recommendation is one of prefer_left, prefer_right, comparable (no measurable structural edge), or insufficient_signal (neither side defines a function).
src/
index.ts # bin entry — resolves project root, starts stdio transport
server.ts # builds the McpServer and registers all tools (transport-agnostic)
core/ # the reusable AST layer
parser.ts # Babel parse + mtime-based parse cache (never throws)
files.ts # glob/dir/file discovery, node_modules ignore, path-traversal sandbox
traverse.ts # shared traversal helpers + cyclomatic-complexity engine
extract.ts # symbol / outline / module-summary extraction
entryPoints.ts # package entry and TS-ESM source-resolution helpers
importGraph.ts # resolved import/re-export graph builder
moduleGraph.ts # dependency-cycle graph helpers
signature.ts # best-effort source signature extraction
context.ts # ServerContext: shared root + cache + batch loader
response.ts # JSON/Markdown formatting, character-limit guard, error results
types.ts # shared structured-output types
tools/ # one file per tool, each exporting register<Tool>(server, ctx)
listSymbols.ts getFileOutline.ts findReferences.ts
searchAst.ts analyzeComplexity.ts summarizeModule.ts
findUnusedExports.ts callGraph.ts importGraph.ts
detectCircularDeps.ts findDeadFiles.ts apiSurface.ts
shared.ts # shared zod schema fragments
test/
core.test.ts # parser, cache, path-safety, complexity
tools.test.ts # every tool, end-to-end through an in-memory MCP client
fixtures/sample-project/ # small, realistic fixtures (incl. a deliberately broken file)
scripts/
smoke.mjs # boots the built server over real stdio and exercises the tools
Design notes:
- Robust by construction. A bad file never crashes a call: the parser returns a structured result, and batch tools collect failures into
parseErrors. Tools that expect a single file return actionable errors when handed a directory or glob. - Sandboxed. Tool inputs are confined to the project root; any path that resolves outside it — including via a symlink that points out of the tree — is rejected (
PathEscapeError) before any file is read. - Context-friendly. Responses carry a 25k-character guard that summarizes oversized payloads and tells the agent how to narrow the query.
npm install
npm run build # bundle with tsup -> dist/index.js (executable, ESM)
npm run typecheck # tsc --noEmit, strict, over src + tests
npm test # vitest run (178 tests)
npm run smoke # build first, then boot the server over stdio and call tools
npm run dev # tsx watch (run the server from source)MIT © Morgan