Build a local TypeScript server

Add the get_note tool

Return one complete note by stable ID with structured output and an explicit not-found result.

Search returns stable IDs but no bodies. Now we add the second half: a tool that takes one ID and returns the complete note. Two small tools instead of one big one keeps every search result light.

Add it below search_notes, still inside createServer():

  const noteSchema = z.object({
    id: z.string(),
    title: z.string(),
    tags: z.array(z.string()),
    body: z.string()
  })

  server.registerTool(
    'get_note',
    {
      description: 'Read one project note by its exact ID',
      inputSchema: z.object({ id: z.string().trim().min(1).max(100) }),
      outputSchema: z.object({ note: noteSchema }),
      annotations: {
        readOnlyHint: true,
        destructiveHint: false
      }
    },
    async ({ id }) => {
      const note = notes.find(candidate => candidate.id === id)

      if (!note) {
        return {
          content: [{ type: 'text', text: `No note found with ID: ${id}` }],
          isError: true
        }
      }

      const output = { note }
      return {
        content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
        structuredContent: output
      }
    }
  )

The lookup is an exact match on id. No prefix search, no fuzzy matching. If the caller wants to discover IDs, that’s what search_notes is for.

Two kinds of failure

This tool can fail in two different ways, and I want you to see the difference.

An empty or malformed id fails schema validation. The handler never runs. The SDK answers with a validation error.

A well-formed ID that doesn’t exist, like missing-note, passes validation and reaches the handler. The handler returns a tool execution error: a normal result with isError: true and a plain text message.

Why does this matter? Because the model reading the result gets a useful correction path in both cases. A validation error says “fix the argument shape”. A not-found error says “this ID is wrong, search again”. Returning an empty success would hide the difference, and the model would have to guess.

Don’t turn errors into discovery

It’s tempting to list every known ID in the not-found message. Don’t. On a private server that turns one failed lookup into a way to enumerate the whole dataset. The search tool is the authorized discovery path. The error only needs to say the ID wasn’t found.

Run npm run check. In the Inspector, calling get_note with deploy-checklist returns this:

{
  "note": {
    "id": "deploy-checklist",
    "title": "Deploy checklist",
    "tags": ["deploy", "release"],
    "body": "Run tests, build the app, then verify the health check."
  }
}

Then call it with missing-note and with an empty string. Only the first call should contain structuredContent. The second returns isError: true, and the third never reaches the handler at all.

Lesson completed