Building an MCP Server from Scratch in TypeScript — Zero to Claude Code

18 min readTech

I built a GA4 and Search Console MCP server in TypeScript so Claude Code could handle my own blog analytics. The moment it connected and Claude started pulling live data on its own — picking the right metrics and date ranges from context, no prompting from me — I understood why MCP exists.

This is a record of building that server from scratch: project setup, the minimal server code, protocol-level verification, connecting to Claude Code, and the design decisions I made along the way. The second half covers something most guides skip: when MCP is the wrong tool, and a plain CLI script is the better call.

What Is MCP

MCP (Model Context Protocol) is an open protocol Anthropic released in 2024 for connecting AI assistants to external tools.

Before MCP, wiring Claude to external data meant writing custom API-calling code for every integration. MCP standardizes the interface: you define “tools” and their input/output contract once, and Claude Code picks them up automatically. One MCP server becomes one set of permanent capabilities.

Building One From Scratch

Enough theory. Here’s the full path from zero to a running server, with the actual output from Claude Code at each step.

Project Setup

$ mkdir mcp-demo && cd mcp-demo
$ npm init -y
$ npm pkg set type=module
$ npm install @modelcontextprotocol/sdk zod
added 91 packages, and audited 92 packages in 1s
found 0 vulnerabilities

Two packages: @modelcontextprotocol/sdk (handles the protocol) and zod (defines tool input schemas).

Minimal Server

SDK v1 recommends the McpServer class with server.tool(). It’s far less boilerplate than the older Server + setRequestHandler pattern.

// index.mjs
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: "demo-server",
  version: "1.0.0",
});

server.tool(
  "count_chars",
  "Count the number of characters in the text",
  { text: z.string().describe("Text to count") },
  async ({ text }) => ({
    content: [{ type: "text", text: `Character count: ${text.length}` }],
  })
);

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

server.tool() takes four arguments: name, description, a Zod schema, and a handler. StdioServerTransport communicates over stdin/stdout — no HTTP server needed.

Verification

You can verify the server at the protocol level by piping in JSON-RPC messages. Using a Python subprocess makes it easy to send them in sequence.

Here’s the actual output I got running it inside Claude Code.

initialize (connection handshake):

{
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": { "listChanged": true }
    },
    "serverInfo": { "name": "demo-server", "version": "1.0.0" }
  },
  "jsonrpc": "2.0",
  "id": 1
}

tools/list (tool listing):

