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.

Our first tool answers one narrow question: which notes mention a term? That’s it. No reading full notes, no editing. One question, one tool.

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
      }
    }
  )

There’s a lot in there, so let’s go through it.

The input schema is a boundary

The input schema isn’t documentation. It runs. It trims the query, rejects empty or oversized text, caps the result count at ten, and gives limit a default of five. Invalid arguments never reach our handler. We don’t need a single if to defend against them.

Two forms of output

The handler returns the same result twice, on purpose. structuredContent is a machine-readable object a client can use directly. The JSON text block keeps the result useful to clients that only display content blocks.

The output schema also checks us. If the handler returns name instead of title by mistake, the mismatch surfaces right away instead of silently changing the public contract.

Annotations are hints

readOnlyHint and destructiveHint help a client present the operation. They enforce nothing. The handler and the credentials behind it have to make the claim true. Here that’s easy: we read an in-memory array and never write to it.

Run npm run check. When we open the Inspector in a few lessons, searching for deploy should return exactly this:

{
  "results": [
    {
      "id": "deploy-checklist",
      "title": "Deploy checklist",
      "tags": ["deploy", "release"]
    }
  ]
}

Plan to test three more cases then: a term with no matches (an empty results array, not an error), a whitespace-only query (rejected before the handler runs), and limit: 11 (also rejected). If any of them behaves differently, the schema is wrong, not the handler.

Lesson completed