ai
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 annotations — readOnlyHint,
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.
Tagged
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