{
  "result": {
    "tools": [
      {
        "name": "count_chars",
        "description": "Count the number of characters in the text",
        "inputSchema": {
          "type": "object",
          "properties": {
            "text": { "type": "string", "description": "Text to count" }
          },
          "required": ["text"]
        }
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}

tools/call (tool execution):

{
  "result": {
    "content": [{ "type": "text", "text": "Character count: 11" }]
  },
  "jsonrpc": "2.0",
  "id": 3
}

The server skeleton is done. From here, replacing count_chars with a real API call is all that’s left.

Connecting to Claude Code

The easiest way to register the server with Claude Code is the CLI:

$ claude mcp add demo-server node /path/to/mcp-demo/index.mjs

Output:

Added stdio MCP server demo-server with command: node /path/to/mcp-demo/index.mjs to local config
File modified: ~/.claude.json [project: /path/to/your-project]

Check the connection with claude mcp list:

$ claude mcp list
Checking MCP server health...

plugin:context7:context7: npx -y @upstash/context7-mcp - Connected
plugin:playwright:playwright: npx @playwright/mcp@latest - Connected
...
demo-server: node /path/to/mcp-demo/index.mjs - Connected

Once ✓ Connected appears, the tool is available from the next claude session onward.

Choosing a Scope

claude mcp add accepts a --scope flag that controls visibility:

ScopeStored inWhen to use
local (default)~/.claude.jsonPersonal dev; anything with credentials
project.mcp.json (project root)Team-shared servers; goes into git
user~/.claude.jsonPersonal tools used across multiple projects

My GA4 server uses a service account key, so I keep it at local. For a team server, use project and pass credentials as environment variables.

Design Decisions for a Real Server

Once the skeleton works, the design questions start. Here’s what I learned building the GA4/Search Console server.

Tool Granularity: One Tool, One API Call

My first instinct was one catch-all tool that returns “all GA4 data.” That doesn’t work well — finer granularity gives the model better accuracy when choosing which tool to call.

I split my server into four tools:

ToolPurpose
run_ga4_reportArbitrary GA4 report
get_realtime_reportReal-time data (last 30 minutes)
search_console_querySearch Console performance
search_console_sitemapsSitemap status

“What’s the real-time situation?” → Claude picks get_realtime_report. “Check last month’s SEO” → search_console_query. Cross-API analysis is left to the model.

Descriptions Drive Tool Selection

The description field is the most important signal Claude uses when choosing a tool. Write it precisely.

server.tool(
  "run_ga4_report",
  "Run a report on Google Analytics 4 data (property 254228045) to retrieve metrics and dimensions for amaino.me.",
  {
    metrics: z.array(z.string()).describe(
      "List of metrics (e.g., 'activeUsers', 'screenPageViews', 'sessions', 'bounceRate')"
    ),
    startDate: z.string().describe("Start date in YYYY-MM-DD format"),
    endDate: z.string().describe("End date in YYYY-MM-DD format"),
  },
  async ({ metrics, startDate, endDate }) => { /* ... */ }
);

Two things matter here: name the specific target (site name, Property ID) so Claude knows exactly whose data it’s touching, and list example values in parameter descriptions so Claude can construct correct arguments.

Convert Responses to Text

GA4’s raw API response is deeply nested JSON. Returning it as-is wastes tokens. I convert to tab-separated text before returning:

const humanReadable =
  `GA4 Report (property ${PROPERTY_ID})\n` +
  `Period: ${startDate} to ${endDate}\n\n` +
  `${headerString}\n${rows?.join("\n")}`;

return {
  content: [{ type: "text", text: humanReadable }],
};

TSV is more token-efficient than JSON for AI consumption, while still conveying structure clearly.

MCP Isn’t Always the Right Tool

One thing I didn’t expect when I started: MCP has real cases where a plain CLI script is the better answer.

The Token Cost of MCP

Every connected MCP server injects its tool definitions into the system prompt — and that cost is paid every turn. My four-tool GA4 server alone runs roughly 1,000–2,000 tokens per turn, once parameter descriptions are included.

With several servers connected, my claude mcp list looks like this:

plugin:context7:context7: npx -y @upstash/context7-mcp - ✓ Connected
plugin:playwright:playwright: npx @playwright/mcp@latest - ✓ Connected
plugin:serena:serena: uvx ... - ✓ Connected
codex: codex mcp-server - ✓ Connected
gmail: node /path/to/gmail-mcp-server/src/index.js - ✓ Connected
council: node /path/to/ai-master/dist/index.js - ✓ Connected
...

Each server’s tool definitions sit in the system prompt at all times. That baseline overhead accumulates silently. Add servers casually and the per-turn cost creeps up without you noticing.

The CLI Alternative

With Claude Code’s Bash tool, a CLI script runs without any tool-definition overhead. Claude calls the command, reads the output, and moves on:

$ node scripts/ga4-report.mjs --metrics activeUsers,screenPageViews --days 30

No schema in the system prompt. Claude Code’s plugin ecosystem is also expanding fast — claude plugin install for community plugins, claude agents for subagent management. In some cases a community plugin covers what you’d otherwise build as a custom MCP server.

When to Choose Each

Prefer MCP when:

  • The model needs to autonomously select and call tools (“analyze my blog’s health” → it picks the right metrics and time range on its own)
  • The tool is called frequently (high utilization offsets the per-turn overhead)
  • Parameters require model inference from context (dates, metrics chosen by reasoning)

Prefer CLI when:

  • A human decides what to run and when (deploys, migrations)
  • The tool is rarely used (per-turn overhead doesn’t pay off)
  • Arguments are fixed — no inference needed
  • A community plugin already covers the use case

For my setup, GA4 and Search Console stay as MCP. An open-ended “analyze my blog” prompt needs the model to decide which metrics and periods to pull, and that only works with MCP. Google Suggest API calls, on the other hand, are CLI: I decide when to run them and supply the keyword myself.

From Zero to Connected — A Recap

Building an MCP server is less daunting than it looks. With SDK v1’s McpServer + server.tool(), a single tool takes a handful of lines.

The full path from zero to Claude Code:

  1. npm init -y && npm install @modelcontextprotocol/sdk zod
  2. Instantiate McpServer, register tools with server.tool()
  3. Verify at the protocol level with JSON-RPC messages
  4. claude mcp add to connect, claude mcp list to confirm ✓ Connected

Four design decisions that paid off: one tool per API call; concrete descriptions with example values; text output instead of raw JSON; and MCP-vs-CLI decided by per-turn token cost, not convenience.

If you want to start without building anything, add a community server first: claude mcp add context7 -- npx -y @upstash/context7-mcp connects in one command. Get the feel of it running, then build your own.

More on scope selection and permission management in Claude Code:

Token cost optimization across the board:

MCP official quickstart:

SDK on npm:

About this site

Personal blog by Amane Inoue. Writing about tech, books, culture, travel, and university life.