Building an MCP Server from Scratch in TypeScript — Zero to Claude Code
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:
| Scope | Stored in | When to use |
|---|---|---|
local (default) | ~/.claude.json | Personal dev; anything with credentials |
project | .mcp.json (project root) | Team-shared servers; goes into git |
user | ~/.claude.json | Personal 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:
| Tool | Purpose |
|---|---|
run_ga4_report | Arbitrary GA4 report |
get_realtime_report | Real-time data (last 30 minutes) |
search_console_query | Search Console performance |
search_console_sitemaps | Sitemap 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:
npm init -y && npm install @modelcontextprotocol/sdk zod- Instantiate
McpServer, register tools withserver.tool() - Verify at the protocol level with JSON-RPC messages
claude mcp addto connect,claude mcp listto 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:

CLAUDE.mdの書き方:複数プロジェクトをClaude Codeで管理する実践構成
複数プロジェクトでCLAUDE.mdを運用するための設計思想と実例。グローバルrules分離・Memory活用・worktree並行作業まで、15プロジェクト管理で固まった10個のノウハウを紹介。
Token cost optimization across the board:
amaino.me/blog/claude-code-prompt-caching-cost-optimization
MCP official quickstart:

Build an MCP server - Model Context Protocol
Get started building your own server to use in Claude for Desktop and other clients.
SDK on npm:
www.npmjs.com/package/@modelcontextprotocol/sdk
Get new articles by email
Related Posts
About this site
Personal blog by Amane Inoue. Writing about tech, books, culture, travel, and university life.