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/dist/webnav.js
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webnav: JS/TS/HTML/CSS navigation as plain async functions returning tool text.
|
|
3
|
+
*
|
|
4
|
+
* Multiplexes three Node-based language servers behind one tool set, routed by
|
|
5
|
+
* file extension: TypeScript 7's native `tsc --lsp --stdio` for
|
|
6
|
+
* `.js`/`.mjs`/`.cjs`/`.jsx` (via `allowJs`) and `.ts`/`.mts`/`.cts`/`.tsx`,
|
|
7
|
+
* and `vscode-html-language-server` / `vscode-css-language-server` for
|
|
8
|
+
* `.html`/`.css`, plus the workspace-wide CSS custom-property / selector index
|
|
9
|
+
* (`css_var`, `selector`). `server.ts` exposes these methods as MCP tools.
|
|
10
|
+
*
|
|
11
|
+
* All state (workspace root, language-server clients, index config) lives on a
|
|
12
|
+
* `Webnav` instance so tests can run several side by side.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { globFiles } from "./glob.js";
|
|
18
|
+
import { resolveCssCommand, resolveHtmlCommand, resolveTsCommand } from "./langCommand.js";
|
|
19
|
+
import { dropImportSymbols } from "./outlineImports.js";
|
|
20
|
+
import { formatToolError, isToolError, ToolInputError } from "./shared/errors.js";
|
|
21
|
+
import { isExcluded } from "./shared/exclude.js";
|
|
22
|
+
import { filterSymbolsByKindAndPath, formatCallers, formatDiagnostics, formatLocation, formatOutline, formatReferences, formatReferencesGrouped, formatWorkspaceSymbols, LOCALS_HOLDER_KINDS, parseKindFilter, pyRepr, symbolAt, symbolKindLabel, uriToRelative, } from "./shared/format.js";
|
|
23
|
+
import { LspClient } from "./shared/lspClient.js";
|
|
24
|
+
import { NoticeBoard } from "./shared/notices.js";
|
|
25
|
+
import { resolveNameQuery } from "./shared/params.js";
|
|
26
|
+
import { relativeWithin, resolveReal } from "./shared/paths.js";
|
|
27
|
+
import { resolveSymbol } from "./shared/resolve.js";
|
|
28
|
+
import { readTextStrict, splitLines } from "./shared/text.js";
|
|
29
|
+
import { WorkspaceSelector } from "./shared/workspace.js";
|
|
30
|
+
import * as webIndex from "./webIndex.js";
|
|
31
|
+
const JS_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
|
32
|
+
const TS_EXTENSIONS = [".ts", ".mts", ".cts"];
|
|
33
|
+
// Everything the one TypeScript language-server instance serves.
|
|
34
|
+
const SCRIPT_EXTENSIONS = new Set([...JS_EXTENSIONS, ...TS_EXTENSIONS, ".jsx", ".tsx"]);
|
|
35
|
+
const SCRIPT_LANGUAGE_IDS = {
|
|
36
|
+
...Object.fromEntries(JS_EXTENSIONS.map((ext) => [ext, "javascript"])),
|
|
37
|
+
...Object.fromEntries(TS_EXTENSIONS.map((ext) => [ext, "typescript"])),
|
|
38
|
+
".jsx": "javascriptreact",
|
|
39
|
+
".tsx": "typescriptreact",
|
|
40
|
+
};
|
|
41
|
+
// The TS language server reads these once at startup; a change restarts it.
|
|
42
|
+
const TS_CONFIG_NAMES = new Set(["tsconfig.json", "jsconfig.json", "package.json"]);
|
|
43
|
+
const SUPPORTED_EXTENSIONS_TEXT = [...[...SCRIPT_EXTENSIONS].sort(), ".html", ".css"].join("/");
|
|
44
|
+
const POSITION_NOTE = "Positions are 1-indexed. `column` is a UTF-16 character offset on the " +
|
|
45
|
+
"line (not a visual/display column): a leading tab counts as one " +
|
|
46
|
+
"character, so after a single tab the next character starts at column 2.";
|
|
47
|
+
export const INSTRUCTIONS = "Code navigation for this project's JS/TS/HTML/CSS, backed by " +
|
|
48
|
+
"TypeScript 7 native tsc LSP (JS/TS) and vscode-langservers-extracted " +
|
|
49
|
+
"(HTML/CSS). Prefer this over grepping for symbol definitions/usages. " +
|
|
50
|
+
"Start with symbol_info (what is X) or outline (what's in this file) for " +
|
|
51
|
+
"JS/TS; callers (who calls X) and implementations (who implements/extends X) " +
|
|
52
|
+
"answer from the type checker; search_symbol is JS/TS-only (the HTML/CSS language servers don't " +
|
|
53
|
+
"implement useful workspace-wide symbol search). The language servers only " +
|
|
54
|
+
"see one file at a time, so `--custom-properties` and `#id`/`.class` " +
|
|
55
|
+
"selectors can't be cross-referenced across files that way; use " +
|
|
56
|
+
"css_var/selector for those instead of hover/definition/references: " +
|
|
57
|
+
"references and definition also answer from that same cross-file " +
|
|
58
|
+
"index automatically when the position is on one of those tokens in " +
|
|
59
|
+
`a .css/.html file. ${POSITION_NOTE}`;
|
|
60
|
+
export class Webnav {
|
|
61
|
+
selector = new WorkspaceSelector("WEBNAV_MCP_WORKSPACE");
|
|
62
|
+
notices;
|
|
63
|
+
workspaceRoot;
|
|
64
|
+
workspaceSource;
|
|
65
|
+
rootsProvider;
|
|
66
|
+
// One or more `label=relative/path` roots to index separately (see
|
|
67
|
+
// webIndex.buildWorkspaceIndex); unset means "index the whole workspace as one root".
|
|
68
|
+
rawWebRoots = process.env.WEBNAV_MCP_ROOTS;
|
|
69
|
+
// Comma-separated workspace-relative files/directories of *generated* script
|
|
70
|
+
// output (e.g. the JS a TypeScript build emits). They are never eagerly
|
|
71
|
+
// opened, are dropped from `search_symbol`, and position tools reject them
|
|
72
|
+
// with a pointer to the source. Navigation-only: the CSS/selector index
|
|
73
|
+
// still reads them, since emitted JS is where a root's runtime usages live.
|
|
74
|
+
rawExclude = process.env.WEBNAV_MCP_EXCLUDE ?? "";
|
|
75
|
+
// A malformed WEBNAV_MCP_ROOTS must not crash the server at startup (the host
|
|
76
|
+
// would only show "server failed to start"): remember the problem and report
|
|
77
|
+
// it as tool text from every index-backed tool instead.
|
|
78
|
+
webRootsError;
|
|
79
|
+
webRoots;
|
|
80
|
+
generatedPaths = [];
|
|
81
|
+
generatedRelative = [];
|
|
82
|
+
clients = new Map();
|
|
83
|
+
// One lock per language server: the TS server's first start opens the whole
|
|
84
|
+
// JS project, which must not hold up HTML/CSS calls.
|
|
85
|
+
clientLocks = new Map();
|
|
86
|
+
workspaceLock = Promise.resolve();
|
|
87
|
+
constructor(options = {}) {
|
|
88
|
+
this.rootsProvider = options.rootsProvider;
|
|
89
|
+
this.workspaceRoot = this.selector.base;
|
|
90
|
+
this.workspaceSource = this.selector.baseSource;
|
|
91
|
+
this.deriveConfig(this.workspaceRoot);
|
|
92
|
+
this.notices = new NoticeBoard("webnav", [path.dirname(fileURLToPath(import.meta.url))]);
|
|
93
|
+
}
|
|
94
|
+
/** Called once the MCP transport is connected; provides `roots/list` access. */
|
|
95
|
+
setRootsProvider(provider) {
|
|
96
|
+
this.rootsProvider = provider;
|
|
97
|
+
}
|
|
98
|
+
deriveConfig(root) {
|
|
99
|
+
this.webRootsError = undefined;
|
|
100
|
+
try {
|
|
101
|
+
this.webRoots = this.rawWebRoots ? webIndex.parseRootsEnv(this.rawWebRoots, root) : undefined;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.webRoots = undefined;
|
|
105
|
+
this.webRootsError = error instanceof Error ? error.message : String(error);
|
|
106
|
+
}
|
|
107
|
+
this.generatedPaths = this.rawExclude
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((part) => part.trim())
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.map((part) => resolveReal(path.resolve(root, part)));
|
|
112
|
+
const resolvedRoot = resolveReal(root);
|
|
113
|
+
this.generatedRelative = this.generatedPaths
|
|
114
|
+
.filter((p) => relativeWithin(p, resolvedRoot) !== undefined)
|
|
115
|
+
.map((p) => relativeWithin(p, resolvedRoot).split(path.sep).join("/"));
|
|
116
|
+
}
|
|
117
|
+
/** Stop every running language server. */
|
|
118
|
+
async dispose() {
|
|
119
|
+
const stale = [...this.clients.values()];
|
|
120
|
+
this.clients.clear();
|
|
121
|
+
await Promise.all(stale.map((client) => this.discardClient(client)));
|
|
122
|
+
}
|
|
123
|
+
// -- workspace ------------------------------------------------------------
|
|
124
|
+
/** Re-target the server at `root`: every language server was started for the previous tree. */
|
|
125
|
+
async configureWorkspace(root, source) {
|
|
126
|
+
await this.dispose();
|
|
127
|
+
this.workspaceRoot = root;
|
|
128
|
+
this.workspaceSource = source;
|
|
129
|
+
this.deriveConfig(root);
|
|
130
|
+
}
|
|
131
|
+
/** Called first by every tool: which checkout/worktree to navigate is decided per request. */
|
|
132
|
+
async useWorkspace() {
|
|
133
|
+
if (!this.rootsProvider) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const provider = this.rootsProvider;
|
|
137
|
+
const run = this.workspaceLock.then(async () => {
|
|
138
|
+
const selection = await this.selector.select(provider);
|
|
139
|
+
if (selection.root !== this.workspaceRoot) {
|
|
140
|
+
await this.configureWorkspace(selection.root, selection.source);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
this.workspaceLock = run.catch(() => { });
|
|
144
|
+
await run;
|
|
145
|
+
}
|
|
146
|
+
// -- language-server clients ----------------------------------------------
|
|
147
|
+
async discardClient(client) {
|
|
148
|
+
try {
|
|
149
|
+
await client.stop();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// a language server that won't stop is reaped with the process
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async getClient(key, make, afterStart) {
|
|
156
|
+
const previous = this.clientLocks.get(key) ?? Promise.resolve();
|
|
157
|
+
const run = previous.then(async () => {
|
|
158
|
+
let client = this.clients.get(key);
|
|
159
|
+
if (!client?.isAlive) {
|
|
160
|
+
if (client) {
|
|
161
|
+
await this.discardClient(client);
|
|
162
|
+
}
|
|
163
|
+
client = make();
|
|
164
|
+
await client.start();
|
|
165
|
+
this.clients.set(key, client);
|
|
166
|
+
if (afterStart) {
|
|
167
|
+
await afterStart(client);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Tell the server about anything created/edited/deleted on disk since the last call.
|
|
171
|
+
await client.refresh();
|
|
172
|
+
return client;
|
|
173
|
+
});
|
|
174
|
+
this.clientLocks.set(key, run.catch(() => { }));
|
|
175
|
+
return run;
|
|
176
|
+
}
|
|
177
|
+
indexes() {
|
|
178
|
+
if (this.webRootsError !== undefined) {
|
|
179
|
+
throw new ToolInputError(`WEBNAV_MCP_ROOTS is misconfigured: ${this.webRootsError}`);
|
|
180
|
+
}
|
|
181
|
+
return webIndex.buildWorkspaceIndex(this.workspaceRoot, this.webRoots);
|
|
182
|
+
}
|
|
183
|
+
/** Script files the language server should see up front, so the first `search_symbol` doesn't report "No Project". */
|
|
184
|
+
projectFiles() {
|
|
185
|
+
let include = [];
|
|
186
|
+
try {
|
|
187
|
+
const config = JSON.parse(fs.readFileSync(path.join(this.workspaceRoot, "jsconfig.json"), "utf8"));
|
|
188
|
+
include = config.include ?? [];
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
let files;
|
|
192
|
+
if (include.length > 0) {
|
|
193
|
+
files = include.flatMap((pattern) => globFiles(this.workspaceRoot, pattern));
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// No jsconfig.json (or no `include`): scan for script files directly rather than opening nothing.
|
|
197
|
+
const roots = this.webRoots ? this.webRoots.map(([, root]) => root) : [this.workspaceRoot];
|
|
198
|
+
files = roots.flatMap((root) => globFiles(root, "**/*"));
|
|
199
|
+
}
|
|
200
|
+
return [...new Set(files)].filter((file) => SCRIPT_EXTENSIONS.has(path.extname(file).toLowerCase()));
|
|
201
|
+
}
|
|
202
|
+
// workspace/symbol is most reliable when project files have been opened;
|
|
203
|
+
// eagerly open the JS/TS project rather than leaving the first search_symbol
|
|
204
|
+
// call (agents' typical first lookup) to miss files nobody has touched yet.
|
|
205
|
+
openProjectFiles = async (client) => {
|
|
206
|
+
for (const file of this.projectFiles()) {
|
|
207
|
+
if (this.isGenerated(file) || isExcluded(file, this.workspaceRoot)) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await client.ensureOpen(file);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (!isToolError(error)) {
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
// unreadable project file, e.g. non-UTF-8: skip it
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
getTsClient() {
|
|
222
|
+
return this.getClient("ts", () => new LspClient({
|
|
223
|
+
workspaceRoot: this.workspaceRoot,
|
|
224
|
+
command: resolveTsCommand(this.workspaceRoot),
|
|
225
|
+
languageId: "javascript",
|
|
226
|
+
languageIds: SCRIPT_LANGUAGE_IDS,
|
|
227
|
+
watchSuffixes: SCRIPT_EXTENSIONS,
|
|
228
|
+
watchIgnore: (p) => this.isGenerated(p),
|
|
229
|
+
openWatchedChanges: true,
|
|
230
|
+
configNames: TS_CONFIG_NAMES,
|
|
231
|
+
onRestart: this.openProjectFiles,
|
|
232
|
+
onNotice: (message) => this.notices.post(message),
|
|
233
|
+
}), this.openProjectFiles);
|
|
234
|
+
}
|
|
235
|
+
getHtmlClient() {
|
|
236
|
+
return this.getClient("html", () => new LspClient({
|
|
237
|
+
workspaceRoot: this.workspaceRoot,
|
|
238
|
+
command: resolveHtmlCommand(this.workspaceRoot),
|
|
239
|
+
languageId: "html",
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
getCssClient() {
|
|
243
|
+
return this.getClient("css", () => new LspClient({
|
|
244
|
+
workspaceRoot: this.workspaceRoot,
|
|
245
|
+
command: resolveCssCommand(this.workspaceRoot),
|
|
246
|
+
languageId: "css",
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
clientFor(filePath) {
|
|
250
|
+
const suffix = path.extname(filePath).toLowerCase();
|
|
251
|
+
if (SCRIPT_EXTENSIONS.has(suffix)) {
|
|
252
|
+
this.rejectGenerated(filePath);
|
|
253
|
+
return this.getTsClient();
|
|
254
|
+
}
|
|
255
|
+
if (suffix === ".html") {
|
|
256
|
+
return this.getHtmlClient();
|
|
257
|
+
}
|
|
258
|
+
if (suffix === ".css") {
|
|
259
|
+
return this.getCssClient();
|
|
260
|
+
}
|
|
261
|
+
throw new ToolInputError(`webnav has no language server for '${filePath}' (supported: ${SUPPORTED_EXTENSIONS_TEXT})`);
|
|
262
|
+
}
|
|
263
|
+
isGenerated(p) {
|
|
264
|
+
const resolved = resolveReal(p);
|
|
265
|
+
return this.generatedPaths.some((root) => resolved === root || relativeWithin(resolved, root) !== undefined);
|
|
266
|
+
}
|
|
267
|
+
rejectGenerated(filePath) {
|
|
268
|
+
if (this.isGenerated(this.resolvePath(filePath))) {
|
|
269
|
+
throw new ToolInputError(`'${filePath}' is generated output (WEBNAV_MCP_EXCLUDE); navigate the source it was built from instead`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
resolvePath(filePath) {
|
|
273
|
+
return path.isAbsolute(filePath) ? filePath : path.join(this.workspaceRoot, filePath);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The `--var`/`#id`/`.class` token at a position in a `.css`/`.html` file, so
|
|
277
|
+
* `references`/`definition` can answer from the cross-file index instead of
|
|
278
|
+
* the single-file language server.
|
|
279
|
+
*/
|
|
280
|
+
indexTokenAt(filePath, line, column) {
|
|
281
|
+
const suffix = path.extname(filePath).toLowerCase();
|
|
282
|
+
if (suffix !== ".css" && suffix !== ".html") {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
let textLine;
|
|
286
|
+
try {
|
|
287
|
+
textLine = splitLines(fs.readFileSync(this.resolvePath(filePath), "utf8"))[line - 1];
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
if (textLine === undefined) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
const token = webIndex.tokenAtPosition(textLine, column);
|
|
296
|
+
if (token === undefined || token.startsWith("--")) {
|
|
297
|
+
return token;
|
|
298
|
+
}
|
|
299
|
+
// A `#fff` colour or a `.5em`-like value looks like a selector token but
|
|
300
|
+
// isn't one; only defer to the index for tokens it actually knows.
|
|
301
|
+
return webIndex.knowsSelector(this.indexes(), token) ? token : undefined;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Answer from the cross-file index. With `filePath` (a position query), only
|
|
305
|
+
* the root that file lives in is reported (each root defines its own values
|
|
306
|
+
* and markup, so mixing in the other roots answers a different question)
|
|
307
|
+
* unless that root has nothing for the token.
|
|
308
|
+
*/
|
|
309
|
+
indexAnswer(token, filePath, definitionsOnly = false) {
|
|
310
|
+
const indexes = this.indexes();
|
|
311
|
+
let scoped = "";
|
|
312
|
+
if (filePath !== undefined) {
|
|
313
|
+
const own = webIndex.rootIndexForFile(indexes, this.resolvePath(filePath));
|
|
314
|
+
if (own) {
|
|
315
|
+
const answer = this.formatIndex([own[0]], token, definitionsOnly);
|
|
316
|
+
if (!answer.includes("was not found") && !answer.includes("not defined or used")) {
|
|
317
|
+
return answer;
|
|
318
|
+
}
|
|
319
|
+
scoped = `(nothing in ${own[0].name}; showing all roots)\n`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return scoped + this.formatIndex(indexes, token, definitionsOnly);
|
|
323
|
+
}
|
|
324
|
+
formatIndex(indexes, token, definitionsOnly) {
|
|
325
|
+
const options = { generated: this.generatedRelative, definitionsOnly };
|
|
326
|
+
return token.startsWith("--")
|
|
327
|
+
? webIndex.formatCssVar(indexes, token, options)
|
|
328
|
+
: webIndex.formatSelector(indexes, token, options);
|
|
329
|
+
}
|
|
330
|
+
checkScriptFile(filePath) {
|
|
331
|
+
const suffix = path.extname(filePath).toLowerCase();
|
|
332
|
+
if (!SCRIPT_EXTENSIONS.has(suffix)) {
|
|
333
|
+
throw new ToolInputError(`webnav outline/symbol_info only support JS/TS files (${[...SCRIPT_EXTENSIONS].sort().join("/")}), ` +
|
|
334
|
+
`got '${filePath}'; use css_var/selector for CSS/HTML`);
|
|
335
|
+
}
|
|
336
|
+
this.rejectGenerated(filePath);
|
|
337
|
+
}
|
|
338
|
+
/** Runs a tool body: picks the workspace, turns expected failures into text, appends pending notices. */
|
|
339
|
+
async run(body) {
|
|
340
|
+
let text;
|
|
341
|
+
try {
|
|
342
|
+
await this.useWorkspace();
|
|
343
|
+
text = await body();
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (!isToolError(error)) {
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
text = formatToolError(error);
|
|
350
|
+
}
|
|
351
|
+
return this.notices.annotate(text);
|
|
352
|
+
}
|
|
353
|
+
// -- tools ------------------------------------------------------------------
|
|
354
|
+
hover(filePath, line, column) {
|
|
355
|
+
return this.run(async () => {
|
|
356
|
+
const client = await this.clientFor(filePath);
|
|
357
|
+
const result = await client.hover(filePath, line, column);
|
|
358
|
+
return formatHoverContents(result.contents) || "No hover information at that position.";
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
workspace() {
|
|
362
|
+
return this.run(async () => `${this.workspaceRoot}\nchosen because: ${this.selector.explain(this.workspaceSource)}`);
|
|
363
|
+
}
|
|
364
|
+
definition(filePath, line, column) {
|
|
365
|
+
return this.run(async () => {
|
|
366
|
+
const token = this.indexTokenAt(filePath, line, column);
|
|
367
|
+
if (token !== undefined) {
|
|
368
|
+
return this.indexAnswer(token, filePath, true);
|
|
369
|
+
}
|
|
370
|
+
const client = await this.clientFor(filePath);
|
|
371
|
+
const locations = await client.definition(filePath, line, column);
|
|
372
|
+
if (locations.length === 0) {
|
|
373
|
+
return "No definition found at that position.";
|
|
374
|
+
}
|
|
375
|
+
return locations.map((loc) => formatLocation(loc, this.workspaceRoot)).join("\n\n");
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
references(filePath, line, column, includeDeclaration = true) {
|
|
379
|
+
return this.run(async () => {
|
|
380
|
+
const token = this.indexTokenAt(filePath, line, column);
|
|
381
|
+
if (token !== undefined) {
|
|
382
|
+
return this.indexAnswer(token, filePath);
|
|
383
|
+
}
|
|
384
|
+
const client = await this.clientFor(filePath);
|
|
385
|
+
const locations = await client.references(filePath, line, column, { includeDeclaration });
|
|
386
|
+
return formatReferences(locations, this.workspaceRoot);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
searchSymbol(args) {
|
|
390
|
+
return this.run(async () => {
|
|
391
|
+
const kinds = parseKindFilter(args.kind);
|
|
392
|
+
const query = resolveNameQuery({
|
|
393
|
+
preferred: "query",
|
|
394
|
+
example: "renderSidebar",
|
|
395
|
+
params: { query: args.query, name: args.name },
|
|
396
|
+
});
|
|
397
|
+
const client = await this.getTsClient();
|
|
398
|
+
const all = await client.workspaceSymbol(query);
|
|
399
|
+
const symbols = all.filter((sym) => !this.isGenerated(this.resolvePath(uriToRelative(sym.location?.uri ?? "", this.workspaceRoot))));
|
|
400
|
+
if (symbols.length === 0) {
|
|
401
|
+
return `No symbols matching ${pyRepr(query)}.`;
|
|
402
|
+
}
|
|
403
|
+
const matching = filterSymbolsByKindAndPath(symbols, this.workspaceRoot, { kinds, path: args.path });
|
|
404
|
+
if (matching.length === 0) {
|
|
405
|
+
const filters = [
|
|
406
|
+
["kind", args.kind],
|
|
407
|
+
["path", args.path],
|
|
408
|
+
]
|
|
409
|
+
.filter(([, v]) => v)
|
|
410
|
+
.map(([k, v]) => `${k}='${v}'`)
|
|
411
|
+
.join(", ");
|
|
412
|
+
return `No symbols matching ${pyRepr(query)} with ${filters} (${symbols.length} without the filters).`;
|
|
413
|
+
}
|
|
414
|
+
return formatWorkspaceSymbols(matching, this.workspaceRoot, { query, fuzzy: args.fuzzy ?? false });
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
symbolInfo(args) {
|
|
418
|
+
return this.run(async () => {
|
|
419
|
+
const includeReferences = args.includeReferences ?? true;
|
|
420
|
+
const name = resolveNameQuery({
|
|
421
|
+
preferred: "name",
|
|
422
|
+
example: "renderSidebar",
|
|
423
|
+
params: { name: args.name, query: args.query },
|
|
424
|
+
});
|
|
425
|
+
const client = await this.getTsClient();
|
|
426
|
+
const resolved = await resolveSymbol(client, this.workspaceRoot, name, args.filePath);
|
|
427
|
+
const relPath = uriToRelative(resolved.uri, this.workspaceRoot);
|
|
428
|
+
this.rejectGenerated(relPath);
|
|
429
|
+
const line = resolved.line + 1;
|
|
430
|
+
const column = resolved.column + 1;
|
|
431
|
+
const hoverResult = await client.hover(relPath, line, column);
|
|
432
|
+
const definitionLocations = await client.definition(relPath, line, column);
|
|
433
|
+
const referenceLocations = includeReferences ? await client.references(relPath, line, column) : [];
|
|
434
|
+
const header = `${resolved.name} [${symbolKindLabel(resolved.kind)}] (${relPath}:${line}:${column})`;
|
|
435
|
+
const hoverText = formatHoverContents(hoverResult.contents) || "No hover information.";
|
|
436
|
+
const definitionText = definitionLocations.length > 0
|
|
437
|
+
? definitionLocations.map((loc) => formatLocation(loc, this.workspaceRoot)).join("\n\n")
|
|
438
|
+
: "No definition found.";
|
|
439
|
+
const parts = [header, "", hoverText, "", "Definition:", definitionText];
|
|
440
|
+
if (includeReferences) {
|
|
441
|
+
parts.push("", "References:", formatReferencesGrouped(referenceLocations, this.workspaceRoot));
|
|
442
|
+
}
|
|
443
|
+
return parts.join("\n");
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
callers(args) {
|
|
447
|
+
return this.run(async () => {
|
|
448
|
+
const name = resolveNameQuery({
|
|
449
|
+
preferred: "name",
|
|
450
|
+
example: "renderSidebar",
|
|
451
|
+
params: { name: args.name, query: args.query },
|
|
452
|
+
});
|
|
453
|
+
const client = await this.getTsClient();
|
|
454
|
+
const resolved = await resolveSymbol(client, this.workspaceRoot, name, args.filePath);
|
|
455
|
+
const relPath = uriToRelative(resolved.uri, this.workspaceRoot);
|
|
456
|
+
this.rejectGenerated(relPath);
|
|
457
|
+
const items = await client.prepareCallHierarchy(relPath, resolved.line + 1, resolved.column + 1);
|
|
458
|
+
const [item] = items;
|
|
459
|
+
if (item === undefined) {
|
|
460
|
+
return `${resolved.name} has no call hierarchy entry at that position (it may not be a callable).`;
|
|
461
|
+
}
|
|
462
|
+
return formatCallers(await client.incomingCalls(item), this.workspaceRoot);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
implementations(args) {
|
|
466
|
+
return this.run(async () => {
|
|
467
|
+
const name = resolveNameQuery({
|
|
468
|
+
preferred: "name",
|
|
469
|
+
example: "Shape",
|
|
470
|
+
params: { name: args.name, query: args.query },
|
|
471
|
+
});
|
|
472
|
+
const client = await this.getTsClient();
|
|
473
|
+
const resolved = await resolveSymbol(client, this.workspaceRoot, name, args.filePath);
|
|
474
|
+
const relPath = uriToRelative(resolved.uri, this.workspaceRoot);
|
|
475
|
+
this.rejectGenerated(relPath);
|
|
476
|
+
const locations = await client.implementation(relPath, resolved.line + 1, resolved.column + 1);
|
|
477
|
+
// The language server lists a class as one of its own implementations; that isn't news.
|
|
478
|
+
const others = locations.filter((loc) => {
|
|
479
|
+
const start = (loc.targetSelectionRange ?? loc.range ?? loc.targetRange)?.start;
|
|
480
|
+
return !((loc.uri ?? loc.targetUri) === resolved.uri &&
|
|
481
|
+
start?.line === resolved.line &&
|
|
482
|
+
start?.character === resolved.column);
|
|
483
|
+
});
|
|
484
|
+
return formatImplementations(client, name, resolved.kind, others, this.workspaceRoot);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
outline(filePath, detailed = false) {
|
|
488
|
+
return this.run(async () => {
|
|
489
|
+
this.checkScriptFile(filePath);
|
|
490
|
+
const client = await this.getTsClient();
|
|
491
|
+
const all = await client.documentSymbol(filePath);
|
|
492
|
+
const symbols = detailed ? all : dropImportSymbols(all, readTextStrict(this.resolvePath(filePath)));
|
|
493
|
+
return formatOutline(symbols, { collapseKinds: detailed ? new Set() : LOCALS_HOLDER_KINDS });
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
diagnostics(filePath) {
|
|
497
|
+
return this.run(async () => {
|
|
498
|
+
const client = await this.clientFor(filePath);
|
|
499
|
+
const items = await client.diagnostics(filePath);
|
|
500
|
+
const lines = [formatDiagnostics(items)];
|
|
501
|
+
const suffix = path.extname(filePath).toLowerCase();
|
|
502
|
+
if (suffix === ".css" || suffix === ".html") {
|
|
503
|
+
try {
|
|
504
|
+
const located = webIndex.rootIndexForFile(this.indexes(), this.resolvePath(filePath));
|
|
505
|
+
if (located) {
|
|
506
|
+
const extra = webIndex.diagnosticsForFile(located[0], located[1]);
|
|
507
|
+
if (extra.length > 0) {
|
|
508
|
+
lines.push(extra.join("\n"));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
if (!(error instanceof ToolInputError)) {
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
lines.push(error.message);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const combined = lines.filter((text) => text && text !== "No diagnostics.");
|
|
520
|
+
return combined.length > 0 ? combined.join("\n") : "No diagnostics.";
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
cssVar(args) {
|
|
524
|
+
return this.run(async () => {
|
|
525
|
+
const name = resolveNameQuery({
|
|
526
|
+
preferred: "name",
|
|
527
|
+
example: "--bg",
|
|
528
|
+
params: { name: args.name, query: args.query },
|
|
529
|
+
});
|
|
530
|
+
return webIndex.formatCssVar(this.indexes(), name, { generated: this.generatedRelative });
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
selectorLookup(args) {
|
|
534
|
+
return this.run(async () => {
|
|
535
|
+
const name = resolveNameQuery({
|
|
536
|
+
preferred: "name",
|
|
537
|
+
example: ".card-title",
|
|
538
|
+
params: { name: args.name, query: args.query },
|
|
539
|
+
});
|
|
540
|
+
return webIndex.formatSelector(this.indexes(), name, { generated: this.generatedRelative });
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** `Class.member [Kind] (path:line:col)` per implementation, deduplicated and in file order. */
|
|
545
|
+
async function formatImplementations(client, name, kind, locations, workspaceRoot) {
|
|
546
|
+
const rows = new Map();
|
|
547
|
+
for (const loc of locations) {
|
|
548
|
+
const uri = loc.uri ?? loc.targetUri ?? "";
|
|
549
|
+
const start = (loc.targetSelectionRange ?? loc.range ?? loc.targetRange)?.start;
|
|
550
|
+
const line = start?.line ?? 0;
|
|
551
|
+
const character = start?.character ?? 0;
|
|
552
|
+
const rel = uriToRelative(uri, workspaceRoot);
|
|
553
|
+
let label = "?";
|
|
554
|
+
let labelKind;
|
|
555
|
+
try {
|
|
556
|
+
const found = symbolAt(await client.documentSymbol(rel), line, character);
|
|
557
|
+
label = found?.label ?? "?";
|
|
558
|
+
labelKind = found?.kind;
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
if (!isToolError(error)) {
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
rows.set(`${rel}:${line}:${character}`, {
|
|
566
|
+
path: rel,
|
|
567
|
+
line,
|
|
568
|
+
text: `${label} [${symbolKindLabel(labelKind)}] (${rel}:${line + 1}:${character + 1})`,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (rows.size === 0) {
|
|
572
|
+
return `No implementations of ${name} [${symbolKindLabel(kind)}] found.`;
|
|
573
|
+
}
|
|
574
|
+
const sorted = [...rows.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line));
|
|
575
|
+
return [`${rows.size} implementation(s) of ${name} [${symbolKindLabel(kind)}]:`, ...sorted.map((r) => r.text)].join("\n");
|
|
576
|
+
}
|
|
577
|
+
function formatHoverContents(contents) {
|
|
578
|
+
if (!contents) {
|
|
579
|
+
return "";
|
|
580
|
+
}
|
|
581
|
+
const one = (c) => c !== null && typeof c === "object" ? String(c.value ?? JSON.stringify(c)) : String(c);
|
|
582
|
+
if (Array.isArray(contents)) {
|
|
583
|
+
return contents.map(one).join("\n").trim();
|
|
584
|
+
}
|
|
585
|
+
return one(contents).trim();
|
|
586
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webnav-ts-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"lsp",
|
|
9
|
+
"typescript",
|
|
10
|
+
"javascript",
|
|
11
|
+
"css",
|
|
12
|
+
"html",
|
|
13
|
+
"code-navigation",
|
|
14
|
+
"ai-agents"
|
|
15
|
+
],
|
|
16
|
+
"author": "Daniel Illescas Romero <contact@daniel-ir.eu>",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"homepage": "https://github.com/illescasDaniel/webnav-ts-mcp#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/illescasDaniel/webnav-ts-mcp.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/illescasDaniel/webnav-ts-mcp/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"webnav-ts-mcp": "dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"lint": "biome check .",
|
|
42
|
+
"check": "npm run lint && npm run typecheck && npm test",
|
|
43
|
+
"pretest": "npm run build",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"parity": "node scripts/parity.mjs",
|
|
46
|
+
"prepack": "npm run build",
|
|
47
|
+
"bench": "node scripts/bench.mjs"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
51
|
+
"typescript": "^7.0.2",
|
|
52
|
+
"vscode-jsonrpc": "^9.0.3",
|
|
53
|
+
"vscode-langservers-extracted": "^4.10.0",
|
|
54
|
+
"zod": "^4.6.5"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@biomejs/biome": "^2.5.14",
|
|
58
|
+
"@types/node": "^22.0.0",
|
|
59
|
+
"vitest": "^5.0.3"
|
|
60
|
+
}
|
|
61
|
+
}
|