How to build an MCP server
By Flavio Copes
Build and test a TypeScript MCP server with one useful tool, stdio transport, input validation, clear errors, timeouts, and client configuration.
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 first. The free MCP course 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.
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:
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:
{
"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:
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:
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:
npm run inspect
The Inspector opens a local interface.
Connect to the server, open the Tools tab, select get-rate, and call it with:
{
"currency": "USD"
}
You should receive a text result similar to:
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:
{
"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:
{
"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:
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:
- Run
npm startand confirm the server waits without exiting. - Run the Inspector and call the tool directly.
- Check the absolute file path in the client configuration.
- Inspect stderr for startup errors.
- 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 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.
Want me to talk about your product? You can sponsor this site.