Fix the arguments, not the model: repairs malformed MCP tool arguments ("true" for true, numbers as strings, unknown keys) against the tool's schema before the…

█▀▄▀█ █▀▀ █▄ █ █▀▄ █▀▀ █▀█
█ ▀ █ ██▄ █ ▀█ █▄▀ ██▄ █▀▄ PATCH LOG

The problem: MCP tool calls fail on the shape of their arguments, not on what they mean. The model sends "true" where the tool wants true, "10" where it wants 10, one label where it wants a list, or a key the tool does not take. The tool refuses, the model guesses, and the turn burns a retry or two. The study behind this library counted ~65 schema errors of exactly this kind: unknown keys, "true" for true, and numbers sent as strings.
mender sits on every MCP tool call (mcp__*), in the main loop and in subagents. When it knows the tool's input schema, it repairs the known-safe mistakes before the call goes out, then tells the model what it fixed so the model learns the right shape. It also notices when a server is down, and tells the model to stop retrying it.
/plugin marketplace add pourya7/claude-code-mods
/plugin install mender@claude-code-mods
"true" / "false" become booleans where the schema wants a boolean."10", "0.5") become numbers where the schema wants a number. They become integers only where the schema wants an integer and the number is whole. A number that a JavaScript number cannot hold digit for digit (a 19-digit ID, anything past 2^53 - 1, a fraction of more than 15 significant digits) stays the string the model sent.[value] where the schema wants an array, when the value fits the array's items. A JSON array string ("[1, 2]") becomes the array."{\"pinned\": true}") becomes the object where the schema wants an object.additionalProperties: false (and has no patternProperties), or when the server itself rejected that key before.meta.pinned, ids[0])."true" in a string field. A union that allows strings leaves strings alone. A coercion that would leave an enum is not made. Valid input passes through untouched, with no note.context note, for example: MENDER repaired the arguments of mcp__notes__create before it ran, against the tool's input schema:
- draft: "true" → true (boolean)
- colour: dropped (the schema allows no such key)
Next time send them in this shape.
{tool, error} for the /mender pane. When the message says which shape was wanted, mender learns it. It reads the error formats of the TypeScript MCP SDK (Zod issue lists), the Python MCP SDK (Pydantic errors), Ajv (data/limit must be number) and Joi ("limit" must be a number). It then attaches a note with the same call in the right shape, and repairs later calls to that tool. Learned shapes are kept across sessions in $.store. /mender forget clears them, and learn: false turns learning off.HTTP 401 / 401 Unauthorized / needs authentication, mender toasts MENDER ▸ <SERVER> DOWN once and attaches a note telling the model to stop retrying and ask you to reconnect it with /mcp. The first successful call to that server clears it, and a later outage toasts again. An ordinary error that only mentions 401 or "unauthorized" (an issue number, a per-item permission) is not an outage. A deny from a hook or permission rule counts only when it reads as a broken connection, never for auth wording.The mods API in Claude Code 2.1.288 does not give a hook the input schema of an MCP tool: $.tool.list() returns names and descriptions only, and the tool.call envelope carries only the arguments. So mender gets its schemas from two places:
userConfig.schemaFile, default ~/.claude/mender/schemas.json). A missing file is fine. It holds either a map of full tool names to input schemas, or a server's tools/list answer under servers: {
"mcp__notes__create": {
"type": "object",
"properties": { "title": { "type": "string" }, "draft": { "type": "boolean" } },
"required": ["title"],
"additionalProperties": false
},
"servers": {
"wiki": { "tools": [{ "name": "search", "inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } } }] }
}
}
When a tool has both, mender uses the schema file with the learned shape laid over it: a type the server asked for replaces one the file does not allow, and keys the server rejected are dropped. Everything else in the file stays.
| Command | What it does |
|---|---|
/mender | Opens the PATCH LOG pane and prints the same as text. |
/mender list | Prints the repairs and the recurring schema errors per tool. |
/mender reload | Reads the schema file again. |
/mender forget [tool] | Forgets the shapes learned from errors, for all tools or one (mcp__wiki__search or wiki/search). |
/mender off / /mender on | Stops or resumes mender for this session. Off is a pure pass-through: no repairs, no learning, no notes, no toasts and no error log. |
userConfig)| Field | Type | Default | Meaning |
|---|---|---|---|
schemaFile | string | ~/.claude/mender/schemas.json | The JSON file of MCP tool input schemas. ~ is your home directory. A missing file is fine; a bad one is reported once and skipped. |
learn | boolean | true | Learn shapes from validation errors and repair later calls to match. Learned shapes are kept across sessions. |
You can change these in the /config menu, or under pluginConfigs.mender in your settings.
The /mender pane, after a few calls. One call was repaired, one tool kept rejecting its arguments, and one server is down:
▀▀▀▀▀▀ MENDER PATCH LOG
██████ 1 CALL FIXED
██▀█▀█ 2 FROM FILE · 1 LEARNED
█▀▀▀▀█▄ ◆ WIKI DOWN · NOT CONNECTED
████████▄
▀▀▀▀▀▀▀▀▀
────────────────────────────────────────────────────────────
REPAIRS
12:00 notes/create draft: "true" → true (boolean)
12:00 notes/create colour: dropped (the schema allows no such key)
RECURRING ERRORS
x1 docs/search MCP error -32602: Input validation error: Invalid arguments for…
[ CLEAR LOG ] [ FORGET LEARNED ] [ TURN OFF ]
With nothing to report, the lists read NOTHING TO MEND YET. and NONE. CLEAN RUN.
This capture is plain text. In the terminal, the sprite is an orange sock with a white and grey cuff and a brown patch stitched in yellow. When a server is down, the sock turns grey. The title is white on brown, repairs are orange, dropped keys are brown and down servers are red. All the colours are from the PICO-8 palette.
The status line under the prompt stays quiet until something happens, then shows counts in under 40 columns, for example MENDER ▸ 3 FIXED · 1 ERR · 1 DOWN. VS Code and claude -p have no pane, so the status line, the toast and /mender list are what you see there.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Changes tool calls | Data leaving the machine |
|---|---|---|---|---|---|---|
None. No $.http. | None. No $.process. | Reads one file, the schema file ($.fs.exists, $.fs.read), and reads HOME to expand ~. Writes no files. Learned shapes go to $.store; the repair log is kept only for the session, in $.state. | No. mender makes no $.model call. Repairs and error reading are deterministic. | No. | Yes. A tool.call hook rewrites the arguments of mcp__* calls before they run (only the repairs listed above), and adds model-only context notes to the result. Built-in tools are never touched. | Nothing new. The repaired arguments go to the same MCP server the model was already calling, and the notes go to your own model. |
type (including type lists), properties, additionalProperties, patternProperties, items, anyOf, oneOf, enum and required. It does not follow $ref, and it does not repair tuple items lists.401 or "unauthorized" is never enough.claude plugin validate mender
claude plugin test mender
Pure logic lives in hooks/schema.ts (the repairer and the schema file reader), hooks/learn.ts (error classification, learning, down detection), hooks/text.ts (notes and status) and hooks/pixels.ts (the half-block sprite renderer). hooks/register.tsx connects them to the engine. The tests drive MCP calls through the engine's $.tool.call with a fake server underneath, and mount the pane on both terminal and desktop.
hooks/register.tsx 429 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MenderRepairEntry, MenderSchema, MenderToolErrors } from '../types'
5import { applyFacts, downReason, factsFrom, isSchemaError, overlayLearned, serverOf } from './learn'
6import { DOWN_SOCK_SPRITE, PICO8, SOCK_SPRITE, spriteRuns } from './pixels'
7import { parseSchemaFile, repairArguments } from './schema'
8import type { JsonSchema, Repair } from './schema'
9import { clockText, downNote, learnedNote, oneLine, repairLine, repairNote, shortTool, statusText } from './text'
10
11type Engine = EngineInterface
12
13const PANE = 'mender'
14const LEARNED_KEY = 'learned'
15const MAX_LOG = 30
16const DEFAULT_SCHEMA_FILE = '~/.claude/mender/schemas.json'
17/** The keys a tool.call envelope carries beside the tool's own arguments. */
18const ENVELOPE = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
19
20const REPAIRS = { plugin: 'mender', key: 'repairs' } as const
21const repairsAtom = atom(REPAIRS, [])
22const FIXED = { plugin: 'mender', key: 'fixed' } as const
23const fixedAtom = atom(FIXED, 0)
24const ERRORS = { plugin: 'mender', key: 'errors' } as const
25const errorsAtom = atom(ERRORS, {})
26const DOWN = { plugin: 'mender', key: 'down' } as const
27const downAtom = atom(DOWN, {})
28const SCHEMAS = { plugin: 'mender', key: 'schemas' } as const
29const schemasAtom = atom(SCHEMAS, {})
30const SCHEMA_PROBLEM = { plugin: 'mender', key: 'schemaProblem' } as const
31const schemaProblemAtom = atom(SCHEMA_PROBLEM, '')
32const LEARNED = { plugin: 'mender', key: 'learned' } as const
33const learnedAtom = atom(LEARNED, {})
34const IS_OFF = { plugin: 'mender', key: 'isOff' } as const
35const offAtom = atom(IS_OFF, false)
36
37const USAGE = [
38 'usage: /mender open the PATCH LOG pane',
39 ' /mender list the same, as text',
40 ' /mender reload re-read the schema file',
41 ' /mender forget [tool] forget the shapes learned from errors (all, or one tool)',
42 ' /mender off | on stop or resume mender for this session (off: a pure pass-through)',
43].join('\n')
44
45const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
46
47const isRecord = (value: unknown): value is Record<string, unknown> =>
48 typeof value === 'object' && value !== null && !Array.isArray(value)
49
50const learnedOf = (value: unknown): Record<string, MenderSchema> => (isRecord(value) ? (value as Record<string, MenderSchema>) : {})
51
52const sumErrors = (errors: Record<string, MenderToolErrors>) =>
53 Object.values(errors).reduce((total, entry) => total + entry.count, 0)
54
55// ── state ────────────────────────────────────────────────────────────────
56
57const showStatus = async ($: Engine) => {
58 try {
59 $.ui.status(
60 statusText({
61 fixed: await read($, fixedAtom),
62 errors: sumErrors(await read($, errorsAtom)),
63 down: Object.keys(await read($, downAtom)).length,
64 }),
65 )
66 } catch {
67 // The status line is decoration.
68 }
69}
70
71const schemaPath = async ($: Engine, configured: string): Promise<string | undefined> => {
72 const path = configured.trim() === '' ? DEFAULT_SCHEMA_FILE : configured.trim()
73 if (!path.startsWith('~')) return path
74 const home = await $.env.get('HOME')
75 return home ? `${home}${path.slice(1)}` : undefined
76}
77
78/** Reads the schema file (absent is fine) and the learned shapes from the store. */
79const load = async ($: Engine, configured: string) => {
80 let schemas: Record<string, JsonSchema> = {}
81 let problem = ''
82 const path = await schemaPath($, configured)
83 try {
84 if (path !== undefined && (await $.fs.exists(path))) {
85 const parsed = parseSchemaFile(await $.fs.read(path))
86 schemas = parsed.schemas
87 if (parsed.problems.length > 0) problem = `${path}: ${parsed.problems.join('; ')}`
88 }
89 } catch (error) {
90 problem = `${path}: could not be read (${errorText(error)})`
91 }
92 await $.state.set(SCHEMAS, schemas as Record<string, MenderSchema>)
93 await $.state.set(SCHEMA_PROBLEM, problem)
94 if (problem !== '') $.ui.toast('MENDER ▸ SCHEMA FILE HAS PROBLEMS · /mender')
95 try {
96 await $.state.set(LEARNED, learnedOf(await $.store.get(LEARNED_KEY)))
97 } catch {
98 // A store that cannot be read leaves the session's shapes as they are.
99 }
100 await showStatus($)
101}
102
103/** The file's schema with what the tool's own errors taught laid over it (either alone when only one exists). */
104const combine = (file: JsonSchema | undefined, learned: JsonSchema | undefined): JsonSchema | undefined =>
105 file === undefined ? learned : learned === undefined ? file : overlayLearned(file, learned)
106
107const schemaFor = async ($: Engine, tool: string): Promise<JsonSchema | undefined> =>
108 combine((await read($, schemasAtom))[tool] as JsonSchema | undefined, (await read($, learnedAtom))[tool] as JsonSchema | undefined)
109
110/** Folds new facts into the stored shapes as they are now, so parallel sessions add up. */
111const learn = async ($: Engine, tool: string, text: string): Promise<JsonSchema | undefined> => {
112 const facts = factsFrom(text)
113 if (facts.length === 0) return undefined
114 let base = await read($, learnedAtom)
115 try {
116 base = { ...base, ...learnedOf(await $.store.get(LEARNED_KEY)) }
117 } catch {
118 // Fall back to the session's copy.
119 }
120 const schema = applyFacts(base[tool] as JsonSchema | undefined, facts)
121 const learned = { ...base, [tool]: schema as MenderSchema }
122 await $.state.set(LEARNED, learned)
123 try {
124 await $.store.set(LEARNED_KEY, learned)
125 } catch {
126 // Kept for the session at least.
127 }
128 return schema
129}
130
131const recordRepair = async ($: Engine, tool: string, repairs: Repair[]) => {
132 const entry: MenderRepairEntry = { at: await $.clock.now(), tool, repairs }
133 await update($, repairsAtom, log => [...log, entry].slice(-MAX_LOG))
134 await update($, fixedAtom, count => count + 1)
135}
136
137const recordError = async ($: Engine, tool: string, text: string) => {
138 const now = await $.clock.now()
139 await update($, errorsAtom, errors => ({
140 ...errors,
141 [tool]: { count: (errors[tool]?.count ?? 0) + 1, lastAt: now, last: oneLine(text, 160) },
142 }))
143}
144
145/** Marks a server down; true when it was not down already (toast once). */
146const markDown = async ($: Engine, server: string, reason: string): Promise<boolean> => {
147 let isNew = false as boolean
148 await update($, downAtom, down => {
149 isNew = !(server in down)
150 return isNew ? { ...down, [server]: reason } : down
151 })
152 return isNew
153}
154
155/** A call to the server worked: it is back. True when it was marked down. */
156const clearDown = async ($: Engine, server: string): Promise<boolean> => {
157 if (!(server in (await read($, downAtom)))) return false
158 await update($, downAtom, down => {
159 const { [server]: _gone, ...rest } = down
160 return rest
161 })
162 return true
163}
164
165// ── text ─────────────────────────────────────────────────────────────────
166
167const listText = async ($: Engine): Promise<string> => {
168 const repairs = await read($, repairsAtom)
169 const errors = await read($, errorsAtom)
170 const down = await read($, downAtom)
171 const schemas = Object.keys(await read($, schemasAtom)).length
172 const learned = Object.keys(await read($, learnedAtom)).length
173 const problem = await read($, schemaProblemAtom)
174 const lines = [
175 `MENDER${(await read($, offAtom)) ? ' (OFF)' : ''} · ${await read($, fixedAtom)} calls fixed · ${schemas} tools from the schema file · ${learned} learned from errors`,
176 ]
177 if (problem !== '') lines.push(`schema file problem: ${problem}`)
178 for (const [server, reason] of Object.entries(down)) lines.push(`DOWN ${server}: ${reason}`)
179 lines.push('REPAIRS')
180 if (repairs.length === 0) lines.push(' none yet')
181 for (const entry of repairs.slice(-10)) {
182 for (const repair of entry.repairs) lines.push(` ${clockText(entry.at)} ${shortTool(entry.tool)} ${repairLine(repair)}`)
183 }
184 lines.push('RECURRING SCHEMA ERRORS')
185 const ranked = Object.entries(errors).sort((a, b) => b[1].count - a[1].count)
186 if (ranked.length === 0) lines.push(' no schema errors this session')
187 for (const [tool, entry] of ranked) lines.push(` x${entry.count} ${shortTool(tool)} ${oneLine(entry.last, 100)}`)
188 return lines.join('\n')
189}
190
191const openPane = async ($: Engine) => {
192 try {
193 await $.ui.open({ id: PANE, title: 'MENDER' })
194 } catch {
195 // No surface places panes (a -p run): the command's text reply stands.
196 }
197}
198
199const forget = async ($: Engine, tool: string): Promise<string> => {
200 const learned = await read($, learnedAtom)
201 const names = tool === '' ? Object.keys(learned) : Object.keys(learned).filter(name => name === tool || shortTool(name) === tool)
202 const kept = Object.fromEntries(Object.entries(learned).filter(([name]) => !names.includes(name)))
203 await $.state.set(LEARNED, kept)
204 try {
205 await $.store.set(LEARNED_KEY, kept)
206 } catch (error) {
207 return `Forgot ${names.length} for this session, but the store could not be written: ${errorText(error)}`
208 }
209 return `Forgot ${names.length} learned shape${names.length === 1 ? '' : 's'}.`
210}
211
212const clearLog = async ($: Engine) => {
213 await $.state.set(REPAIRS, [])
214 await $.state.set(FIXED, 0)
215 await $.state.set(ERRORS, {})
216 await showStatus($)
217}
218
219export const register: Register = (on, options) => {
220 const configuredFile = typeof options.schemaFile === 'string' ? options.schemaFile : ''
221 const isLearning = options.learn !== false
222
223 on('session.start', async ($, e, next) => {
224 try {
225 await $.command.register({
226 name: 'mender',
227 description: 'Show MCP argument repairs and recurring schema errors',
228 argumentHint: '[list | reload | forget [tool] | off | on]',
229 })
230 await load($, configuredFile)
231 } catch (error) {
232 $.ui.toast(`MENDER: could not start (${errorText(error)})`)
233 }
234 return next(e)
235 })
236
237 on('command.run', { command: 'mender' }, async ($, e) => {
238 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
239 switch (verb.toLowerCase()) {
240 case '':
241 await openPane($)
242 return { text: await listText($) }
243 case 'list':
244 return { text: await listText($) }
245 case 'reload':
246 await load($, configuredFile)
247 return { text: await listText($) }
248 case 'forget':
249 return { text: await forget($, rest.join(' ').trim()) }
250 case 'off':
251 await $.state.set(IS_OFF, true)
252 return { text: 'MENDER OFF for this session: MCP calls go through untouched, with no notes, toasts or error log.' }
253 case 'on':
254 await $.state.set(IS_OFF, false)
255 return { text: 'MENDER ON.' }
256 default:
257 return { text: USAGE }
258 }
259 })
260
261 on('tool.call', async ($, e, next) => {
262 if (!e.tool.startsWith('mcp__')) return next(e)
263 let isOff = false
264 try {
265 isOff = await read($, offAtom)
266 } catch {
267 // Unknown means on.
268 }
269 // Off is a true pass-through: no repair, no notes, no toasts, no bookkeeping.
270 if (isOff) return next(e)
271 const tool = e.tool
272 const envelope: Record<string, unknown> = {}
273 const args: Record<string, unknown> = {}
274 for (const [key, value] of Object.entries(e as Record<string, unknown>)) (ENVELOPE.has(key) ? envelope : args)[key] = value
275
276 let repairs: Repair[] = []
277 let sent = args
278 try {
279 const schema = await schemaFor($, tool)
280 if (schema !== undefined) ({ args: sent, repairs } = repairArguments(schema, args))
281 } catch {
282 repairs = []
283 sent = args
284 }
285
286 const ran = await next(repairs.length > 0 ? ({ ...envelope, ...sent } as typeof e) : e)
287
288 const notes: string[] = []
289 try {
290 if (repairs.length > 0) {
291 notes.push(repairNote(tool, repairs))
292 await recordRepair($, tool, repairs)
293 }
294 const failure = ran.deny ?? (ran.isError === true ? (ran.text ?? '') : undefined)
295 const server = serverOf(tool)
296 let isBack = false
297 if (failure === undefined) {
298 if (server !== undefined) isBack = await clearDown($, server)
299 } else {
300 const reason = downReason(failure, { isDeny: ran.deny !== undefined })
301 if (reason !== undefined && server !== undefined) {
302 if (await markDown($, server, reason)) $.ui.toast(`MENDER ▸ ${server.toUpperCase()} DOWN`)
303 notes.push(downNote(server, reason))
304 } else if (isSchemaError(failure)) {
305 await recordError($, tool, failure)
306 const learned = isLearning ? await learn($, tool, failure) : undefined
307 if (learned !== undefined) {
308 // The shape the next call is repaired against: the file's schema, if any, with the lesson on top.
309 const shape = combine((await read($, schemasAtom))[tool] as JsonSchema | undefined, learned) ?? learned
310 const again = repairArguments(shape, sent)
311 notes.push(learnedNote(tool, factsFrom(failure), again.repairs.length > 0 ? again.args : undefined))
312 }
313 }
314 }
315 if (repairs.length > 0 || failure !== undefined || isBack) await showStatus($)
316 } catch {
317 // Bookkeeping never changes the call's outcome.
318 }
319
320 if (notes.length === 0) return ran
321 if (ran.deny !== undefined) return { deny: [ran.deny, ...notes].join('\n') }
322 return { ...ran, context: [...(ran.context ?? []), ...notes] } as typeof ran
323 })
324
325 // ── drawing ──────────────────────────────────────────────────────────────
326
327 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
328 const { Box, Text, Button } = $.ui.resolve(e)
329 const repairs = await read($, repairsAtom)
330 const errors = await read($, errorsAtom)
331 const down = await read($, downAtom)
332 const fixed = await read($, fixedAtom)
333 const isOff = await read($, offAtom)
334 const problem = await read($, schemaProblemAtom)
335 const fileCount = Object.keys(await read($, schemasAtom)).length
336 const learnedCount = Object.keys(await read($, learnedAtom)).length
337 const width = Math.max(24, e.props.bodyColumns)
338 const isDown = Object.keys(down).length > 0
339 const sprite = spriteRuns(isDown ? DOWN_SOCK_SPRITE : SOCK_SPRITE)
340 const lines = repairs.flatMap(entry => entry.repairs.map(repair => ({ entry, repair }))).slice(-8)
341 const ranked = Object.entries(errors).sort((a, b) => b[1].count - a[1].count).slice(0, 6)
342 const toolWidth = Math.min(24, Math.max(8, ...lines.map(line => shortTool(line.entry.tool).length), ...ranked.map(([tool]) => shortTool(tool).length)))
343
344 return (
345 <Box flexDirection="column" width={width}>
346 <Box flexDirection="row" gap={2}>
347 <Box flexDirection="column">
348 {sprite.map((row, y) => (
349 <Box key={`sock-row-${y}`} flexDirection="row">
350 {row.map(run => (
351 <Text color={run.color} backgroundColor={run.backgroundColor}>
352 {run.text}
353 </Text>
354 ))}
355 </Box>
356 ))}
357 </Box>
358 <Box flexDirection="column" flexShrink={1}>
359 <Box flexDirection="row" gap={1}>
360 <Text key="title" bold color={PICO8.w} backgroundColor={PICO8.b}>
361 {' MENDER '}
362 </Text>
363 <Text bold color={isOff ? PICO8.d : PICO8.o}>
364 {isOff ? 'OFF' : 'PATCH LOG'}
365 </Text>
366 </Box>
367 <Text key="fixed" color={PICO8.o}>{`${fixed} CALL${fixed === 1 ? '' : 'S'} FIXED`}</Text>
368 <Text color={PICO8.l}>{`${fileCount} FROM FILE · ${learnedCount} LEARNED`}</Text>
369 {Object.entries(down).map(([server, reason]) => (
370 <Text key={`down-${server}`} color={PICO8.r} wrap="truncate-end">
371 {`◆ ${server.toUpperCase()} DOWN · ${reason.toUpperCase()}`}
372 </Text>
373 ))}
374 {problem !== '' && (
375 <Text key="problem" color={PICO8.y} wrap="truncate-end">
376 {`! SCHEMA FILE: ${problem}`}
377 </Text>
378 )}
379 </Box>
380 </Box>
381 <Text color={PICO8.d}>{'─'.repeat(Math.min(width, 60))}</Text>
382 <Text bold color={PICO8.o}>
383 REPAIRS
384 </Text>
385 {lines.length === 0 && (
386 <Text key="no-repairs" color={PICO8.l}>
387 NOTHING TO MEND YET.
388 </Text>
389 )}
390 {lines.map(({ entry, repair }, index) => (
391 <Box key={`repair-${index}`} flexDirection="row" gap={1}>
392 <Text color={PICO8.d}>{clockText(entry.at)}</Text>
393 <Text color={PICO8.w}>{shortTool(entry.tool).padEnd(toolWidth).slice(0, toolWidth)}</Text>
394 <Text color={repair.kind === 'drop' ? PICO8.b : PICO8.o} wrap="truncate-end">
395 {repairLine(repair)}
396 </Text>
397 </Box>
398 ))}
399 <Text bold color={PICO8.b}>
400 RECURRING ERRORS
401 </Text>
402 {ranked.length === 0 && (
403 <Text key="no-errors" color={PICO8.l}>
404 NONE. CLEAN RUN.
405 </Text>
406 )}
407 {ranked.map(([tool, entry]) => (
408 <Box key={`error-${tool}`} flexDirection="row" gap={1}>
409 <Text color={PICO8.r}>{`x${entry.count}`.padStart(3)}</Text>
410 <Text color={PICO8.w}>{shortTool(tool).padEnd(toolWidth).slice(0, toolWidth)}</Text>
411 <Text color={PICO8.l} wrap="truncate-end">
412 {oneLine(entry.last, 80)}
413 </Text>
414 </Box>
415 ))}
416 <Box flexDirection="row" gap={1} marginTop={1}>
417 <Button key="mender-clear" label="CLEAR LOG" onPress={() => clearLog($)} />
418 <Button key="mender-forget" label="FORGET LEARNED" onPress={() => forget($, '')} />
419 <Button
420 key="mender-toggle"
421 label={isOff ? 'TURN ON' : 'TURN OFF'}
422 onPress={() => $.state.set(IS_OFF, !isOff)}
423 />
424 </Box>
425 </Box>
426 )
427 })
428}
429hooks/learn.ts 232 lines1// Reads an MCP tool's error text: is it a schema complaint, what shape did the
2// server ask for, and is the server down rather than the call wrong.
3
4import type { JsonSchema } from './schema'
5
6export type LearnedType = 'boolean' | 'integer' | 'number' | 'array' | 'object'
7
8/** One thing a validation error says about the expected shape. */
9export type Fact = { path: string[]; expected: LearnedType } | { path: string[]; rejectKeys: string[] }
10
11const LEARNABLE = new Set<string>(['boolean', 'integer', 'number', 'array', 'object'])
12
13const SCHEMA_ERROR =
14 /-32602|invalid (?:params|arguments|input)\b|input validation error|validation errors? for|must be (?:an? )?(?:boolean|integer|number|string|array|object)\b|expected (?:boolean|number|integer|array|object|string)\b|input should be a valid|extra inputs are not permitted|unrecogni[sz]ed keys?|additional propert/i
15
16/** True when the error text is a complaint about the arguments' shape. */
17export const isSchemaError = (text: string): boolean => SCHEMA_ERROR.test(text)
18
19/**
20 * Outage shapes, anchored to transport and auth wording so an ordinary error
21 * that mentions "401" or "unauthorized" (an issue number, a per-item
22 * permission) never reads as a dead server. `isConnection` marks the ones that
23 * also count in a deny; a deny comes from a hook or a policy, which may well
24 * say "unauthorized" about a healthy server.
25 */
26const DOWN: readonly { pattern: RegExp; reason: string; isConnection: boolean }[] = [
27 {
28 pattern: /\b(?:mcp )?server\b[^\n]{0,80}?\bis not connected\b|\bnot connected to (?:the |any )?(?:mcp )?server\b/i,
29 reason: 'not connected',
30 isConnection: true,
31 },
32 { pattern: /\b(?:server|transport|client|connection|session)\s+(?:(?:was|has been|is|got)\s+)?disconnected\b/i, reason: 'disconnected', isConnection: true },
33 { pattern: /\bconnection (?:closed|refused|lost|reset)\b/i, reason: 'connection lost', isConnection: true },
34 { pattern: /\bECONN(?:REFUSED|RESET)\b/, reason: 'connection refused', isConnection: true },
35 { pattern: /\bfailed to connect\b/i, reason: 'failed to connect', isConnection: true },
36 { pattern: /\b(?:HTTP(?:\/[\d.]+)?|status(?: code)?)\s*:?\s*401\b|\b401 Unauthori[sz]ed\b|\binvalid_token\b/i, reason: 'unauthorized', isConnection: false },
37 {
38 pattern: /\bunauthenticated\b|\bauthentication (?:required|failed)\b|\bneeds? (?:re-?)?auth|\bre-?authenticate\b/i,
39 reason: 'needs auth',
40 isConnection: false,
41 },
42 { pattern: /\b(?:access |auth |oauth )?token (?:has )?expired\b/i, reason: 'token expired', isConnection: false },
43]
44
45/**
46 * Why the server looks down, or undefined when the error is about the call
47 * itself. With `isDeny`, only a broken connection counts.
48 */
49export const downReason = (text: string, { isDeny = false }: { isDeny?: boolean } = {}): string | undefined => {
50 if (isSchemaError(text)) return undefined
51 return DOWN.find(({ pattern, isConnection }) => (isConnection || !isDeny) && pattern.test(text))?.reason
52}
53
54/** `mcp__<server>__<tool>` → `<server>`. */
55export const serverOf = (tool: string): string | undefined => {
56 if (!tool.startsWith('mcp__')) return undefined
57 const server = tool.split('__')[1]
58 return server === undefined || server === '' ? undefined : server
59}
60
61const splitPath = (text: string): string[] =>
62 text
63 .replace(/\[(\d+)\]/g, '.$1')
64 .split(/[./]/)
65 .filter(part => part !== '')
66
67const learnable = (expected: unknown): LearnedType | undefined =>
68 typeof expected === 'string' && LEARNABLE.has(expected.toLowerCase()) ? (expected.toLowerCase() as LearnedType) : undefined
69
70/** Zod (the MCP TypeScript SDK's validator): a JSON list of issues. */
71const zodFacts = (text: string): Fact[] | undefined => {
72 const start = text.search(/\[\s*\{/)
73 const end = text.lastIndexOf(']')
74 if (start < 0 || end <= start) return undefined
75 let issues: unknown
76 try {
77 issues = JSON.parse(text.slice(start, end + 1))
78 } catch {
79 return undefined
80 }
81 if (!Array.isArray(issues)) return undefined
82 const facts: Fact[] = []
83 for (const issue of issues) {
84 if (typeof issue !== 'object' || issue === null) continue
85 const { code, expected, path, keys } = issue as Record<string, unknown>
86 const at = Array.isArray(path) ? path.map(String) : []
87 if (code === 'unrecognized_keys' && Array.isArray(keys)) facts.push({ path: at, rejectKeys: keys.map(String) })
88 else if (code === 'invalid_type' && learnable(expected) !== undefined) facts.push({ path: at, expected: learnable(expected)! })
89 }
90 return facts
91}
92
93const PYDANTIC_TYPES: readonly [RegExp, LearnedType][] = [
94 [/valid boolean|type=bool_/i, 'boolean'],
95 [/valid integer|type=int_/i, 'integer'],
96 [/valid number|type=float_/i, 'number'],
97 [/valid (?:list|array|tuple)|type=(?:list|tuple)_/i, 'array'],
98 [/valid dictionary|valid object|type=(?:dict|model)_type/i, 'object'],
99]
100
101/** Pydantic (the MCP Python SDK's validator): a path line, then an indented message. */
102const pydanticFacts = (text: string): Fact[] => {
103 if (!/validation errors? for/i.test(text)) return []
104 const lines = text.split('\n')
105 const facts: Fact[] = []
106 for (let i = 0; i + 1 < lines.length; i += 1) {
107 const pathLine = lines[i] ?? ''
108 const message = lines[i + 1] ?? ''
109 if (/^\s/.test(pathLine) || pathLine.trim() === '' || /validation error/i.test(pathLine) || !/^\s+\S/.test(message)) continue
110 const path = splitPath(pathLine.trim())
111 if (/extra inputs are not permitted|type=extra_forbidden/i.test(message)) {
112 const key = path.pop()
113 if (key !== undefined) facts.push({ path, rejectKeys: [key] })
114 continue
115 }
116 const hit = PYDANTIC_TYPES.find(([pattern]) => pattern.test(message))
117 if (hit) facts.push({ path, expected: hit[1] })
118 }
119 return facts
120}
121
122/** Ajv and Joi style messages: `data/draft must be boolean`, `"limit" must be a number`. */
123const messageFacts = (text: string): Fact[] => {
124 const facts: Fact[] = []
125 let rest = text
126 for (const match of text.matchAll(/"([\w.[\]-]+)" must be (?:an? )?(boolean|integer|number|array|object)\b/gi)) {
127 facts.push({ path: splitPath(match[1] ?? ''), expected: learnable(match[2])! })
128 rest = rest.replace(match[0], ' ')
129 }
130 for (const match of rest.matchAll(
131 /(?:\b(?:data|instance)((?:[./][\w-]+|\[\d+\])*)|(?:^|\s)((?:\/[\w-]+)+))\s+must be (?:an? )?(boolean|integer|number|array|object)\b/gi,
132 )) {
133 facts.push({ path: splitPath(match[1] ?? match[2] ?? ''), expected: learnable(match[3])! })
134 }
135 for (const match of rest.matchAll(
136 /(?:\b(?:data|instance)((?:[./][\w-]+)*)\s+)?must NOT have additional properties\s*\(?\s*additionalProperty["']?\s*[:=]\s*["']?([\w-]+)/gi,
137 )) {
138 facts.push({ path: splitPath(match[1] ?? ''), rejectKeys: [match[2] ?? ''] })
139 }
140 const unrecognized = /Unrecogni[sz]ed keys?(?:\(s\))? in object: ((?:'[^']+'(?:,\s*)?)+)/i.exec(rest)
141 if (unrecognized) {
142 const keys = [...(unrecognized[1] ?? '').matchAll(/'([^']+)'/g)].map(key => key[1] ?? '')
143 facts.push({ path: [], rejectKeys: keys })
144 }
145 return facts
146}
147
148/** What the error text says about the expected shape; empty when nothing usable. */
149export const factsFrom = (text: string): Fact[] => {
150 if (!isSchemaError(text)) return []
151 const zod = zodFacts(text)
152 if (zod !== undefined && zod.length > 0) return zod
153 const pydantic = pydanticFacts(text)
154 if (pydantic.length > 0) return pydantic
155 return messageFacts(text)
156}
157
158const isIndex = (part: string) => /^\d+$/.test(part)
159
160/** Path parts that would walk onto a prototype; the text comes from the server, so never trusted. */
161const UNSAFE_PARTS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype'])
162
163const ownChild = (properties: Record<string, JsonSchema>, key: string): JsonSchema => {
164 if (!Object.prototype.hasOwnProperty.call(properties, key)) properties[key] = {}
165 return properties[key] as JsonSchema
166}
167
168/** Folds facts into a (partial) schema; returns a new schema, never mutating `base`. */
169export const applyFacts = (base: JsonSchema | undefined, facts: readonly Fact[]): JsonSchema => {
170 const schema: JsonSchema = base === undefined ? { type: 'object', properties: {} } : JSON.parse(JSON.stringify(base))
171 for (const fact of facts) {
172 if (fact.path.some(part => UNSAFE_PARTS.has(part))) continue
173 let node = schema
174 for (const part of fact.path) {
175 if (isIndex(part)) {
176 node.type = 'array'
177 const items = typeof node.items === 'object' && !Array.isArray(node.items) ? node.items : {}
178 node.items = items
179 node = items
180 } else {
181 if (node.type === undefined || node.type !== 'object') node.type = 'object'
182 node = ownChild((node.properties ??= {}), part)
183 }
184 }
185 if ('expected' in fact) {
186 node.type = fact.expected
187 } else {
188 node.type = 'object'
189 node.properties ??= {}
190 node['x-mender-reject'] = [...new Set([...(node['x-mender-reject'] ?? []), ...fact.rejectKeys])]
191 }
192 }
193 return schema
194}
195
196const typesOf = (schema: JsonSchema): string[] =>
197 typeof schema.type === 'string' ? [schema.type] : Array.isArray(schema.type) ? schema.type.filter(one => typeof one === 'string') : []
198
199/**
200 * Lays a learned shape over a schema from the file, returning a new schema.
201 * What the server said wins: a type it asked for replaces one the file does not
202 * already allow, and keys it rejected are added. Everything else in the file
203 * (closed objects, enums, unions) stays.
204 */
205export const overlayLearned = (base: JsonSchema, learned: JsonSchema): JsonSchema => {
206 const out: JsonSchema = { ...base }
207 const wanted = typesOf(learned)
208 if (wanted.length > 0 && !wanted.every(type => typesOf(base).includes(type))) {
209 out.type = learned.type
210 delete out.anyOf
211 delete out.oneOf
212 }
213 const rejected = learned['x-mender-reject']
214 if (Array.isArray(rejected) && rejected.length > 0) {
215 out['x-mender-reject'] = [...new Set([...(base['x-mender-reject'] ?? []), ...rejected])]
216 }
217 if (learned.properties !== undefined) {
218 const properties: Record<string, JsonSchema> = { ...(base.properties ?? {}) }
219 for (const [key, child] of Object.entries(learned.properties)) {
220 if (UNSAFE_PARTS.has(key)) continue
221 const own = Object.prototype.hasOwnProperty.call(properties, key) ? properties[key] : undefined
222 properties[key] = overlayLearned(own ?? {}, child)
223 }
224 out.properties = properties
225 }
226 if (learned.items !== undefined && !Array.isArray(learned.items)) {
227 const items = base.items !== undefined && !Array.isArray(base.items) ? base.items : {}
228 out.items = overlayLearned(items, learned.items)
229 }
230 return out
231}
232hooks/pixels.ts 77 lines1// PICO-8 palette and a tiny half-block sprite renderer: two pixel rows per
2// terminal row, the top pixel as the text color, the bottom as the background.
3
4export const PICO8 = {
5 k: '#000000', // black
6 n: '#1D2B53', // navy
7 p: '#7E2553', // plum
8 g: '#008751', // green
9 b: '#AB5236', // brown (mender's signature)
10 d: '#5F574F', // dark grey
11 l: '#C2C3C7', // light grey
12 w: '#FFF1E8', // white
13 r: '#FF004D', // red
14 o: '#FFA300', // orange (mender's signature)
15 y: '#FFEC27', // yellow
16 i: '#00E436', // lime
17 u: '#29ADFF', // blue
18 v: '#83769C', // lavender
19 m: '#FF77A8', // pink
20 e: '#FFCCAA', // peach
21} as const
22
23export type Run = { text: string; color?: string; backgroundColor?: string }
24
25const colorOf = (pixel: string | undefined): string | undefined =>
26 pixel === undefined || pixel === '.' ? undefined : (PICO8 as Record<string, string>)[pixel]
27
28/** Turns a grid of palette keys ('.' transparent) into rows of merged runs. */
29export const spriteRuns = (grid: readonly string[]): Run[][] => {
30 const rows: Run[][] = []
31 for (let y = 0; y < grid.length; y += 2) {
32 const top = grid[y] ?? ''
33 const bottom = grid[y + 1] ?? ''
34 const width = Math.max(top.length, bottom.length)
35 const runs: Run[] = []
36 for (let x = 0; x < width; x += 1) {
37 const up = colorOf(top[x])
38 const down = colorOf(bottom[x])
39 const cell: Run =
40 up === undefined && down === undefined
41 ? { text: ' ' }
42 : up === undefined
43 ? { text: '▄', color: down }
44 : down === undefined
45 ? { text: '▀', color: up }
46 : { text: '▀', color: up, backgroundColor: down }
47 const last = runs[runs.length - 1]
48 if (last && last.text[0] === cell.text && last.color === cell.color && last.backgroundColor === cell.backgroundColor) {
49 last.text += cell.text
50 } else {
51 runs.push(cell)
52 }
53 }
54 rows.push(runs)
55 }
56 return rows
57}
58
59/** An orange sock with a brown patch, stitched in yellow: 12 x 12 pixels, 6 terminal rows. */
60export const SOCK_SPRITE: readonly string[] = [
61 '..wwwwww....',
62 '..llllll....',
63 '..oooooo....',
64 '..oooooo....',
65 '..obbbbo....',
66 '..obybyo....',
67 '..obbbbo....',
68 '..ooooooo...',
69 '..oooooooo..',
70 '..ooooooooo.',
71 '...ooooooooo',
72 '....bbbbbbb.',
73]
74
75/** The same sock, grey, when a server is down. */
76export const DOWN_SOCK_SPRITE: readonly string[] = SOCK_SPRITE.map(line => line.replace(/[oy]/g, 'd').replace(/[bw]/g, 'l'))
77hooks/schema.ts 263 lines1// Repairs an MCP tool's arguments against a JSON schema: only known-safe
2// mistakes, only where the value does not already fit, never an invented value.
3
4/** The subset of JSON Schema mender reads. Anything else is ignored. */
5export type JsonSchema = {
6 type?: string | string[]
7 properties?: Record<string, JsonSchema>
8 additionalProperties?: boolean | JsonSchema
9 patternProperties?: Record<string, JsonSchema>
10 items?: JsonSchema | JsonSchema[]
11 anyOf?: JsonSchema[]
12 oneOf?: JsonSchema[]
13 enum?: unknown[]
14 required?: string[]
15 /** Keys the server itself rejected (learned from its validation errors). */
16 'x-mender-reject'?: string[]
17 [keyword: string]: unknown
18}
19
20export type RepairKind = 'boolean' | 'number' | 'array' | 'object' | 'drop'
21
22export type Repair = {
23 /** Where, as `filter.open` or `ids[0]`. */
24 path: string
25 kind: RepairKind
26 /** The value as the model sent it, as JSON. */
27 from: string
28 /** The value as it went to the tool, as JSON ('' for a dropped key). */
29 to: string
30}
31
32type JsonType = 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null'
33
34const NUMERIC = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
35
36/**
37 * True when a numeric string survives the trip to a JS number digit for digit:
38 * a whole number within 2^53 - 1, or a fraction of at most 15 significant
39 * digits. A 19-digit ID must not quietly become a different ID.
40 */
41const isExact = (numeric: string): boolean => {
42 const [mantissa = ''] = numeric.replace(/^-/, '').split(/[eE]/)
43 const digits = mantissa.replace('.', '').replace(/^0+/, '').replace(/0+$/, '')
44 const value = Number(numeric)
45 return Number.isInteger(value) ? Number.isSafeInteger(value) && digits.length <= 16 : digits.length <= 15
46}
47
48const isPlainObject = (value: unknown): value is Record<string, unknown> =>
49 typeof value === 'object' && value !== null && !Array.isArray(value)
50
51const asSchema = (value: unknown): JsonSchema | undefined => (isPlainObject(value) ? (value as JsonSchema) : undefined)
52
53/** Short JSON for a repair line. */
54export const shortJson = (value: unknown, max = 40): string => {
55 let text: string
56 try {
57 text = JSON.stringify(value) ?? String(value)
58 } catch {
59 text = String(value)
60 }
61 return text.length > max ? `${text.slice(0, max - 1)}…` : text
62}
63
64const branchesOf = (schema: JsonSchema): JsonSchema[] =>
65 [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].map(asSchema).filter((one): one is JsonSchema => one !== undefined)
66
67/** The JSON types a schema node allows, or undefined when it says nothing usable. */
68const allowedTypes = (schema: JsonSchema): Set<JsonType> | undefined => {
69 if (typeof schema.type === 'string') return new Set([schema.type as JsonType])
70 if (Array.isArray(schema.type)) return new Set(schema.type.filter((one): one is JsonType => typeof one === 'string'))
71 const branches = branchesOf(schema)
72 if (branches.length > 0) {
73 const all = new Set<JsonType>()
74 for (const branch of branches) {
75 const types = allowedTypes(branch)
76 if (types === undefined) return undefined
77 for (const type of types) all.add(type)
78 }
79 return all
80 }
81 if (schema.properties !== undefined) return new Set(['object'])
82 if (schema.items !== undefined) return new Set(['array'])
83 return undefined
84}
85
86const fitsType = (value: unknown, type: JsonType): boolean => {
87 switch (type) {
88 case 'string':
89 return typeof value === 'string'
90 case 'number':
91 return typeof value === 'number' && Number.isFinite(value)
92 case 'integer':
93 return typeof value === 'number' && Number.isInteger(value)
94 case 'boolean':
95 return typeof value === 'boolean'
96 case 'array':
97 return Array.isArray(value)
98 case 'object':
99 return isPlainObject(value)
100 case 'null':
101 return value === null
102 }
103}
104
105const fitsEnum = (schema: JsonSchema, value: unknown): boolean =>
106 !Array.isArray(schema.enum) || schema.enum.some(one => JSON.stringify(one) === JSON.stringify(value))
107
108/** The branch to descend into for a value of this type (the node itself when it has no branches). */
109const shapeFor = (schema: JsonSchema, type: 'object' | 'array'): JsonSchema => {
110 const branches = branchesOf(schema)
111 if (branches.length === 0) return schema
112 return branches.find(branch => allowedTypes(branch)?.has(type)) ?? schema
113}
114
115const join = (path: string, key: string) => (path === '' ? key : `${path}.${key}`)
116
117const parseJson = (text: string): unknown => {
118 try {
119 return JSON.parse(text)
120 } catch {
121 return undefined
122 }
123}
124
125const repairObject = (schema: JsonSchema, value: Record<string, unknown>, path: string, repairs: Repair[]) => {
126 const shape = shapeFor(schema, 'object')
127 const properties = asSchema(shape.properties) as Record<string, JsonSchema> | undefined
128 const rejected = Array.isArray(shape['x-mender-reject']) ? shape['x-mender-reject'] : []
129 const isClosed =
130 shape.additionalProperties === false && properties !== undefined && Object.keys(shape.patternProperties ?? {}).length === 0
131 const out: Record<string, unknown> = {}
132 for (const [key, child] of Object.entries(value)) {
133 const known = properties !== undefined && Object.prototype.hasOwnProperty.call(properties, key)
134 if ((!known && isClosed) || (!known && rejected.includes(key))) {
135 repairs.push({ path: join(path, key), kind: 'drop', from: shortJson(child), to: '' })
136 continue
137 }
138 const childSchema = known ? asSchema(properties?.[key]) : undefined
139 const repaired = childSchema === undefined ? child : repairValue(childSchema, child, join(path, key), repairs)
140 // defineProperty keeps a key such as `__proto__` an own key, as the model sent it.
141 Object.defineProperty(out, key, { value: repaired, enumerable: true, writable: true, configurable: true })
142 }
143 return out
144}
145
146const repairArray = (schema: JsonSchema, value: unknown[], path: string, repairs: Repair[]) => {
147 const items = asSchema(shapeFor(schema, 'array').items)
148 if (items === undefined) return value
149 return value.map((item, index) => repairValue(items, item, `${path}[${index}]`, repairs))
150}
151
152/** Tries to turn `value` into one of `types`; undefined when no safe coercion fits. */
153const coerce = (schema: JsonSchema, value: unknown, types: Set<JsonType>, path: string): { value: unknown; kind: RepairKind } | undefined => {
154 if (typeof value === 'string') {
155 const trimmed = value.trim()
156 const lower = trimmed.toLowerCase()
157 if (types.has('boolean') && (lower === 'true' || lower === 'false')) {
158 const candidate = lower === 'true'
159 if (fitsEnum(schema, candidate)) return { value: candidate, kind: 'boolean' }
160 }
161 if ((types.has('number') || types.has('integer')) && NUMERIC.test(trimmed) && isExact(trimmed)) {
162 const candidate = Number(trimmed)
163 const fits = types.has('number') ? Number.isFinite(candidate) : Number.isSafeInteger(candidate)
164 if (fits && fitsEnum(schema, candidate)) return { value: candidate, kind: 'number' }
165 }
166 if (types.has('object') && trimmed.startsWith('{')) {
167 const parsed = parseJson(trimmed)
168 if (isPlainObject(parsed)) return { value: parsed, kind: 'object' }
169 }
170 if (types.has('array') && trimmed.startsWith('[')) {
171 const parsed = parseJson(trimmed)
172 if (Array.isArray(parsed)) return { value: parsed, kind: 'array' }
173 }
174 }
175 if (types.has('array') && !Array.isArray(value) && value !== undefined) {
176 // A single value wraps only when it fits (or can be made to fit) the items.
177 const items = asSchema(shapeFor(schema, 'array').items)
178 const itemTypes = items === undefined ? undefined : allowedTypes(items)
179 if (itemTypes === undefined || [...itemTypes].some(type => fitsType(value, type))) return { value: [value], kind: 'array' }
180 if (items !== undefined && coerce(items, value, itemTypes, path) !== undefined) return { value: [value], kind: 'array' }
181 }
182 return undefined
183}
184
185const repairValue = (schema: JsonSchema, value: unknown, path: string, repairs: Repair[]): unknown => {
186 const types = allowedTypes(schema)
187 if (types === undefined) return value
188 const fitting = [...types].find(type => fitsType(value, type))
189 if (fitting !== undefined) {
190 if (isPlainObject(value) && types.has('object')) return repairObject(schema, value, path, repairs)
191 if (Array.isArray(value) && types.has('array')) return repairArray(schema, value, path, repairs)
192 return value
193 }
194 const coerced = coerce(schema, value, types, path)
195 if (coerced === undefined) return value
196 repairs.push({ path, kind: coerced.kind, from: shortJson(value), to: shortJson(coerced.value) })
197 if (isPlainObject(coerced.value)) return repairObject(schema, coerced.value, path, repairs)
198 if (Array.isArray(coerced.value)) return repairArray(schema, coerced.value, path, repairs)
199 return coerced.value
200}
201
202/**
203 * Repairs `args` against the tool's input schema. Returns new arguments and the
204 * repairs made; the input is never mutated, and a value that already fits the
205 * schema is never changed.
206 */
207export const repairArguments = (
208 schema: JsonSchema,
209 args: Record<string, unknown>,
210): { args: Record<string, unknown>; repairs: Repair[] } => {
211 const repairs: Repair[] = []
212 const root: JsonSchema = allowedTypes(schema) === undefined ? { ...schema, type: 'object' } : schema
213 const repaired = repairValue(root, args, '', repairs)
214 return { args: isPlainObject(repaired) ? repaired : args, repairs }
215}
216
217/**
218 * Reads a schema file. Two shapes, which may be mixed:
219 * - a map of full tool names to input schemas: `{ "mcp__notes__create": { ... } }`
220 * - a server's `tools/list` answer: `{ "servers": { "notes": { "tools": [{ "name", "inputSchema" }] } } }`
221 */
222export const parseSchemaFile = (text: string): { schemas: Record<string, JsonSchema>; problems: string[] } => {
223 let data: unknown
224 try {
225 data = JSON.parse(text)
226 } catch (error) {
227 return { schemas: {}, problems: [`not JSON (${error instanceof Error ? error.message : String(error)})`] }
228 }
229 if (!isPlainObject(data)) return { schemas: {}, problems: ['not a JSON object'] }
230 const schemas: Record<string, JsonSchema> = {}
231 const problems: string[] = []
232 for (const [key, value] of Object.entries(data)) {
233 if (key === 'servers') {
234 if (!isPlainObject(value)) {
235 problems.push('"servers" is not an object')
236 continue
237 }
238 for (const [server, listing] of Object.entries(value)) {
239 const tools = isPlainObject(listing) ? listing.tools : listing
240 if (!Array.isArray(tools)) {
241 problems.push(`servers.${server} has no "tools" list`)
242 continue
243 }
244 for (const tool of tools) {
245 const name = isPlainObject(tool) ? tool.name : undefined
246 const schema = isPlainObject(tool) ? asSchema(tool.inputSchema ?? tool.input_schema) : undefined
247 if (typeof name !== 'string' || schema === undefined) problems.push(`servers.${server}: a tool without a name or inputSchema`)
248 else schemas[`mcp__${server}__${name}`] = schema
249 }
250 }
251 continue
252 }
253 if (!key.startsWith('mcp__')) {
254 problems.push(`"${key}" is not an MCP tool name (mcp__<server>__<tool>)`)
255 continue
256 }
257 const schema = asSchema(value)
258 if (schema === undefined) problems.push(`"${key}" is not a schema object`)
259 else schemas[key] = schema
260 }
261 return { schemas, problems }
262}
263hooks/text.ts 68 lines1// The words mender says: to the model (context notes), to you (status, list).
2
3import type { Fact } from './learn'
4import type { Repair } from './schema'
5import { shortJson } from './schema'
6
7export const repairLine = (repair: Repair): string =>
8 repair.kind === 'drop'
9 ? `${repair.path}: dropped (the schema allows no such key)`
10 : `${repair.path}: ${repair.from} → ${repair.to} (${repair.kind})`
11
12/** The model-visible note for a call mender repaired before it ran. */
13export const repairNote = (tool: string, repairs: readonly Repair[]): string =>
14 [
15 `MENDER repaired the arguments of ${tool} before it ran, against the tool's input schema:`,
16 ...repairs.map(repair => `- ${repairLine(repair)}`),
17 'Next time send them in this shape.',
18 ].join('\n')
19
20export const factText = (fact: Fact): string => {
21 const where = fact.path.join('.')
22 if ('expected' in fact) return `${where === '' ? 'the arguments' : where} must be ${fact.expected}`
23 return `no key ${fact.rejectKeys.join(', ')} ${where === '' ? 'at the top level' : `in ${where}`}`
24}
25
26/** The model-visible note after a schema error mender learned from. */
27export const learnedNote = (tool: string, facts: readonly Fact[], repaired?: Record<string, unknown>): string =>
28 [
29 `MENDER: ${tool} rejected the shape of its arguments. The error says:`,
30 ...facts.map(fact => `- ${factText(fact)}`),
31 repaired === undefined
32 ? 'mender will repair this shape on later calls.'
33 : `mender will repair this shape on later calls. The same call in the right shape: ${shortJson(repaired, 600)}`,
34 ].join('\n')
35
36/** The model-visible note when a server looks down. */
37export const downNote = (server: string, reason: string): string =>
38 `MENDER: the MCP server "${server}" looks down (${reason}). Stop retrying its tools: tell the user to reconnect it with /mcp, then carry on.`
39
40export type StatusCounts = { fixed: number; errors: number; down: number }
41
42export const statusText = ({ fixed, errors, down }: StatusCounts): string | undefined => {
43 const parts = [
44 fixed > 0 ? `${fixed} FIXED` : '',
45 errors > 0 ? `${errors} ERR` : '',
46 down > 0 ? `${down} DOWN` : '',
47 ].filter(part => part !== '')
48 return parts.length === 0 ? undefined : `MENDER ▸ ${parts.join(' · ')}`
49}
50
51/** `mcp__notes__create_note` → `notes/create_note`. */
52export const shortTool = (tool: string): string => {
53 if (!tool.startsWith('mcp__')) return tool
54 const [, server = '', ...rest] = tool.split('__')
55 return `${server}/${rest.join('__')}`
56}
57
58export const clockText = (at: number): string => {
59 const date = new Date(at)
60 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
61}
62
63/** One line, at most `max` characters. */
64export const oneLine = (text: string, max = 80): string => {
65 const flat = text.replace(/\s+/g, ' ').trim()
66 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
67}
68types/index.d.ts 38 lines1// mender's $.state contract. Values here live for the session and survive a
2// hot reload; learned shapes are mirrored to $.store so they outlive it.
3
4/** A JSON schema object, as the schema file or a learned shape holds it. */
5export type MenderSchema = Record<string, unknown>
6
7/** One argument mender changed: where, what kind, from and to (as JSON). */
8export type MenderRepair = { path: string; kind: 'boolean' | 'number' | 'array' | 'object' | 'drop'; from: string; to: string }
9
10/** One repaired call. */
11export type MenderRepairEntry = { at: number; tool: string; repairs: MenderRepair[] }
12
13/** Schema errors one tool kept returning this session. */
14export type MenderToolErrors = { count: number; lastAt: number; last: string }
15
16declare module 'claude-code' {
17 interface PluginState {
18 mender: {
19 /** Recent repaired calls, newest last (capped). */
20 repairs: MenderRepairEntry[]
21 /** Calls repaired this session. */
22 fixed: number
23 /** Schema/validation errors that still came back, per tool. */
24 errors: Record<string, MenderToolErrors>
25 /** Servers that look down, with why; cleared when a call to one succeeds. */
26 down: Record<string, string>
27 /** Schemas from the schema file, per tool. */
28 schemas: Record<string, MenderSchema>
29 /** What the schema file load said ('' when it loaded or is absent). */
30 schemaProblem: string
31 /** Shapes learned from validation errors (mirrors $.store). */
32 learned: Record<string, MenderSchema>
33 /** `/mender off` for this session. */
34 isOff: boolean
35 }
36 }
37}
38