← Blog

August 3, 2026 · 9 min read

TypeScript MCP Server Tutorial: Build, Test, and Deploy in 30 Minutes

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.

Prerequisites

Node.js 20+, a Claude Desktop or Cursor installation, and about 30 minutes. No prior MCP experience needed.

1. Set up the project

mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true
  },
  "include": ["src/**/*"]
}

2. Write the server

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)

3. Understand the structure

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.

4. Test locally with Claude Desktop

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.

5. Add proper error handling

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,
    }
  }
}

6. Deploy as an HTTP server

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.

7. What to build next

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.

Skip the server entirely for docs

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.