SLOPSHOPPER

mender

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…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-04pourya7/claude-code-mods/mender
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mender
│ ┃ MENDER ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀ MENDER PATCH LOG │ ┃ ▀▀▀▀▀▀ 0 CALLS FIXED ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀ 0 FROM FILE · 0 LEARNED ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▄ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▄ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ REPAIRS │ ┃ NOTHING TO MEND YET. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ RECURRING ERRORS │ ┃ NONE. CLEAN RUN. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ CLEAR LOG ] [ FORGET LEARNED ] [ TURN OFF › /mender │ ⎿ mender: MENDER · 0 calls fixed · 0 tools from the schema file · │ ⎿ mender: REPAIRS │ ⎿ mender: none yet │ ⎿ mender: RECURRING SCHEMA ERRORS │ ⎿ mender: no schema errors this session │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · MENDER
▀▀▀▀▀▀ MENDER PATCH LOG ▀▀▀▀▀▀ 0 CALLS FIXED ▀▀▀▀▀▀ 0 FROM FILE · 0 LEARNED ▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀ ──────────────────────────────────────────────────────── REPAIRS NOTHING TO MEND YET. RECURRING ERRORS NONE. CLEAN RUN. [ CLEAR LOG ] [ FORGET LEARNED ] [ TURN OFF ]
README
█▀▄▀█ █▀▀ █▄ █ █▀▄ █▀▀ █▀█
█ ▀ █ ██▄ █ ▀█ █▄▀ ██▄ █▀▄   PATCH LOG

mender repairing a cart tool call: one tag sent as a string becomes a list and an unknown key is dropped before the call, then the PATCH LOG pane

mender — fix the arguments, not the model

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.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install mender@claude-code-mods

How it behaves

  • Repairs, only against a schema. For a tool whose input schema mender knows, it changes a value only when the value does not already fit the schema:
  • "true" / "false" become booleans where the schema wants a boolean.
  • Numeric strings ("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.
  • A single value becomes [value] where the schema wants an array, when the value fits the array's items. A JSON array string ("[1, 2]") becomes the array.
  • A JSON object string ("{\"pinned\": true}") becomes the object where the schema wants an object.
  • Unknown keys are dropped only when the schema says additionalProperties: false (and has no patternProperties), or when the server itself rejected that key before.
  • Nested objects and array items are repaired the same way, with their path (meta.pinned, ids[0]).
  • Never invents. A missing required key stays missing. A value that already fits stays as it is, even "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.
  • Tells the model. Each repaired call gets a model-only 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.
  • Learns from errors. When an MCP call still fails with a schema or validation message, mender records {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.
  • Notices a server that is down. When an MCP call fails because the server is not connected, refused or dropped the connection, or answered 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.

Where the schemas come from

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:

  1. A schema file (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" } } } }] }
     }
   }
  1. Shapes learned from the tool's own validation errors (see above). A learned shape is partial. It knows only what the errors said, so it never drops a key the server has not rejected.

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.

Commands

CommandWhat it does
/menderOpens the PATCH LOG pane and prints the same as text.
/mender listPrints the repairs and the recurring schema errors per tool.
/mender reloadReads 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 onStops or resumes mender for this session. Off is a pure pass-through: no repairs, no learning, no notes, no toasts and no error log.

Configuration (userConfig)

FieldTypeDefaultMeaning
schemaFilestring~/.claude/mender/schemas.jsonThe JSON file of MCP tool input schemas. ~ is your home directory. A missing file is fine; a bad one is reported once and skipped.
learnbooleantrueLearn 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 UI

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.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsChanges tool callsData 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.

Limits

  • No schema, no repair. Until a tool has an entry in the schema file or has returned a readable validation error, mender passes its calls through unchanged. The first malformed call to an unknown tool still fails once; the next one is repaired.
  • The JSON Schema subset. mender reads 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.
  • Error formats. Learning reads the common validator formats listed above. Other error texts are still recorded as schema errors when they look like one, but teach nothing.
  • Down detection reads error text. A server that fails with an unusual message is not flagged as down, and the patterns are kept narrow on purpose, so a bare 401 or "unauthorized" is never enough.

Development

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.

Source 6 files
hooks/register.tsx 429 lines
1import { 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}
429
hooks/learn.ts 232 lines
1// 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}
232
hooks/pixels.ts 77 lines
1// 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'))
77
hooks/schema.ts 263 lines
1// 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}
263
hooks/text.ts 68 lines
1// 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}
68
types/index.d.ts 38 lines
1// 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