Building an MCP Server with Authentication
The Model Context Protocol (MCP) is an open standard that enables AI models (like Claude or Gemini) to securely interact with local or remote tools and data. In this tutorial, we will build a functional MCP server using TypeScript and Express, featuring a practical example of a weather tool.
We will primarily focus on implementing an Authentication layer using API keys to ensure only authorized clients can access your tools.
The Complete Code (Quick Start)
If you're already familiar with Node.js and just need the fully functional implementation, here is the complete index.ts file. You can find the detailed step-by-step breakdown immediately following this section.
import express from "express";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
const SERVER_API_KEY = process.env.SERVER_API_KEY;
if (!SERVER_API_KEY) {
console.error("Warning: SERVER_API_KEY is not set in .env. Authentication is effectively disabled if not checked properly.");
}
// 1. MCP Server Setup
const server = new Server(
{
name: "weather-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// 2. Define Tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get-weather",
description: "Get the current weather and air quality for a city. Returns temperature (Celsius), wind speed (km/h), and PM2.5 air quality (µg/m³).",
inputSchema: {
type: "object",
properties: {
city: {
type: "string",
description: "The name of the city",
},
},
required: ["city"],
},
},
],
};
});
// 3. Handle Tool Calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get-weather") {
const city = request.params.arguments?.city as string;
if (!city) {
throw new Error("City is required");
}
try {
// Geocoding: Get lat/long for the city
const geoResponse = await axios.get(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`
);
const geoData = geoResponse.data as {
results?: { latitude: number; longitude: number; name: string; country: string }[];
};
if (!geoData.results || geoData.results.length === 0) {
return {
content: [{ type: "text", text: `Could not find coordinates for city: ${city}` }],
isError: true,
};
}
const { latitude, longitude, name, country } = geoData.results[0];
// Weather: Get current weather for coordinates
const weatherResponse = await axios.get(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`
);
const weatherData = weatherResponse.data as {
current_weather: { temperature: number; windspeed: number };
};
// Air Quality: Get current PM2.5 for coordinates
const airQualityResponse = await axios.get(
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${latitude}&longitude=${longitude}¤t=pm2_5`
);
const airQualityData = airQualityResponse.data as {
current: { pm2_5: number };
};
const current = weatherData.current_weather;
const pm25 = airQualityData.current.pm2_5;
return {
content: [
{
type: "text",
text: `Current weather in ${name}, ${country}:
- Temperature: ${current.temperature}°C
- Wind Speed: ${current.windspeed} km/h
- PM2.5 Air Quality: ${pm25} µg/m³`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error fetching weather for ${city}: ${error.response?.data?.message || error.message}`,
},
],
isError: true,
};
}
}
throw new Error(`Tool not found: ${request.params.name}`);
});
// 4. SSE Transport Handling
let transport: SSEServerTransport | null = null;
// 5. Auth Middleware
const authMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
const apiKey = req.headers["x-api-key"] || req.query["api-key"];
if (apiKey !== SERVER_API_KEY) {
return res.status(401).json({ error: "Unauthorized: Invalid or missing API Key" });
}
next();
};
app.get("/sse", authMiddleware, async (req, res) => {
console.log("New SSE connection established");
transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", authMiddleware, async (req, res) => {
if (!transport) {
return res.status(400).send("No active SSE transport");
}
await transport.handlePostMessage(req, res);
});
app.listen(port, () => {
console.log(`Weather MCP server running at http://localhost:${port}`);
console.log(`SSE endpoint: http://localhost:${port}/sse`);
console.log(`Message endpoint: http://localhost:${port}/messages`);
});
Step-by-Step Breakdown
1. Prerequisites & Setup
Before writing the code, ensure you have Node.js (v18+) installed. Initialize a new Node.js project and install the necessary dependencies:
npm init -y
npm install @modelcontextprotocol/sdk express axios dotenv
- Express: Handles the HTTP server and SSE (Server-Sent Events) transport.
- MCP SDK: The official library for defining tools and handling protocol messages.
- Open-Meteo: A free weather API that doesn't require an external API key.
- Axios: For fetching data from external APIs.
2. The Authentication Middleware
Authorization is critical when exposing tools to an AI. Instead of leaving the endpoints open, we implement a simple API key check via an Express middleware:
const authMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
const apiKey = req.headers["x-api-key"] || req.query["api-key"];
if (apiKey !== process.env.SERVER_API_KEY) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
};
This middleware will intercept requests to our MCP endpoints and reject any connection that doesn't provide the correct x-api-key.
3. Setting Up the Express App and Endpoints
With the middleware ready, we can initialize our Express app and secure our Server-Sent Events (SSE) routes:
app.get("/sse", authMiddleware, async (req, res) => {
console.log("New SSE connection established");
transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", authMiddleware, async (req, res) => {
if (!transport) {
return res.status(400).send("No active SSE transport");
}
await transport.handlePostMessage(req, res);
});
4. Defining the Weather Tool
Next, we define our tool. An MCP tool needs a clear name, a description (which the LLM uses to decide when to trigger it), and an input schema defining the expected arguments.
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get-weather",
description: "Get current weather and air quality for a city. Returns temp, wind, and PM2.5.",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
},
required: ["city"],
},
},
],
};
});
5. Implementing the Tool Logic
Finally, we handle the actual execution when the AI calls get-weather. The logic performs three steps:
- Geocoding: Converts the city name into coordinates.
- Weather Data: Fetches temperature and wind speed.
- Air Quality: Fetches the PM2.5 measurement.
By combining these, the LLM can answer complex questions like "What's the air quality like in Tokyo?" using a single tool call. (Refer to the CallToolRequestSchema handler in the Complete Code section above for the exact Axios calls).
6. Configuring the Client
To use this server with Gemini CLI (or Claude Desktop), you need to add it to your configuration and provide the correct headers for the authentication layer to succeed.
Example config.json:
{
"mcpServers": {
"weather-mcp": {
"url": "http://localhost:3000/sse",
"headers": {
"x-api-key": "your-secret-key"
}
}
}
}
Conclusion
You've now built an MCP server that is not only functional but also secure. By using SSE transport and custom Express middleware, you can deploy this server and connect it to various AI clients while maintaining control over who uses your tools.
Happy coding!
