webnav-ts-mcp 0.2.0 → 0.3.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/README.md +26 -0
- package/dist/batch.js +235 -0
- package/dist/server.js +415 -315
- package/dist/webnav.js +3 -1
- package/dist/write/fixTools.js +6 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,6 +140,31 @@ own edit alone. `move`'s HTML/CSS rewrite understands relative and root-absolute
|
|
|
140
140
|
listed as mentions, not rewritten. HTML/CSS edits are checked by their language
|
|
141
141
|
servers (syntax and CSS lint), not against the cross-file index.
|
|
142
142
|
|
|
143
|
+
## Several calls in one request: `batch`
|
|
144
|
+
|
|
145
|
+
Reading three files or changing five symbols no longer costs one round-trip each.
|
|
146
|
+
`batch` takes a list of calls for any tool above (reads and writes mixed):
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{"calls": [
|
|
150
|
+
{"tool": "outline", "args": {"file_path": "src/app.ts"}},
|
|
151
|
+
{"tool": "symbol_info", "args": {"name": "Cart.total"}},
|
|
152
|
+
{"tool": "edit", "args": {"file_path": "src/app.ts", "old_string": "x = 1", "new_string": "x = 2"}},
|
|
153
|
+
{"tool": "edit_symbol", "args": {"action": "delete", "name": "legacyParse"}}
|
|
154
|
+
]}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- Calls run **in order**, each through the real tool, so every write is type-checked,
|
|
158
|
+
previewed or applied, and undoable on its own (one edit id per write), and later calls
|
|
159
|
+
see what earlier ones wrote.
|
|
160
|
+
- One numbered section per call, then a summary line (`batch: 3/4 ok, 1 failed`).
|
|
161
|
+
- A bad tool name or argument is reported in its own section with a hint; the rest still run.
|
|
162
|
+
- `stop_on_error` (default: stop when the batch contains a write, continue when it only
|
|
163
|
+
reads) skips the remaining calls after the first failure or write that was not applied.
|
|
164
|
+
- Not atomic: calls that ran stay applied. After an edit, prefer name-based calls over
|
|
165
|
+
line/column positions computed earlier. Max 25 calls, no nesting, each result capped at
|
|
166
|
+
20,000 characters.
|
|
167
|
+
|
|
143
168
|
## Environment
|
|
144
169
|
|
|
145
170
|
| Variable | Default | Purpose |
|
|
@@ -156,6 +181,7 @@ servers (syntax and CSS lint), not against the cross-file index.
|
|
|
156
181
|
src/
|
|
157
182
|
cli.ts stdio entry point (exits when the host closes stdin)
|
|
158
183
|
server.ts registers the tools on the MCP SDK
|
|
184
|
+
batch.ts the `batch` engine: runs several registered tools in one request
|
|
159
185
|
webnav.ts the tools as plain async functions returning text; owns all state
|
|
160
186
|
webIndex.ts CSS var / selector index
|
|
161
187
|
langCommand.ts how to launch tsc / html / css language servers
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `batch`: several tool calls in one request.
|
|
3
|
+
*
|
|
4
|
+
* An agent that wants to read three files or change five symbols would otherwise spend one
|
|
5
|
+
* round-trip (and the tokens around it) per call. `batch` takes the list, runs the calls in
|
|
6
|
+
* order through the real tool functions, and returns one numbered section per call. Each
|
|
7
|
+
* call keeps its own behaviour: every write is type-checked, previewed or applied, and
|
|
8
|
+
* undoable on its own, and later calls see what earlier calls wrote.
|
|
9
|
+
*
|
|
10
|
+
* The registry is plain data (a zod shape plus a run function per tool), so unit tests can
|
|
11
|
+
* register fake tools and drive `runBatch` without a language server or the MCP SDK.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { pyRepr } from "./shared/format.js";
|
|
15
|
+
export const MAX_CALLS = 25;
|
|
16
|
+
export const MAX_CALL_CHARS = 20_000;
|
|
17
|
+
// Text a write tool returns when it did what was asked (applied, undone, previewed, or nothing to do).
|
|
18
|
+
const WRITE_OK_MARKERS = ["Applied as edit", "Applied edit", "Reverted edit", "Preview only", "No changes"];
|
|
19
|
+
function availableTools(registry) {
|
|
20
|
+
return [...registry.keys()].sort();
|
|
21
|
+
}
|
|
22
|
+
/** Levenshtein edit distance, for the unknown-tool suggestion. */
|
|
23
|
+
function editDistance(a, b) {
|
|
24
|
+
const width = b.length;
|
|
25
|
+
let previous = Array.from({ length: width + 1 }, (_, j) => j);
|
|
26
|
+
for (let i = 1; i <= a.length; i++) {
|
|
27
|
+
const current = new Array(width + 1).fill(0);
|
|
28
|
+
current[0] = i;
|
|
29
|
+
for (let j = 1; j <= width; j++) {
|
|
30
|
+
const left = previous[j];
|
|
31
|
+
const up = current[j - 1] + 1;
|
|
32
|
+
const diagonal = previous[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1);
|
|
33
|
+
current[j] = Math.min(left + 1, up, diagonal);
|
|
34
|
+
}
|
|
35
|
+
previous = current;
|
|
36
|
+
}
|
|
37
|
+
return previous[width];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* ` Did you mean 'x' or 'y'?` — the two closest registered names, or "" when none is close.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately NOT the Python original's difflib.get_close_matches scoring (SequenceMatcher
|
|
43
|
+
* ratio >= 0.6; this keeps its n=2): this is Levenshtein distance <= floor(0.4 * max(len)).
|
|
44
|
+
* The two agree on realistic typos and Levenshtein is simpler — don't "fix" this to match difflib.
|
|
45
|
+
*/
|
|
46
|
+
function suggestion(name, registry) {
|
|
47
|
+
const close = availableTools(registry)
|
|
48
|
+
.map((candidate) => ({ candidate, distance: editDistance(name, candidate) }))
|
|
49
|
+
.filter(({ candidate, distance }) => distance <= Math.floor(Math.max(name.length, candidate.length) * 0.4))
|
|
50
|
+
.sort((a, b) => a.distance - b.distance || (a.candidate < b.candidate ? -1 : 1))
|
|
51
|
+
.slice(0, 2)
|
|
52
|
+
.map(({ candidate }) => pyRepr(candidate));
|
|
53
|
+
return close.length > 0 ? ` Did you mean ${close.join(" or ")}?` : "";
|
|
54
|
+
}
|
|
55
|
+
function unknownToolHint(name, registry) {
|
|
56
|
+
return `Unknown tool ${pyRepr(name)}.${suggestion(name, registry)} Available: ${availableTools(registry).join(", ")}.`;
|
|
57
|
+
}
|
|
58
|
+
/** True when the parameter may be left out: `.optional()` or `.default(...)` in the zod shape. */
|
|
59
|
+
function mayBeOmitted(schema) {
|
|
60
|
+
return schema.safeParse(undefined).success;
|
|
61
|
+
}
|
|
62
|
+
/** `name(a, b=...)`: parameters without a default shown bare, with one shown as `=...`. */
|
|
63
|
+
function signatureHint(name, tool) {
|
|
64
|
+
const parts = Object.entries(tool.inputSchema).map(([key, schema]) => (mayBeOmitted(schema) ? `${key}=...` : key));
|
|
65
|
+
return `${name}(${parts.join(", ")})`;
|
|
66
|
+
}
|
|
67
|
+
/** Python-repr-ish text for a call argument in a section header: `'x'`, `7`, `true`, `['a', "b"]`. */
|
|
68
|
+
function reprValue(value) {
|
|
69
|
+
if (typeof value === "string") {
|
|
70
|
+
return pyRepr(value);
|
|
71
|
+
}
|
|
72
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
73
|
+
return String(value);
|
|
74
|
+
}
|
|
75
|
+
if (value === null || value === undefined) {
|
|
76
|
+
return "null";
|
|
77
|
+
}
|
|
78
|
+
// One style throughout (Python repr-ish): strings single-quoted, arrays `[a, b]`, objects `{k: v}`.
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
return `[${value.map((item) => reprValue(item)).join(", ")}]`;
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === "object") {
|
|
83
|
+
const items = Object.entries(value).map(([key, v]) => `${pyRepr(key)}: ${reprValue(v)}`);
|
|
84
|
+
return `{${items.join(", ")}}`;
|
|
85
|
+
}
|
|
86
|
+
return JSON.stringify(value) ?? "null";
|
|
87
|
+
}
|
|
88
|
+
/** `edit(file_path='src/a.ts', old_string='...')` — only the arguments the call passed. */
|
|
89
|
+
function label(name, args) {
|
|
90
|
+
const shown = Object.entries(args)
|
|
91
|
+
.map(([key, value]) => {
|
|
92
|
+
const repr = reprValue(value);
|
|
93
|
+
return repr.length <= 60 ? `${key}=${repr}` : `${key}=...`;
|
|
94
|
+
})
|
|
95
|
+
.join(", ");
|
|
96
|
+
return `${name}(${shown})`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validates one call; returns it ready to run, or the error text for its section.
|
|
100
|
+
* The flat shape `{"tool": "outline", "file_path": "x.ts"}` is accepted too: keys beside
|
|
101
|
+
* "tool" are treated as arguments.
|
|
102
|
+
*/
|
|
103
|
+
function prepare(registry, index, raw) {
|
|
104
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
105
|
+
return `Call ${index} must be an object like {"tool": "outline", "args": {"file_path": "src/a.ts"}}.`;
|
|
106
|
+
}
|
|
107
|
+
const rest = { ...raw };
|
|
108
|
+
let name = rest.tool;
|
|
109
|
+
delete rest.tool;
|
|
110
|
+
if (!name) {
|
|
111
|
+
name = rest.name;
|
|
112
|
+
delete rest.name;
|
|
113
|
+
}
|
|
114
|
+
if (typeof name !== "string" || name === "") {
|
|
115
|
+
return 'Call is missing "tool" (the tool name, e.g. "outline").';
|
|
116
|
+
}
|
|
117
|
+
if (name === "batch") {
|
|
118
|
+
return "batch calls cannot be nested.";
|
|
119
|
+
}
|
|
120
|
+
const tool = registry.get(name);
|
|
121
|
+
if (!tool) {
|
|
122
|
+
return unknownToolHint(name, registry);
|
|
123
|
+
}
|
|
124
|
+
let args = rest.args;
|
|
125
|
+
delete rest.args;
|
|
126
|
+
if (args === undefined || args === null) {
|
|
127
|
+
args = rest;
|
|
128
|
+
}
|
|
129
|
+
else if (Object.keys(rest).length > 0) {
|
|
130
|
+
const extra = Object.keys(rest).sort().map(pyRepr).join(", ");
|
|
131
|
+
return `Put [${extra}] inside "args" (call shape: {"tool": ..., "args": {...}}).`;
|
|
132
|
+
}
|
|
133
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
134
|
+
return `"args" must be an object of ${name} parameters: ${signatureHint(name, tool)}.`;
|
|
135
|
+
}
|
|
136
|
+
const parameters = args;
|
|
137
|
+
const unknown = Object.keys(parameters).filter((key) => !Object.hasOwn(tool.inputSchema, key));
|
|
138
|
+
if (unknown.length > 0) {
|
|
139
|
+
return `${name} has no parameter ${unknown.map(pyRepr).join(", ")}. Parameters: ${signatureHint(name, tool)}.`;
|
|
140
|
+
}
|
|
141
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
142
|
+
const result = tool.inputSchema[key]?.safeParse(value);
|
|
143
|
+
if (result && !result.success) {
|
|
144
|
+
const message = result.error.issues[0]?.message ?? "invalid value";
|
|
145
|
+
return `${name}: invalid ${pyRepr(key)}: ${message}. Parameters: ${signatureHint(name, tool)}.`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const missing = Object.entries(tool.inputSchema)
|
|
149
|
+
.filter(([key, schema]) => !mayBeOmitted(schema) && !Object.hasOwn(parameters, key))
|
|
150
|
+
.map(([key]) => key);
|
|
151
|
+
if (missing.length > 0) {
|
|
152
|
+
return `${name}: missing required ${missing.map(pyRepr).join(", ")}. Parameters: ${signatureHint(name, tool)}.`;
|
|
153
|
+
}
|
|
154
|
+
// Fill in the defaults exactly as the MCP server would have; the tools rely on them. The checks
|
|
155
|
+
// above accept every value the per-key schemas accept, so a failure here means those checks
|
|
156
|
+
// missed something (e.g. an object-level rule, which a per-key ToolShape cannot express) —
|
|
157
|
+
// report it instead of silently running the call with default-less arguments.
|
|
158
|
+
const filled = z.object(tool.inputSchema).safeParse(parameters);
|
|
159
|
+
if (!filled.success) {
|
|
160
|
+
const issue = filled.error.issues[0];
|
|
161
|
+
const where = issue !== undefined && issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
162
|
+
return `${name}: internal error validating arguments: ${where}${issue?.message ?? "invalid arguments"}.`;
|
|
163
|
+
}
|
|
164
|
+
return { name, tool, args: parameters, call: filled.data };
|
|
165
|
+
}
|
|
166
|
+
/** A write tool's result counts as a failure when it did not do what was asked. */
|
|
167
|
+
function writeFailed(tool, text) {
|
|
168
|
+
return tool.writes && (text.includes("NOT applied") || !WRITE_OK_MARKERS.some((marker) => text.includes(marker)));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Runs the calls in order through the registered tools and returns one numbered section per
|
|
172
|
+
* call, then a summary line. One broken call never loses the others' results.
|
|
173
|
+
*
|
|
174
|
+
* `stop_on_error`: stop at the first call that fails or does not apply a write, skipping the
|
|
175
|
+
* rest. Default (null/omitted): stop when the batch contains a write, keep going when it
|
|
176
|
+
* only reads.
|
|
177
|
+
*/
|
|
178
|
+
export async function runBatch(calls, stopOnError, registry) {
|
|
179
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
180
|
+
return 'batch needs a non-empty "calls" list, e.g. [{"tool": "outline", "args": {"file_path": "src/a.ts"}}].';
|
|
181
|
+
}
|
|
182
|
+
if (calls.length > MAX_CALLS) {
|
|
183
|
+
return `batch takes at most ${MAX_CALLS} calls, got ${calls.length}. Split it into several batches.`;
|
|
184
|
+
}
|
|
185
|
+
const prepared = calls.map((raw, i) => prepare(registry, i + 1, raw));
|
|
186
|
+
const hasWrite = prepared.some((item) => typeof item !== "string" && item.tool.writes);
|
|
187
|
+
const stop = stopOnError === undefined || stopOnError === null ? hasWrite : stopOnError;
|
|
188
|
+
const sections = [];
|
|
189
|
+
let failed = 0;
|
|
190
|
+
let skipped = 0;
|
|
191
|
+
let halted = false;
|
|
192
|
+
const total = prepared.length;
|
|
193
|
+
for (const [offset, item] of prepared.entries()) {
|
|
194
|
+
const index = offset + 1;
|
|
195
|
+
if (halted) {
|
|
196
|
+
skipped += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (typeof item === "string") {
|
|
200
|
+
sections.push(`[${index}/${total}] ERROR\n${item}`);
|
|
201
|
+
failed += 1;
|
|
202
|
+
halted = stop;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const header = `[${index}/${total}] ${label(item.name, item.args)}`;
|
|
206
|
+
let text;
|
|
207
|
+
try {
|
|
208
|
+
text = await item.tool.run(item.call);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
212
|
+
sections.push(`${header}\nERROR: ${message}`);
|
|
213
|
+
failed += 1;
|
|
214
|
+
halted = stop;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (writeFailed(item.tool, text)) {
|
|
218
|
+
failed += 1;
|
|
219
|
+
halted = stop;
|
|
220
|
+
text = `NOT DONE\n${text}`;
|
|
221
|
+
}
|
|
222
|
+
if (text.length > MAX_CALL_CHARS) {
|
|
223
|
+
text = `${text.slice(0, MAX_CALL_CHARS)}\n... [truncated ${text.length - MAX_CALL_CHARS} characters; narrow this call]`;
|
|
224
|
+
}
|
|
225
|
+
sections.push(`${header}\n${text}`);
|
|
226
|
+
}
|
|
227
|
+
let summary = `batch: ${total - failed - skipped}/${total} ok`;
|
|
228
|
+
if (failed > 0) {
|
|
229
|
+
summary += `, ${failed} failed`;
|
|
230
|
+
}
|
|
231
|
+
if (skipped > 0) {
|
|
232
|
+
summary += `, ${skipped} skipped after the first failure (stop_on_error)`;
|
|
233
|
+
}
|
|
234
|
+
return [...sections, summary].join("\n\n");
|
|
235
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
* parameters are all optional in the schema: a missing one is answered with a
|
|
6
6
|
* short hint by the tool itself, which beats the SDK's schema-validation error
|
|
7
7
|
* for agents that guess `query` vs `name`.
|
|
8
|
+
*
|
|
9
|
+
* Every tool is declared once as a spec (`name`, `description`, `inputSchema`,
|
|
10
|
+
* `annotations`, `writes`, `run`). `createServer` registers each spec on the
|
|
11
|
+
* MCP server, and the `batch` engine resolves its calls against the same specs,
|
|
12
|
+
* so a batch call is validated and run exactly like the tool called alone.
|
|
8
13
|
*/
|
|
9
14
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
15
|
import { z } from "zod";
|
|
16
|
+
import { runBatch } from "./batch.js";
|
|
11
17
|
import { INSTRUCTIONS, Webnav } from "./webnav.js";
|
|
12
18
|
const POSITION_DOC = "`line` and `column` are 1-indexed. `column` is a UTF-16 character offset on the line " +
|
|
13
19
|
"(not a visual/display column): a leading tab counts as one character.";
|
|
@@ -25,6 +31,37 @@ const writeParams = {
|
|
|
25
31
|
const text = (value) => ({
|
|
26
32
|
content: [{ type: "text", text: value }],
|
|
27
33
|
});
|
|
34
|
+
/**
|
|
35
|
+
* Declares a tool: the zod shape is registered with the MCP server (whose own schema parsing
|
|
36
|
+
* fills the same defaults) and is reused by `batch` to validate each call.
|
|
37
|
+
*/
|
|
38
|
+
function tool(name, config) {
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
description: config.description,
|
|
42
|
+
inputSchema: config.inputSchema,
|
|
43
|
+
writes: config.writes,
|
|
44
|
+
annotations: config.annotations,
|
|
45
|
+
run: config.run,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const BATCH_DESCRIPTION = "Run several webnav tool calls in ONE request (saves round-trips and tokens).\n\n" +
|
|
49
|
+
'Example: `batch(calls=[{"tool": "outline", "args": {"file_path": "src/app.ts"}}, ' +
|
|
50
|
+
'{"tool": "symbol_info", "args": {"name": "Cart.total"}}])`. Works for reads and writes alike, e.g. ' +
|
|
51
|
+
'two edits in different files: `[{"tool": "edit", "args": {"file_path": "src/app.ts", "old_string": ' +
|
|
52
|
+
'"x = 1", "new_string": "x = 2"}}, {"tool": "edit_symbol", "args": {...}}]`.\n\n' +
|
|
53
|
+
"Each call takes the same arguments as the tool called alone (`args` is that tool's parameter object) " +
|
|
54
|
+
"and behaves as if you had called it separately: calls run in order, so a later call sees what an " +
|
|
55
|
+
"earlier write changed, and every write is type-checked, previewed/applied and undoable on its own " +
|
|
56
|
+
"(see its own section for the edit id). The result has one numbered section per call, then a summary " +
|
|
57
|
+
"line.\n\n" +
|
|
58
|
+
"`stop_on_error`: stop at the first call that fails or does not apply a write (the rest are skipped " +
|
|
59
|
+
"and listed). Default (null): stop when the batch contains a write, keep going when it only reads. " +
|
|
60
|
+
"Up to 25 calls; `batch` cannot be nested.\n\n" +
|
|
61
|
+
"Not atomic: calls that already ran stay applied (each has its own undo id). Line/column positions " +
|
|
62
|
+
"you computed before the batch may be stale after an earlier edit in it, so prefer name-based calls " +
|
|
63
|
+
"(symbol_info, edit_symbol, rename_symbol) after writes. A preview id is invalid once a later call " +
|
|
64
|
+
"changes the same file.";
|
|
28
65
|
export function createServer(version) {
|
|
29
66
|
const server = new McpServer({ name: "webnav", version }, { instructions: INSTRUCTIONS });
|
|
30
67
|
const webnav = new Webnav({
|
|
@@ -40,320 +77,383 @@ export function createServer(version) {
|
|
|
40
77
|
line: z.number().int(),
|
|
41
78
|
column: z.number().int(),
|
|
42
79
|
};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
'
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
'
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
80
|
+
// Registered in order; `batch` is LAST so its registry can reach every other tool
|
|
81
|
+
// (a call for "batch" inside a batch is refused, so it never enters the registry itself).
|
|
82
|
+
const registry = new Map();
|
|
83
|
+
const specs = [
|
|
84
|
+
tool("hover", {
|
|
85
|
+
description: `Get type/documentation info for the symbol at a position.\n\n${POSITION_DOC}`,
|
|
86
|
+
inputSchema: position,
|
|
87
|
+
run: async ({ file_path, line, column }) => webnav.hover(file_path, line, column),
|
|
88
|
+
writes: false,
|
|
89
|
+
}),
|
|
90
|
+
tool("workspace", {
|
|
91
|
+
description: "Which directory is webnav navigating, and why? Use when results look like they come from the wrong checkout/worktree.",
|
|
92
|
+
inputSchema: {},
|
|
93
|
+
run: async () => webnav.workspace(),
|
|
94
|
+
writes: false,
|
|
95
|
+
}),
|
|
96
|
+
tool("definition", {
|
|
97
|
+
description: "Go to the definition of the symbol at a position.\n\n" +
|
|
98
|
+
`${POSITION_DOC} On a \`--custom-property\`/\`#id\`/\`.class\` token in a \`.css\`/` +
|
|
99
|
+
'`.html` file (or a name inside an HTML `id="..."`/`class="..."` value), ' +
|
|
100
|
+
"answers from the cross-file index (see css_var/selector) " +
|
|
101
|
+
"instead of the single-file language server: for `definition`, just the " +
|
|
102
|
+
"definition(s) in the file's own root (each `WEBNAV_MCP_ROOTS` root " +
|
|
103
|
+
"is separate); for `references`, that root's definitions and usages.",
|
|
104
|
+
inputSchema: position,
|
|
105
|
+
run: async ({ file_path, line, column }) => webnav.definition(file_path, line, column),
|
|
106
|
+
writes: false,
|
|
107
|
+
}),
|
|
108
|
+
tool("references", {
|
|
109
|
+
description: "Find all usages of the symbol at a position across the workspace.\n\n" +
|
|
110
|
+
`${POSITION_DOC} On a \`--custom-property\`/\`#id\`/\`.class\` token in a \`.css\`/` +
|
|
111
|
+
'`.html` file (or a name inside an HTML `id="..."`/`class="..."` value), ' +
|
|
112
|
+
"answers from the cross-file index (see css_var/selector) " +
|
|
113
|
+
"instead of the single-file language server, limited to the file's own root " +
|
|
114
|
+
"(each `WEBNAV_MCP_ROOTS` root is separate) unless it has no hits there.",
|
|
115
|
+
inputSchema: { ...position, include_declaration: z.boolean().default(true) },
|
|
116
|
+
run: async ({ file_path, line, column, include_declaration }) => webnav.references(file_path, line, column, include_declaration),
|
|
117
|
+
writes: false,
|
|
118
|
+
}),
|
|
119
|
+
tool("search_symbol", {
|
|
120
|
+
description: "Search JS/TS files for a symbol by name (function, class, const, etc.).\n\n" +
|
|
121
|
+
"JS/TS-only: the HTML/CSS language servers don't implement useful " +
|
|
122
|
+
"workspace-wide symbol search (webnav does not reimplement it). Prefer " +
|
|
123
|
+
"`symbol_info` for a one-call summary. Returned positions point at the " +
|
|
124
|
+
"identifier name and use the same character-offset column convention as " +
|
|
125
|
+
"the other tools. Results include a SymbolKind label and are capped. " +
|
|
126
|
+
"`name` is accepted as an alias for `query`. Narrow broad queries with " +
|
|
127
|
+
"`kind` (SymbolKind labels, comma-separated: `class`, `function,method`, " +
|
|
128
|
+
"`interface`, ...) and `path` (workspace-relative prefix such as `src/`, " +
|
|
129
|
+
"or a glob such as `src/**/*.ts`). Production code ranks before tests. " +
|
|
130
|
+
"Loose fuzzy hits whose names don't contain the query are summarised as a " +
|
|
131
|
+
"count when real matches exist; pass `fuzzy=true` to list them too.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
query: z.string().optional(),
|
|
134
|
+
name: z.string().optional(),
|
|
135
|
+
kind: z.string().optional(),
|
|
136
|
+
path: z.string().optional(),
|
|
137
|
+
fuzzy: z.boolean().default(false),
|
|
138
|
+
},
|
|
139
|
+
run: async (args) => webnav.searchSymbol(args),
|
|
140
|
+
writes: false,
|
|
141
|
+
}),
|
|
142
|
+
tool("symbol_info", {
|
|
143
|
+
description: 'What is X and where is it used? Example: `symbol_info(name="renderSidebar")`.\n\n' +
|
|
144
|
+
"One-call summary for a JS/TS name: header, hover text, definition, and " +
|
|
145
|
+
"references grouped by file, the usual first lookup instead of chaining " +
|
|
146
|
+
"search_symbol → hover → definition → references by hand. Pass `file_path` " +
|
|
147
|
+
"to disambiguate; `query` is accepted as an alias for `name`. For CSS/HTML " +
|
|
148
|
+
"cross-file lookups use `css_var` / `selector` instead.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
name: z.string().optional(),
|
|
151
|
+
query: z.string().optional(),
|
|
152
|
+
file_path: z.string().optional(),
|
|
153
|
+
include_references: z.boolean().default(true),
|
|
154
|
+
},
|
|
155
|
+
run: async ({ name, query, file_path, include_references }) => webnav.symbolInfo({ name, query, filePath: file_path, includeReferences: include_references }),
|
|
156
|
+
writes: false,
|
|
157
|
+
}),
|
|
158
|
+
tool("callers", {
|
|
159
|
+
description: 'Who calls this function? Example: `callers(name="renderSidebar")`.\n\n' +
|
|
160
|
+
"Narrower than references, since it leaves out imports and type-only usages " +
|
|
161
|
+
"and only lists actual call sites, each with the calling function and its " +
|
|
162
|
+
"call-site lines. `name` resolves the same way as symbol_info (dotted " +
|
|
163
|
+
"`Class.method` accepted, inherited members included; pass `file_path` to " +
|
|
164
|
+
"disambiguate a common name). `query` is accepted as an alias for `name`. JS/TS only.",
|
|
165
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional(), file_path: z.string().optional() },
|
|
166
|
+
run: async ({ name, query, file_path }) => webnav.callers({ name, query, filePath: file_path }),
|
|
167
|
+
writes: false,
|
|
168
|
+
}),
|
|
169
|
+
tool("implementations", {
|
|
170
|
+
description: 'Who implements or extends this? Example: `implementations(name="Shape")`.\n\n' +
|
|
171
|
+
"For an interface or class, lists the classes that implement or extend it " +
|
|
172
|
+
"(transitively); for an interface or abstract method (`Shape.area`), lists the " +
|
|
173
|
+
"methods that implement it. Backed by the language server's " +
|
|
174
|
+
"`textDocument/implementation`, so it follows the type checker, not naming. " +
|
|
175
|
+
"`name` resolves like symbol_info (pass `file_path` to disambiguate); `query` is " +
|
|
176
|
+
"accepted as an alias. JS/TS only.",
|
|
177
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional(), file_path: z.string().optional() },
|
|
178
|
+
run: async ({ name, query, file_path }) => webnav.implementations({ name, query, filePath: file_path }),
|
|
179
|
+
writes: false,
|
|
180
|
+
}),
|
|
181
|
+
tool("outline", {
|
|
182
|
+
description: 'What\'s in this file? Example: `outline(file_path="src/app.ts")`.\n\n' +
|
|
183
|
+
"Indented outline (functions, classes, interfaces, with `:start-end` line " +
|
|
184
|
+
"spans) of a JS/TS file in source order, so you can navigate without reading " +
|
|
185
|
+
"it in full. Locals, callbacks and object-literal keys inside functions and " +
|
|
186
|
+
"variables are left out; pass `detailed=true` to include them. Follow up " +
|
|
187
|
+
"with hover/definition/references at a listed line, or symbol_info by name.",
|
|
188
|
+
inputSchema: { file_path: z.string(), detailed: z.boolean().default(false) },
|
|
189
|
+
run: async ({ file_path, detailed }) => webnav.outline(file_path, detailed),
|
|
190
|
+
writes: false,
|
|
191
|
+
}),
|
|
192
|
+
tool("diagnostics", {
|
|
193
|
+
description: "Get the relevant language server's diagnostics (errors/warnings) for a single file.\n\n" +
|
|
194
|
+
"For `.css`/`.html` files, this also includes index-derived warnings the " +
|
|
195
|
+
"single-file language server can't see: `var(--x)` used with no matching " +
|
|
196
|
+
"declaration anywhere in the same indexed root, and CSS selectors " +
|
|
197
|
+
"(`#id`/`.class`) with no HTML/JS reference in that root.",
|
|
198
|
+
inputSchema: { file_path: z.string() },
|
|
199
|
+
run: async ({ file_path }) => webnav.diagnostics(file_path),
|
|
200
|
+
writes: false,
|
|
201
|
+
}),
|
|
202
|
+
tool("css_var", {
|
|
203
|
+
description: 'Where is this `--custom-property` defined and used? Example: `css_var(name="--bg")`.\n\n' +
|
|
204
|
+
"The CSS/HTML language servers only see one file at a time, so `var(--x)` " +
|
|
205
|
+
"usages can't be cross-referenced across files that way. This scans " +
|
|
206
|
+
'`.css` files and HTML `<style>`/`style="…"` blocks/attributes instead. ' +
|
|
207
|
+
"`name` may be given with or without the leading `--`. Definitions (value " +
|
|
208
|
+
"+ enclosing context, e.g. `@media (prefers-color-scheme: dark) › :root`) " +
|
|
209
|
+
"and usages (grouped by file with line numbers) are reported separately per " +
|
|
210
|
+
"configured root (see `WEBNAV_MCP_ROOTS`; a single unnamed root by default), " +
|
|
211
|
+
"since each may define its own values. `query` is accepted as an alias for `name`.",
|
|
212
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional() },
|
|
213
|
+
run: async (args) => webnav.cssVar(args),
|
|
214
|
+
writes: false,
|
|
215
|
+
}),
|
|
216
|
+
tool("selector", {
|
|
217
|
+
description: 'Who uses this `#id` or `.class`? Example: `selector(name=".card-title")`.\n\n' +
|
|
218
|
+
"Looks up the selector across the whole workspace.\n\n" +
|
|
219
|
+
"Cross-references CSS rule definitions, HTML `id=`/`class=` attributes, " +
|
|
220
|
+
"and JS usages (`getElementById`, `classList.add/remove/toggle/contains`, " +
|
|
221
|
+
"`querySelector`/`querySelectorAll`, `className` assignment, and any JS " +
|
|
222
|
+
"string literal exactly equal to the bare name, e.g. an id passed to a " +
|
|
223
|
+
'project\'s own helper like `onClick("btn-save", …)`, labeled "string ' +
|
|
224
|
+
'literal"). Hits in generated output (`WEBNAV_MCP_EXCLUDE`) are labeled ' +
|
|
225
|
+
"`[generated]`; edit their source instead. This is something " +
|
|
226
|
+
"the single-file CSS/HTML language servers can't do. `name` must include " +
|
|
227
|
+
"the leading `#` or `.`. Grouped by file with line numbers, separately per " +
|
|
228
|
+
"configured root (see `WEBNAV_MCP_ROOTS`). A JS hit built from string " +
|
|
229
|
+
'concatenation (e.g. `getElementById("view-" + x)`) is reported against ' +
|
|
230
|
+
'only its static prefix and labeled "dynamic partial match"; a query whose ' +
|
|
231
|
+
"name starts with such a prefix (e.g. `#view-components` against a stored " +
|
|
232
|
+
'`#view-`) also surfaces that hit, labeled "dynamic partial match via ' +
|
|
233
|
+
"'<prefix>'\", instead of being silently dropped or guessed. `query` is " +
|
|
234
|
+
"accepted as an alias for `name`.",
|
|
235
|
+
inputSchema: { name: z.string().optional(), query: z.string().optional() },
|
|
236
|
+
run: async (args) => webnav.selectorLookup(args),
|
|
237
|
+
writes: false,
|
|
238
|
+
}),
|
|
239
|
+
tool("edit", {
|
|
240
|
+
description: 'Edit text in a JS/TS/HTML/CSS file, type-checked: `edit(file_path="src/app.ts", old_string="x = 1", new_string="x = 2")`.\n\n' +
|
|
241
|
+
"For changes that are not a whole definition (a few lines, an import, a rule, markup) or for rewriting a " +
|
|
242
|
+
"whole file (`new_text`, which also creates a new file). To replace, add or delete a function/class/method " +
|
|
243
|
+
"by name use `edit_symbol`. `old_string` must match exactly once unless `replace_all=true` " +
|
|
244
|
+
"(line endings follow the file). `include_dependents=false` skips checking the importers. " +
|
|
245
|
+
WRITE_RULE,
|
|
246
|
+
inputSchema: {
|
|
247
|
+
file_path: z.string().optional(),
|
|
248
|
+
old_string: z.string().optional(),
|
|
249
|
+
new_string: z.string().optional(),
|
|
250
|
+
replace_all: z.boolean().default(false),
|
|
251
|
+
new_text: z.string().optional(),
|
|
252
|
+
include_dependents: z.boolean().default(true),
|
|
253
|
+
...writeParams,
|
|
254
|
+
},
|
|
255
|
+
run: async ({ max_new_errors, ...a }) => webnav.edit({
|
|
256
|
+
filePath: a.file_path,
|
|
257
|
+
oldString: a.old_string,
|
|
258
|
+
newString: a.new_string,
|
|
259
|
+
replaceAll: a.replace_all,
|
|
260
|
+
newText: a.new_text,
|
|
261
|
+
includeDependents: a.include_dependents,
|
|
262
|
+
apply: a.apply,
|
|
263
|
+
maxNewErrors: max_new_errors,
|
|
264
|
+
}),
|
|
265
|
+
annotations: WRITES,
|
|
266
|
+
writes: true,
|
|
267
|
+
}),
|
|
268
|
+
tool("edit_symbol", {
|
|
269
|
+
description: "Replace, add or delete a JS/TS function, class, interface, enum, const or member by name. Examples: " +
|
|
270
|
+
'`edit_symbol(action="replace", name="Cart.total", source="total(): number { ... }")`, ' +
|
|
271
|
+
'`edit_symbol(action="insert", file_path="src/utils.ts", name="parse", source="function helper() {}")`, ' +
|
|
272
|
+
'`edit_symbol(action="delete", name="legacyParse")`.\n\n' +
|
|
273
|
+
'- `action="replace"`: `name` (dotted `Class.method` ok) and `source` (the complete new definition, ' +
|
|
274
|
+
"decorators included; re-indented to the old one's place in the file's own indent style). The doc comment " +
|
|
275
|
+
"above is kept unless `source` brings its own. A changed signature shows up as errors at call sites.\n" +
|
|
276
|
+
'- `action="insert"`: `source` and `file_path`. `position` is relative to `name`: "after" (default when ' +
|
|
277
|
+
'`name` is given) / "before" a symbol, "into" a class/interface/enum/namespace (appended to its body), or ' +
|
|
278
|
+
'"end" of the file (default without `name`; creates the file if missing). Refuses a name that already exists there.\n' +
|
|
279
|
+
'- `action="delete"`: `name` only. Refuses while anything still uses it and lists the users (`force=true` ' +
|
|
280
|
+
"deletes anyway and shows what breaks). Imports only the deleted code used are removed (`prune_imports`). " +
|
|
281
|
+
"Strings, comments and docs naming it are listed and the deletion is previewed instead of applied.\n\n" +
|
|
282
|
+
"`file_path` narrows `name` when several symbols share it. `imports` (replace/insert) is a list of " +
|
|
283
|
+
'import statements the new code needs (`["import { Money } from \\"./money\\""]`), merged into the file if missing. ' +
|
|
284
|
+
WRITE_RULE,
|
|
285
|
+
inputSchema: {
|
|
286
|
+
action: z.enum(["replace", "insert", "delete"]).optional(),
|
|
287
|
+
name: z.string().optional(),
|
|
288
|
+
source: z.string().optional(),
|
|
289
|
+
file_path: z.string().optional(),
|
|
290
|
+
position: z.enum(["after", "before", "into", "end"]).optional(),
|
|
291
|
+
imports: z.array(z.string()).optional(),
|
|
292
|
+
force: z.boolean().default(false),
|
|
293
|
+
prune_imports: z.boolean().default(true),
|
|
294
|
+
...writeParams,
|
|
295
|
+
},
|
|
296
|
+
run: async ({ max_new_errors, ...a }) => webnav.editSymbol({
|
|
297
|
+
action: a.action,
|
|
298
|
+
name: a.name,
|
|
299
|
+
source: a.source,
|
|
300
|
+
filePath: a.file_path,
|
|
301
|
+
position: a.position,
|
|
302
|
+
imports: a.imports,
|
|
303
|
+
force: a.force,
|
|
304
|
+
pruneImports: a.prune_imports,
|
|
305
|
+
apply: a.apply,
|
|
306
|
+
maxNewErrors: max_new_errors,
|
|
307
|
+
}),
|
|
308
|
+
annotations: WRITES,
|
|
309
|
+
writes: true,
|
|
310
|
+
}),
|
|
311
|
+
tool("rename_symbol", {
|
|
312
|
+
description: 'Rename a symbol everywhere it is used. Examples: `rename_symbol(name="Cart.total", new_name="sum")`, ' +
|
|
313
|
+
'`rename_symbol(name="--accent", new_name="--brand")`, `rename_symbol(name=".card-title", new_name="heading")`.\n\n' +
|
|
314
|
+
"JS/TS: give `name` (dotted `Class.method` ok; `file_path` narrows it) or `file_path` + `line` + `column` " +
|
|
315
|
+
"(1-indexed). Built on the TypeScript language server's rename (imports, aliases, overriding members); " +
|
|
316
|
+
'`parameter="x"` with a function\'s name renames one parameter. Places that still say the old name that ' +
|
|
317
|
+
"the type checker cannot link (strings, comments, docs, other languages) are listed.\n\n" +
|
|
318
|
+
"CSS/HTML: a `name` starting with `--`, `#` or `.` renames that custom property / id / class across " +
|
|
319
|
+
"stylesheets (declarations, `var()`, selectors), HTML (`class`, `id`, and the attributes that refer to ids: " +
|
|
320
|
+
'`for`, `aria-*`, `href="#id"`) and scripts (`getElementById`, `classList`, `querySelector`, `className`, ' +
|
|
321
|
+
'`setProperty`/`getPropertyValue`, markup inside strings). Names built dynamically (`"row-" + id`) are listed, ' +
|
|
322
|
+
"not renamed; script string literals that merely equal the name are listed and renamed only with " +
|
|
323
|
+
"`include_string_literals=true`. Refuses a new name that already exists (`force=true` merges). With several " +
|
|
324
|
+
"`WEBNAV_MCP_ROOTS` pass `file_path` to say which root. " +
|
|
325
|
+
WRITE_RULE,
|
|
326
|
+
inputSchema: {
|
|
327
|
+
new_name: z.string().optional(),
|
|
328
|
+
name: z.string().optional(),
|
|
329
|
+
file_path: z.string().optional(),
|
|
330
|
+
line: z.number().int().optional(),
|
|
331
|
+
column: z.number().int().optional(),
|
|
332
|
+
parameter: z.string().optional(),
|
|
333
|
+
force: z.boolean().default(false),
|
|
334
|
+
include_string_literals: z.boolean().default(false),
|
|
335
|
+
allow_large: z.boolean().default(false),
|
|
336
|
+
...writeParams,
|
|
337
|
+
},
|
|
338
|
+
run: async ({ max_new_errors, ...a }) => webnav.renameSymbol({
|
|
339
|
+
newName: a.new_name,
|
|
340
|
+
name: a.name,
|
|
341
|
+
filePath: a.file_path,
|
|
342
|
+
line: a.line,
|
|
343
|
+
column: a.column,
|
|
344
|
+
parameter: a.parameter,
|
|
345
|
+
force: a.force,
|
|
346
|
+
includeStringLiterals: a.include_string_literals,
|
|
347
|
+
allowLarge: a.allow_large,
|
|
348
|
+
apply: a.apply,
|
|
349
|
+
maxNewErrors: max_new_errors,
|
|
350
|
+
}),
|
|
351
|
+
annotations: WRITES,
|
|
352
|
+
writes: true,
|
|
353
|
+
}),
|
|
354
|
+
tool("move", {
|
|
355
|
+
description: "Move a declaration to another file, or a whole file, and fix every reference. Examples: " +
|
|
356
|
+
'`move(name="parseDate", to_file="src/app/dates.ts")`, `move(file_path="src/old/util.ts", to_file="src/lib/util.ts")`.\n\n' +
|
|
357
|
+
"With `name`: moves that top-level function/class/interface/enum/type/const (`file_path` narrows which). " +
|
|
358
|
+
"The destination is created if missing; the code gets the imports it needs (found by the language server), " +
|
|
359
|
+
'`export` is added if it was private, every `import { name } from "old"` elsewhere is repointed, the old ' +
|
|
360
|
+
"file imports it from the new place if it still uses it (`keep_reexport=true` also leaves `export { name } " +
|
|
361
|
+
'from "new"`), and imports only the moved code used are dropped. Import cycles are warned about.\n\n' +
|
|
362
|
+
"Without `name`: `file_path` is the file to move or rename (any file; `to_file` may be a directory). Every " +
|
|
363
|
+
"import/re-export in JS/TS (the language server's own rewrite), `<script src>`/`<link href>`/`<img src>` in " +
|
|
364
|
+
"HTML (relative and root-absolute), and `url()`/`@import` in CSS follow, and the moved file's own relative " +
|
|
365
|
+
"references are re-based. Configs, path aliases and string paths are listed, not rewritten. " +
|
|
366
|
+
WRITE_RULE,
|
|
367
|
+
inputSchema: {
|
|
368
|
+
to_file: z.string().optional(),
|
|
369
|
+
name: z.string().optional(),
|
|
370
|
+
file_path: z.string().optional(),
|
|
371
|
+
keep_reexport: z.boolean().default(false),
|
|
372
|
+
allow_large: z.boolean().default(false),
|
|
373
|
+
...writeParams,
|
|
374
|
+
},
|
|
375
|
+
run: async ({ max_new_errors, ...a }) => webnav.move({
|
|
376
|
+
toFile: a.to_file,
|
|
377
|
+
name: a.name,
|
|
378
|
+
filePath: a.file_path,
|
|
379
|
+
keepReexport: a.keep_reexport,
|
|
380
|
+
allowLarge: a.allow_large,
|
|
381
|
+
apply: a.apply,
|
|
382
|
+
maxNewErrors: max_new_errors,
|
|
383
|
+
}),
|
|
384
|
+
annotations: WRITES,
|
|
385
|
+
writes: true,
|
|
386
|
+
}),
|
|
387
|
+
tool("quick_fix", {
|
|
388
|
+
description: 'Apply the TypeScript language server\'s own fixes to a JS/TS file: `quick_fix(file_path="src/app.ts")`.\n\n' +
|
|
389
|
+
"`action=\"fix\"` (default) fixes the file's errors one by one with the server's code actions (missing " +
|
|
390
|
+
"imports, misspelled names, ...), re-checking after each; `line` limits it to errors starting on that line. " +
|
|
391
|
+
"A fix with several candidates is listed, not guessed. The other actions are source actions on the whole " +
|
|
392
|
+
"file: `organize_imports`, `remove_unused_imports`, `sort_imports`, `fix_all`. " +
|
|
393
|
+
WRITE_RULE,
|
|
394
|
+
inputSchema: {
|
|
395
|
+
file_path: z.string().optional(),
|
|
396
|
+
line: z.number().int().optional(),
|
|
397
|
+
action: z.enum(["fix", "organize_imports", "remove_unused_imports", "sort_imports", "fix_all"]).optional(),
|
|
398
|
+
...writeParams,
|
|
399
|
+
},
|
|
400
|
+
run: async ({ max_new_errors, ...a }) => webnav.quickFix({
|
|
401
|
+
filePath: a.file_path,
|
|
402
|
+
line: a.line,
|
|
403
|
+
action: a.action,
|
|
404
|
+
apply: a.apply,
|
|
405
|
+
maxNewErrors: max_new_errors,
|
|
406
|
+
}),
|
|
407
|
+
annotations: WRITES,
|
|
408
|
+
writes: true,
|
|
409
|
+
}),
|
|
410
|
+
tool("verify_changes", {
|
|
411
|
+
description: "What did the edits since a git revision break? Compares the language servers' diagnostics now against " +
|
|
412
|
+
"`since` (default `HEAD`).\n\nRun it after editing with any tool (your own Edit/Write included): the " +
|
|
413
|
+
"changed JS/TS/HTML/CSS files and the script files importing them are checked, with the committed versions " +
|
|
414
|
+
'substituted in for the "before" side. Read-only.',
|
|
415
|
+
inputSchema: { since: z.string().default("HEAD"), include_dependents: z.boolean().default(true) },
|
|
416
|
+
run: async ({ since, include_dependents }) => webnav.verifyChanges({ since, includeDependents: include_dependents }),
|
|
417
|
+
annotations: READS,
|
|
418
|
+
writes: false,
|
|
419
|
+
}),
|
|
420
|
+
tool("apply_edit", {
|
|
421
|
+
description: "Write a previewed edit (the id comes from a write tool's preview). Fails, writing nothing, if any file " +
|
|
422
|
+
"changed since the preview.",
|
|
423
|
+
inputSchema: { id: z.string().optional(), allow_large: z.boolean().default(false) },
|
|
424
|
+
run: async ({ id, allow_large }) => webnav.applyEdit(id ?? "", allow_large),
|
|
425
|
+
annotations: WRITES,
|
|
426
|
+
writes: true,
|
|
427
|
+
}),
|
|
428
|
+
tool("undo_edit", {
|
|
429
|
+
description: "Revert an edit applied by the write tools (the latest one by default). Refuses when any of its files " +
|
|
430
|
+
"changed after the edit, so later work is never overwritten.",
|
|
431
|
+
inputSchema: { id: z.string().optional() },
|
|
432
|
+
run: async ({ id }) => webnav.undoEdit(id),
|
|
433
|
+
annotations: WRITES,
|
|
434
|
+
writes: true,
|
|
435
|
+
}),
|
|
436
|
+
// Last: the batch can reach every tool registered above and carries the strictest hints of them.
|
|
437
|
+
tool("batch", {
|
|
438
|
+
description: BATCH_DESCRIPTION,
|
|
439
|
+
inputSchema: {
|
|
440
|
+
calls: z.array(z.record(z.string(), z.unknown())),
|
|
441
|
+
stop_on_error: z.boolean().nullable().optional(),
|
|
442
|
+
},
|
|
443
|
+
run: ({ calls, stop_on_error }) => runBatch(calls, stop_on_error, registry),
|
|
444
|
+
annotations: WRITES,
|
|
445
|
+
writes: true,
|
|
446
|
+
}),
|
|
447
|
+
];
|
|
448
|
+
for (const spec of specs) {
|
|
449
|
+
server.registerTool(spec.name, {
|
|
450
|
+
description: spec.description,
|
|
451
|
+
inputSchema: spec.inputSchema,
|
|
452
|
+
annotations: spec.annotations,
|
|
453
|
+
}, async (args) => text(await spec.run(args)));
|
|
454
|
+
if (spec.name !== "batch") {
|
|
455
|
+
registry.set(spec.name, spec);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
175
458
|
return { server, webnav };
|
|
176
459
|
}
|
|
177
|
-
function registerWriteTools(server, webnav) {
|
|
178
|
-
server.registerTool("edit", {
|
|
179
|
-
description: 'Edit text in a JS/TS/HTML/CSS file, type-checked: `edit(file_path="src/app.ts", old_string="x = 1", new_string="x = 2")`.\n\n' +
|
|
180
|
-
"For changes that are not a whole definition (a few lines, an import, a rule, markup) or for rewriting a " +
|
|
181
|
-
"whole file (`new_text`, which also creates a new file). To replace, add or delete a function/class/method " +
|
|
182
|
-
"by name use `edit_symbol`. `old_string` must match exactly once unless `replace_all=true` " +
|
|
183
|
-
"(line endings follow the file). `include_dependents=false` skips checking the importers. " +
|
|
184
|
-
WRITE_RULE,
|
|
185
|
-
inputSchema: {
|
|
186
|
-
file_path: z.string().optional(),
|
|
187
|
-
old_string: z.string().optional(),
|
|
188
|
-
new_string: z.string().optional(),
|
|
189
|
-
replace_all: z.boolean().default(false),
|
|
190
|
-
new_text: z.string().optional(),
|
|
191
|
-
include_dependents: z.boolean().default(true),
|
|
192
|
-
...writeParams,
|
|
193
|
-
},
|
|
194
|
-
annotations: WRITES,
|
|
195
|
-
}, async ({ max_new_errors, ...a }) => text(await webnav.edit({
|
|
196
|
-
filePath: a.file_path,
|
|
197
|
-
oldString: a.old_string,
|
|
198
|
-
newString: a.new_string,
|
|
199
|
-
replaceAll: a.replace_all,
|
|
200
|
-
newText: a.new_text,
|
|
201
|
-
includeDependents: a.include_dependents,
|
|
202
|
-
apply: a.apply,
|
|
203
|
-
maxNewErrors: max_new_errors,
|
|
204
|
-
})));
|
|
205
|
-
server.registerTool("edit_symbol", {
|
|
206
|
-
description: "Replace, add or delete a JS/TS function, class, interface, enum, const or member by name. Examples: " +
|
|
207
|
-
'`edit_symbol(action="replace", name="Cart.total", source="total(): number { ... }")`, ' +
|
|
208
|
-
'`edit_symbol(action="insert", file_path="src/utils.ts", name="parse", source="function helper() {}")`, ' +
|
|
209
|
-
'`edit_symbol(action="delete", name="legacyParse")`.\n\n' +
|
|
210
|
-
'- `action="replace"`: `name` (dotted `Class.method` ok) and `source` (the complete new definition, ' +
|
|
211
|
-
"decorators included; re-indented to the old one's place in the file's own indent style). The doc comment " +
|
|
212
|
-
"above is kept unless `source` brings its own. A changed signature shows up as errors at call sites.\n" +
|
|
213
|
-
'- `action="insert"`: `source` and `file_path`. `position` is relative to `name`: "after" (default when ' +
|
|
214
|
-
'`name` is given) / "before" a symbol, "into" a class/interface/enum/namespace (appended to its body), or ' +
|
|
215
|
-
'"end" of the file (default without `name`; creates the file if missing). Refuses a name that already exists there.\n' +
|
|
216
|
-
'- `action="delete"`: `name` only. Refuses while anything still uses it and lists the users (`force=true` ' +
|
|
217
|
-
"deletes anyway and shows what breaks). Imports only the deleted code used are removed (`prune_imports`). " +
|
|
218
|
-
"Strings, comments and docs naming it are listed and the deletion is previewed instead of applied.\n\n" +
|
|
219
|
-
"`file_path` narrows `name` when several symbols share it. `imports` (replace/insert) is a list of " +
|
|
220
|
-
'import statements the new code needs (`["import { Money } from \\"./money\\""]`), merged into the file if missing. ' +
|
|
221
|
-
WRITE_RULE,
|
|
222
|
-
inputSchema: {
|
|
223
|
-
action: z.enum(["replace", "insert", "delete"]).optional(),
|
|
224
|
-
name: z.string().optional(),
|
|
225
|
-
source: z.string().optional(),
|
|
226
|
-
file_path: z.string().optional(),
|
|
227
|
-
position: z.enum(["after", "before", "into", "end"]).optional(),
|
|
228
|
-
imports: z.array(z.string()).optional(),
|
|
229
|
-
force: z.boolean().default(false),
|
|
230
|
-
prune_imports: z.boolean().default(true),
|
|
231
|
-
...writeParams,
|
|
232
|
-
},
|
|
233
|
-
annotations: WRITES,
|
|
234
|
-
}, async ({ max_new_errors, ...a }) => text(await webnav.editSymbol({
|
|
235
|
-
action: a.action,
|
|
236
|
-
name: a.name,
|
|
237
|
-
source: a.source,
|
|
238
|
-
filePath: a.file_path,
|
|
239
|
-
position: a.position,
|
|
240
|
-
imports: a.imports,
|
|
241
|
-
force: a.force,
|
|
242
|
-
pruneImports: a.prune_imports,
|
|
243
|
-
apply: a.apply,
|
|
244
|
-
maxNewErrors: max_new_errors,
|
|
245
|
-
})));
|
|
246
|
-
server.registerTool("rename_symbol", {
|
|
247
|
-
description: 'Rename a symbol everywhere it is used. Examples: `rename_symbol(name="Cart.total", new_name="sum")`, ' +
|
|
248
|
-
'`rename_symbol(name="--accent", new_name="--brand")`, `rename_symbol(name=".card-title", new_name="heading")`.\n\n' +
|
|
249
|
-
"JS/TS: give `name` (dotted `Class.method` ok; `file_path` narrows it) or `file_path` + `line` + `column` " +
|
|
250
|
-
"(1-indexed). Built on the TypeScript language server's rename (imports, aliases, overriding members); " +
|
|
251
|
-
'`parameter="x"` with a function\'s name renames one parameter. Places that still say the old name that ' +
|
|
252
|
-
"the type checker cannot link (strings, comments, docs, other languages) are listed.\n\n" +
|
|
253
|
-
"CSS/HTML: a `name` starting with `--`, `#` or `.` renames that custom property / id / class across " +
|
|
254
|
-
"stylesheets (declarations, `var()`, selectors), HTML (`class`, `id`, and the attributes that refer to ids: " +
|
|
255
|
-
'`for`, `aria-*`, `href="#id"`) and scripts (`getElementById`, `classList`, `querySelector`, `className`, ' +
|
|
256
|
-
'`setProperty`/`getPropertyValue`, markup inside strings). Names built dynamically (`"row-" + id`) are listed, ' +
|
|
257
|
-
"not renamed; script string literals that merely equal the name are listed and renamed only with " +
|
|
258
|
-
"`include_string_literals=true`. Refuses a new name that already exists (`force=true` merges). With several " +
|
|
259
|
-
"`WEBNAV_MCP_ROOTS` pass `file_path` to say which root. " +
|
|
260
|
-
WRITE_RULE,
|
|
261
|
-
inputSchema: {
|
|
262
|
-
new_name: z.string().optional(),
|
|
263
|
-
name: z.string().optional(),
|
|
264
|
-
file_path: z.string().optional(),
|
|
265
|
-
line: z.number().int().optional(),
|
|
266
|
-
column: z.number().int().optional(),
|
|
267
|
-
parameter: z.string().optional(),
|
|
268
|
-
force: z.boolean().default(false),
|
|
269
|
-
include_string_literals: z.boolean().default(false),
|
|
270
|
-
allow_large: z.boolean().default(false),
|
|
271
|
-
...writeParams,
|
|
272
|
-
},
|
|
273
|
-
annotations: WRITES,
|
|
274
|
-
}, async ({ max_new_errors, ...a }) => text(await webnav.renameSymbol({
|
|
275
|
-
newName: a.new_name,
|
|
276
|
-
name: a.name,
|
|
277
|
-
filePath: a.file_path,
|
|
278
|
-
line: a.line,
|
|
279
|
-
column: a.column,
|
|
280
|
-
parameter: a.parameter,
|
|
281
|
-
force: a.force,
|
|
282
|
-
includeStringLiterals: a.include_string_literals,
|
|
283
|
-
allowLarge: a.allow_large,
|
|
284
|
-
apply: a.apply,
|
|
285
|
-
maxNewErrors: max_new_errors,
|
|
286
|
-
})));
|
|
287
|
-
server.registerTool("move", {
|
|
288
|
-
description: "Move a declaration to another file, or a whole file, and fix every reference. Examples: " +
|
|
289
|
-
'`move(name="parseDate", to_file="src/app/dates.ts")`, `move(file_path="src/old/util.ts", to_file="src/lib/util.ts")`.\n\n' +
|
|
290
|
-
"With `name`: moves that top-level function/class/interface/enum/type/const (`file_path` narrows which). " +
|
|
291
|
-
"The destination is created if missing; the code gets the imports it needs (found by the language server), " +
|
|
292
|
-
'`export` is added if it was private, every `import { name } from "old"` elsewhere is repointed, the old ' +
|
|
293
|
-
"file imports it from the new place if it still uses it (`keep_reexport=true` also leaves `export { name } " +
|
|
294
|
-
'from "new"`), and imports only the moved code used are dropped. Import cycles are warned about.\n\n' +
|
|
295
|
-
"Without `name`: `file_path` is the file to move or rename (any file; `to_file` may be a directory). Every " +
|
|
296
|
-
"import/re-export in JS/TS (the language server's own rewrite), `<script src>`/`<link href>`/`<img src>` in " +
|
|
297
|
-
"HTML (relative and root-absolute), and `url()`/`@import` in CSS follow, and the moved file's own relative " +
|
|
298
|
-
"references are re-based. Configs, path aliases and string paths are listed, not rewritten. " +
|
|
299
|
-
WRITE_RULE,
|
|
300
|
-
inputSchema: {
|
|
301
|
-
to_file: z.string().optional(),
|
|
302
|
-
name: z.string().optional(),
|
|
303
|
-
file_path: z.string().optional(),
|
|
304
|
-
keep_reexport: z.boolean().default(false),
|
|
305
|
-
allow_large: z.boolean().default(false),
|
|
306
|
-
...writeParams,
|
|
307
|
-
},
|
|
308
|
-
annotations: WRITES,
|
|
309
|
-
}, async ({ max_new_errors, ...a }) => text(await webnav.move({
|
|
310
|
-
toFile: a.to_file,
|
|
311
|
-
name: a.name,
|
|
312
|
-
filePath: a.file_path,
|
|
313
|
-
keepReexport: a.keep_reexport,
|
|
314
|
-
allowLarge: a.allow_large,
|
|
315
|
-
apply: a.apply,
|
|
316
|
-
maxNewErrors: max_new_errors,
|
|
317
|
-
})));
|
|
318
|
-
server.registerTool("quick_fix", {
|
|
319
|
-
description: 'Apply the TypeScript language server\'s own fixes to a JS/TS file: `quick_fix(file_path="src/app.ts")`.\n\n' +
|
|
320
|
-
"`action=\"fix\"` (default) fixes the file's errors one by one with the server's code actions (missing " +
|
|
321
|
-
"imports, misspelled names, ...), re-checking after each; `line` limits it to errors starting on that line. " +
|
|
322
|
-
"A fix with several candidates is listed, not guessed. The other actions are source actions on the whole " +
|
|
323
|
-
"file: `organize_imports`, `remove_unused_imports`, `sort_imports`, `fix_all`. " +
|
|
324
|
-
WRITE_RULE,
|
|
325
|
-
inputSchema: {
|
|
326
|
-
file_path: z.string().optional(),
|
|
327
|
-
line: z.number().int().optional(),
|
|
328
|
-
action: z.enum(["fix", "organize_imports", "remove_unused_imports", "sort_imports", "fix_all"]).optional(),
|
|
329
|
-
...writeParams,
|
|
330
|
-
},
|
|
331
|
-
annotations: WRITES,
|
|
332
|
-
}, async ({ max_new_errors, ...a }) => text(await webnav.quickFix({
|
|
333
|
-
filePath: a.file_path,
|
|
334
|
-
line: a.line,
|
|
335
|
-
action: a.action,
|
|
336
|
-
apply: a.apply,
|
|
337
|
-
maxNewErrors: max_new_errors,
|
|
338
|
-
})));
|
|
339
|
-
server.registerTool("verify_changes", {
|
|
340
|
-
description: "What did the edits since a git revision break? Compares the language servers' diagnostics now against " +
|
|
341
|
-
"`since` (default `HEAD`).\n\nRun it after editing with any tool (your own Edit/Write included): the " +
|
|
342
|
-
"changed JS/TS/HTML/CSS files and the script files importing them are checked, with the committed versions " +
|
|
343
|
-
'substituted in for the "before" side. Read-only.',
|
|
344
|
-
inputSchema: { since: z.string().default("HEAD"), include_dependents: z.boolean().default(true) },
|
|
345
|
-
annotations: READS,
|
|
346
|
-
}, async ({ since, include_dependents }) => text(await webnav.verifyChanges({ since, includeDependents: include_dependents })));
|
|
347
|
-
server.registerTool("apply_edit", {
|
|
348
|
-
description: "Write a previewed edit (the id comes from a write tool's preview). Fails, writing nothing, if any file " +
|
|
349
|
-
"changed since the preview.",
|
|
350
|
-
inputSchema: { id: z.string().optional(), allow_large: z.boolean().default(false) },
|
|
351
|
-
annotations: WRITES,
|
|
352
|
-
}, async ({ id, allow_large }) => text(await webnav.applyEdit(id ?? "", allow_large)));
|
|
353
|
-
server.registerTool("undo_edit", {
|
|
354
|
-
description: "Revert an edit applied by the write tools (the latest one by default). Refuses when any of its files " +
|
|
355
|
-
"changed after the edit, so later work is never overwritten.",
|
|
356
|
-
inputSchema: { id: z.string().optional() },
|
|
357
|
-
annotations: WRITES,
|
|
358
|
-
}, async ({ id }) => text(await webnav.undoEdit(id)));
|
|
359
|
-
}
|
package/dist/webnav.js
CHANGED
|
@@ -71,7 +71,9 @@ export const INSTRUCTIONS = "Code navigation for this project's JS/TS/HTML/CSS,
|
|
|
71
71
|
".classes), move (a declaration or a file), quick_fix. They type-check the " +
|
|
72
72
|
"edit before writing and write it when it adds no errors (max_new_errors=0; " +
|
|
73
73
|
"apply=false only previews); verify_changes shows what the working tree " +
|
|
74
|
-
|
|
74
|
+
"broke, undo_edit reverts. To read or change several files or symbols, send them as " +
|
|
75
|
+
"one batch(calls=[{tool, args}, ...]) instead of many separate calls. " +
|
|
76
|
+
POSITION_NOTE;
|
|
75
77
|
export class Webnav {
|
|
76
78
|
selector = new WorkspaceSelector("WEBNAV_MCP_WORKSPACE");
|
|
77
79
|
notices;
|
package/dist/write/fixTools.js
CHANGED
|
@@ -126,9 +126,12 @@ export async function quickFix(host, args) {
|
|
|
126
126
|
notes.push(`not fixed: ${(d.range?.start?.line ?? 0) + 1}:${(d.range?.start?.character ?? 0) + 1} ${d.message?.split("\n")[0]} (${u.why})`);
|
|
127
127
|
}
|
|
128
128
|
if (result.applied.length === 0) {
|
|
129
|
+
// Start with "No changes" so `batch` classifies the no-op as ok (see its WRITE_OK_MARKERS).
|
|
129
130
|
return result.unfixed.length === 0
|
|
130
|
-
?
|
|
131
|
-
:
|
|
131
|
+
? `No changes: ${rel}${args.line ? `:${args.line}` : ""} has no errors to fix.`
|
|
132
|
+
: `No changes: quick fix in ${rel}${args.line ? ` (line ${args.line})` : ""} applied 0 fix(es).\n${notes
|
|
133
|
+
.map((n) => ` - ${n}`)
|
|
134
|
+
.join("\n")}`;
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
else {
|
|
@@ -140,7 +143,7 @@ export async function quickFix(host, args) {
|
|
|
140
143
|
const edits = chosen && editsForFile(chosen, file, text);
|
|
141
144
|
title = `${chosen?.title ?? action} in ${rel}`;
|
|
142
145
|
if (!chosen || !edits) {
|
|
143
|
-
return `
|
|
146
|
+
return `No changes: the language server offers nothing for ${action} in ${rel}.`;
|
|
144
147
|
}
|
|
145
148
|
next = applyEdits(text, edits);
|
|
146
149
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webnav-ts-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|