← All articles
Jun 12, 20264 min read

What I Learned Building an MCP Server in TypeScript

  • MCP
  • TypeScript
  • Node.js
  • AI tooling
  • Claude Code
What I Learned Building an MCP Server in TypeScript

The Model Context Protocol has quietly become the standard way to give AI coding agents new capabilities. I shipped agy-bridge, an MCP server that lets Claude Code delegate heavy work to Google's Antigravity CLI, and the process taught me things the protocol docs don't. This is the post I wish I'd read first.

The minimum viable server is genuinely small

With the official TypeScript SDK, a working stdio server is ~30 lines:

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

server.tool(
  "analyze_files",
  "Delegate file analysis. USE THIS whenever a file is >200 lines.",
  { files: z.array(z.string()).min(1), question: z.string() },
  async ({ files, question }) => ({
    content: [{ type: "text", text: await analyze(files, question) }],
  })
);

await server.connect(new StdioServerTransport());

Zod schemas double as validation and as the JSON Schema the client sees. The interesting work is everything around this skeleton.

Tool descriptions are prompts, not documentation

This is the single most important lesson. Your tool description isn't read by a human browsing an API reference — it's read by a model deciding, mid-task, whether to call you. It's a prompt.

Compare:

"Analyzes files using an external model."

"Delegate file analysis instead of reading files yourself. USE THIS whenever a file is large (>200 lines) or the task spans more than 3 files. The files never enter your context — only the answer does."

The first gets ignored. The second gives the model a concrete trigger condition ("more than 200 lines"), a contrast with its default behavior ("instead of reading files yourself"), and a reason to care ("never enter your context"). After rewriting descriptions in this style, delegation rates changed dramatically with zero code changes.

The same logic argues for several specific tools over one generic one. agy-bridge exposes six (analyze_files, deep_search, web_lookup, adversarial_review, follow_up, delegate) that mostly converge on the same execution path. The split exists because each description can name its own trigger.

Error messages are instructions too

When your tool fails, the model reads the error and decides what to do next — and its default is often the worst option. When agy-bridge failed, Claude would silently perform the heavy work itself, defeating the tool's purpose while appearing to succeed.

The fix was embarrassing in its simplicity — put instructions in the error text:

agy delegation failed: <reason>.
Do NOT perform this work yourself. Report the failure to the user.

I gated it behind an env flag (AGY_ON_FAILURE=strict) so users can choose fallback behavior. Treat every string your server returns — success or failure — as part of an ongoing conversation with the calling model.

stdout is sacred

A stdio MCP server speaks JSON-RPC over stdout. One stray console.log corrupts the stream and produces baffling client-side parse errors with no obvious cause. All diagnostics go to console.error. If you wrap child processes, make sure their stdout is captured rather than inherited. While you're at it: if a wrapped CLI reads stdin, close the child's stdin explicitly (child.stdin.end()) — an inherited open stdin made my CLI wait for EOF forever, and the hang reproduced only under the MCP client, not in manual testing.

Bound your output

A delegation tool can return arbitrarily large results — a "summarize this repo" answer can be 200KB, instantly flooding the context window the tool was built to protect. Cap output (agy-bridge defaults to 50K chars, configurable), and make the truncation notice say how to raise the limit, because the model reads that too.

Sessions without state

Multi-turn workflows need continuity, but a stateless server is dramatically easier to ship. My compromise: piggyback on state that already exists. The wrapped CLI persists conversations on disk; after each call the server resolves the conversation ID and appends it to the response in a structured trailer. The client model carries the session ID forward into the next call. The server stores nothing.

Design for testing without the real backend

agy-bridge wraps a CLI that needs auth and quota — useless in CI. The runner takes its exec functions through a tiny injected interface, so 35 unit tests cover flag construction, model-table parsing, truncation, config overrides, and strict-mode errors with no binary and no network. One pattern, full coverage of everything except the CLI itself.

Ship it as npx-able

Friction kills adoption. Publish with a bin entry, bundle to a single ESM file (tsup), and your install instructions become one line of JSON config with npx -y your-server. agy-bridge ships as a 7.7KB package; nobody clones anything.


The protocol is the easy part — the SDK handles it. What makes an MCP server good is treating the model as your user: descriptions that persuade, errors that instruct, output that respects the context window. Full source: github.com/sshahzaiib/agy-bridge.

WRITTEN BY

Shahzaib Muhammad Akram

Senior Frontend EngineerCyberjaya, Malaysia