# How to build an MCP server

> Build and test a TypeScript MCP server with one useful tool, stdio transport, input validation, clear errors, timeouts, and client configuration.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-03 | Updated: 2026-09-09 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/build-mcp-server/

An MCP server is a program that exposes tools and data to an AI application.

The application starts the server, asks what it offers, and lets the model call it. The server is your code, so you decide what each call can do.

In this post we build one from scratch. It has a single tool that looks up a currency exchange rate. Along the way we validate the input, handle failures, add a timeout, and connect the server to Cursor.

If MCP is new to you, read [what MCP is](https://flaviocopes.com/what-is-mcp/) first. The free [MCP course](https://flaviocopes.com/courses/mcp/) covers the client, host, and server roles in more detail.

## The mental model

There are three parts in this example:

- **The host** is the AI application, such as Cursor.
- **The client** lives inside the host and speaks MCP.
- **The server** is the program we are about to write.

Our server exposes one **tool** named `get-rate`.

The model does not call the exchange-rate API directly. It asks the MCP client to call `get-rate` with a small JSON object. The server validates that object, performs the HTTP request, and returns content.

```text
model -> MCP client -> get-rate tool -> exchange-rate API
model <- MCP client <- text result   <- JSON response
```

The model can only ask for `get-rate`, and never touches the API itself. Validation, credentials, timeouts and side effects all live in our code.

## Create the project

You need Node.js 20 or later.

Create the project and install the official TypeScript server SDK:

```bash
mkdir mcp-rates
cd mcp-rates
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir src
```

The SDK ships as ES modules, so `type=module` is required. We use `tsx` to run TypeScript without a separate build step.

Add a script to `package.json`:

```json
{
  "scripts": {
    "start": "tsx src/index.ts",
    "inspect": "npx @modelcontextprotocol/inspector tsx src/index.ts"
  }
}
```

You can add a proper TypeScript build later. For now, running the source directly is one less thing to debug.

## Build the tool

Create `src/index.ts`:

```ts
import { McpServer } from '@modelcontextprotocol/server'
import { serveStdio } from '@modelcontextprotocol/server/stdio'
import * as z from 'zod/v4'

function createServer() {
  const server = new McpServer({
    name: 'rates',
    version: '1.0.0'
  })

  server.registerTool(
    'get-rate',
    {
      description: 'Get a currency exchange rate against EUR',
      inputSchema: z.object({
        currency: z
          .string()
          .length(3)
          .describe('Three-letter currency code, such as USD')
      })
    },
    async ({ currency }) => {
      const code = currency.toUpperCase()
      const controller = new AbortController()
      const timeout = setTimeout(() => controller.abort(), 5000)

      try {
        const url =
          `https://api.frankfurter.app/latest?from=EUR&to=${code}`
        const response = await fetch(url, {
          signal: controller.signal
        })

        if (!response.ok) {
          return {
            content: [{
              type: 'text',
              text: `The rates service returned HTTP ${response.status}`
            }],
            isError: true
          }
        }

        const data = await response.json() as {
          rates?: Record<string, number>
        }
        const rate = data.rates?.[code]

        if (typeof rate !== 'number') {
          return {
            content: [{
              type: 'text',
              text: `No EUR rate was found for ${code}`
            }],
            isError: true
          }
        }

        return {
          content: [{
            type: 'text',
            text: `1 EUR = ${rate} ${code}`
          }]
        }
      } catch (error) {
        const message = error instanceof Error
          ? error.message
          : 'Unknown error'

        return {
          content: [{
            type: 'text',
            text: `Could not retrieve the rate: ${message}`
          }],
          isError: true
        }
      } finally {
        clearTimeout(timeout)
      }
    }
  )

  return server
}

