webnav-ts-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  An MCP server that gives AI agents JS/TS/HTML/CSS navigation, plus a
4
4
  cross-file index of CSS custom properties and `#id`/`.class` selectors that
5
- single-file language servers can't provide. It is the TypeScript port of the
6
- Python [`webnav-mcp`](https://pypi.org/project/webnav-mcp/), so a web project can
7
- add it with its own package manager and have no Python or `uv` in the chain.
5
+ single-file language servers can't provide. It needs only Node.js and installs
6
+ with a web project's own package manager.
8
7
 
9
8
  ## Quick start
10
9
 
@@ -100,20 +99,7 @@ tab counts as one character).
100
99
  | `WEBNAV_MCP_WORKSPACE` | unset: follows the client's MCP roots when they name a worktree of the same git repository, else `CLAUDE_PROJECT_DIR`, else the working directory | Pins the project root (never overridden). See the `workspace` tool |
101
100
  | `WEBNAV_MCP_ROOTS` | the whole workspace as one root, labelled `web` | Comma-separated `label=relative/path` pairs to index separately, e.g. `app=src,prototypes=design` |
102
101
  | `WEBNAV_MCP_EXCLUDE` | nothing | Comma-separated workspace-relative paths of generated script output (e.g. the JS a TS build emits). Not opened, hidden from `search_symbol`, rejected by the position tools; the CSS/selector index still reads them |
