@frmwrkd/snag-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 +19 -0
- package/README.md +187 -0
- package/dist/client.d.ts +544 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1074 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/dist/resources.d.ts +19 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +281 -0
- package/dist/resources.js.map +1 -0
- package/dist/tokens.d.ts +69 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +90 -0
- package/dist/tokens.js.map +1 -0
- package/dist/tools.d.ts +115 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +868 -0
- package/dist/tools.js.map +1 -0
- package/package.json +44 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five tools. Not fifty.
|
|
3
|
+
*
|
|
4
|
+
* doc 02 §9.2:
|
|
5
|
+
*
|
|
6
|
+
* > **Five tools. Not fifty.** Chrome DevTools MCP ships ~17,000 tokens of tool
|
|
7
|
+
* > definitions before a single call; that cost is paid on every turn. Stay lean.
|
|
8
|
+
*
|
|
9
|
+
* | Tool | Signature | Returns |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | `snag_list` | `(status?, wave?, limit=20)` | compact task list — id, title, severity, wave, first suspect file |
|
|
12
|
+
* | `snag_get` | `(id)` | full task via `structuredContent` + `resource_link`s to screenshots on disk |
|
|
13
|
+
* | `snag_claim` | `(id)` | marks `claimed`, returns the full task |
|
|
14
|
+
* | `snag_resolve` | `(id, commit?, note?)` | asks snagd's verify preflight, then marks `verifying` and queues a verify run — **never `resolved` directly** |
|
|
15
|
+
* | `snag_screenshot` | `(id)` | image content — **only when explicitly called** |
|
|
16
|
+
*
|
|
17
|
+
* ## Three rules encoded here, each with a test guarding it
|
|
18
|
+
*
|
|
19
|
+
* 1. **`TOOLS.length === 5`.** A sixth tool costs every turn of every session
|
|
20
|
+
* in this repo forever. `test/tools.test.ts` fails loudly if one appears.
|
|
21
|
+
* 2. **`snag_get` never inlines base64** (locked decision D13). Screenshots go
|
|
22
|
+
* out as `resource_link`s with `file://` paths; Claude Code can `Read` a
|
|
23
|
+
* local PNG directly, which sidesteps the 25,000-token output cap entirely.
|
|
24
|
+
* 3. **`snag_resolve` never sets `resolved`** while Verify is enabled (doc 02
|
|
25
|
+
* §5.3: "`resolved` is **only ever set by Verify**, never by the agent
|
|
26
|
+
* directly"). The one documented exception — `plan.verify_on_resolve:
|
|
27
|
+
* false` — is what snagd's verify preflight reports as `verify_disabled`,
|
|
28
|
+
* and it stamps `verified: false` through the daemon's transition table.
|
|
29
|
+
*
|
|
30
|
+
* ## Why `content` is not a JSON copy of `structuredContent`
|
|
31
|
+
*
|
|
32
|
+
* The MCP spec says a tool returning structured content SHOULD also return the
|
|
33
|
+
* serialised JSON as a text block, for clients that predate `structuredContent`.
|
|
34
|
+
* Doing that doubles every payload, and doubling payloads is the exact failure
|
|
35
|
+
* mode this package exists to avoid. So `content` instead carries a short,
|
|
36
|
+
* denser text block containing the parts an agent acts on **verbatim** — the
|
|
37
|
+
* `suspect_files` paths and line ranges — plus a pointer to
|
|
38
|
+
* `.snag/tasks/<label> - <title>.md`, which has the rest with no cap, no
|
|
39
|
+
* protocol overhead, and full prompt-cache benefit (doc 02 §9.1).
|
|
40
|
+
*/
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import { pathToFileURL } from 'node:url';
|
|
43
|
+
import { SeveritySchema, TASK_STATUSES, TaskSchema, TaskStatusSchema, VISUAL_KINDS, findTransition, isTaskLabel, isTaskUid, isTerminalStatus, toInlineJsonSchema, } from '@frmwrkd/snag-core';
|
|
44
|
+
import { z } from 'zod';
|
|
45
|
+
import { DaemonRequestError, DaemonUnavailableError } from './client.js';
|
|
46
|
+
import { IMAGE_VISUAL_TOKENS_STANDARD } from './tokens.js';
|
|
47
|
+
/* ------------------------------------------------------------ shared bits */
|
|
48
|
+
/**
|
|
49
|
+
* `SNAG-<n>` or `tsk_<ulid>` — the daemon's `GET /tasks/:id` accepts either.
|
|
50
|
+
*
|
|
51
|
+
* Validated with `.refine` rather than `.regex` on purpose: a `refine` leaves
|
|
52
|
+
* the emitted JSON Schema as a bare `{"type":"string"}` while still rejecting
|
|
53
|
+
* malformed ids server-side. The format belongs in the tool description, which
|
|
54
|
+
* the model reads anyway; a `pattern` keyword would be ~50 characters of
|
|
55
|
+
* always-on cost for a hint the description already gives.
|
|
56
|
+
*/
|
|
57
|
+
const TaskRefSchema = z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.refine((value) => isTaskLabel(value) || isTaskUid(value), {
|
|
61
|
+
message: 'Expected a task label (SNAG-<n>) or a task uid (tsk_<ulid>)',
|
|
62
|
+
});
|
|
63
|
+
/** Belt-and-braces on the list size. 50 summaries is ~2,400 tokens. */
|
|
64
|
+
export const LIST_LIMIT_DEFAULT = 20;
|
|
65
|
+
export const LIST_LIMIT_MAX = 50;
|
|
66
|
+
/**
|
|
67
|
+
* Refuse to inline a screenshot larger than this. doc 03 §4.4 records that the
|
|
68
|
+
* MCP spec gives **no** explicit byte limit on image content and marks the real
|
|
69
|
+
* binding constraint **[U]**; the provider limit is 10 MB of base64. 3.5 MB of
|
|
70
|
+
* PNG is ~4.7 MB base64, comfortably inside that, and anything bigger is a
|
|
71
|
+
* full-page capture that the agent should `Read` from disk instead.
|
|
72
|
+
*/
|
|
73
|
+
export const SCREENSHOT_MAX_INLINE_BYTES = 3_500_000;
|
|
74
|
+
/* ---------------------------------------------------- compact projections */
|
|
75
|
+
/**
|
|
76
|
+
* doc 02 §9.2: "compact task list — id, title, severity, wave, first suspect
|
|
77
|
+
* file". `uid` is deliberately absent: it is 30 characters × every row, and
|
|
78
|
+
* every tool here accepts the `SNAG-<n>` label instead. `kind` is one token and
|
|
79
|
+
* tells the agent whether `snag_screenshot` is worth calling at all.
|
|
80
|
+
*/
|
|
81
|
+
const TaskSummarySchema = z.object({
|
|
82
|
+
id: z.string(),
|
|
83
|
+
title: z.string(),
|
|
84
|
+
severity: SeveritySchema,
|
|
85
|
+
kind: z.string(),
|
|
86
|
+
wave: z.number().int().nonnegative(),
|
|
87
|
+
status: TaskStatusSchema,
|
|
88
|
+
/** VERBATIM. The first `suspect_files` entry — never paraphrased. */
|
|
89
|
+
suspect_file: z.string().nullable(),
|
|
90
|
+
suspect_lines: z.tuple([z.number().int(), z.number().int()]).nullable(),
|
|
91
|
+
});
|
|
92
|
+
export function summarise(task) {
|
|
93
|
+
const first = task.suspect_files[0];
|
|
94
|
+
return {
|
|
95
|
+
id: task.id,
|
|
96
|
+
title: task.title,
|
|
97
|
+
severity: task.severity,
|
|
98
|
+
kind: task.kind,
|
|
99
|
+
wave: task.wave,
|
|
100
|
+
status: task.status,
|
|
101
|
+
suspect_file: first?.path ?? null,
|
|
102
|
+
suspect_lines: first?.lines ?? null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const SourceSchema = z.enum(['daemon', 'files']);
|
|
106
|
+
/* ----------------------------------------------------------- I/O schemas */
|
|
107
|
+
const ListInputSchema = z.object({
|
|
108
|
+
status: TaskStatusSchema.optional(),
|
|
109
|
+
wave: z.number().int().nonnegative().optional(),
|
|
110
|
+
limit: z.number().int().positive().max(LIST_LIMIT_MAX).default(LIST_LIMIT_DEFAULT),
|
|
111
|
+
});
|
|
112
|
+
const ListOutputSchema = z.object({
|
|
113
|
+
tasks: z.array(TaskSummarySchema),
|
|
114
|
+
returned: z.number().int().nonnegative(),
|
|
115
|
+
/** How many matched before `limit` was applied. */
|
|
116
|
+
total: z.number().int().nonnegative(),
|
|
117
|
+
source: SourceSchema,
|
|
118
|
+
/** One line. Which expensive call is worth making next. */
|
|
119
|
+
hint: z.string(),
|
|
120
|
+
});
|
|
121
|
+
const GetInputSchema = z.object({ id: TaskRefSchema });
|
|
122
|
+
/**
|
|
123
|
+
* `GET /verify/:taskId`, projected. Present on `snag_get` once Verify has been
|
|
124
|
+
* involved — the task is `verifying`, a run is in flight, or one has settled
|
|
125
|
+
* since snagd started. `reason` is the daemon's, verbatim.
|
|
126
|
+
*/
|
|
127
|
+
const VerifySummarySchema = z.object({
|
|
128
|
+
running: z.boolean(),
|
|
129
|
+
outcome: z.string().nullable(),
|
|
130
|
+
reason: z.string().nullable(),
|
|
131
|
+
last_at: z.string().nullable(),
|
|
132
|
+
});
|
|
133
|
+
const TaskDetailSchema = z.object({
|
|
134
|
+
task: TaskSchema,
|
|
135
|
+
/** Repo-relative markdown path — `Read` this, it has no output cap. */
|
|
136
|
+
markdown_file: z.string().nullable(),
|
|
137
|
+
/** Repo-relative screenshot paths. Also emitted as `resource_link`s. */
|
|
138
|
+
screenshots: z.array(z.string()),
|
|
139
|
+
source: SourceSchema,
|
|
140
|
+
/** Jam's `getDetails` pattern: which expensive calls are worth making. */
|
|
141
|
+
next_actions: z.array(z.string()),
|
|
142
|
+
verify: VerifySummarySchema.optional(),
|
|
143
|
+
});
|
|
144
|
+
const ClaimOutputSchema = TaskDetailSchema.extend({
|
|
145
|
+
previous_status: TaskStatusSchema,
|
|
146
|
+
});
|
|
147
|
+
const ResolveInputSchema = z.object({
|
|
148
|
+
id: TaskRefSchema,
|
|
149
|
+
/**
|
|
150
|
+
* A sha you already committed, or `true` to have snagd commit the fix now.
|
|
151
|
+
*
|
|
152
|
+
* doc 02 §9.2 forbids a sixth tool — the toolset is a permanent per-turn tax —
|
|
153
|
+
* so committing is a parameter of the call that already records a commit,
|
|
154
|
+
* rather than a `snag_commit` of its own.
|
|
155
|
+
*/
|
|
156
|
+
commit: z.union([z.string(), z.boolean()]).optional(),
|
|
157
|
+
/** With `commit: true`: the repo-relative paths you edited for this task. */
|
|
158
|
+
files: z.array(z.string().min(1)).optional(),
|
|
159
|
+
note: z.string().optional(),
|
|
160
|
+
});
|
|
161
|
+
const ResolveOutputSchema = z.object({
|
|
162
|
+
id: z.string(),
|
|
163
|
+
uid: z.string(),
|
|
164
|
+
/**
|
|
165
|
+
* `verifying` — or `resolved` **only** when snagd has Verify disabled, or
|
|
166
|
+
* where it was left (`in_progress`, usually) when Verify cannot run on it.
|
|
167
|
+
*/
|
|
168
|
+
status: TaskStatusSchema,
|
|
169
|
+
verify_queued: z.boolean(),
|
|
170
|
+
/** `false` records a resolve taken on trust with Verify disabled (doc 02 §5.3). */
|
|
171
|
+
verified: z.boolean().nullable(),
|
|
172
|
+
detail: z.string(),
|
|
173
|
+
});
|
|
174
|
+
const ScreenshotInputSchema = z.object({
|
|
175
|
+
id: TaskRefSchema,
|
|
176
|
+
/** Which screenshot, when a task merged several captures. */
|
|
177
|
+
index: z.number().int().nonnegative().default(0),
|
|
178
|
+
});
|
|
179
|
+
const READ_ONLY = {
|
|
180
|
+
readOnlyHint: true,
|
|
181
|
+
openWorldHint: false,
|
|
182
|
+
};
|
|
183
|
+
const MUTATING = {
|
|
184
|
+
readOnlyHint: false,
|
|
185
|
+
destructiveHint: false,
|
|
186
|
+
idempotentHint: true,
|
|
187
|
+
openWorldHint: false,
|
|
188
|
+
};
|
|
189
|
+
/* ------------------------------------------------------------- helpers */
|
|
190
|
+
function textBlock(text) {
|
|
191
|
+
return { type: 'text', text };
|
|
192
|
+
}
|
|
193
|
+
function errorResult(message) {
|
|
194
|
+
return { content: [textBlock(message)], isError: true };
|
|
195
|
+
}
|
|
196
|
+
/** Turn a thrown daemon error into an `isError` result the agent can act on. */
|
|
197
|
+
function daemonErrorResult(error) {
|
|
198
|
+
if (error instanceof DaemonUnavailableError)
|
|
199
|
+
return errorResult(error.message);
|
|
200
|
+
if (error instanceof DaemonRequestError) {
|
|
201
|
+
return errorResult(`snagd refused (${error.status} ${error.code}): ${error.message}`);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
function notFound(id) {
|
|
206
|
+
return errorResult(`No task ${id}. Call snag_list first, or Read .snag/tasks/ — ids look like SNAG-3 or tsk_<ulid>.`);
|
|
207
|
+
}
|
|
208
|
+
/** Repo-relative screenshot paths that actually exist on disk. */
|
|
209
|
+
function existingScreenshots(task, backend) {
|
|
210
|
+
const present = [];
|
|
211
|
+
const missing = [];
|
|
212
|
+
for (const relative of task.evidence_refs.screenshots) {
|
|
213
|
+
const absolute = backend.files.resolveInsideRoot(relative);
|
|
214
|
+
if (absolute !== null && backend.files.readBytes(relative) !== null)
|
|
215
|
+
present.push(relative);
|
|
216
|
+
else
|
|
217
|
+
missing.push(relative);
|
|
218
|
+
}
|
|
219
|
+
return { present, missing };
|
|
220
|
+
}
|
|
221
|
+
function resourceLink(backend, relative) {
|
|
222
|
+
const absolute = backend.files.resolveInsideRoot(relative);
|
|
223
|
+
if (absolute === null)
|
|
224
|
+
return null;
|
|
225
|
+
return {
|
|
226
|
+
type: 'resource_link',
|
|
227
|
+
uri: pathToFileURL(absolute).href,
|
|
228
|
+
name: path.basename(relative),
|
|
229
|
+
mimeType: 'image/png',
|
|
230
|
+
description: `Screenshot on disk — Read it directly (~${IMAGE_VISUAL_TOKENS_STANDARD} visual tokens).`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const isVisualKind = (kind) => VISUAL_KINDS.includes(kind);
|
|
234
|
+
/**
|
|
235
|
+
* Jam's `getDetails` pattern (doc 02 §9.2): one cheap call tells the agent which
|
|
236
|
+
* expensive calls are worth making. D13 is the rule being applied — a
|
|
237
|
+
* screenshot is ~1,568 visual tokens ≈ 1,200 words, worth it for a layout bug
|
|
238
|
+
* and wasteful for "button does nothing".
|
|
239
|
+
*/
|
|
240
|
+
function nextActions(task, markdownFile, screenshots, verify = null) {
|
|
241
|
+
const actions = [];
|
|
242
|
+
if (verify !== null) {
|
|
243
|
+
const action = verifyAction(task, verify);
|
|
244
|
+
if (action !== null)
|
|
245
|
+
actions.push(action);
|
|
246
|
+
}
|
|
247
|
+
if (markdownFile !== null) {
|
|
248
|
+
actions.push(`Read ${markdownFile} — full repro/expected/actual/acceptance, no output cap.`);
|
|
249
|
+
}
|
|
250
|
+
if (task.status === 'open') {
|
|
251
|
+
actions.push(`snag_claim("${task.id}") before you edit anything.`);
|
|
252
|
+
}
|
|
253
|
+
if (task.depends_on.length > 0) {
|
|
254
|
+
actions.push(`Blocked by ${task.depends_on.length} task(s): ${task.depends_on.join(', ')} — snag_get accepts uids.`);
|
|
255
|
+
}
|
|
256
|
+
if (screenshots.length > 0) {
|
|
257
|
+
actions.push(isVisualKind(task.kind)
|
|
258
|
+
? `snag_screenshot("${task.id}") — kind is "${task.kind}", the pixels are the evidence.`
|
|
259
|
+
: `Screenshot exists but kind is "${task.kind}"; the anchors above are cheaper than ~${IMAGE_VISUAL_TOKENS_STANDARD} visual tokens.`);
|
|
260
|
+
}
|
|
261
|
+
if (task.suspect_files.length > 0) {
|
|
262
|
+
actions.push('Open the suspect_files at the given line ranges. Do not Grep for a file already named there.');
|
|
263
|
+
}
|
|
264
|
+
return actions;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* What to do about Verify, given where it left the task. The retry advice is
|
|
268
|
+
* reserved for the cases a retry can fix: nothing queued, or a run that could
|
|
269
|
+
* not drive the browser. A task Verify could not judge goes to a human.
|
|
270
|
+
*/
|
|
271
|
+
function verifyAction(task, verify) {
|
|
272
|
+
if (verify.running) {
|
|
273
|
+
return `Verify is running in Chrome — snag_get("${task.id}") again in ~30 s. It is not done yet.`;
|
|
274
|
+
}
|
|
275
|
+
if (task.status === 'verifying') {
|
|
276
|
+
if (verify.outcome === null) {
|
|
277
|
+
return `No verify run is in flight and none has finished since snagd started — snag_resolve("${task.id}") queues one.`;
|
|
278
|
+
}
|
|
279
|
+
if (verify.outcome === 'error') {
|
|
280
|
+
return `The last verify run could not finish (reason above). Once that is fixed, snag_resolve("${task.id}") queues another.`;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (task.status === 'needs_review') {
|
|
285
|
+
return `Verify could not judge this fix (reason above). A human reviews it — do not call snag_resolve on it again.`;
|
|
286
|
+
}
|
|
287
|
+
if (task.status === 'open' && verify.outcome === 'failed') {
|
|
288
|
+
return `Verify re-opened it (reason above) and attached fresh evidence to the task. Read that before editing again.`;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
/** One line on Verify for the text block. Reason verbatim — it quotes console errors. */
|
|
293
|
+
function verifyLine(verify) {
|
|
294
|
+
const last = verify.outcome === null
|
|
295
|
+
? 'no run has finished since snagd started'
|
|
296
|
+
: `last run ${verify.outcome}${verify.last_at === null ? '' : ` at ${verify.last_at}`}${verify.reason === null ? '' : ` — ${verify.reason}`}`;
|
|
297
|
+
return `verify: ${verify.running ? 'a run is in flight; ' : ''}${last}`;
|
|
298
|
+
}
|
|
299
|
+
/** The dense text block that accompanies a task's `structuredContent`. */
|
|
300
|
+
function taskTextBlock(task, markdownFile, source, verify = null) {
|
|
301
|
+
const lines = [
|
|
302
|
+
`${task.id} · ${task.severity} · ${task.kind} · wave ${task.wave} · ${task.status}`,
|
|
303
|
+
task.title,
|
|
304
|
+
];
|
|
305
|
+
if (verify !== null)
|
|
306
|
+
lines.push(verifyLine(verify));
|
|
307
|
+
if (task.suspect_files.length > 0) {
|
|
308
|
+
lines.push('suspect_files (verbatim edit anchors):');
|
|
309
|
+
for (const file of task.suspect_files) {
|
|
310
|
+
// A path with no range still tells the agent which file to open, which is
|
|
311
|
+
// strictly better than omitting the entry — and it is honest about the
|
|
312
|
+
// precision it has, rather than inventing a range.
|
|
313
|
+
const where = file.lines === undefined ? file.path : `${file.path}:${file.lines[0]}-${file.lines[1]}`;
|
|
314
|
+
lines.push(` ${where} — ${file.why}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
lines.push('suspect_files: none — this task has no anchor yet.');
|
|
319
|
+
}
|
|
320
|
+
if (markdownFile !== null)
|
|
321
|
+
lines.push(`markdown: ${markdownFile}`);
|
|
322
|
+
if (source === 'files') {
|
|
323
|
+
lines.push('source: .snag/plan.json (snagd is not running) — status may lag the live index.');
|
|
324
|
+
}
|
|
325
|
+
return lines.join('\n');
|
|
326
|
+
}
|
|
327
|
+
function detailResult(task, markdownFile, source, backend, options = {}) {
|
|
328
|
+
const { extra = {}, leadingText, verify = null } = options;
|
|
329
|
+
const { present, missing } = existingScreenshots(task, backend);
|
|
330
|
+
const actions = nextActions(task, markdownFile, present, verify);
|
|
331
|
+
const content = [];
|
|
332
|
+
if (leadingText !== undefined)
|
|
333
|
+
content.push(textBlock(leadingText));
|
|
334
|
+
content.push(textBlock(taskTextBlock(task, markdownFile, source, verify)));
|
|
335
|
+
for (const relative of present) {
|
|
336
|
+
const link = resourceLink(backend, relative);
|
|
337
|
+
if (link !== null)
|
|
338
|
+
content.push(link);
|
|
339
|
+
}
|
|
340
|
+
if (missing.length > 0) {
|
|
341
|
+
content.push(textBlock(`Screenshot paths recorded but missing on disk: ${missing.join(', ')}`));
|
|
342
|
+
}
|
|
343
|
+
if (actions.length > 0)
|
|
344
|
+
content.push(textBlock(`Next: ${actions.join(' · ')}`));
|
|
345
|
+
return {
|
|
346
|
+
content,
|
|
347
|
+
structuredContent: {
|
|
348
|
+
task,
|
|
349
|
+
markdown_file: markdownFile,
|
|
350
|
+
screenshots: present,
|
|
351
|
+
source,
|
|
352
|
+
next_actions: actions,
|
|
353
|
+
...(verify === null ? {} : { verify }),
|
|
354
|
+
...extra,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/* ---------------------------------------------------------------- tools */
|
|
359
|
+
const snagList = {
|
|
360
|
+
name: 'snag_list',
|
|
361
|
+
title: 'List Snag tasks',
|
|
362
|
+
description: 'Compact queue of Snag tasks: id, title, severity (p0 most severe), kind, wave, status, and the first suspect file with its line range. Defaults to non-terminal tasks in wave order. Start here, then snag_get only the ones you will work.',
|
|
363
|
+
inputSchema: ListInputSchema,
|
|
364
|
+
outputSchema: ListOutputSchema,
|
|
365
|
+
annotations: READ_ONLY,
|
|
366
|
+
async handle(args, backend) {
|
|
367
|
+
const input = ListInputSchema.parse(args);
|
|
368
|
+
const limit = Math.min(input.limit, LIST_LIMIT_MAX);
|
|
369
|
+
let result;
|
|
370
|
+
try {
|
|
371
|
+
// The daemon filters on a single status; "everything not finished" is a
|
|
372
|
+
// set, so over-fetch and narrow here. The over-fetch is bounded.
|
|
373
|
+
result = await backend.listTasks({
|
|
374
|
+
...(input.status === undefined ? {} : { status: input.status }),
|
|
375
|
+
...(input.wave === undefined ? {} : { wave: input.wave }),
|
|
376
|
+
limit: input.status === undefined ? Math.min(limit * 3, 150) : limit,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return daemonErrorResult(error);
|
|
381
|
+
}
|
|
382
|
+
const matched = input.status === undefined
|
|
383
|
+
? result.tasks.filter((task) => !isTerminalStatus(task.status))
|
|
384
|
+
: result.tasks;
|
|
385
|
+
const tasks = matched.slice(0, limit).map(summarise);
|
|
386
|
+
const openInFirstWave = matched.filter((task) => task.wave === (matched[0]?.wave ?? 0) && task.status === 'open').length;
|
|
387
|
+
const hint = matched.length === 0
|
|
388
|
+
? 'Queue is empty. Nothing to work.'
|
|
389
|
+
: `${openInFirstWave} open in wave ${matched[0]?.wave ?? 0}. snag_get one for repro + suspect_files, then snag_claim it.`;
|
|
390
|
+
const structuredContent = {
|
|
391
|
+
tasks,
|
|
392
|
+
returned: tasks.length,
|
|
393
|
+
total: matched.length,
|
|
394
|
+
source: result.source,
|
|
395
|
+
hint,
|
|
396
|
+
};
|
|
397
|
+
return {
|
|
398
|
+
content: [
|
|
399
|
+
textBlock(formatListText(tasks, structuredContent.total, result.source, hint,
|
|
400
|
+
// Only ever non-null when plan.json is present AND unparseable, so
|
|
401
|
+
// this cannot turn a genuinely clear queue into a scary message.
|
|
402
|
+
tasks.length === 0 ? backend.files.emptyTasksReason() : null)),
|
|
403
|
+
],
|
|
404
|
+
structuredContent,
|
|
405
|
+
};
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
function formatListText(tasks, total, source, hint,
|
|
409
|
+
/** Set when the list is empty *because the plan could not be read*. */
|
|
410
|
+
emptyReason = null) {
|
|
411
|
+
if (tasks.length === 0) {
|
|
412
|
+
// An unreadable plan is not an empty queue, and must never be reported as
|
|
413
|
+
// one — that tells the agent to stop when it should be telling someone to
|
|
414
|
+
// look at the file.
|
|
415
|
+
if (emptyReason !== null)
|
|
416
|
+
return emptyReason;
|
|
417
|
+
return source === 'files'
|
|
418
|
+
? 'No tasks in .snag/plan.json (snagd is not running). Run Merge & Plan, or Read .snag/tasks/.'
|
|
419
|
+
: 'No tasks match. The queue is clear.';
|
|
420
|
+
}
|
|
421
|
+
const rows = tasks.map((task) => {
|
|
422
|
+
const anchor = task.suspect_file === null
|
|
423
|
+
? 'no anchor'
|
|
424
|
+
: `${task.suspect_file}${task.suspect_lines === null ? '' : `:${task.suspect_lines[0]}-${task.suspect_lines[1]}`}`;
|
|
425
|
+
return `${task.id}\t${task.severity}\tw${task.wave}\t${task.status}\t${task.title}\t${anchor}`;
|
|
426
|
+
});
|
|
427
|
+
const header = source === 'files'
|
|
428
|
+
? `${tasks.length}/${total} tasks from .snag/plan.json (snagd is not running; statuses may lag)`
|
|
429
|
+
: `${tasks.length}/${total} tasks`;
|
|
430
|
+
return `${header}\nid\tsev\twave\tstatus\ttitle\tsuspect_file:lines\n${rows.join('\n')}\n${hint}`;
|
|
431
|
+
}
|
|
432
|
+
const snagGet = {
|
|
433
|
+
name: 'snag_get',
|
|
434
|
+
title: 'Get one Snag task',
|
|
435
|
+
description: 'One task in full, by SNAG-<n> or tsk_<ulid>: repro, expected, actual, acceptance criteria, and suspect_files with verbatim paths and line ranges. Screenshots come back as resource_links to files on disk, never inlined. After snag_resolve, `verify` says whether Verify is running and how its last run ended.',
|
|
436
|
+
inputSchema: GetInputSchema,
|
|
437
|
+
outputSchema: TaskDetailSchema,
|
|
438
|
+
annotations: READ_ONLY,
|
|
439
|
+
async handle(args, backend) {
|
|
440
|
+
const input = GetInputSchema.parse(args);
|
|
441
|
+
let found;
|
|
442
|
+
try {
|
|
443
|
+
found = await backend.getTask(input.id);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
return daemonErrorResult(error);
|
|
447
|
+
}
|
|
448
|
+
if (found === null)
|
|
449
|
+
return notFound(input.id);
|
|
450
|
+
// snag_resolve returns before Verify has run, and tells the agent to come
|
|
451
|
+
// back here. The status alone cannot say "still running" from "ran, and
|
|
452
|
+
// could not judge it", so ask the daemon, which keeps the last report.
|
|
453
|
+
// Shown only once Verify is involved: a task that never met it gets
|
|
454
|
+
// nothing, rather than a block of nulls.
|
|
455
|
+
let verify = null;
|
|
456
|
+
if (found.source === 'daemon') {
|
|
457
|
+
const status = await backend.verifyStatus(found.task.uid);
|
|
458
|
+
if (status !== null &&
|
|
459
|
+
(status.running || status.outcome !== null || found.task.status === 'verifying')) {
|
|
460
|
+
verify = status;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return detailResult(found.task, found.file, found.source, backend, { verify });
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
const snagClaim = {
|
|
467
|
+
name: 'snag_claim',
|
|
468
|
+
title: 'Claim a Snag task',
|
|
469
|
+
description: 'Take ownership of a task: status open -> claimed. Returns the full task. Claim before editing so a second agent in the same wave does not pick it up.',
|
|
470
|
+
inputSchema: GetInputSchema,
|
|
471
|
+
outputSchema: ClaimOutputSchema,
|
|
472
|
+
annotations: MUTATING,
|
|
473
|
+
async handle(args, backend) {
|
|
474
|
+
const input = GetInputSchema.parse(args);
|
|
475
|
+
let found;
|
|
476
|
+
try {
|
|
477
|
+
found = await backend.getTask(input.id);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
return daemonErrorResult(error);
|
|
481
|
+
}
|
|
482
|
+
if (found === null)
|
|
483
|
+
return notFound(input.id);
|
|
484
|
+
const previous = found.task.status;
|
|
485
|
+
if (previous === 'claimed' || previous === 'in_progress') {
|
|
486
|
+
return detailResult(found.task, found.file, found.source, backend, {
|
|
487
|
+
extra: { previous_status: previous },
|
|
488
|
+
leadingText: `${found.task.id} is already ${previous}; nothing to do.`,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
let claimed;
|
|
492
|
+
try {
|
|
493
|
+
claimed = await backend.setStatus(found.task.id, 'claimed');
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
return daemonErrorResult(error);
|
|
497
|
+
}
|
|
498
|
+
return detailResult(claimed, found.file, 'daemon', backend, {
|
|
499
|
+
extra: { previous_status: previous },
|
|
500
|
+
});
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* The transitions `snag_resolve` may take, in order, from wherever the task is.
|
|
505
|
+
*
|
|
506
|
+
* doc 02 §5.3's table only has `in_progress -> verifying`. The step into
|
|
507
|
+
* `in_progress` is normally made by the `PostToolUse` hook on the first
|
|
508
|
+
* `Edit`/`Write` of a `suspect_files` path — but the hook is optional, and a
|
|
509
|
+
* task stuck at `claimed` with the work already done must still be resolvable.
|
|
510
|
+
* So a `claimed` task is walked through `in_progress` first. Both hops go
|
|
511
|
+
* through the daemon, which enforces the table; nothing here bypasses it.
|
|
512
|
+
*/
|
|
513
|
+
function resolvePath(from, target) {
|
|
514
|
+
if (from === 'claimed')
|
|
515
|
+
return ['in_progress', target];
|
|
516
|
+
if (from === 'open')
|
|
517
|
+
return ['claimed', 'in_progress', target];
|
|
518
|
+
return [target];
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Whether `resolvePath` from `from` reaches `target` using only edges an agent
|
|
522
|
+
* may take — in doc 02 §5.3's table, and neither `user_only` nor
|
|
523
|
+
* `verify_only`. A `condition` is left to the daemon, which owns the config.
|
|
524
|
+
* Asked *before* moving anything, so a walk that would stop half-way is never
|
|
525
|
+
* started.
|
|
526
|
+
*/
|
|
527
|
+
function agentCanReach(from, target) {
|
|
528
|
+
let at = from;
|
|
529
|
+
for (const step of resolvePath(from, target)) {
|
|
530
|
+
if (step === at)
|
|
531
|
+
continue;
|
|
532
|
+
const transition = findTransition(at, step);
|
|
533
|
+
if (transition === undefined || transition.user_only === true || transition.verify_only === true) {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
at = step;
|
|
537
|
+
}
|
|
538
|
+
return at === target;
|
|
539
|
+
}
|
|
540
|
+
/** Walk `task` along `resolvePath` through the daemon. Throws what `setStatus` throws. */
|
|
541
|
+
async function walkTo(task, target, backend) {
|
|
542
|
+
let current = task;
|
|
543
|
+
for (const step of resolvePath(current.status, target)) {
|
|
544
|
+
if (current.status === step)
|
|
545
|
+
continue;
|
|
546
|
+
current = await backend.setStatus(current.id, step);
|
|
547
|
+
}
|
|
548
|
+
return current;
|
|
549
|
+
}
|
|
550
|
+
function resolveResult(task, verifyQueued, detail) {
|
|
551
|
+
return {
|
|
552
|
+
content: [textBlock(detail)],
|
|
553
|
+
structuredContent: {
|
|
554
|
+
id: task.id,
|
|
555
|
+
uid: task.uid,
|
|
556
|
+
status: task.status,
|
|
557
|
+
verify_queued: verifyQueued,
|
|
558
|
+
verified: task.verified ?? null,
|
|
559
|
+
detail,
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* doc 02 §5.3: "If Verify is disabled in config, `snag_resolve` moves straight
|
|
565
|
+
* to `resolved` and records `verified: false`." That is the ONLY path on which
|
|
566
|
+
* this tool may name `resolved`, it is taken only on snagd's own word that
|
|
567
|
+
* Verify is off, and the daemon still gates the edge on
|
|
568
|
+
* `plan.verify_on_resolve === false`.
|
|
569
|
+
*/
|
|
570
|
+
async function resolveWithoutVerify(task, preflight, backend) {
|
|
571
|
+
if (!agentCanReach(task.status, 'resolved')) {
|
|
572
|
+
return resolveResult(task, false, `${task.id} is ${task.status} and was left there. snagd has Verify disabled (${preflight.message}), ` +
|
|
573
|
+
`and without Verify a task is resolved directly only from in_progress (doc 02 §5.3). ` +
|
|
574
|
+
`Verify cannot run for this task, so a human must resolve it.`);
|
|
575
|
+
}
|
|
576
|
+
const was = task.status;
|
|
577
|
+
let resolved;
|
|
578
|
+
try {
|
|
579
|
+
resolved = await walkTo(task, 'resolved', backend);
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
return daemonErrorResult(error);
|
|
583
|
+
}
|
|
584
|
+
return resolveResult(resolved, false, `${resolved.id} ${was === 'resolved' ? 'was already' : 'is now'} resolved, without Verify: snagd has it disabled (${preflight.message}). ` +
|
|
585
|
+
`It is recorded verified: false — taken on trust, not checked.`);
|
|
586
|
+
}
|
|
587
|
+
const snagResolve = {
|
|
588
|
+
name: 'snag_resolve',
|
|
589
|
+
title: 'Mark a Snag task fixed',
|
|
590
|
+
description: 'Claim that a task is fixed: status -> verifying, and a verify run is queued. It does NOT mark the task resolved: only Verify does, after re-opening the page, re-resolving the anchor and checking the console errors the capture recorded. A fix Verify cannot judge goes to needs_review; a task Verify cannot run on is left for a human. Pass commit: true with files: [paths you edited] and snagd commits them first — exactly those paths, never `git add -A`, never a push — and records the sha against the task. Pass a sha instead if you committed yourself.',
|
|
591
|
+
inputSchema: ResolveInputSchema,
|
|
592
|
+
outputSchema: ResolveOutputSchema,
|
|
593
|
+
annotations: MUTATING,
|
|
594
|
+
async handle(args, backend) {
|
|
595
|
+
const input = ResolveInputSchema.parse(args);
|
|
596
|
+
let found;
|
|
597
|
+
try {
|
|
598
|
+
found = await backend.getTask(input.id);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
return daemonErrorResult(error);
|
|
602
|
+
}
|
|
603
|
+
if (found === null)
|
|
604
|
+
return notFound(input.id);
|
|
605
|
+
// Ask snagd whether Verify can run on this task at all, BEFORE any
|
|
606
|
+
// transition. `verifying` has no exit an agent can take: a task parked
|
|
607
|
+
// there with no URL to re-visit — an authored task — or with Verify
|
|
608
|
+
// switched off stays there forever, looking like progress.
|
|
609
|
+
let preflight;
|
|
610
|
+
try {
|
|
611
|
+
preflight = await backend.verifyPreflight(found.task.uid);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
return daemonErrorResult(error);
|
|
615
|
+
}
|
|
616
|
+
let task = found.task;
|
|
617
|
+
if (!preflight.ok) {
|
|
618
|
+
if (preflight.code === 'verify_disabled') {
|
|
619
|
+
return resolveWithoutVerify(task, preflight, backend);
|
|
620
|
+
}
|
|
621
|
+
if (preflight.code === 'no_capture_url') {
|
|
622
|
+
// The work is done as far as the agent knows, so a claimed task moves
|
|
623
|
+
// on to in_progress — and no further. Retrying cannot conjure a URL.
|
|
624
|
+
if (agentCanReach(task.status, 'in_progress')) {
|
|
625
|
+
try {
|
|
626
|
+
task = await walkTo(task, 'in_progress', backend);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
return daemonErrorResult(error);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return resolveResult(task, false, `${task.id} is ${task.status}, not verifying: Verify cannot run for this task — ${preflight.message}. ` +
|
|
633
|
+
`Calling snag_resolve again will not change that; a human must check the fix and resolve it.`);
|
|
634
|
+
}
|
|
635
|
+
if (preflight.code !== 'verify_unavailable') {
|
|
636
|
+
return errorResult(`snagd refused the verify preflight (${preflight.status} ${preflight.code}): ${preflight.message} — ` +
|
|
637
|
+
`${task.id} was left ${task.status}; nothing changed.`);
|
|
638
|
+
}
|
|
639
|
+
// `verify_unavailable` is the one refusal time fixes: no extension is
|
|
640
|
+
// attached *yet*. The task moves on and the run is asked for anyway —
|
|
641
|
+
// a tab may have reconnected since the preflight.
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
task = await walkTo(task, 'verifying', backend);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
return daemonErrorResult(error);
|
|
648
|
+
}
|
|
649
|
+
// Commit before Verify, so the sha recorded against the task is the one
|
|
650
|
+
// that contains the fix Verify is about to judge.
|
|
651
|
+
let sha = typeof input.commit === 'string' ? input.commit : undefined;
|
|
652
|
+
let committed = '';
|
|
653
|
+
if (input.commit === true) {
|
|
654
|
+
let answer;
|
|
655
|
+
try {
|
|
656
|
+
answer = await backend.commit({
|
|
657
|
+
task: task.id,
|
|
658
|
+
...(input.files === undefined ? {} : { files: input.files }),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
return daemonErrorResult(error);
|
|
663
|
+
}
|
|
664
|
+
if (answer.sha !== undefined)
|
|
665
|
+
sha = answer.sha;
|
|
666
|
+
// snagd's own sentence either way: `main is in git.protected_branches …`
|
|
667
|
+
// names the fix; "could not commit" would not.
|
|
668
|
+
committed =
|
|
669
|
+
answer.code === 'committed'
|
|
670
|
+
? ` Committed ${answer.sha?.slice(0, 8) ?? ''} — ${answer.files.join(', ')}.`
|
|
671
|
+
: ` Not committed: ${answer.message}.`;
|
|
672
|
+
}
|
|
673
|
+
const verify = await backend.queueVerify(task.uid, {
|
|
674
|
+
...(sha === undefined ? {} : { commit: sha }),
|
|
675
|
+
...(input.note === undefined ? {} : { note: input.note }),
|
|
676
|
+
});
|
|
677
|
+
const alreadyVerifying = found.task.status === 'verifying' && task.status === 'verifying';
|
|
678
|
+
const verifyingNow = `${task.id} ${alreadyVerifying ? 'was already' : 'is now'} verifying.`;
|
|
679
|
+
let detail;
|
|
680
|
+
if (verify.code === 'verify_unavailable') {
|
|
681
|
+
// The daemon's message names the fix (reload a tab, call again). Nothing
|
|
682
|
+
// is lost: the task stays `verifying` until a run can happen.
|
|
683
|
+
detail = `${verifyingNow} Verify could not start: ${verify.detail}`;
|
|
684
|
+
}
|
|
685
|
+
else if (verify.queued) {
|
|
686
|
+
detail = `${verifyingNow} ${verify.detail}. It is not done until Verify says so.`;
|
|
687
|
+
}
|
|
688
|
+
else if (verify.code === undefined) {
|
|
689
|
+
// snagd itself stopped answering between the transition and the queue —
|
|
690
|
+
// the same kind of absence as no extension, and fixed the same way.
|
|
691
|
+
detail = `${verifyingNow} ${verify.detail}. It stays verifying; once snagd answers again, snag_resolve queues the run.`;
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
detail = `${verifyingNow} ${verify.detail}. No verify run was queued, so it stays verifying until a human looks at it.`;
|
|
695
|
+
}
|
|
696
|
+
return resolveResult(task, verify.queued, `${detail}${committed}`);
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const snagScreenshot = {
|
|
700
|
+
name: 'snag_screenshot',
|
|
701
|
+
title: 'Fetch a Snag screenshot',
|
|
702
|
+
description: `Return a task's screenshot as an image. Costs ~${IMAGE_VISUAL_TOKENS_STANDARD} visual tokens (about 1,200 words), so call it only when the bug is visual — layout, spacing, contrast. For "button does nothing" the anchor and console text in snag_get are strictly better.`,
|
|
703
|
+
inputSchema: ScreenshotInputSchema,
|
|
704
|
+
annotations: READ_ONLY,
|
|
705
|
+
async handle(args, backend) {
|
|
706
|
+
const input = ScreenshotInputSchema.parse(args);
|
|
707
|
+
let found;
|
|
708
|
+
try {
|
|
709
|
+
found = await backend.getTask(input.id);
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
return daemonErrorResult(error);
|
|
713
|
+
}
|
|
714
|
+
if (found === null)
|
|
715
|
+
return notFound(input.id);
|
|
716
|
+
const { present } = existingScreenshots(found.task, backend);
|
|
717
|
+
const relative = present[input.index];
|
|
718
|
+
if (relative === undefined) {
|
|
719
|
+
return errorResult(present.length === 0
|
|
720
|
+
? `${found.task.id} has no screenshot on disk. evidence_refs.screenshots: ${JSON.stringify(found.task.evidence_refs.screenshots)}`
|
|
721
|
+
: `${found.task.id} has ${present.length} screenshot(s); index ${input.index} is out of range.`);
|
|
722
|
+
}
|
|
723
|
+
const bytes = backend.files.readBytes(relative);
|
|
724
|
+
if (bytes === null)
|
|
725
|
+
return errorResult(`Could not read ${relative}.`);
|
|
726
|
+
if (bytes.byteLength > SCREENSHOT_MAX_INLINE_BYTES) {
|
|
727
|
+
const link = resourceLink(backend, relative);
|
|
728
|
+
return {
|
|
729
|
+
content: [
|
|
730
|
+
textBlock(`${relative} is ${bytes.byteLength} bytes, over the ${SCREENSHOT_MAX_INLINE_BYTES}-byte inline limit. Read the file directly instead.`),
|
|
731
|
+
...(link === null ? [] : [link]),
|
|
732
|
+
],
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
content: [
|
|
737
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType: 'image/png' },
|
|
738
|
+
textBlock(`${relative} — ${found.task.id}: ${found.task.title}`),
|
|
739
|
+
],
|
|
740
|
+
};
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
/* ------------------------------------------------------------- registry */
|
|
744
|
+
/**
|
|
745
|
+
* ⚠️ EXACTLY FIVE. doc 02 §9.2 and doc 03 §1.3: Chrome DevTools MCP ships
|
|
746
|
+
* ~17,000 tokens of tool definitions before a single call and pays that on
|
|
747
|
+
* every turn; Vercel measured a 3.5× speed-up and 37% fewer tokens from cutting
|
|
748
|
+
* an agent's toolset by 80%. Adding a sixth tool taxes every turn of every
|
|
749
|
+
* session in every repo that installs this server. If you need one more verb,
|
|
750
|
+
* add a parameter to an existing tool or put the data in `.snag/`, which has no
|
|
751
|
+
* cap at all.
|
|
752
|
+
*/
|
|
753
|
+
export const TOOLS = [
|
|
754
|
+
snagList,
|
|
755
|
+
snagGet,
|
|
756
|
+
snagClaim,
|
|
757
|
+
snagResolve,
|
|
758
|
+
snagScreenshot,
|
|
759
|
+
];
|
|
760
|
+
export const TOOL_COUNT = 5;
|
|
761
|
+
export function findTool(name) {
|
|
762
|
+
return TOOLS.find((tool) => tool.name === name);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* The wire form of a tool definition — this is what costs tokens on every turn.
|
|
766
|
+
*
|
|
767
|
+
* `$schema` and `title` are stripped from the generated documents: the MCP spec
|
|
768
|
+
* fixes tool schemas at JSON Schema 2020-12, so the `$schema` keyword is
|
|
769
|
+
* redundant, and the tool already carries a `title`. Together they are ~135
|
|
770
|
+
* tokens across the nine schemas here, paid on every turn, for zero
|
|
771
|
+
* information.
|
|
772
|
+
*/
|
|
773
|
+
function wireSchema(schema, name) {
|
|
774
|
+
const document = toInlineJsonSchema(schema, name);
|
|
775
|
+
delete document['$schema'];
|
|
776
|
+
delete document['title'];
|
|
777
|
+
return openTupleTail(document);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Drop the redundant `items: false` from a length-pinned tuple.
|
|
781
|
+
*
|
|
782
|
+
* `@frmwrkd/snag-core`'s `toInlineJsonSchema` correctly rewrites a draft-7 tuple into
|
|
783
|
+
* the 2020-12 form:
|
|
784
|
+
*
|
|
785
|
+
* ```json
|
|
786
|
+
* { "type": "array", "minItems": 2, "maxItems": 2,
|
|
787
|
+
* "prefixItems": [{"type":"integer"},{"type":"integer"}], "items": false }
|
|
788
|
+
* ```
|
|
789
|
+
*
|
|
790
|
+
* That is exactly right per the spec — and the MCP TypeScript SDK v1.30.0
|
|
791
|
+
* rejects it. Its default validator is `AjvJsonSchemaValidator`, which
|
|
792
|
+
* constructs `new Ajv(...)` from `ajv`'s **draft-07** entry point. Draft-07 has
|
|
793
|
+
* no `prefixItems` keyword, so Ajv ignores it and applies `items: false` to
|
|
794
|
+
* every element, and a client validating `structuredContent` against the
|
|
795
|
+
* declared `outputSchema` fails a perfectly valid `[80, 96]` with:
|
|
796
|
+
*
|
|
797
|
+
* > MCP error -32602: Structured content does not match the tool's output
|
|
798
|
+
* > schema: data/tasks/0/suspect_lines/0 boolean schema is false
|
|
799
|
+
*
|
|
800
|
+
* Verified against `ajv@8.20.0` and reproduced end to end over stdio, which is
|
|
801
|
+
* the only place it shows up — the server-side path never sees it.
|
|
802
|
+
*
|
|
803
|
+
* When `maxItems === prefixItems.length`, `items` is vacuous in 2020-12 (there
|
|
804
|
+
* can be no element past the prefix), so deleting it changes nothing for a
|
|
805
|
+
* 2020-12 validator and makes a draft-07 validator fall back to `minItems` /
|
|
806
|
+
* `maxItems`, which still pin the arity. Where the arity is *not* pinned,
|
|
807
|
+
* `items: false` is load-bearing and is left alone.
|
|
808
|
+
*
|
|
809
|
+
* `Task.suspect_files[].lines` is the tuple that hits this, and it is the one
|
|
810
|
+
* field an agent uses to open a file at the right place. Losing it to a
|
|
811
|
+
* validation error would be a silent, total failure of `snag_get`.
|
|
812
|
+
*/
|
|
813
|
+
export function openTupleTail(node) {
|
|
814
|
+
if (Array.isArray(node))
|
|
815
|
+
return node.map(openTupleTail);
|
|
816
|
+
if (typeof node !== 'object' || node === null)
|
|
817
|
+
return node;
|
|
818
|
+
const source = node;
|
|
819
|
+
const out = {};
|
|
820
|
+
const prefixItems = source['prefixItems'];
|
|
821
|
+
const pinned = Array.isArray(prefixItems) &&
|
|
822
|
+
source['items'] === false &&
|
|
823
|
+
typeof source['maxItems'] === 'number' &&
|
|
824
|
+
source['maxItems'] === prefixItems.length;
|
|
825
|
+
for (const [key, value] of Object.entries(source)) {
|
|
826
|
+
if (pinned && key === 'items')
|
|
827
|
+
continue;
|
|
828
|
+
out[key] = openTupleTail(value);
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
}
|
|
832
|
+
export function toolDefinitions() {
|
|
833
|
+
return TOOLS.map((tool) => {
|
|
834
|
+
const definition = {
|
|
835
|
+
name: tool.name,
|
|
836
|
+
title: tool.title,
|
|
837
|
+
description: tool.description,
|
|
838
|
+
inputSchema: wireSchema(tool.inputSchema, `${tool.name}_input`),
|
|
839
|
+
annotations: tool.annotations,
|
|
840
|
+
};
|
|
841
|
+
if (tool.outputSchema !== undefined) {
|
|
842
|
+
definition.outputSchema = wireSchema(tool.outputSchema, `${tool.name}_output`);
|
|
843
|
+
}
|
|
844
|
+
return definition;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
/** Dispatch a `tools/call`. Unknown names are an error result, not a throw. */
|
|
848
|
+
export async function callTool(name, args, backend) {
|
|
849
|
+
const tool = findTool(name);
|
|
850
|
+
if (tool === undefined) {
|
|
851
|
+
return errorResult(`Unknown tool ${name}. This server has exactly ${TOOL_COUNT}: ${TOOLS.map((t) => t.name).join(', ')}.`);
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
return await tool.handle(args ?? {}, backend);
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
if (error instanceof z.ZodError) {
|
|
858
|
+
return errorResult(`Bad arguments for ${name}: ${error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
859
|
+
}
|
|
860
|
+
if (error instanceof DaemonUnavailableError || error instanceof DaemonRequestError) {
|
|
861
|
+
return daemonErrorResult(error);
|
|
862
|
+
}
|
|
863
|
+
return errorResult(`${name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Exposed for the regression test that pins the status vocabulary. */
|
|
867
|
+
export const KNOWN_TASK_STATUSES = TASK_STATUSES;
|
|
868
|
+
//# sourceMappingURL=tools.js.map
|