void serveStdio(createServer)
console.error('rates MCP server running on stdio')
```

Let's go through the important parts.

`registerTool()` takes a name, a configuration object, and a handler. The `inputSchema` does two jobs: it tells the model what arguments exist, and it validates them before our handler runs.

The name and the descriptions are what the model reads to decide when to call the tool. A tool called `run` with no description forces it to guess. `get-rate` plus "Three-letter currency code, such as USD" leaves little room for error.

The handler returns a list of content blocks. Here one text block is enough.

When the rates service fails, we return `isError: true` with a message. The model gets a failure it can explain to the user, and the server keeps running.

The `AbortController` gives the fetch a five-second limit. Without it, a slow upstream service would hang the tool call, and the client with it.

## Why stdout is special

`serveStdio()` reads MCP messages from standard input and writes responses to standard output.

That means stdout belongs to the protocol. One `console.log()` can corrupt the JSON-RPC stream and disconnect the client.

Use `console.error()` for local diagnostics:

```ts
console.error('rates MCP server running on stdio')
```

This writes to stderr, which the host can show without mixing it into MCP messages.

## Test without an AI client

Before you debug the server through a model, test the tool directly with the MCP Inspector:

```bash
npm run inspect
```

The Inspector opens a local interface.

Connect to the server, open the Tools tab, select `get-rate`, and call it with:

```json
{
  "currency": "USD"
}
```

You should receive a text result similar to:

```text
1 EUR = 1.17 USD
```

The rate changes, so do not test an exact number. Test the result shape and the currency code.

Now try invalid input:

```json
{
  "currency": "US dollars"
}
```

The SDK rejects the call before the handler runs, because the schema requires exactly three characters. Our code never sees bad input.

Try an unknown code like `XYZ` too. That one passes the schema, reaches the handler, and comes back as an `isError` result.

## Connect the server to Cursor

Add the server to `.cursor/mcp.json` in the project where you want to use it:

```json
{
  "mcpServers": {
    "rates": {
      "command": "npx",
      "args": [
        "tsx",
        "/Users/flavio/mcp-rates/src/index.ts"
      ]
    }
  }
}
```

Use the absolute path on your machine. The host might start the command from another directory, so a relative path can point at the wrong file.

Restart the client after changing its MCP configuration. Then ask:

```text
What is the EUR to USD exchange rate right now?
```

You should see a request to call `get-rate` with `USD`.

If the tool does not appear, check the layers in order:

1. Run `npm start` and confirm the server waits without exiting.
2. Run the Inspector and call the tool directly.
3. Check the absolute file path in the client configuration.
4. Inspect stderr for startup errors.
5. Restart the AI client.

Going in this order tells you whether the problem is in the server or in the client configuration.

## Tool errors are not protocol errors

A malformed MCP message is a protocol error. The SDK deals with that, and we never see it.

A valid call that cannot produce a rate is a tool error. That one is ours, and we answer it with `isError: true` and a sentence that explains what went wrong.

An unknown currency or a `404` from the rates API is normal operation, not a crash. Returning a result lets the model tell the user "no rate found for XYZ" and move on. If we threw instead, the client would see a failed request with no useful text.

Throwing is for cases where the server cannot continue. A missing API key at startup, for example, should stop the process right away.

## Keep the first server narrow

MCP servers can also expose resources and prompts. You do not need them on day one.

Start with one tool, give it a precise name and a small schema, and make sure it does one bounded thing with no side effects the model does not know about. Test it in the Inspector. Add more once you are confident about what the first one can and cannot do.

There is a security angle here too. `get-rate` can only reach one public endpoint. A `run-command` tool could execute anything the model invents. My advice is to give the server the smallest authority it needs: don't pass your whole environment into it, don't expose broad filesystem paths, and don't hide destructive actions behind friendly tool names.

## How I would use an MCP server

MCP makes sense to me when I want the same project-specific tool available in more than one AI client. A read-only lookup is the typical case: something like a tool that returns product IDs from a small catalog, so the model gets the exact stored value instead of guessing from source files.

Anything that writes, I would keep separate from anything that reads. A tool that drafts a change is harmless. A tool that publishes, deploys or buys something needs stricter validation and an approval step before it runs.

For a one-off script that only I run from the terminal, I would not bother with MCP. A plain command is easier to test and maintain. MCP pays off when several AI hosts need to discover and reuse the same tool.

## Where to go next

The free [Build with MCP course](https://flaviocopes.com/courses/build-with-mcp/) continues from here with resources, prompts, remote HTTP transport, authorization, and deployment. If the TypeScript types were the unfamiliar part, start with the [TypeScript course](https://flaviocopes.com/courses/typescript/).
