webnav-ts-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +257 -0
- package/dist/cli.js +34 -0
- package/dist/glob.js +65 -0
- package/dist/langCommand.js +71 -0
- package/dist/outlineImports.js +46 -0
- package/dist/server.js +165 -0
- package/dist/shared/errors.js +80 -0
- package/dist/shared/exclude.js +32 -0
- package/dist/shared/format.js +598 -0
- package/dist/shared/log.js +11 -0
- package/dist/shared/lspClient.js +653 -0
- package/dist/shared/lspTypes.js +2 -0
- package/dist/shared/notices.js +83 -0
- package/dist/shared/params.js +33 -0
- package/dist/shared/paths.js +37 -0
- package/dist/shared/resolve.js +398 -0
- package/dist/shared/text.js +29 -0
- package/dist/shared/workspace.js +139 -0
- package/dist/webIndex.js +773 -0
- package/dist/webnav.js +586 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Illescas Romero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# webnav-ts-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that gives AI agents JS/TS/HTML/CSS navigation, plus a
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
You need Node.js ≥ 20:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx webnav-ts-mcp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Register it with your MCP host. For Claude Code, from the project root:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add webnav -- npx webnav-ts-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or add it to a project `.mcp.json` (Claude Code) or `.cursor/mcp.json` (Cursor):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"webnav": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["webnav-ts-mcp"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
In Cursor, also set `"env": {"WEBNAV_MCP_WORKSPACE": "${workspaceFolder}"}`,
|
|
37
|
+
because Cursor may start MCP servers with your home directory as the working
|
|
38
|
+
directory.
|
|
39
|
+
|
|
40
|
+
To pin it per project instead of resolving through `npx` each time:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install --save-dev webnav-ts-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Language servers
|
|
47
|
+
|
|
48
|
+
Requests are routed to three Node language servers by file extension:
|
|
49
|
+
|
|
50
|
+
| Extension | Backend |
|
|
51
|
+
|-----------|---------|
|
|
52
|
+
| `.js` / `.mjs` / `.cjs` / `.jsx` / `.ts` / `.mts` / `.cts` / `.tsx` | TypeScript 7 native LSP: `tsc --lsp --stdio` (JS via `allowJs` / `jsconfig.json`) |
|
|
53
|
+
| `.html` | `vscode-html-language-server` |
|
|
54
|
+
| `.css` | `vscode-css-language-server` |
|
|
55
|
+
|
|
56
|
+
They are ordinary npm dependencies, so nothing is downloaded at runtime. A
|
|
57
|
+
TypeScript ≥ 7 in the navigated project wins; otherwise the copy installed with
|
|
58
|
+
webnav-ts-mcp is used. (An app on an older TypeScript still works, at the cost of
|
|
59
|
+
the extra install size: see [Costs](#costs).)
|
|
60
|
+
|
|
61
|
+
## Tools
|
|
62
|
+
|
|
63
|
+
**For JS/TS, start with the name-based tools:**
|
|
64
|
+
|
|
65
|
+
| Tool | Answers |
|
|
66
|
+
|------|---------|
|
|
67
|
+
| `symbol_info` | What is this? Header, hover, definition and references in one call |
|
|
68
|
+
| `outline` | What's in this file? (source order; locals and imports left out unless `detailed=true`) |
|
|
69
|
+
| `callers` | Who calls this function/method? (call hierarchy) |
|
|
70
|
+
| `implementations` | Who implements/extends this interface, class or method? |
|
|
71
|
+
| `search_symbol` | JS/TS workspace symbol search (ranked, capped; optional `kind=` / `path=` filters) |
|
|
72
|
+
| `workspace` | Which directory is being navigated, and why |
|
|
73
|
+
|
|
74
|
+
Then use the position tools once you have a `path:line:col`:
|
|
75
|
+
|
|
76
|
+
| Tool | Answers |
|
|
77
|
+
|------|---------|
|
|
78
|
+
| `hover` | Type and docs at a position |
|
|
79
|
+
| `definition` | Go to definition (CSS/HTML tokens answer from the index below) |
|
|
80
|
+
| `references` | All usages (CSS/HTML tokens answer from the index below) |
|
|
81
|
+
| `diagnostics` | Language-server diagnostics; CSS/HTML also get unreferenced-selector and undefined-variable warnings |
|
|
82
|
+
|
|
83
|
+
**Cross-file CSS/HTML index:**
|
|
84
|
+
|
|
85
|
+
| Tool | Answers |
|
|
86
|
+
|------|---------|
|
|
87
|
+
| `css_var` | Where is `--name` defined and used? |
|
|
88
|
+
| `selector` | Where is `#id` or `.class` defined and used (CSS, HTML, JS)? |
|
|
89
|
+
|
|
90
|
+
`search_symbol`, `symbol_info`, `outline`, `callers` and `implementations` are
|
|
91
|
+
**JS/TS only**. Use `css_var` and `selector` for markup and stylesheets.
|
|
92
|
+
`name` and `query` are accepted as aliases of each other on the name-based tools.
|
|
93
|
+
Positions are **1-indexed**; `column` is a UTF-16 character offset (a leading
|
|
94
|
+
tab counts as one character).
|
|
95
|
+
|
|
96
|
+
## Environment
|
|
97
|
+
|
|
98
|
+
| Variable | Default | Purpose |
|
|
99
|
+
|----------|---------|---------|
|
|
100
|
+
| `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
|
+
| `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
|
+
| `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)) |
|
|
117
|
+
|
|
118
|
+
## Layout
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
src/
|
|
122
|
+
cli.ts stdio entry point (exits when the host closes stdin)
|
|
123
|
+
server.ts registers the twelve tools on the MCP SDK
|
|
124
|
+
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)
|
|
126
|
+
langCommand.ts how to launch tsc / html / css language servers
|
|
127
|
+
glob.ts just enough glob for jsconfig `include`
|
|
128
|
+
shared/ port of mcp_nav_shared: lspClient, format, resolve, workspace,
|
|
129
|
+
notices, errors, params, exclude
|
|
130
|
+
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
|
+
```
|
|
133
|
+
|
|
134
|
+
`shared/` is deliberately a directory, not a second package: see
|
|
135
|
+
[Shared library](#the-shared-library).
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm install
|
|
141
|
+
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
|
|
144
|
+
node bin/launch.mjs # checkout launcher: installs + builds when needed, then starts the server
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Source: [github.com/illescasDaniel/webnav-ts-mcp](https://github.com/illescasDaniel/webnav-ts-mcp).
|
|
148
|
+
|
|
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
|
|
172
|
+
|
|
173
|
+
`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.
|
|
176
|
+
|
|
177
|
+
**Behavioural parity**, measured by `scripts/parity.mjs` (real MCP over stdio,
|
|
178
|
+
identical calls, diffed text):
|
|
179
|
+
|
|
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
|
|
197
|
+
|
|
198
|
+
- **The MCP SDK's stdio server does not treat stdin EOF as a close.** With a
|
|
199
|
+
language server child alive, the process kept running after the host went
|
|
200
|
+
away (found by installing the tarball and launching via `npx`). `cli.ts` now
|
|
201
|
+
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
|
|
215
|
+
each base with `textDocument/definition`, breadth-first across files (generic
|
|
216
|
+
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).
|
|
222
|
+
|
|
223
|
+
### Costs
|
|
224
|
+
|
|
225
|
+
- **Install size:** a fresh app installing this next to its own TypeScript 5:
|
|
226
|
+
177 MB `node_modules` (`vscode-langservers-extracted` 66 MB, TypeScript 7
|
|
227
|
+
~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`.)
|
|
235
|
+
- **Not verified:** Windows, macOS, Node 20/24. Only Linux + Node 22.
|
|
236
|
+
|
|
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
|
+
## License
|
|
256
|
+
|
|
257
|
+
MIT. See [LICENSE](https://github.com/illescasDaniel/webnav-ts-mcp/blob/main/LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Console entry point: serve over stdio. */
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { createServer } from "./server.js";
|
|
7
|
+
function packageVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL("../package.json", import.meta.url);
|
|
10
|
+
return JSON.parse(fs.readFileSync(fileURLToPath(url), "utf8")).version ?? "0.0.0";
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const { server, webnav } = createServer(packageVersion());
|
|
17
|
+
let closing = false;
|
|
18
|
+
async function shutdown(code) {
|
|
19
|
+
if (closing) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
closing = true;
|
|
23
|
+
await webnav.dispose();
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
process.on("SIGINT", () => void shutdown(0));
|
|
27
|
+
process.on("SIGTERM", () => void shutdown(0));
|
|
28
|
+
server.server.onclose = () => void shutdown(0);
|
|
29
|
+
// The SDK's stdio transport never reports EOF as a close. A host that disconnects
|
|
30
|
+
// (or a wrapper like `npx` that doesn't forward signals) would otherwise leave
|
|
31
|
+
// this process and its language servers running.
|
|
32
|
+
process.stdin.on("end", () => void shutdown(0));
|
|
33
|
+
process.stdin.on("close", () => void shutdown(0));
|
|
34
|
+
await server.connect(new StdioServerTransport());
|
package/dist/glob.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Just enough glob for `jsconfig.json` `include` entries. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { EXCLUDED_DIR_NAMES } from "./shared/exclude.js";
|
|
5
|
+
import { escapeRegExp } from "./shared/text.js";
|
|
6
|
+
function globToRegExp(pattern) {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
9
|
+
const ch = pattern.charAt(i);
|
|
10
|
+
if (ch === "*" && pattern.charAt(i + 1) === "*") {
|
|
11
|
+
if (pattern.charAt(i + 2) === "/") {
|
|
12
|
+
out += "(?:.*/)?";
|
|
13
|
+
i += 2;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
out += ".*";
|
|
17
|
+
i += 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else if (ch === "*") {
|
|
21
|
+
out += "[^/]*";
|
|
22
|
+
}
|
|
23
|
+
else if (ch === "?") {
|
|
24
|
+
out += "[^/]";
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
out += escapeRegExp(ch);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return new RegExp(`^${out}$`);
|
|
31
|
+
}
|
|
32
|
+
/** Files under `root` matching a workspace-relative glob (a plain directory name matches everything below it). */
|
|
33
|
+
export function globFiles(root, pattern) {
|
|
34
|
+
let normalized = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
35
|
+
if (!/[*?]/.test(normalized)) {
|
|
36
|
+
normalized += "/**/*";
|
|
37
|
+
if (fs.existsSync(path.join(root, pattern)) && fs.statSync(path.join(root, pattern)).isFile()) {
|
|
38
|
+
return [path.join(root, pattern)];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const re = globToRegExp(normalized);
|
|
42
|
+
const matches = [];
|
|
43
|
+
const walk = (dir, rel) => {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
if (!EXCLUDED_DIR_NAMES.has(entry.name)) {
|
|
55
|
+
walk(path.join(dir, entry.name), childRel);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (re.test(childRel)) {
|
|
59
|
+
matches.push(path.join(dir, entry.name));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(root, "");
|
|
64
|
+
return matches;
|
|
65
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves how to launch the Node-based language servers webnav multiplexes to.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the Python distribution, the servers are ordinary npm dependencies of
|
|
5
|
+
* this package, so there is nothing to download at runtime: a TypeScript 7+
|
|
6
|
+
* install in the navigated project wins (it matches the project's own
|
|
7
|
+
* compiler), else the copy that came with webnav. Both are launched through
|
|
8
|
+
* the current `node` executable rather than `.bin` shims, which sidesteps
|
|
9
|
+
* `.cmd` handling on Windows and PATH differences under GUI-launched hosts.
|
|
10
|
+
*
|
|
11
|
+
* JS/TS uses TypeScript 7's native LSP (`tsc --lsp --stdio`).
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { ToolInputError } from "./shared/errors.js";
|
|
17
|
+
function readPackageJson(dir) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Directories to look for `node_modules/<pkg>` in: the navigated workspace, then every ancestor of this module (webnav's own install, hoisted or nested). */
|
|
26
|
+
function searchDirs(workspaceRoot) {
|
|
27
|
+
const dirs = [workspaceRoot];
|
|
28
|
+
for (let dir = path.dirname(fileURLToPath(import.meta.url));; dir = path.dirname(dir)) {
|
|
29
|
+
dirs.push(dir);
|
|
30
|
+
if (path.dirname(dir) === dir) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return dirs;
|
|
35
|
+
}
|
|
36
|
+
function findPackage(workspaceRoot, name, accept = () => true) {
|
|
37
|
+
for (const base of searchDirs(workspaceRoot)) {
|
|
38
|
+
const dir = path.join(base, "node_modules", ...name.split("/"));
|
|
39
|
+
const pkg = readPackageJson(dir);
|
|
40
|
+
if (pkg?.name === name && accept(pkg)) {
|
|
41
|
+
return { dir, pkg };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function binPath(dir, pkg, binName) {
|
|
47
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[binName];
|
|
48
|
+
return bin ? path.join(dir, bin) : undefined;
|
|
49
|
+
}
|
|
50
|
+
const isTypescript7 = (pkg) => Number.parseInt((pkg.version ?? "").split(".")[0] ?? "", 10) >= 7;
|
|
51
|
+
/** Launch TypeScript 7's native language server: `tsc --lsp --stdio`. */
|
|
52
|
+
export function resolveTsCommand(workspaceRoot) {
|
|
53
|
+
const found = findPackage(workspaceRoot, "typescript", isTypescript7);
|
|
54
|
+
const tsc = found && binPath(found.dir, found.pkg, "tsc");
|
|
55
|
+
if (!tsc) {
|
|
56
|
+
throw new ToolInputError("TypeScript 7 (`typescript@^7`) was not found in the workspace or in webnav's own dependencies; " +
|
|
57
|
+
"reinstall webnav-ts-mcp or run `npm install --save-dev typescript@^7` in the project.");
|
|
58
|
+
}
|
|
59
|
+
return [process.execPath, tsc, "--lsp", "--stdio"];
|
|
60
|
+
}
|
|
61
|
+
function resolveLangserver(workspaceRoot, binName) {
|
|
62
|
+
const found = findPackage(workspaceRoot, "vscode-langservers-extracted");
|
|
63
|
+
const bin = found && binPath(found.dir, found.pkg, binName);
|
|
64
|
+
if (!bin) {
|
|
65
|
+
throw new ToolInputError("`vscode-langservers-extracted` was not found in the workspace or in webnav's own dependencies; " +
|
|
66
|
+
"reinstall webnav-ts-mcp or run `npm install --save-dev vscode-langservers-extracted` in the project.");
|
|
67
|
+
}
|
|
68
|
+
return [process.execPath, bin, "--stdio"];
|
|
69
|
+
}
|
|
70
|
+
export const resolveHtmlCommand = (workspaceRoot) => resolveLangserver(workspaceRoot, "vscode-html-language-server");
|
|
71
|
+
export const resolveCssCommand = (workspaceRoot) => resolveLangserver(workspaceRoot, "vscode-css-language-server");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps import bindings out of `outline`. TypeScript's language server reports every
|
|
3
|
+
* imported name as a top-level `Variable`, which drowns the file's own structure; the
|
|
4
|
+
* statements are found in the source text, so a name imported over several lines is
|
|
5
|
+
* hidden too. `outline(detailed=true)` still lists them.
|
|
6
|
+
*/
|
|
7
|
+
import { splitLines } from "./shared/text.js";
|
|
8
|
+
// A statement's last line ends in the module specifier (optionally followed by an import attribute).
|
|
9
|
+
const ENDS_WITH_SPECIFIER = /["'][^"']*["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?\s*(?:\/\/.*)?$/;
|
|
10
|
+
const IMPORT_EQUALS = /^import\s+(?:type\s+)?[\w$]+\s*=/;
|
|
11
|
+
const MAX_IMPORT_LINES = 500;
|
|
12
|
+
/** 0-based lines covered by top-level `import` statements. */
|
|
13
|
+
export function importLines(source) {
|
|
14
|
+
const lines = splitLines(source);
|
|
15
|
+
const covered = new Set();
|
|
16
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17
|
+
if (!/^import\b/.test(lines[i] ?? "")) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
let end = i;
|
|
21
|
+
if (!(IMPORT_EQUALS.test(lines[i] ?? "") && !/["']/.test(lines[i] ?? ""))) {
|
|
22
|
+
while (end < lines.length && end - i < MAX_IMPORT_LINES && !ENDS_WITH_SPECIFIER.test(lines[end] ?? "")) {
|
|
23
|
+
end++;
|
|
24
|
+
}
|
|
25
|
+
if (end >= lines.length || end - i >= MAX_IMPORT_LINES) {
|
|
26
|
+
end = i; // no specifier found: treat it as a one-line statement
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (let line = i; line <= end; line++) {
|
|
30
|
+
covered.add(line);
|
|
31
|
+
}
|
|
32
|
+
i = end;
|
|
33
|
+
}
|
|
34
|
+
return covered;
|
|
35
|
+
}
|
|
36
|
+
/** `symbols` without the ones declared on an import line (works for both `documentSymbol` shapes). */
|
|
37
|
+
export function dropImportSymbols(symbols, source) {
|
|
38
|
+
const covered = importLines(source);
|
|
39
|
+
if (covered.size === 0) {
|
|
40
|
+
return symbols;
|
|
41
|
+
}
|
|
42
|
+
return symbols.filter((sym) => {
|
|
43
|
+
const line = (sym.range ?? sym.location?.range)?.start?.line;
|
|
44
|
+
return line === undefined || !covered.has(line);
|
|
45
|
+
});
|
|
46
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP surface of webnav: registers `Webnav` methods as tools.
|
|
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
|
|
6
|
+
* parameters are all optional in the schema: a missing one is answered with a
|
|
7
|
+
* short hint by the tool itself, which beats the SDK's schema-validation error
|
|
8
|
+
* for agents that guess `query` vs `name`.
|
|
9
|
+
*/
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { INSTRUCTIONS, Webnav } from "./webnav.js";
|
|
13
|
+
const POSITION_DOC = "`line` and `column` are 1-indexed. `column` is a UTF-16 character offset on the line " +
|
|
14
|
+
"(not a visual/display column): a leading tab counts as one character.";
|
|
15
|
+
const text = (value) => ({
|
|
16
|
+
content: [{ type: "text", text: value }],
|
|
17
|
+
});
|
|
18
|
+
export function createServer(version) {
|
|
19
|
+
const server = new McpServer({ name: "webnav", version }, { instructions: INSTRUCTIONS });
|
|
20
|
+
const webnav = new Webnav({
|
|
21
|
+
rootsProvider: async () => {
|
|
22
|
+
if (!server.server.getClientCapabilities()?.roots) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return (await server.server.listRoots()).roots.map((root) => root.uri);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const position = {
|
|
29
|
+
file_path: z.string(),
|
|
30
|
+
line: z.number().int(),
|
|
31
|
+
column: z.number().int(),
|
|
32
|
+
};
|
|
33
|
+
server.registerTool("hover", {
|
|
34
|
+
description: `Get type/documentation info for the symbol at a position.\n\n${POSITION_DOC}`,
|
|
35
|
+
inputSchema: position,
|
|
36
|
+
}, async ({ file_path, line, column }) => text(await webnav.hover(file_path, line, column)));
|
|
37
|
+
server.registerTool("workspace", {
|
|
38
|
+
description: "Which directory is webnav navigating, and why? Use when results look like they come from the wrong checkout/worktree.",
|
|
39
|
+
inputSchema: {},
|
|
40
|
+
}, async () => text(await webnav.workspace()));
|
|
41
|
+
server.registerTool("definition", {
|
|
42
|
+
description: "Go to the definition of the symbol at a position.\n\n" +
|
|
43
|
+
`${POSITION_DOC} On a \`--custom-property\`/\`#id\`/\`.class\` token in a \`.css\`/` +
|
|
44
|
+
'`.html` file (or a name inside an HTML `id="..."`/`class="..."` value), ' +
|
|
45
|
+
"answers from the cross-file index (see css_var/selector) " +
|
|
46
|
+
"instead of the single-file language server: for `definition`, just the " +
|
|
47
|
+
"definition(s) in the file's own root (each `WEBNAV_MCP_ROOTS` root " +
|
|
48
|
+
"is separate); for `references`, that root's definitions and usages.",
|
|
49
|
+
inputSchema: position,
|
|
50
|
+
}, async ({ file_path, line, column }) => text(await webnav.definition(file_path, line, column)));
|
|
51
|
+
server.registerTool("references", {
|
|
52
|
+
description: "Find all usages of the symbol at a position across the workspace.\n\n" +
|
|
53
|
+
`${POSITION_DOC} On a \`--custom-property\`/\`#id\`/\`.class\` token in a \`.css\`/` +
|
|
54
|
+
'`.html` file (or a name inside an HTML `id="..."`/`class="..."` value), ' +
|
|
55
|
+
"answers from the cross-file index (see css_var/selector) " +
|
|
56
|
+
"instead of the single-file language server, limited to the file's own root " +
|
|
57
|
+
"(each `WEBNAV_MCP_ROOTS` root is separate) unless it has no hits there.",
|
|
58
|
+
inputSchema: { ...position, include_declaration: z.boolean().default(true) },
|
|
59
|
+
}, async ({ file_path, line, column, include_declaration }) => text(await webnav.references(file_path, line, column, include_declaration)));
|
|
60
|
+
server.registerTool("search_symbol", {
|
|
61
|
+
description: "Search JS/TS files for a symbol by name (function, class, const, etc.).\n\n" +
|
|
62
|
+
"JS/TS-only: the HTML/CSS language servers don't implement useful " +
|
|
63
|
+
"workspace-wide symbol search (webnav does not reimplement it). Prefer " +
|
|
64
|
+
"`symbol_info` for a one-call summary. Returned positions point at the " +
|
|
65
|
+
"identifier name and use the same character-offset column convention as " +
|
|
66
|
+
"the other tools. Results include a SymbolKind label and are capped. " +
|
|
67
|
+
"`name` is accepted as an alias for `query`. Narrow broad queries with " +
|
|
68
|
+
"`kind` (SymbolKind labels, comma-separated: `class`, `function,method`, " +
|
|
69
|
+
"`interface`, ...) and `path` (workspace-relative prefix such as `src/`, " +
|
|
70
|
+
"or a glob such as `src/**/*.ts`). Production code ranks before tests. " +
|
|
71
|
+
"Loose fuzzy hits whose names don't contain the query are summarised as a " +
|
|
72
|
+
"count when real matches exist; pass `fuzzy=true` to list them too.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
query: z.string().optional(),
|
|
75
|
+
name: z.string().optional(),
|
|
76
|
+
kind: z.string().optional(),
|
|
77
|
+
path: z.string().optional(),
|
|
78
|
+
fuzzy: z.boolean().default(false),
|
|
79
|
+
},
|
|
80
|
+
}, async (args) => text(await webnav.searchSymbol(args)));
|
|
81
|
+
server.registerTool("symbol_info", {
|
|
82
|
+
description: 'What is X and where is it used? Example: `symbol_info(name="renderSidebar")`.\n\n' +
|
|
83
|
+
"One-call summary for a JS/TS name: header, hover text, definition, and " +
|
|
84
|
+
"references grouped by file, the usual first lookup instead of chaining " +
|
|
85
|
+
"search_symbol → hover → definition → references by hand. Pass `file_path` " +
|
|
86
|
+
"to disambiguate; `query` is accepted as an alias for `name`. For CSS/HTML " +
|
|
87
|
+
"cross-file lookups use `css_var` / `selector` instead.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
name: z.string().optional(),
|
|
90
|
+
query: z.string().optional(),
|
|
91
|
+
file_path: z.string().optional(),
|
|
92
|
+
include_references: z.boolean().default(true),
|
|
93
|
+
},
|
|
94
|
+
}, async ({ name, query, file_path, include_references }) => text(await webnav.symbolInfo({ name, query, filePath: file_path, includeReferences: include_references })));
|
|
95
|
+
server.registerTool("callers", {
|
|
96
|
+
description: 'Who calls this function? Example: `callers(name="renderSidebar")`.\n\n' +
|
|
97
|
+
"Narrower than references, since it leaves out imports and type-only usages " +
|
|
98
|
+
"and only lists actual call sites, each with the calling function and its " +
|
|
99
|
+
"call-site lines. `name` resolves the same way as symbol_info (dotted " +
|
|
100
|
+
"`Class.method` accepted, inherited members included; pass `file_path` to " +
|
|
101
|
+
"disambiguate a common name). `query` is accepted as an alias for `name`. JS/TS only.",
|
|
102
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional(), file_path: z.string().optional() },
|
|
103
|
+
}, async ({ name, query, file_path }) => text(await webnav.callers({ name, query, filePath: file_path })));
|
|
104
|
+
server.registerTool("implementations", {
|
|
105
|
+
description: 'Who implements or extends this? Example: `implementations(name="Shape")`.\n\n' +
|
|
106
|
+
"For an interface or class, lists the classes that implement or extend it " +
|
|
107
|
+
"(transitively); for an interface or abstract method (`Shape.area`), lists the " +
|
|
108
|
+
"methods that implement it. Backed by the language server's " +
|
|
109
|
+
"`textDocument/implementation`, so it follows the type checker, not naming. " +
|
|
110
|
+
"`name` resolves like symbol_info (pass `file_path` to disambiguate); `query` is " +
|
|
111
|
+
"accepted as an alias. JS/TS only.",
|
|
112
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional(), file_path: z.string().optional() },
|
|
113
|
+
}, async ({ name, query, file_path }) => text(await webnav.implementations({ name, query, filePath: file_path })));
|
|
114
|
+
server.registerTool("outline", {
|
|
115
|
+
description: 'What\'s in this file? Example: `outline(file_path="src/app.ts")`.\n\n' +
|
|
116
|
+
"Indented outline (functions, classes, interfaces, with `:start-end` line " +
|
|
117
|
+
"spans) of a JS/TS file in source order, so you can navigate without reading " +
|
|
118
|
+
"it in full. Locals, callbacks and object-literal keys inside functions and " +
|
|
119
|
+
"variables are left out; pass `detailed=true` to include them. Follow up " +
|
|
120
|
+
"with hover/definition/references at a listed line, or symbol_info by name.",
|
|
121
|
+
inputSchema: { file_path: z.string(), detailed: z.boolean().default(false) },
|
|
122
|
+
}, async ({ file_path, detailed }) => text(await webnav.outline(file_path, detailed)));
|
|
123
|
+
server.registerTool("diagnostics", {
|
|
124
|
+
description: "Get the relevant language server's diagnostics (errors/warnings) for a single file.\n\n" +
|
|
125
|
+
"For `.css`/`.html` files, this also includes index-derived warnings the " +
|
|
126
|
+
"single-file language server can't see: `var(--x)` used with no matching " +
|
|
127
|
+
"declaration anywhere in the same indexed root, and CSS selectors " +
|
|
128
|
+
"(`#id`/`.class`) with no HTML/JS reference in that root.",
|
|
129
|
+
inputSchema: { file_path: z.string() },
|
|
130
|
+
}, async ({ file_path }) => text(await webnav.diagnostics(file_path)));
|
|
131
|
+
server.registerTool("css_var", {
|
|
132
|
+
description: 'Where is this `--custom-property` defined and used? Example: `css_var(name="--bg")`.\n\n' +
|
|
133
|
+
"The CSS/HTML language servers only see one file at a time, so `var(--x)` " +
|
|
134
|
+
"usages can't be cross-referenced across files that way. This scans " +
|
|
135
|
+
'`.css` files and HTML `<style>`/`style="…"` blocks/attributes instead. ' +
|
|
136
|
+
"`name` may be given with or without the leading `--`. Definitions (value " +
|
|
137
|
+
"+ enclosing context, e.g. `@media (prefers-color-scheme: dark) › :root`) " +
|
|
138
|
+
"and usages (grouped by file with line numbers) are reported separately per " +
|
|
139
|
+
"configured root (see `WEBNAV_MCP_ROOTS`; a single unnamed root by default), " +
|
|
140
|
+
"since each may define its own values. `query` is accepted as an alias for `name`.",
|
|
141
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional() },
|
|
142
|
+
}, async (args) => text(await webnav.cssVar(args)));
|
|
143
|
+
server.registerTool("selector", {
|
|
144
|
+
description: 'Who uses this `#id` or `.class`? Example: `selector(name=".card-title")`.\n\n' +
|
|
145
|
+
"Looks up the selector across the whole workspace.\n\n" +
|
|
146
|
+
"Cross-references CSS rule definitions, HTML `id=`/`class=` attributes, " +
|
|
147
|
+
"and JS usages (`getElementById`, `classList.add/remove/toggle/contains`, " +
|
|
148
|
+
"`querySelector`/`querySelectorAll`, `className` assignment, and any JS " +
|
|
149
|
+
"string literal exactly equal to the bare name, e.g. an id passed to a " +
|
|
150
|
+
'project\'s own helper like `onClick("btn-save", …)`, labeled "string ' +
|
|
151
|
+
'literal"). Hits in generated output (`WEBNAV_MCP_EXCLUDE`) are labeled ' +
|
|
152
|
+
"`[generated]`; edit their source instead. This is something " +
|
|
153
|
+
"the single-file CSS/HTML language servers can't do. `name` must include " +
|
|
154
|
+
"the leading `#` or `.`. Grouped by file with line numbers, separately per " +
|
|
155
|
+
"configured root (see `WEBNAV_MCP_ROOTS`). A JS hit built from string " +
|
|
156
|
+
'concatenation (e.g. `getElementById("view-" + x)`) is reported against ' +
|
|
157
|
+
'only its static prefix and labeled "dynamic partial match"; a query whose ' +
|
|
158
|
+
"name starts with such a prefix (e.g. `#view-components` against a stored " +
|
|
159
|
+
'`#view-`) also surfaces that hit, labeled "dynamic partial match via ' +
|
|
160
|
+
"'<prefix>'\", instead of being silently dropped or guessed. `query` is " +
|
|
161
|
+
"accepted as an alias for `name`.",
|
|
162
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional() },
|
|
163
|
+
}, async (args) => text(await webnav.selectorLookup(args)));
|
|
164
|
+
return { server, webnav };
|
|
165
|
+
}
|