webnav-ts-mcp 0.1.1 → 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 +100 -7
- package/dist/batch.js +235 -0
- package/dist/server.js +426 -131
- package/dist/shared/lspClient.js +147 -16
- package/dist/shared/overlayGate.js +75 -0
- package/dist/shared/paths.js +34 -0
- package/dist/webIndex.js +1 -1
- package/dist/webnav.js +116 -13
- package/dist/write/applyTools.js +18 -0
- package/dist/write/common.js +30 -0
- package/dist/write/deps.js +111 -0
- package/dist/write/diagnosticsDelta.js +122 -0
- package/dist/write/editTool.js +87 -0
- package/dist/write/edits.js +337 -0
- package/dist/write/finish.js +116 -0
- package/dist/write/fixTools.js +155 -0
- package/dist/write/jsImports.js +270 -0
- package/dist/write/mentions.js +135 -0
- package/dist/write/moveTool.js +425 -0
- package/dist/write/renameTool.js +324 -0
- package/dist/write/simulate.js +146 -0
- package/dist/write/symbolLookup.js +229 -0
- package/dist/write/symbolTools.js +359 -0
- package/dist/write/transaction.js +264 -0
- package/dist/write/verifyTool.js +99 -0
- package/dist/write/webTokens.js +301 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
An MCP server that gives AI agents JS/TS/HTML/CSS navigation, plus a
|
|
4
4
|
cross-file index of CSS custom properties and `#id`/`.class` selectors that
|
|
5
|
-
single-file language servers can't provide
|
|
6
|
-
|
|
5
|
+
single-file language servers can't provide, and write tools that type-check an
|
|
6
|
+
edit before it is written. It needs only Node.js and installs with a web
|
|
7
|
+
project's own package manager.
|
|
7
8
|
|
|
8
9
|
## Quick start
|
|
9
10
|
|
|
@@ -92,6 +93,78 @@ Then use the position tools once you have a `path:line:col`:
|
|
|
92
93
|
Positions are **1-indexed**; `column` is a UTF-16 character offset (a leading
|
|
93
94
|
tab counts as one character).
|
|
94
95
|
|
|
96
|
+
## Write tools: change code with a type check before every write
|
|
97
|
+
|
|
98
|
+
The navigation tools above are read-only. These change code, and share one rule:
|
|
99
|
+
**the edit is shown to the language servers as an in-memory overlay first, the
|
|
100
|
+
edited files and the script files importing them are re-checked, and the
|
|
101
|
+
response says which diagnostics the edit would add or fix. Nothing is written
|
|
102
|
+
until that is on the table.**
|
|
103
|
+
|
|
104
|
+
| Tool | Does |
|
|
105
|
+
|------|------|
|
|
106
|
+
| `edit` | Type-checked text edit of a JS/TS/HTML/CSS file: `old_string`/`new_string` or a whole `new_text` (also creates a file). For anything that is not a whole definition. Line endings follow the file |
|
|
107
|
+
| `edit_symbol` | `action="replace"` / `"insert"` / `"delete"` a function, class, interface, enum, const or member by name: re-indented to the file's own style (tabs or spaces), doc comment kept, `imports=[...]` merged into the file, blank-line spacing handled. Delete refuses while anything uses the symbol and prunes imports only the deleted code used |
|
|
108
|
+
| `rename_symbol` | JS/TS: rename through the TypeScript language server (imports, aliases, overriding members; `parameter=` for one parameter). CSS/HTML: a name starting with `--`, `#` or `.` renames that custom property / id / class across stylesheets, `<style>` and `style=""`, markup (`class`, `id`, `for`, `aria-*`, `href="#id"`) and scripts (`getElementById`, `classList`, `querySelector`, `className`, `setProperty`, markup in strings). Names built dynamically and script strings that merely equal the name are listed, not renamed; other places still using the old name (docs, `.vue`/`.scss`, strings) are listed too |
|
|
109
|
+
| `move` | With `name`: move a top-level function/class/interface/type/const to another file with the imports it needs, repoint every importer and re-export, drop imports only the moved code used, warn about import cycles. Without it: move or rename the file `file_path` (any file): imports (the language server's own rewrite), `<script src>`/`<link href>`/`<img src>` and CSS `url()`/`@import` follow, and the moved file's own relative references are re-based |
|
|
110
|
+
| `quick_fix` | Apply the language server's own fixes (missing imports, misspellings, ...) one by one, re-checking after each; or a source action: `organize_imports`, `remove_unused_imports`, `sort_imports`, `fix_all`. Ambiguous fixes are listed instead of guessed |
|
|
111
|
+
| `verify_changes` | After editing with any tool (your own included): which diagnostics the working tree gained or lost since a git revision (default `HEAD`) |
|
|
112
|
+
| `apply_edit` / `undo_edit` | Write a previewed edit by id; revert an applied one |
|
|
113
|
+
|
|
114
|
+
One rule for every write tool: it writes when the edit adds no more than
|
|
115
|
+
`max_new_errors` (default 0) errors in the edited files and their importers and
|
|
116
|
+
the result parses. Otherwise nothing is written and you get the diagnostics plus
|
|
117
|
+
an id for `apply_edit`. `apply=false` always previews. Hints and warnings are
|
|
118
|
+
shown but not counted. Syntax errors (TypeScript's 1xxx codes, any CSS error)
|
|
119
|
+
block a write even with `max_new_errors=null`.
|
|
120
|
+
|
|
121
|
+
Safety rules, enforced in code:
|
|
122
|
+
|
|
123
|
+
- Only `.js`/`.mjs`/`.cjs`/`.jsx`/`.ts`/`.mts`/`.cts`/`.tsx`/`.html`/`.css` files inside the
|
|
124
|
+
workspace are edited (`move` also carries any file, such as an image a stylesheet
|
|
125
|
+
uses); `node_modules`, `dist`, `.git` and the other skipped folders, generated output
|
|
126
|
+
(`WEBNAV_MCP_EXCLUDE`) and symlinks are refused.
|
|
127
|
+
- A preview remembers the exact bytes of every file; `apply_edit` refuses if any file
|
|
128
|
+
changed since. Files are replaced atomically (temp file + rename), and a failure
|
|
129
|
+
part-way restores the ones already written.
|
|
130
|
+
- Every applied edit is undoable (`undo_edit`) unless a file was changed after it.
|
|
131
|
+
- Line endings and a leading BOM are preserved.
|
|
132
|
+
- Edits touching more than 40 files need `allow_large=true`.
|
|
133
|
+
- `WEBNAV_MCP_READ_ONLY=1` turns every write into a refusal (previews still work).
|
|
134
|
+
|
|
135
|
+
Scope: the importers that get re-checked are found by relative `import`/`export
|
|
136
|
+
... from`/`require()`/`import()` specifiers (`./a`, `./a.js` for `a.ts`, `./dir`
|
|
137
|
+
for `dir/index.ts`); a file reached only through a path alias is checked with its
|
|
138
|
+
own edit alone. `move`'s HTML/CSS rewrite understands relative and root-absolute
|
|
139
|
+
(`/src/main.ts`) references; bundler config, path aliases and string paths are
|
|
140
|
+
listed as mentions, not rewritten. HTML/CSS edits are checked by their language
|
|
141
|
+
servers (syntax and CSS lint), not against the cross-file index.
|
|
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
|
+
|
|
95
168
|
## Environment
|
|
96
169
|
|
|
97
170
|
| Variable | Default | Purpose |
|
|
@@ -99,6 +172,7 @@ tab counts as one character).
|
|
|
99
172
|
| `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 |
|
|
100
173
|
| `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` |
|
|
101
174
|
| `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 |
|
|
175
|
+
| `WEBNAV_MCP_READ_ONLY` | unset | `1`/`true`: write tools refuse to write (previews and checks still work) |
|
|
102
176
|
| `WEBNAV_MCP_PUBLIC` | nothing | Comma-separated workspace-relative stylesheets (files or directories) that are a public API, e.g. a design-token file consumed by other projects. `diagnostics` stops reporting their custom properties as "declared but never used" and their selectors as "never referenced"; undefined `var()` usages are still reported |
|
|
103
177
|
|
|
104
178
|
## Layout
|
|
@@ -106,13 +180,18 @@ tab counts as one character).
|
|
|
106
180
|
```
|
|
107
181
|
src/
|
|
108
182
|
cli.ts stdio entry point (exits when the host closes stdin)
|
|
109
|
-
server.ts registers the
|
|
183
|
+
server.ts registers the tools on the MCP SDK
|
|
184
|
+
batch.ts the `batch` engine: runs several registered tools in one request
|
|
110
185
|
webnav.ts the tools as plain async functions returning text; owns all state
|
|
111
186
|
webIndex.ts CSS var / selector index
|
|
112
187
|
langCommand.ts how to launch tsc / html / css language servers
|
|
113
188
|
glob.ts just enough glob for jsconfig `include`
|
|
114
189
|
shared/ lspClient, format, resolve, workspace, notices, errors,
|
|
115
190
|
params, exclude
|
|
191
|
+
write/ the write tools: edits (plans, diffs), transaction (guard,
|
|
192
|
+
journal, undo), simulate + diagnosticsDelta (the type check),
|
|
193
|
+
finish (preview/apply), one module per tool, webTokens
|
|
194
|
+
(CSS var/id/class finder), jsImports (import rewriting)
|
|
116
195
|
tests/ vitest; fixtures/sample-app is the shared fixture
|
|
117
196
|
```
|
|
118
197
|
|
|
@@ -133,10 +212,10 @@ Source: [github.com/illescasDaniel/webnav-ts-mcp](https://github.com/illescasDan
|
|
|
133
212
|
`webnav` is glue: it spawns three Node language servers and formats their answers,
|
|
134
213
|
plus a regex-grade index.
|
|
135
214
|
|
|
136
|
-
**Tests:** vitest (unit tests for the index/format code
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
and the built executable).
|
|
215
|
+
**Tests:** vitest (unit tests for the index/format code and the pure parts of the
|
|
216
|
+
write tools, real language servers against the fixture and temporary projects,
|
|
217
|
+
MCP protocol over an in-memory transport incl. worktree selection via client roots
|
|
218
|
+
with a real `git worktree`, LSP client failure modes, and the built executable).
|
|
140
219
|
|
|
141
220
|
### Things worth knowing
|
|
142
221
|
|
|
@@ -159,6 +238,20 @@ and the built executable).
|
|
|
159
238
|
aren't followed).
|
|
160
239
|
- **`callers` and `implementations`** are offered because the server advertises
|
|
161
240
|
`callHierarchyProvider` and `implementationProvider`.
|
|
241
|
+
- **Type-checking an edit without writing it** relies on documents the client
|
|
242
|
+
opens with `didOpen`/`didChange` shadowing the disk (`LspClient.withOverlay`).
|
|
243
|
+
tsc's native server resolves imports of a file that exists only as an overlay,
|
|
244
|
+
but needs its *directory* to exist and remembers a failed resolution, so the
|
|
245
|
+
overlay opens new files first and creates (then removes) empty directories.
|
|
246
|
+
- **A file with no `import`/`export` is a global script** to TypeScript. Moving
|
|
247
|
+
the last export out of a module turns it into one, and every top-level name in
|
|
248
|
+
it becomes visible everywhere; the type check can then miss references that
|
|
249
|
+
should have broken.
|
|
250
|
+
- **Symbol ranges from `documentSymbol` are tight:** no doc comment, no
|
|
251
|
+
`export const` keyword, one symbol per declarator. `edit_symbol` and `move`
|
|
252
|
+
widen them (`extentOf`) and refuse declarators that share a statement.
|
|
253
|
+
- **The server's unused-import hints identify what a deletion orphaned:** hints
|
|
254
|
+
after the edit minus hints before it, so imports that were already unused stay.
|
|
162
255
|
|
|
163
256
|
### Costs
|
|
164
257
|
|
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
|
+
}
|