Building Your Own MCP Server in an Hour: A Note Store for the Agent

What actually goes into a minimal MCP server: SDK selection, tool design, validation and the mistakes worth making once.

5 min read
  • #AI Engineering
  • #Mcp
  • #Self Hosting

After the article about the new MCP specification, one question remained: how much work does it really take to build a server of your own? The answer from this experiment is straightforward. The server in this article took less than an hour, including tests. It has two tools, roughly ninety lines of code and communicates over stdio. The interesting part is not the code but where the actual work lies.

The example is deliberately simple: a note store that lets an agent save short entries and find them again. That is a real use case, for example when a coding agent needs to recover a decision from an earlier session. Once the mechanism is understood, any internal system can sit behind the same interface.

TypeScript instead of Python

There are official MCP SDKs for TypeScript and Python. I chose TypeScript for one practical reason: distribution through npx. An agent host starts the server as a child process, and npx mein-server needs neither a virtual environment nor a system-wide Python installation. With the Python SDK, the question of which environment the host can actually see comes up surprisingly quickly.

The example uses @modelcontextprotocol/sdk version 1.30.0 together with zod for input schemas. Those are the only dependencies:

@modelcontextprotocol/sdk
zod

The zod schemas are not decoration. The SDK turns them into the JSON Schemas returned by tools/list and validates every input before my code runs. The test further down shows why that matters.

The server

This is the complete server, shortened only by leaving out the note persistence itself, which is just readFile/writeFile against a JSON file:

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: "notes-server",
  version: "0.1.0",
});

server.registerTool(
  "add_note",
  {
    title: "Notiz speichern",
    description:
      "Speichert eine kurze Notiz mit Schlagworten im lokalen Notizspeicher.",
    inputSchema: {
      text: z.string().min(1).max(2000),
      tags: z.array(z.string()).max(10).optional(),
    },
  },
  async ({ text, tags }) => {
    const notes = await loadNotes();
    const note = {
      id: crypto.randomUUID().slice(0, 8),
      text,
      tags: tags ?? [],
      created: new Date().toISOString(),
    };
    notes.push(note);
    await saveNotes(notes);
    return {
      content: [
        { type: "text", text: `Notiz ${note.id} gespeichert (${notes.length} Einträge).` },
      ],
    };
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);

The second tool follows the same pattern: an optional query, a limit with a default value, and filtering across text and tags:

server.registerTool(
  "search_notes",
  {
    title: "Notizen durchsuchen",
    description:
      "Durchsucht Text und Schlagworte aller gespeicherten Notizen. Leere Anfrage liefert die neuesten Einträge.",
    inputSchema: {
      query: z.string().max(200).optional(),
      limit: z.number().int().min(1).max(50).default(10),
    },
  },
  async ({ query, limit }) => {
    const notes = await loadNotes();
    const q = query?.toLowerCase();
    const hits = notes
      .filter(
        (n) =>
          !q ||
          n.text.toLowerCase().includes(q) ||
          n.tags.some((t) => t.toLowerCase().includes(q)),
      )
      .slice(-limit)
      .reverse();
    if (hits.length === 0) {
      return { content: [{ type: "text", text: "Keine passenden Notizen gefunden." }] };
    }
    const lines = hits.map(
      (n) =>
        `[${n.id}] ${n.created.slice(0, 10)} ${n.text}` +
        (n.tags.length ? ` (#${n.tags.join(" #")})` : ""),
    );
    return { content: [{ type: "text", text: lines.join("\n") }] };
  },
);

Three decisions are worth explaining. First, every tool answers with plain text rather than structured JSON. The model calling the tool reads the result as context; a sentence such as “Notiz 48870a7a gespeichert (1 Einträge)” is more immediately useful to it than an object it first has to interpret. Second, every note receives a short opaque ID. A later tool can then delete or update a specific note without requiring the model to repeat the complete text. Third, persistence is a simple JSON file. That is intentionally primitive and at the same time the most honest limitation of the example, which I return to below.

stdio instead of HTTP

The server communicates over standard input and output, not HTTP. For a local server that is started as a child process by exactly one host, that is the right choice: no port, no certificate and no authentication. The process boundary is the security boundary.

HTTP becomes interesting once several clients or machines need to use the same server. That is exactly what the new stateless specification is intended for, which I wrote about here. For getting started, stdio is the shorter route, and everything else in this article applies to both transports.

The server is registered in the host with an entry such as this:

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/pfad/zu/server.mjs"],
      "env": { "NOTES_FILE": "/pfad/zu/notes.json" }
    }
  }
}

Testing without an agent host

A complete agent host is not required to test the server. The SDK contains everything needed for a small test client, and the official MCP Inspector is another option. My test client starts the server, lists its tools and calls both of them:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "node",
  args: ["server.mjs"],
  env: { ...process.env, NOTES_FILE: "./test-notes.json" },
});

const client = new Client({ name: "test-client", version: "0.1.0" });
await client.connect(transport);

const { tools } = await client.listTools();
const add = await client.callTool({
  name: "add_note",
  arguments: { text: "MCP-Server gebaut und getestet", tags: ["mcp"] },
});

This is the actual output from that run:

TOOLS: add_note: Speichert eine kurze Notiz mit Schlagworten im lokalen Notizspeicher.
       search_notes: Durchsucht Text und Schlagworte aller gespeicherten Notizen.
ADD: Notiz 48870a7a gespeichert (1 Einträge).
SEARCH: [48870a7a] 2026-07-29 MCP-Server gebaut und getestet (#mcp)

The failure cases are more interesting. Calling add_note with an empty string never reaches my code:

MCP error -32602: Input validation error: Invalid arguments for tool add_note:
Too small: expected string to have >=1 characters

And for a tool that does not exist:

MCP error -32602: Tool no_such_tool not found

Both return as normal error responses rather than crashing the process. That matters because the calling agent can read the error and correct its behaviour. Exceptions raised inside tool code should therefore also be translated into readable error responses instead of killing the server process.

The dead end: stdout belongs to the protocol

The mistake almost everyone makes with a first stdio server is an innocent console.log("Server startet...") near the beginning of the file. With stdio, all standard output belongs to the JSON-RPC channel. Any line that is not JSON is protocol garbage.

I tested this rather than relying on the warning in the documentation. With SDK 1.30.0 the result is more nuanced than the usual folklore suggests. The client reports Unexpected token 'S', "Server startet..." is not valid JSON, but the connection survives because the parser discards that line and continues processing subsequent valid messages. Tool calls still worked in my test.

What this example does not solve

The JSON file is the largest remaining weakness. It has no locking. Two hosts writing at the same time can lose entries. For a local server with one client that is acceptable; beyond that, SQLite belongs behind the tools. The effort is small because the tool interface does not need to change.

Second, search is a simple substring match. Once the store grows, semantic search through embeddings becomes useful. Again, that is a local implementation concern rather than an MCP concern; the interface can remain the same.

Third, the implementation depends on an SDK for a protocol that is moving quickly. Revision 2026-07-28 has just removed sessions and marked several components as deprecated. Anyone building a server today should pin the SDK version and keep an eye on release notes.

Conclusion

The MCP mechanics are the easy part: install the SDK, register tools, connect a transport. That takes an hour, and the code in this article is fully runnable.

The real work is tool design. A tool’s description ends up in the model’s context and influences whether the agent uses it correctly. Tool granularity determines how many calls an agent needs for a task. Error responses determine whether the agent can recover from a failure or merely repeat itself. Those are design questions, not protocol questions, and they are why writing a small MCP server is more than a coding exercise. It teaches you how a model sees your software.