Build a local TypeScript server
Add the search_notes tool
Register a read-only search tool with constrained input and output schemas that describe the complete public contract.
8 minute lesson
Our first tool answers one narrow question: which notes mention a term?
Inside createServer(), before return server, add:
const noteSummarySchema = z.object({
id: z.string(),
title: z.string(),
tags: z.array(z.string())
})
const searchOutput = z.object({
results: z.array(noteSummarySchema)
})
server.registerTool(
'search_notes',
{
description: 'Search project notes by title, tag, or body without changing them',
inputSchema: z.object({
query: z.string().trim().min(1).max(100).describe('Text to find'),
limit: z.number().int().min(1).max(10).default(5)
}),
outputSchema: searchOutput,
annotations: {
readOnlyHint: true,
destructiveHint: false
}
},
async ({ query, limit }) => {
const needle = query.toLowerCase()
const results = notes
.filter(note => [note.title, note.body, ...note.tags]
.some(value => value.toLowerCase().includes(needle)))
.slice(0, limit)
.map(({ id, title, tags }) => ({ id, title, tags }))
const output = { results }
return {
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
structuredContent: output
}
}
)
The input schema is an executable boundary. It trims the query, rejects empty or oversized text, caps the result count, and gives limit a predictable default. Invalid arguments never reach the handler.
The output has two forms for a reason. structuredContent gives clients a machine-readable object. The JSON text block keeps the result useful to clients that only display content blocks.
The output schema checks the server too. If the handler accidentally returns name instead of title, the mismatch becomes visible instead of silently changing the public contract.
Tool annotations help a client present the operation, but they are hints. They do not enforce read-only behavior. The handler and its backend credentials must make that claim true.
Run npm run check. Then test these cases later in the Inspector: a match, no matches, whitespace-only input, and limit: 11.
Lesson completed