103
-
104
- Tool names, parameter names and environment variables are identical to the
105
- Python package, so existing prompts, hosts and configs keep working.
106
-
107
- ## Differences from the Python package
108
-
109
- | | Python `webnav-mcp` | webnav-ts-mcp |
110
- |---|---|---|
111
- | Runtime | Python ≥ 3.11 **and** Node ≥ 18 | Node ≥ 20 only (tested on 22) |
112
- | Language servers | found in the project's `node_modules`, then the package dir, then `PATH`, then `npx --yes` (downloads at first use) | ordinary npm dependencies: a TypeScript ≥ 7 in the *project* wins, else the copy installed with webnav. Nothing is downloaded at runtime |
113
- | Launch | `.bin` shims (`.cmd` special case on Windows) | `node <server>.js`, no shims, no `PATH` dependence |
114
- | JSON-RPC framing | hand-written | [`vscode-jsonrpc`](https://www.npmjs.com/package/vscode-jsonrpc) |
115
- | UTF-16 columns | encode/decode helpers | JS strings already are UTF-16 |
116
- | Cold start (init handshake) | ~530 ms | ~100 ms (see [Speed](#speed)) |
102
+ | `WEBNAV_MCP_PUBLIC` | nothing | Comma-separated workspace-relative stylesheets (files or directories) that are a public API, e.g. a design-token file consumed by other projects. `diagnostics` stops reporting their custom properties as "declared but never used" and their selectors as "never referenced"; undefined `var()` usages are still reported |
117
103
 
118
104
  ## Layout
119
105
 
@@ -122,136 +108,66 @@ src/
122
108
  cli.ts stdio entry point (exits when the host closes stdin)
123
109
  server.ts registers the twelve tools on the MCP SDK
124
110
  webnav.ts the tools as plain async functions returning text; owns all state
125
- webIndex.ts CSS var / selector index (port of web_index.py)
111
+ webIndex.ts CSS var / selector index
126
112
  langCommand.ts how to launch tsc / html / css language servers
127
113
  glob.ts just enough glob for jsconfig `include`
128
- shared/ port of mcp_nav_shared: lspClient, format, resolve, workspace,
129
- notices, errors, params, exclude
114
+ shared/ lspClient, format, resolve, workspace, notices, errors,
115
+ params, exclude
130
116
  tests/ vitest; fixtures/sample-app is the shared fixture
131
- scripts/parity.mjs drives Python and TS servers side by side and diffs the output
132
117
  ```
133
118
 
134
- `shared/` is deliberately a directory, not a second package: see
135
- [Shared library](#the-shared-library).
136
-
137
119
  ## Development
138
120
 
139
121
  ```bash
140
122
  npm install
141
123
  npm run check # biome + tsc + vitest (pretest builds dist/)
142
- npm run bench # speed/memory vs the Python package (same sibling checkout requirement)
143
- npm run parity # needs the Python webnav-mcp checked out at ../webnav-mcp with `uv sync`; see the script header
124
+ npm run upload # check + publish to npm (--build-only / --dry-run / --otp CODE after `--`); needs `npm login`
125
+ npm run test-package # install the published version in a throwaway project and drive it over MCP stdio
144
126
  node bin/launch.mjs # checkout launcher: installs + builds when needed, then starts the server
145
127
  ```
146
128
 
147
129
  Source: [github.com/illescasDaniel/webnav-ts-mcp](https://github.com/illescasDaniel/webnav-ts-mcp).
148
130
 
149
- ## Speed
150
-
151
- Measured with `npm run bench` on Linux (Node 24, Python venv binary started directly so `uv`'s ~20 ms
152
- is excluded), real MCP over stdio against a real web project (SpaceMaker: 3 roots, ~60 JS/TS/CSS/HTML
153
- files, a 3k-line wireframe), median of 7 alternating runs, 15 warm calls per tool:
154
-
155
- | Metric | Python | TypeScript | |
156
- |---|---|---|---|
157
- | Cold start (spawn → `initialize`) | 528 ms | 103 ms | 5.1× |
158
- | First `symbol_info` (spawns `tsc` LSP) | 717 ms | 106 ms | 6.7× |
159
- | First HTML `diagnostics` (spawns HTML server) | 1197 ms | 978 ms | 1.2× |
160
- | Warm `symbol_info` / `references` / `hover` | 8.3 / 7.9 / 7.2 ms | 4.4 / 3.6 / 2.7 ms | ~2× |
161
- | Warm `outline` / `search_symbol` | 7.1 / 8.7 ms | 2.0 / 4.0 ms | 2–3.5× |
162
- | Warm `css_var` / `selector` (own index) | 2.6 / 2.6 ms | 0.5 / 0.7 ms | 4–5× |
163
- | Whole session (connect + every tool once + 15 warm calls each) | 3.5 s | 1.6 s | 2.2× |
164
- | Memory, server + language servers | 477 MiB | 359 MiB | 1.3× |
165
- | Shutdown after stdin closes | 61 ms | 13 ms | 4.6× |
166
-
167
- Warm calls are dominated by the language servers, which are identical in both, so the gap there is
168
- the fixed cost of the MCP layer. Launching through `npx webnav-ts-mcp` adds roughly 0.2 s to the
169
- cold start (npm resolving the `.bin` shim); a locally installed copy started with `node` does not.
170
-
171
- ## Notes from the port
131
+ ## Design notes
172
132
 
173
133
  `webnav` is glue: it spawns three Node language servers and formats their answers,
174
- plus a regex-grade index. Nothing in it depends on Python. The MCP SDK, the LSP
175
- plumbing and the file scanning all have first-class Node equivalents.
134
+ plus a regex-grade index.
176
135
 
177
- **Behavioural parity**, measured by `scripts/parity.mjs` (real MCP over stdio,
178
- identical calls, diffed text):
136
+ **Tests:** vitest (unit tests for the index/format code, real language servers
137
+ against the fixture, MCP protocol over an in-memory transport incl. worktree
138
+ selection via client roots with a real `git worktree`, LSP client failure modes,
139
+ and the built executable).
179
140
 
180
- - A real web project ([SpaceMaker](https://github.com/illescasDaniel/SpaceMaker)'s assets, three roots): **37 / 37 identical**
181
- - `tests/fixtures/sample-app`, including edits behind the servers' backs (fix a
182
- type error, create/delete a file, change a stylesheet, change `jsconfig.json`
183
- and get the restart notice): **71 / 71 identical** (counting the one intentional
184
- inherited-member difference below)
185
-
186
- The one intentional difference is the reason string of "Cannot read file as
187
- UTF-8 text: …", which is a Python codec detail; the parity script masks it.
188
-
189
- **Tests:** 152 vitest tests (unit ports of the index/format tests, real
190
- language servers against the fixture, MCP protocol over an in-memory transport
191
- incl. worktree selection via client roots with a real `git worktree`, LSP
192
- client failure modes, and the built executable). The Python side has ~260 tests
193
- across the two packages; the port covers the behaviour that matters for the
194
- tools, not every case one-to-one.
195
-
196
- ### Things the port surfaced
141
+ ### Things worth knowing
197
142
 
198
143
  - **The MCP SDK's stdio server does not treat stdin EOF as a close.** With a
199
144
  language server child alive, the process kept running after the host went
200
145
  away (found by installing the tarball and launching via `npx`). `cli.ts` now
201
146
  handles `stdin` `end`/`close`; `tests/cli.test.ts` fails without it.
202
- - **UTF-16 handling gets simpler:** JS strings are UTF-16, so the column
203
- conversion helpers in the Python `format.py`/`lsp_client.py` disappear.
204
- - **One known regex divergence:** Python's `\w` is Unicode-aware, JS's is ASCII.
205
- The index regexes use `(?<![\w-])` to avoid matching `data-id=`; a non-ASCII
206
- letter directly before `id=`/`class=`/`style=` is treated differently (a
207
- match in JS, none in Python). Vanishingly rare in real markup, not covered by
208
- parity fixtures.
209
- - **Inherited-member lookup needed a different approach.** The Python package
210
- finds `Class.member` for a member declared on a base class through the LSP's
211
- type hierarchy, but TypeScript 7's language server advertises no type
212
- hierarchy (checked against its `initialize` capabilities), so
213
- `symbol_info("Widget.greet")` used to say "not found" in both implementations.
214
- This port reads the `extends`/`implements` clauses from the source and resolves
147
+ - **Columns are UTF-16 offsets,** which is what LSP uses and what JS strings
148
+ already are, so no conversion is needed.
149
+ - **Regex index limit:** `\w` is ASCII in JS. The index regexes use
150
+ `(?<![\w-])` to avoid matching `data-id=`, so a non-ASCII letter directly
151
+ before `id=`/`class=`/`style=` still counts as a match. Vanishingly rare in
152
+ real markup.
153
+ - **Inherited-member lookup needs a different approach.** TypeScript 7's
154
+ language server advertises no type hierarchy (checked against its `initialize`
155
+ capabilities), so a member declared on a base class can't be found that way.
156
+ webnav reads the `extends`/`implements` clauses from the source and resolves
215
157
  each base with `textDocument/definition`, breadth-first across files (generic
216
158
  bases, qualified names and mixin calls handled; `.d.ts`/`node_modules` bases
217
- aren't followed). It is the one intentional difference the parity script
218
- shows.
219
- - **Two tools the Python webnav never had:** the same capability check showed the
220
- server offers `callHierarchyProvider` and `implementationProvider`, so this
221
- port adds `callers` and `implementations` (12 tools now; the Python one has 10).
159
+ aren't followed).
160
+ - **`callers` and `implementations`** are offered because the server advertises
161
+ `callHierarchyProvider` and `implementationProvider`.
222
162
 
223
163
  ### Costs
224
164
 
225
165
  - **Install size:** a fresh app installing this next to its own TypeScript 5:
226
166
  177 MB `node_modules` (`vscode-langservers-extracted` 66 MB, TypeScript 7
227
167
  ~50 MB with its native binary). An app already on TypeScript ≥ 7 shares that
228
- copy. The Python package had the same npm payload, plus Python.
229
- - **Code size:** ~4.2k lines of TypeScript (2.3k of it in `shared/`) vs ~3.7k
230
- lines of Python for webnav + the shared library (which also carries
231
- codenav-only code that wasn't ported).
232
- - **Not ported** (codenav-only in the shared library): `typeDefinition`,
233
- scratch documents, source-root helpers. (Call hierarchy was ported after all,
234
- for `callers`.)
168
+ copy.
235
169
  - **Not verified:** Windows, macOS, Node 20/24. Only Linux + Node 22.
236
170
 
237
- ### The shared library
238
-
239
- The user-visible downside of the port is that `mcp-nav-shared` (LSP client,
240
- formatting, symbol resolution, workspace selection) now exists twice. Options,
241
- roughly in order of effort:
242
-
243
- 1. **Keep `src/shared/` internal to the TS package** (what this package does).
244
- Zero coordination cost; the two copies can drift. Mitigated by
245
- `scripts/parity.mjs`, which fails when their output diverges.
246
- 2. **Publish it as its own npm package** (`mcp-nav-shared`) only if a second TS
247
- server appears. `codenav` is tied to `ty` (Python) and has no reason to move.
248
- 3. **Retire the Python `webnav-mcp`** and keep only the TS one, leaving
249
- `mcp-nav-shared` for `codenav-mcp` alone. Removes the duplication, breaks
250
- `uvx webnav-mcp` users.
251
-
252
- The Python shared library's webnav-relevant part is small (LSP client, format,
253
- resolve, workspace, notices), which is why the duplication is tolerable.
254
-
255
171
  ## License
256
172
 
257
173
  MIT. See [LICENSE](https://github.com/illescasDaniel/webnav-ts-mcp/blob/main/LICENSE).
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Resolves how to launch the Node-based language servers webnav multiplexes to.
3
3
  *
4
- * Unlike the Python distribution, the servers are ordinary npm dependencies of
4
+ * The servers are ordinary npm dependencies of
5
5
  * this package, so there is nothing to download at runtime: a TypeScript 7+
6
6
  * install in the navigated project wins (it matches the project's own
7
7
  * compiler), else the copy that came with webnav. Both are launched through
package/dist/server.js CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * MCP surface of webnav: registers `Webnav` methods as tools.
3
3
  *
4
- * Tool names and parameter names (snake_case) match the Python `webnav-mcp`
5
- * so hosts and prompts written against either keep working. Name-ish
4
+ * Tool and parameter names are snake_case. Name-ish
6
5
  * parameters are all optional in the schema: a missing one is answered with a
7
6
  * short hint by the tool itself, which beats the SDK's schema-validation error
8
7
  * for agents that guess `query` vs `name`.
@@ -1,7 +1,7 @@
1
1
  /** Path helpers: symlink-resolving normalisation and containment checks. */
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- /** Like Python's `Path.resolve()`: absolute, symlinks resolved when the path exists. */
4
+ /** Absolute path with symlinks resolved when the path exists. */
5
5
  export function resolveReal(p) {
6
6
  const abs = path.resolve(p);
7
7
  try {
@@ -22,7 +22,7 @@ export function relativeWithin(child, parent) {
22
22
  export function toPosix(p) {
23
23
  return p.split(path.sep).join("/");
24
24
  }
25
- /** Component-wise path ordering, matching how Python orders `Path` objects. */
25
+ /** Component-wise path ordering. */
26
26
  export function comparePaths(a, b) {
27
27
  const pa = a.split(path.sep);
28
28
  const pb = b.split(path.sep);
package/dist/webIndex.js CHANGED
@@ -738,6 +738,19 @@ export function undefinedVarUsages(idx) {
738
738
  }
739
739
  return result;
740
740
  }
741
+ /**
742
+ * Declared custom properties never read by any `var()` in the root. A JS/TS
743
+ * string literal equal to the name (e.g. `getPropertyValue("--x")`) counts as a use.
744
+ */
745
+ export function unusedVarDeclarations(idx) {
746
+ const result = [];
747
+ for (const [name, decls] of idx.varDeclarations) {
748
+ if (!idx.varUsages.has(name) && !idx.stringLiterals.has(name)) {
749
+ result.push(...decls);
750
+ }
751
+ }
752
+ return result;
753
+ }
741
754
  /**
742
755
  * CSS-defined tokens with no HTML/JS reference. A dynamically-built JS hit
743
756
  * elsewhere in the root counts as a reference for any token it's a prefix of;
@@ -754,14 +767,23 @@ export function unreferencedSelectors(idx) {
754
767
  }
755
768
  return unreferenced.sort(cmp);
756
769
  }
757
- /** Index-derived warning lines for one file: undefined `var(--x)` usages (no fallback) and CSS selectors with no HTML/JS reference in this root. */
758
- export function diagnosticsForFile(idx, fileRel) {
770
+ /** Index-derived warning lines for one file: undefined `var(--x)` usages (no fallback), unused `--x` declarations and CSS selectors with no HTML/JS reference in this root. */
771
+ export function diagnosticsForFile(idx, fileRel, publicPaths = []) {
759
772
  const warnings = [];
760
773
  for (const usage of undefinedVarUsages(idx)) {
761
774
  if (usage.file === fileRel) {
762
775
  warnings.push(`${usage.line}:1 [warning] var(${usage.name}) is never defined in ${idx.name}`);
763
776
  }
764
777
  }
778
+ // `WEBNAV_MCP_PUBLIC` files are an API for other projects: nothing in them is "unused".
779
+ if (isGenerated(fileRel, publicPaths)) {
780
+ return sortWarnings(warnings);
781
+ }
782
+ for (const decl of unusedVarDeclarations(idx)) {
783
+ if (decl.file === fileRel) {
784
+ warnings.push(`${decl.line}:1 [warning] ${decl.name} is declared but never used in ${idx.name}`);
785
+ }
786
+ }
765
787
  for (const token of new Set(unreferencedSelectors(idx))) {
766
788
  for (const hit of idx.selectorHits.get(token) ?? []) {
767
789
  if (hit.kind === "css" && hit.file === fileRel) {
@@ -769,5 +791,8 @@ export function diagnosticsForFile(idx, fileRel) {
769
791
  }
770
792
  }
771
793
  }
794
+ return sortWarnings(warnings);
795
+ }
796
+ function sortWarnings(warnings) {
772
797
  return warnings.sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10));
773
798
  }
package/dist/webnav.js CHANGED
@@ -72,6 +72,11 @@ export class Webnav {
72
72
  // with a pointer to the source. Navigation-only: the CSS/selector index
73
73
  // still reads them, since emitted JS is where a root's runtime usages live.
74
74
  rawExclude = process.env.WEBNAV_MCP_EXCLUDE ?? "";
75
+ // Comma-separated workspace-relative files/directories of stylesheets that
76
+ // are a public API (design tokens for other projects). `diagnostics` never
77
+ // reports their custom properties or selectors as unused.
78
+ rawPublic = process.env.WEBNAV_MCP_PUBLIC ?? "";
79
+ publicRelative = [];
75
80
  // A malformed WEBNAV_MCP_ROOTS must not crash the server at startup (the host
76
81
  // would only show "server failed to start"): remember the problem and report
77
82
  // it as tool text from every index-backed tool instead.
@@ -113,6 +118,13 @@ export class Webnav {
113
118
  this.generatedRelative = this.generatedPaths
114
119
  .filter((p) => relativeWithin(p, resolvedRoot) !== undefined)
115
120
  .map((p) => relativeWithin(p, resolvedRoot).split(path.sep).join("/"));
121
+ this.publicRelative = this.rawPublic
122
+ .split(",")
123
+ .map((part) => part.trim())
124
+ .filter(Boolean)
125
+ .map((part) => relativeWithin(resolveReal(path.resolve(root, part)), resolvedRoot))
126
+ .filter((rel) => rel !== undefined)
127
+ .map((rel) => rel.split(path.sep).join("/"));
116
128
  }
117
129
  /** Stop every running language server. */
118
130
  async dispose() {
@@ -503,7 +515,7 @@ export class Webnav {
503
515
  try {
504
516
  const located = webIndex.rootIndexForFile(this.indexes(), this.resolvePath(filePath));
505
517
  if (located) {
506
- const extra = webIndex.diagnosticsForFile(located[0], located[1]);
518
+ const extra = webIndex.diagnosticsForFile(located[0], located[1], this.publicRelative);
507
519
  if (extra.length > 0) {
508
520
  lines.push(extra.join("\n"));
509
521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webnav-ts-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server exposing JS/TS/HTML/CSS language-server features plus a workspace-wide CSS custom-property/selector index as MCP tools. TypeScript port of the Python webnav-mcp.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -42,9 +42,9 @@
42
42
  "check": "npm run lint && npm run typecheck && npm test",
43
43
  "pretest": "npm run build",
44
44
  "test": "vitest run",
45
- "parity": "node scripts/parity.mjs",
46
45
  "prepack": "npm run build",
47
- "bench": "node scripts/bench.mjs"
46
+ "upload": "bash scripts/publish.sh",
47
+ "test-package": "node scripts/test_package.mjs"
48
48
  },
49
49
  "dependencies": {
50
50
  "@modelcontextprotocol/sdk": "^1.31.0",