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 +29 -113
- package/dist/langCommand.js +1 -1
- package/dist/server.js +1 -2
- package/dist/shared/paths.js +2 -2
- package/dist/webIndex.js +27 -2
- package/dist/webnav.js +13 -1
- package/package.json +3 -3
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
|
|
6
|
-
|
|
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
|
|
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/
|
|
129
|
-
|
|
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
|
|
143
|
-
npm run
|
|
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
|
-
##
|
|
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.
|
|
175
|
-
plumbing and the file scanning all have first-class Node equivalents.
|
|
134
|
+
plus a regex-grade index.
|
|
176
135
|
|
|
177
|
-
**
|
|
178
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
- **
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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).
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
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).
|
package/dist/langCommand.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolves how to launch the Node-based language servers webnav multiplexes to.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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`.
|
package/dist/shared/paths.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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",
|