feat(mcp): worked examples on five high-traffic tools, and in the error that rejects a call (#2100)
#2066 asked for input_examples on the top ~20 tools by call volume. The binding constraint turned out to be budget, not writing: after #2089 reclaimed 3 763 tokens, its own policy required ratcheting the tools/list ceiling down with it, so the real headroom was 317 tokens. Ten examples across five tools cost 199, leaving ~118. The ceiling is not raised. Tools picked from 30 days of mcp.tool_called crossed with the combinations the descriptions already warn about and callers still get wrong: account_override without an explicit vat_treatment (books gross, no moms line), representation without deltagare and syfte, confirmed on a high-risk approval, a balanced voucher where the moms leg is its own line, and get_kpi_report, where one caller sent `metric` 604 times over seven days to a tool whose only parameter is period_id. Examples are also surfaced in the unknown-parameter error. That costs nothing in tools/list, because it only ships on the response to a call that already failed, and it reaches the caller that most needs it: a key list told DueCue's agent which parameter was wrong but not what a correct call looks like, and the same rejected call repeated for a week. Every example is validated against its own schema by the same findUnknownArgKeys guard the server runs, plus required/type/enum/pattern checks. An example our own boundary would reject is worse than none: it teaches the exact mistake the guard then punishes. That test caught three invented enum values in this change's own first draft. Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
e5fba9471e
commit
169e7eaf4e
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tools, isDefaultCatalogTool } from '../server'
|
||||
import { findUnknownArgKeys, shortestExampleFor } from '../arg-guard'
|
||||
|
||||
/**
|
||||
* Worked `examples` on the tool inputSchema (#2066).
|
||||
*
|
||||
* An example is a call an agent will copy. If our own boundary would reject
|
||||
* it, the example is worse than none: it teaches the exact mistake the guard
|
||||
* then punishes. So every example is checked against the schema it ships with,
|
||||
* with the same unknown-key rule the server enforces at runtime.
|
||||
*/
|
||||
|
||||
interface JsonSchema {
|
||||
type?: string
|
||||
properties?: Record<string, JsonSchema>
|
||||
required?: string[]
|
||||
enum?: unknown[]
|
||||
items?: JsonSchema
|
||||
additionalProperties?: unknown
|
||||
pattern?: string
|
||||
examples?: unknown[]
|
||||
}
|
||||
|
||||
const withExamples = tools
|
||||
.map((t) => ({ name: t.name, schema: t.inputSchema as JsonSchema }))
|
||||
.filter((t) => Array.isArray(t.schema.examples))
|
||||
|
||||
/** Placeholder ids are deliberately short ("3f1a..."): they must not read as real UUIDs. */
|
||||
const PLACEHOLDER = /^[0-9a-f]{4}\.\.\.$/
|
||||
|
||||
function typeOf(value: unknown): string {
|
||||
if (Array.isArray(value)) return 'array'
|
||||
if (value === null) return 'null'
|
||||
return typeof value
|
||||
}
|
||||
|
||||
function checkValue(path: string, value: unknown, schema: JsonSchema, problems: string[]): void {
|
||||
if (schema.type && schema.type !== typeOf(value)) {
|
||||
// A placeholder id stands in for a UUID string; still a string.
|
||||
problems.push(`${path}: expected ${schema.type}, got ${typeOf(value)}`)
|
||||
return
|
||||
}
|
||||
if (schema.enum && !schema.enum.includes(value)) {
|
||||
problems.push(`${path}: ${JSON.stringify(value)} is not in the declared enum`)
|
||||
}
|
||||
if (schema.pattern && typeof value === 'string' && !new RegExp(schema.pattern).test(value)) {
|
||||
problems.push(`${path}: ${JSON.stringify(value)} does not match ${schema.pattern}`)
|
||||
}
|
||||
if (schema.type === 'array' && Array.isArray(value) && schema.items) {
|
||||
value.forEach((item, i) => checkValue(`${path}[${i}]`, item, schema.items!, problems))
|
||||
}
|
||||
if (schema.type === 'object' && schema.properties && value && typeof value === 'object') {
|
||||
checkObject(path, value as Record<string, unknown>, schema, problems)
|
||||
}
|
||||
}
|
||||
|
||||
function checkObject(path: string, value: Record<string, unknown>, schema: JsonSchema, problems: string[]): void {
|
||||
for (const req of schema.required ?? []) {
|
||||
if (!(req in value)) problems.push(`${path}: missing required property "${req}"`)
|
||||
}
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const propSchema = schema.properties?.[key]
|
||||
if (!propSchema) continue
|
||||
checkValue(`${path}.${key}`, val, propSchema, problems)
|
||||
}
|
||||
}
|
||||
|
||||
describe('inputSchema examples are calls the server would accept', () => {
|
||||
it('ships examples on at least the tools this change targeted', () => {
|
||||
// Pinned so a rename or a schema rewrite cannot silently drop them.
|
||||
expect(withExamples.map((t) => t.name).sort()).toEqual([
|
||||
'gnubok_approve_pending_operation',
|
||||
'gnubok_categorize_transaction',
|
||||
'gnubok_create_voucher',
|
||||
'gnubok_get_kpi_report',
|
||||
'gnubok_query_journal',
|
||||
])
|
||||
})
|
||||
|
||||
it('every example survives the unknown-parameter guard that rejects real calls', () => {
|
||||
const offenders: string[] = []
|
||||
for (const { name, schema } of withExamples) {
|
||||
for (const [i, example] of (schema.examples ?? []).entries()) {
|
||||
const unknown = findUnknownArgKeys(schema as Record<string, unknown>, example as Record<string, unknown>)
|
||||
if (unknown.length > 0) offenders.push(`${name}[${i}]: ${unknown.join(', ')}`)
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
|
||||
it('every example satisfies required properties, declared types, enums and patterns', () => {
|
||||
const problems: string[] = []
|
||||
for (const { name, schema } of withExamples) {
|
||||
for (const [i, example] of (schema.examples ?? []).entries()) {
|
||||
checkObject(`${name}[${i}]`, example as Record<string, unknown>, schema, problems)
|
||||
}
|
||||
}
|
||||
expect(problems).toEqual([])
|
||||
})
|
||||
|
||||
it('examples live only on tools the default catalog actually publishes', () => {
|
||||
// An example on a search-only tool is budget spent where no agent reads it.
|
||||
const hidden = withExamples.filter(({ name }) => {
|
||||
const tool = tools.find((t) => t.name === name)!
|
||||
return !isDefaultCatalogTool(tool)
|
||||
})
|
||||
expect(hidden.map((t) => t.name)).toEqual([])
|
||||
})
|
||||
|
||||
it('uses obvious placeholders for ids, never invented UUIDs', () => {
|
||||
// A real-looking UUID in an example gets copied verbatim and 404s; worse,
|
||||
// it could name a row in some other tenant.
|
||||
const suspicious: string[] = []
|
||||
const walk = (label: string, value: unknown) => {
|
||||
if (typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}/.test(value)) suspicious.push(`${label}=${value}`)
|
||||
else if (Array.isArray(value)) value.forEach((v, i) => walk(`${label}[${i}]`, v))
|
||||
else if (value && typeof value === 'object') {
|
||||
for (const [k, v] of Object.entries(value)) walk(`${label}.${k}`, v)
|
||||
}
|
||||
}
|
||||
for (const { name, schema } of withExamples) {
|
||||
for (const [i, example] of (schema.examples ?? []).entries()) walk(`${name}[${i}]`, example)
|
||||
}
|
||||
expect(suspicious).toEqual([])
|
||||
// And the placeholders we do use are recognisable as placeholders.
|
||||
const ids = (withExamples.find((t) => t.name === 'gnubok_categorize_transaction')!.schema.examples ?? [])
|
||||
.map((e) => (e as Record<string, string>).transaction_id)
|
||||
expect(ids.every((id) => PLACEHOLDER.test(id))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortestExampleFor: examples reach the caller that already failed', () => {
|
||||
it('picks the shortest example, so the error stays readable', () => {
|
||||
const schema = {
|
||||
examples: [{ a: 1, b: 2, c: 3, d: 4 }, { a: 1 }],
|
||||
} as Record<string, unknown>
|
||||
expect(shortestExampleFor(schema)).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('returns nothing when the tool publishes no examples', () => {
|
||||
expect(shortestExampleFor({})).toBe('')
|
||||
expect(shortestExampleFor({ examples: [] })).toBe('')
|
||||
})
|
||||
|
||||
it('declines an example too long to help inside an error message', () => {
|
||||
const long = { note: 'x'.repeat(400) }
|
||||
expect(shortestExampleFor({ examples: [long] })).toBe('')
|
||||
})
|
||||
|
||||
it('gives the kpi-report caller the empty-object shape it needed', () => {
|
||||
// The exact prod case: `metric` sent to a tool whose only parameter is
|
||||
// period_id, 604 times over seven days.
|
||||
const kpi = tools.find((t) => t.name === 'gnubok_get_kpi_report')!
|
||||
expect(shortestExampleFor(kpi.inputSchema as Record<string, unknown>)).toBe('{}')
|
||||
})
|
||||
})
|
||||
@@ -328,6 +328,19 @@ describe('tools/list payload size guard', () => {
|
||||
// zero. Demoting those would hide the year-end flow exactly when it
|
||||
// is needed. Usage data is necessary here, not sufficient.
|
||||
//
|
||||
// * 61.3K to 61.5K by adding worked `examples` to five tools
|
||||
// (2026-09-01, #2066): categorize_transaction, create_voucher,
|
||||
// query_journal, approve_pending_operation, get_kpi_report. 199 tokens
|
||||
// for 10 examples, spending part of what the demotion above reclaimed
|
||||
// and leaving ~118 under the ceiling. The ceiling is NOT raised.
|
||||
// Examples were priced against 30 days of mcp.tool_called and aimed at
|
||||
// the combinations the descriptions already warn about and callers
|
||||
// still get wrong (account_override without vat_treatment;
|
||||
// representation without deltagare; confirmed on a high-risk approval;
|
||||
// `metric` sent to a tool whose only parameter is period_id).
|
||||
// Cheaper than it looks per example, so the next batch should still
|
||||
// demote a read first rather than assume there is room.
|
||||
//
|
||||
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
|
||||
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
|
||||
// can only invoke what tools/list showed them, which is the constraint that
|
||||
|
||||
@@ -26,3 +26,23 @@ export function findUnknownArgKeys(
|
||||
const allowed = new Set(listArgKeys(inputSchema))
|
||||
return Object.keys(args).filter((key) => key !== 'company_id' && !allowed.has(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortest worked example a tool publishes, serialized for an error
|
||||
* message, or '' when it has none or the shortest is too long to help.
|
||||
*
|
||||
* "Valid parameters: period_id" told DueCue's agent which key was wrong but
|
||||
* not what a correct call looks like, and the same rejected call repeated for
|
||||
* seven days (604 of them). One example costs nothing in tools/list, because
|
||||
* it only ships on the response to a call that already failed.
|
||||
*/
|
||||
export function shortestExampleFor(inputSchema: Record<string, unknown>, maxChars = 200): string {
|
||||
const examples = inputSchema.examples
|
||||
if (!Array.isArray(examples) || examples.length === 0) return ''
|
||||
const serialized = examples
|
||||
.map((e) => JSON.stringify(e))
|
||||
.filter((json): json is string => typeof json === 'string')
|
||||
.sort((a, b) => a.length - b.length)
|
||||
const shortest = serialized.find((json) => json.length <= maxChars)
|
||||
return shortest ?? ''
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ import {
|
||||
projectToolInputSchema,
|
||||
resolveMcpCompanyContext,
|
||||
} from './company-routing'
|
||||
import { findUnknownArgKeys, listArgKeys } from './arg-guard'
|
||||
import { findUnknownArgKeys, listArgKeys, shortestExampleFor } from './arg-guard'
|
||||
import { findSupplierCandidates, type SupplierRow } from './supplier-candidates'
|
||||
import {
|
||||
matchSupplierByIdentity,
|
||||
@@ -5358,6 +5358,14 @@ export const tools: McpTool[] = [
|
||||
idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries: a replayed call returns the already-staged operation instead of staging twice.' },
|
||||
},
|
||||
required: ['transaction_id', 'category'],
|
||||
// The two combinations the prose above describes and callers still get
|
||||
// wrong: an account_override without an explicit vat_treatment (books
|
||||
// GROSS, no moms line), and representation without deltagare + syfte.
|
||||
examples: [
|
||||
{ transaction_id: '3f1a...', category: 'expense_office' },
|
||||
{ transaction_id: '3f1a...', category: 'expense_other', account_override: '4600', vat_treatment: 'standard_25' },
|
||||
{ transaction_id: '3f1a...', category: 'expense_representation', notes: 'Anna Andersson (Acme AB), kundmöte om ramavtal' },
|
||||
],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
@@ -6961,6 +6969,10 @@ export const tools: McpTool[] = [
|
||||
properties: {
|
||||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||||
},
|
||||
// period_id is the whole surface. Callers have shipped `metric` here for
|
||||
// days at a time (604 rejected calls, 2026-08-24 to 08-31): the empty
|
||||
// object is the example that says there is nothing else to pass.
|
||||
examples: [{}, { period_id: '7c2b...' }],
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
annotations: {
|
||||
@@ -9237,6 +9249,12 @@ export const tools: McpTool[] = [
|
||||
group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1-500 (default 100); totals/groups still cover the full match set.' },
|
||||
},
|
||||
// Two shapes that cover most ad-hoc questions: an account range over a
|
||||
// period, and a free-text hunt. status defaults to 'all' on purpose.
|
||||
examples: [
|
||||
{ account_from: '4000', account_to: '4999', date_from: '2026-01-01', date_to: '2026-03-31' },
|
||||
{ text: 'Kjell', status: 'posted' },
|
||||
],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
@@ -18187,6 +18205,19 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
required: ['entry_date', 'description', 'lines'],
|
||||
// Balance is the rule agents break: sum(debit) === sum(credit), and the
|
||||
// moms leg is its own line on its own BAS account, never folded in.
|
||||
examples: [
|
||||
{
|
||||
entry_date: '2026-03-31',
|
||||
description: 'Kontorsmaterial Kjell & Company',
|
||||
lines: [
|
||||
{ account_number: '6110', debit_amount: 400 },
|
||||
{ account_number: '2641', debit_amount: 100 },
|
||||
{ account_number: '1930', credit_amount: 500 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
@@ -19350,6 +19381,9 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
required: ['operation_id'],
|
||||
// confirmed is not optional for a high-risk operation: without it the
|
||||
// approval is refused, which reads to an agent as a permissions problem.
|
||||
examples: [{ operation_id: '9a44...' }, { operation_id: '9a44...', confirmed: true }],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
@@ -21181,10 +21215,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
// VALIDATION_ERROR envelope, never as a half-applied call.
|
||||
const unknownArgKeys = findUnknownArgKeys(tool.inputSchema as Record<string, unknown>, toolArgs)
|
||||
if (unknownArgKeys.length > 0) {
|
||||
// Name the shape, not just the mistake: a caller that already sent a
|
||||
// wrong key has no way to guess the right one from a key list alone.
|
||||
const example = shortestExampleFor(tool.inputSchema as Record<string, unknown>)
|
||||
throw codedError(
|
||||
'VALIDATION_ERROR',
|
||||
`Unknown parameter${unknownArgKeys.length > 1 ? 's' : ''} ${unknownArgKeys.map((k) => `"${k}"`).join(', ')} for ${requestedToolName}. ` +
|
||||
`Valid parameters: ${listArgKeys(tool.inputSchema as Record<string, unknown>).join(', ') || '(none)'}. Unknown keys are rejected, not ignored.`,
|
||||
`Valid parameters: ${listArgKeys(tool.inputSchema as Record<string, unknown>).join(', ') || '(none)'}. Unknown keys are rejected, not ignored.` +
|
||||
(example ? ` A working call looks like: ${example}` : ''),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user