An MCP server that produces a header-file view of TypeScript source for AI coding agents: function, class, and type signatures annotated with their original line numbers, requestable per file, per directory, or for the whole project.
// ==== src/services/userService.ts — 312 lines, 8 exports ====
export class UserService { // L24-198
constructor(db: Database, cache?: CacheLayer) // L31
getUser(id: UserId): Promise<User | null> // L45
// Fetches a user by ID, checking cache first.
patch(id: UserId, p: Partial<User>): Promise<User> // L69 ⚠ deprecated
}
// -- types: L201-240 --
export type UserId = Brand<string, "UserId">;
export interface User { id: UserId; name: string; email: string; roles: Role[]; }
export function createUserService(cfg: Config): UserService // L245
export function processOrder(o: Ordr): number // L319-321 ⚠ TS2304: Cannot find name 'Ordr'. — type unreliableThe idea is the same as a C header: an agent reads the compact view first, then opens the source only at the line numbers it needs. Return types come from the TypeScript type checker, so inferred returns are shown explicitly. Files with type or syntax errors still produce a header; unreliable signatures are marked individually (// L319-321 ⚠ TS2304: Cannot find name 'Ordr') rather than failing the whole file.
Several existing approaches cover parts of this. Which one fits depends on the agent and the task; ts-header was built for a specific gap.
tsc --emitDeclarationOnlyproduces.d.tsfiles, which are the closest analog to a header. Differences: declaration emit needs a compilable project, writes files into the tree, does not carry source line numbers, and only covers the public surface. If you want a build artifact for consumers of a library,.d.tsis the right tool; for navigation by an agent, especially in code that does not currently compile, it falls short.- LSP-backed MCP servers (Serena, lsmcp, symbols-mcp) expose symbol queries as individual tools: get an outline, look up a symbol, find references. They are more precise and more capable than ts-header for surgical questions. The tradeoff is that answering "what is in this codebase" takes a sequence of tool calls with the right arguments, which stronger agents handle well and weaker ones often do not. ts-header collapses that workflow into one tool whose output is complete for the requested path.
- Repo maps (Aider's repo map, RepoMapper) generate a single ranked summary of the most important symbols, fitted to a token budget. That is a good format for automatic context injection. It is deliberately lossy, though: symbols that don't rank get omitted, and there is no way to ask for the full contents of one specific file.
What ts-header does that the above do not, in combination:
- Complete per-path output (nothing ranked away) with original line numbers as jump targets
- One tool for all levels: project overview, directory listing, file header
- Works on code that does not compile, with per-symbol error markers
- Includes non-exported and function-local declarations on request
- No files written by default, so nothing can leak into version control
- Focused results with fewer tool calls to improve LLM attention
(See the comparison table below.)
One tool, ts_header(path, depth?, docs?, max_tokens?, filter?). Navigation depth is expressed by the path, so the interaction model is the familiar ls / cat pattern rather than a set of tools to choose between.
path: file or directory, relative to the workspace. A directory of directories returns a project overview; a directory of files returns a per-file export list (with barrel-file detection and[test]tagging); a file returns the full header."."gives the project overview. Also accepts an array of file paths or a glob pattern ("src/**/*.ts") for batch multi-file headers in one call, capped at 20 files; the token budget is split across the batch, and per-item problems (missing file, directory, non-TS) are reported inline without failing the rest.depth:exports(default) |all(adds non-exported declarations and private members) |deep(also descends into function bodies: inner functions, closures, locally declared classes).docs:none|brief(default: first JSDoc sentence; shown after single-line signatures, before multi-line declarations) |full(complete JSDoc including@paramand@throws).@deprecatedis always shown regardless of mode. A JSDoc block at the very top of a file is treated as the file's description rather than the first declaration's: it renders under the header banner and as a short annotation on the file's row in directory listings.max_tokens: approximate output budget, default 4000. Truncated output says so and how to request more.filter: show only symbols whose name matches a pattern (case-insensitive regex; plain text works too). Applies at every level, sots_header(".", {filter: "booking"})acts as a lightweight typed symbol search across the project. Filters names only; full-text search inside function bodies remains grep's job.includeImports: when true, the header opens with a// -- imports --block listing the file's import statements, collapsed to one line each and elided to 120 characters. Off by default (imports rarely earn their tokens for orientation).
Line numbers point at the declaration itself; any JSDoc block sits immediately above it. When reading source at a reported line, include a few lines of upward context — or use docs: "full" to get the complete JSDoc in the header without opening the file at all.
For framework-wrapper declarations like Convex's export const f = mutation({ args: {...}, handler: ... }), the header adds a // args: line under the signature showing the validator shape as written (or the handler's parameter list when there is no args property). The line is suppressed when it would only restate the property names already visible in the checked signature; when too long, whole properties are elided with …n more rather than cut mid-identifier.
| Capabilities | ts-header | LSP Servers | Repo Maps (Aider) | tsc --emitDeclaration |
|---|---|---|---|---|
| Retains Line Numbers | Yes (Lnn) | Yes (symbol) | No | No |
| Token Budget Aware | Yes | No | Yes | No |
| Works on Uncompilable Code | Yes | Yes | Yes | No |
| One-Shot Project Navigation | Yes | No (Multi-step) | Yes (Lossy) | No |
| Includes Private/Local Symbols | Optional | Yes | No | No |
Agents that orient by grepping and reading whole files burn tokens on noise and still miss things. To measure the difference, we ran the same query — "Explain how waitlist queues work in this project" — over a large monorepo in two sessions with the same LLM: one with ts_header, one without.
| Dimension | Without ts_header |
With ts_header |
|---|---|---|
| Initial discovery strategy | Unfiltered grep_search("waitlist") (200 matches) |
Structured ts_header directory scan & filter |
| Module coverage | Found 4 key files; missed 2 | Found all 5 waitlist-related files |
| Tool execution efficiency | Trial-and-error grepping + full-file viewing | Directory overview → filtered signatures → targeted line views |
| Explanation completeness | Solid overview, missed two entry points | Complete end-to-end trace, including admin releases and background notifications |
The session with ts_header was more efficient (index-driven discovery instead of trial-and-error grepping) and more complete (it found entry points the other session overlooked). The full session-by-session breakdown is in docs/case-study.md.
Requires Node 22+ and pnpm.
git clone https://github.com/trophygeek/ts-header-mcp.git ~/tools/ts-header-mcp
cd ~/tools/ts-header-mcp
pnpm install
pnpm test # quiet unit suite (extractor, formatter, router)
pnpm run smoke-test # verbose: prints full example headers for eyeballing
pnpm run build # -> dist/server.js and dist/cli.jsCloning outside your working repositories (e.g. ~/tools) keeps the server out of your projects' version control.
Note for TypeScript 6.0+: the compiler no longer auto-includes packages from node_modules/@types, so the project tsconfig sets "types": ["node"] explicitly. If you see TS2591: Cannot find name 'process' during pnpm run build, that line is missing.
For non-MCP integrations or command-line scripting, you can run the CLI directly from the terminal.
# Run using node
node dist/cli.js <workspaceRoot> <path> [options]
# Or via the ts-header-cli command if installed/linked
ts-header-cli <workspaceRoot> <path> [options]--depth=exports|all|deep: Level of declaration detail to extract.--docs=none|brief|full: JSDoc detail level.--max-tokens=N: Approximate token budget limit.--filter=PATTERN: RegEx pattern to filter symbols by name.
The output is written to stdout, and any errors to stderr (exits with code 0 on success, 1 on error).
The server speaks stdio. The second args entry is the workspace it serves; if omitted, it uses the process working directory.
Create .vscode/mcp.json in your workspace (this file is safe to commit) or run MCP: Open User Configuration from the Command Palette for a user-level config. Note the root key is servers, not mcpServers, and ${workspaceFolder} is supported:
{
"servers": {
"ts-header": {
"type": "stdio",
"command": "node",
"args": ["/home/you/tools/ts-header-mcp/dist/server.js", "${workspaceFolder}"]
}
}
}MCP tools are only available in Copilot's Agent mode (mode dropdown in the Chat view). If the server misbehaves, MCP: List Servers → select it → Show Output shows its stderr.
Create .cursor/mcp.json in the project, or ~/.cursor/mcp.json for all projects. Cursor uses the mcpServers root key; use absolute paths:
{
"mcpServers": {
"ts-header": {
"command": "node",
"args": ["/home/you/tools/ts-header-mcp/dist/server.js", "/path/to/your/workspace"]
}
}
}Enable the server under Cursor Settings → MCP if it does not appear automatically. For a global config serving multiple projects, add one entry per workspace (e.g. ts-header-projA, ts-header-projB).
The shared config is ~/.gemini/config/mcp_config.json (pre-2.0 installs used ~/.gemini/antigravity/mcp_config.json). You can open the right file from inside the IDE: agent panel … menu → MCP Servers → Manage MCP Servers → View raw config. Merge an entry into the existing mcpServers object (same shape as the Cursor example, absolute paths, no ~ in the JSON), then refresh under Settings → Customizations → Installed MCP Servers.
Prompt to have Antigravity's agent do the setup for you
Replace the two placeholders and paste into the agent panel:
Set up a local MCP server called ts-header for me. Follow these steps exactly and
verify each one before moving to the next. Do not skip the test step.
SOURCE: https://github.com/trophygeek/ts-header-mcp.git
WORKSPACE IT WILL SERVE: <PATH_TO_MY_TS_PROJECT>
1. PRE-FLIGHT CHECK:
Run `node -v` to ensure my environment has Node.js version 22 or higher.
If the version is lower, stop immediately and explain that Node 22+ is required.
Also confirm pnpm is available (`pnpm -v`); if not, run `corepack enable`.
2. INSTALL LOCATION: outside any git repo I work in:
git clone the SOURCE into ~/tools/ts-header. Never copy it into my current
workspace; nothing from this setup may be committed to any of my repositories.
3. BUILD AND TEST:
cd ~/tools/ts-header
pnpm install
pnpm test # unit suite: must end with "fail 0"
pnpm run build # produces dist/server.js
If a test fails, stop and show me the full output. Do not modify the source
to make tests pass without telling me what you changed and why.
4. REGISTER THE SERVER:
Find my MCP config: ~/.gemini/config/mcp_config.json on current Antigravity
versions (older installs used ~/.gemini/antigravity/mcp_config.json ...use
whichever exists; if both exist, use the one the IDE opens via the agent
panel "…" menu > MCP Servers > Manage MCP Servers > View raw config).
MERGE the following entry into the existing "mcpServers" object. Do NOT
overwrite or remove any server already in the file. Show me a diff of the
config change before saving.
"ts-header": {
"command": "node",
"args": ["<ABSOLUTE HOME PATH>/tools/ts-header/dist/server.js",
"<WORKSPACE IT WILL SERVE>"]
}
Use real absolute paths (no ~ inside the JSON).
5. ACTIVATE AND VERIFY:
Refresh MCP servers (Settings > Customizations > Installed MCP Servers >
Refresh, or restart the IDE). Confirm "ts-header" appears with one tool,
ts_header. If the server fails to start, run
node ~/tools/ts-header/dist/server.js <WORKSPACE> manually, capture stderr,
and show it to me before changing anything.
6. SMOKE-TEST THE TOOL by calling it three times and showing me each output:
ts_header(".") -> project overview
ts_header("<some src directory>") -> per-file export list
ts_header("<one .ts file from that list>") -> signatures with // L45 numbers
7. FROM NOW ON, in this workspace: when you need to understand TypeScript code,
call ts_header on the directory or file FIRST, and only open source files at
the specific line numbers it reports. Do not read whole .ts files for
orientation. Add this rule to your project rules/memory if you support that.
Report back with: install path, which config file you edited, the config diff,
test results, and the three smoke-test outputs.
For clients that cannot launch a stdio server, http-server.mjs wraps the same router and serves plain JSON over local HTTP. One example integration lives in mirica-tool/.
Registering the server is only half the setup. Without an explicit instruction, agents tend to fall back to reading whole files for orientation. Add a rule to your project's agent instructions file (AGENTS.md, .cursorrules, CLAUDE.md, or the equivalent):
# TypeScript Project Analysis
- When analyzing TypeScript projects, always call the `ts_header` tool first to
get file and directory structures instead of reading entire source files.
- Open source files only at the specific line numbers `ts_header` reports.In field testing this single rule was the difference between agents using the tool consistently and ignoring it.
By default the server performs no disk writes; all caching is in-memory, and it runs in a read-only sandbox. Compiler options are overridden with noEmit and incremental: false when programs are created, so a workspace tsconfig with incremental: true cannot cause a .tsbuildinfo file to appear in your repository. There is a regression test for that case.
A persistent cache is available as an opt-in via TS_HEADER_CACHE=1. Its location resolves in order: TS_HEADER_CACHE_DIR, then $XDG_CACHE_HOME/ts-header, then os.tmpdir()/ts-header. Each candidate is probe-written at startup; if none is writable the server logs one line and continues memory-only. If you deliberately point the cache inside a workspace, a .gitignore containing * is written into the cache directory.
Directory listings respect the workspace-root .gitignore (a common subset: *, ?, **, negation, directory patterns) on top of a built-in skip list (node_modules, dist, .git, ...). Nested .gitignore files are not read. Directly addressing an ignored file still works; the filter applies to listings only.
Other environment variables:
TS_HEADER_GITIGNORE=0: disable .gitignore handling in listingsTS_HEADER_DOCS=none|brief|full: server-wide default for thedocsoption, useful for testing whether doc summaries earn their tokens for your agentsTS_HEADER_DENSE_MIN: grouping threshold for dense declaration blocks, default 6 source linesTS_HEADER_MAX_PROJECTS: LanguageService LRU size for monorepos, default 4
server.ts (MCP layer, the only module that imports the SDK) and cli.ts (CLI entry point) → router.ts (path → project overview | directory listing | file header, plus the content-hash cache) → formatter.ts (all layout rules) ← FileHeaderModel (plain JSON contract in model.ts) ← extractor.ts (raw TypeScript compiler API) ← project.ts (nearest-tsconfig discovery, one long-lived ts.LanguageService per config, project-reference sources folded in, LRU eviction).
The extractor sits behind the FileHeaderModel contract, so it can be replaced (for example by a tree-sitter implementation) without changes to the formatter, cache, or MCP layers. ts-header-design.md contains the design rationale and the decision log.
Roughly in priority order. The first two come from the original design doc (see ts-header-design.md §12); the rest accumulated during field testing on a real monorepo.
- Budget-driven tree depth. The project overview currently shows one directory level. Better: expand subdirectories while the token budget allows, collapsing the largest subtrees first, so depth falls out of
max_tokensrather than a fixed constant. - Solution-builder project references. Monorepo references are handled by folding referenced projects' source files into one program — correct, but programs get large in big workspaces. Using the compiler's solution-builder machinery would keep per-package programs separate.
- File-watcher invalidation. Freshness is currently per-request mtime checking (correct, but more
statcalls than necessary on large projects). Anfs.watch-based invalidator would make repeated directory listings cheaper. - Multi-workspace serving. The server serves the single workspace given at startup; one config entry per project is the workaround. Resolving the workspace per-call (from the request path or client
cwd) would let one registration serve everything. docs:"auto". Include the brief doc sentence only when it adds information beyond the symbol name (skip "Fetches a user" onfetchUser). Needs a heuristic that fails gracefully; blocked on the eval above to know whether it matters.- Machine-readable output. An optional JSON variant of
FileHeaderModelfor orchestrators that want to post-process rather than read. The model is already plain JSON internally; this is mostly aformatparameter. - Nested
.gitignorefiles. Only the workspace-root.gitignoreis read today; per-directory ignore files are a straightforward extension ofsrc/ignore.ts. - Vue/Svelte SFC script blocks. Extract from
<script lang="ts">sections. Low priority unless a target codebase needs it. - Overload polish. Overload chains render tightly, but the implementation signature (not callable in TS) still appears alongside the declaration overloads;
.d.tsconvention would hide it while keeping its L-range on the chain.
Deliberately out of scope: editing/refactoring (read-only tool), full-text search inside function bodies (grep does this better), and importance ranking (headers are complete by design; ranked summaries are Aider-repo-map territory).
pnpm testruns the unit suite (test/unit/, Node's built-in test runner via tsx, 66 tests): formatter tests are pure-function tests over hand-built models (annotations, docs modes, dense-block grouping, error banners, truncation, TOC layout); extractor tests build one program over the shared fixture and assert on the model structure (depth semantics, inferred return types, overloads, JSDoc extraction, diagnostic attachment, barrel detection); router tests build a throwaway workspace in the OS temp dir and cover all three navigation levels, thefilterparameter,.gitignorehandling, guard rails, and the zero-writes invariant (including with anincremental: truetsconfig). A Convex-shaped fixture guards the framework-code regressions found in field testing: largeexport const x = framework({...})declarations keep their own line annotations, and rendered types are length-capped withimport("...")qualifiers stripped.pnpm run smoke-testis intentionally verbose: it prints complete rendered headers for the fixture at two depth/docs combinations, for human review of the output format, followed by a summary set of assertions.server.tstargets@modelcontextprotocol/sdk^1.x. It was written in an environment without registry access, so the SDK wiring is the one part not exercised by the test suite; everything beneath it is executed and type-checked under--strict.
Known limitations are tracked as backlog items 2 and 3 above. Separately, the design doc describes a systematic evaluation of an agent's wrong-file reads with and without the tool; the informal comparison in docs/case-study.md exists, but that evaluation has not been run yet.