August 3, 2026 · 9 min read
The MCP ecosystem has a Python tutorial problem. Most guides reach for Python. But most production web tooling — and most of the MCP client surface — is TypeScript. This tutorial fixes that: a complete walkthrough of building an MCP server in TypeScript, testing it locally with Claude Desktop, and deploying it as a hosted endpoint.
You'll build a real server that exposes two tools: one that fetches and summarises a URL, and one that returns the current status of a domain in the AgentReady index. By the end you'll understand the full request-response cycle well enough to build your own.
Node.js 20+, a Claude Desktop or Cursor installation, and about 30 minutes. No prior MCP experience needed.
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsxCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}Create src/index.ts. The MCP SDK exposes a McpServer class that handles the protocol for you — you only need to define tools.
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-mcp-server',
version: '1.0.0',
})
// Tool 1: fetch and summarise a URL
server.tool(
'fetch_url',
'Fetch the text content of a URL and return the first 2000 characters.',
{ url: z.string().url().describe('The URL to fetch') },
async ({ url }) => {
const res = await fetch(url, { headers: { 'User-Agent': 'MCP-Server/1.0' } })
if (!res.ok) {
return { content: [{ type: 'text', text: `Error: HTTP ${res.status}` }], isError: true }
}
const text = await res.text()
const stripped = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
return {
content: [{ type: 'text', text: stripped.slice(0, 2000) }],
}
}
)
// Tool 2: check AgentReady index status for a domain
server.tool(
'check_indexed',
'Check whether a domain is indexed in AgentReady and queryable by AI agents.',
{ domain: z.string().describe('Domain to check, e.g. stripe.com') },
async ({ domain }) => {
const res = await fetch(`https://www.agentready.it.com/api/sites/by-domain/${domain}`)
if (res.status === 404) {
return { content: [{ type: 'text', text: `${domain} is not yet indexed in AgentReady.` }] }
}
if (!res.ok) {
return { content: [{ type: 'text', text: `Could not check status for ${domain}.` }], isError: true }
}
const data = await res.json()
return {
content: [{
type: 'text',
text: `${domain} is indexed. Status: ${data.status}. Pages: ${data.page_count}.`,
}],
}
}
)
const transport = new StdioServerTransport()
await server.connect(transport)Each server.tool() call takes four arguments: the tool name, a plain-English description (this is what the AI reads to decide when to call it — make it specific), a Zod schema for the inputs, and an async handler that returns a result.
The result shape is { content: [{ type: "text", text: string }] }. You can return multiple content blocks, and you can set isError: true to signal a tool failure without crashing the server.
Tool descriptions matter more than you think. The AI decides which tool to call based entirely on the description. Vague descriptions get called at the wrong time. Specific descriptions — including what the tool does not do — lead to much better agent behavior.
Add a start script to package.json:
"scripts": {
"start": "tsx src/index.ts"
}Then register the server in Claude Desktop's config (~/Library/Application Support/Claude/claude_desktop_config.json on Mac):
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["--import", "tsx/esm", "/absolute/path/to/src/index.ts"]
}
}
}Restart Claude Desktop. Open a new conversation and you should see your tools available. Ask Claude: "Check if stripe.com is indexed in AgentReady" — it should call check_indexed automatically.
Production tools need input validation and graceful errors. The Zod schema handles input validation — invalid inputs are rejected before your handler runs. For runtime errors, always return isError: true rather than throwing, so the AI can recover and explain the failure.
async ({ url }) => {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
// ... handler logic
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return {
content: [{ type: 'text', text: `Failed to fetch ${url}: ${message}` }],
isError: true,
}
}
}stdio works for local use, but to share your server with a team or make it available to remote clients, you need HTTP transport. The SDK supports this with StreamableHTTPServerTransport.
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import http from 'http'
const httpServer = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/mcp') {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
await server.connect(transport)
await transport.handleRequest(req, res)
return
}
res.writeHead(404)
res.end()
})
httpServer.listen(3000, () => console.log('MCP server running on :3000'))Deploy this to any Node.js host — Railway, Fly.io, Render, or a plain VPS. The MCP URL your clients connect to will be https://your-host.com/mcp.
Note the sessionIdGenerator: undefined. As of the 2026-07-28 spec, MCP servers should be stateless — no session IDs, no shared state between requests. The SDK defaults to stateless when you omit the generator.
A few extensions worth adding once you have the basic pattern working:
Resources — MCP resources let you expose documents or data that the AI can read without calling a tool. Useful for configuration, context, or reference data.
Prompts — MCP prompts are templated instructions the client can surface to the user. They let you bundle good system prompts with your server.
Authentication — The 2026-07-28 spec adds OAuth 2.0 as the standard auth mechanism. If your server needs authentication, the new EMA (Enterprise MCP Auth) spec is the path forward.
If your goal is to make a website or documentation queryable by AI agents, you don't need to build a server. AgentReady handles the crawling, indexing, embedding, and MCP endpoint — you just paste a URL.
Building a custom server makes sense when you have proprietary data, internal APIs, or actions (not just reads). For making existing public docs queryable, the hosted path is faster and free to start.