ai

A tool call is just a schema

A tool call is just a schema

Anthropic published the Model Context Protocol at the end of November, and for the first few months I filed it under "another plugin format". Every assistant vendor has one; they last about as long as the vendor's interest does. What changed my mind was March. The spec picked up a proper HTTP transport and an OAuth story, OpenAI said its Agents SDK would speak it, and Cursor and Windsurf already did. When the people who compete with the protocol's author adopt it, it has stopped being a plugin format.

So last weekend I wrote a server. Nothing ambitious: this site has a folder of markdown posts with front matter, and I wanted to ask Claude Desktop questions about them — which posts mention Kafka, what did I write in 2022, is there a tag I have used exactly once. A retrieval problem small enough to fit in a weekend and real enough that I would notice if the answers were wrong.

The protocol took an hour

The TypeScript SDK is @modelcontextprotocol/sdk, and the whole server is one file. You declare tools with a name, a description, and a zod schema for the arguments, hand it a transport, and you are done:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'

const server = new McpServer({ name: 'deyanpeev-posts', version: '0.1.0' })

server.tool(
  'list_posts',
  'List every post with its date, label and tags. Newest first.',
  { tag: z.string().optional().describe('Only posts carrying this tag') },
  async ({ tag }) => ({
    content: [{ type: 'text', text: JSON.stringify(listPosts(tag)) }],
  })
)

server.tool(
  'read_post',
  'Return the full markdown of one post by its slug.',
  { slug: z.string() },
  async ({ slug }) => ({ content: [{ type: 'text', text: readPost(slug) }] })
)

await server.connect(new StdioServerTransport())

Point claude_desktop_config.json at node server.mjs, restart the app, and the tools appear. I used stdio because the client and server were on the same laptop; the new streamable HTTP transport is for when they are not, and I did not need it. Under the hood this is JSON-RPC 2.0 with three verbs I cared about — initialize, tools/list, tools/call — and if you have ever written a language server, the shape is familiar to the point of déjà vu. That is not an accident; the spec says as much.

The thing I want to stress is that there was no clever part. The listing function is fs.readdirSync plus gray-matter, the same two calls the site itself makes at build time. An hour in, it worked. Then I sat with it for the rest of the weekend, and this is where the actual content is.

The description is the API

A tool is a name, a JSON schema, and a paragraph of prose. The model never sees your implementation. It sees the paragraph, and it decides whether to call you based on that paragraph alone. Which means the description is not documentation for the tool — it is the interface, in the same way the type signature is the interface for a function.

My first list_posts description was "Lists posts." Claude called it when I asked about posts, and did not call it when I asked "what have I written about Redis?", because nothing told it that the listing carried tags and labels and would have answered the question. Once the description said what came back and how it was ordered, the model used it correctly, and it used it first, before reaching for read_post on individual files.

I have spent years writing OpenAPI descriptions that no human ever read. It turns out I was writing them for a reader that had not been invented yet, and that reader takes them literally.

Deciding what to expose

This is where the weekend went. The temptation, with a folder of files and a protocol that makes exposing things trivial, is to expose everything: list, read, search, and — why not — write_post, delete_post. It is a personal site. What is the harm.

I did not, and the reasons turned into a checklist I expect to reuse.

Read and write are different products. A read-only server can be handed to any client with no further thought. The moment there is a write tool, every question about prompt injection becomes live: a post I am reading contains text that looks like an instruction, and the model has a tool that acts on instructions. The March spec added annotationsreadOnlyHint, destructiveHint — precisely so a client can treat the two kinds differently, and I set readOnlyHint: true on both of mine. But an annotation is a hint. The stronger guarantee is that the tool does not exist.

Expose operations, not the filesystem. I nearly wrote a generic read_file that took a path. It would have been shorter than read_post. It would also have been a path-traversal bug with extra steps, and it would have made the model's job harder, because "read a file" carries no information about what is in the file. read_post(slug) says exactly what you get. Narrow tools are safer and they are better, which is not a trade-off I am used to getting for free.

Every tool costs context. Each tool's schema and description is sent to the model on every turn. Two tools is nothing. Twenty is a page of text the model reads before it reads your question, and it makes the choice between them worse, not better. I ended with two tools and no search_posts, because listing with a tag filter plus reading covered every question I had actually asked.

Where I landed

I keep coming back to how little of this is about MCP. Swap the transport and the framing, and the questions are the ones you ask about any API you hand to a party you do not fully control: what can it read, what can it change, does the contract say what it does, how much of it does the caller have to hold in its head at once. The protocol made the boring part boring enough that I had a whole weekend left over for the interesting one.

The server lives in a scratch folder, not in the site's repository, and I am not sure it will graduate. What will is the checklist. The next time someone proposes giving a model a tool, the first question is going to be which of these it is: a read that cannot hurt, or a write that needs a reason.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev