
Summary
This guide walks through how to expose a service securely as an MCP server, using Civic Auth via the @civic/auth-mcp library.
The full source-code for this guide is available here
Background
You’ve probably already heard about MCP (Model Context Protocol), the groundbreaking standard that allows LLMs to actually get things done in the real world. But when you start moving your MCP server out of your local dev environment and onto the web, you quickly hit a snag: authentication. After all, when your server lives online, you can’t just let anybody use it.
In this guide, we’ll show you how to create an MCP server from scratch, host it alongside your existing web APIs, and secure it using Civic Auth.
Setup
Let’s say you already have a working Express-based TODO app. The backend exposes basic functionality like adding and listing todos. You’re getting user information from an auth header, likely containing a session ID or JWT, using a helper function extractFromAuthHeader. You also have a simple service layer that manages the actual data.
Here’s what your app.ts might look like:
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json());
app.use(cors());
app.get("/todo", (req, res) => {
const userId = extractFromAuthHeader(req);
const todos = service.getTodos(userId);
res.json(todos);
});
app.post("/todo", (req, res) => {
const userId = extractFromAuthHeader(req);
const todo = service.createTodo(userId, req.body);
res.status(201).json(todo);
});
app.listen(3000, () => console.log("Todo app listening on port 3000"));
You don’t need to worry about how extractFromAuthHeader or service are implemented for now. We’re focusing purely on adding MCP support and protecting that with Civic Auth.
Step 1: Install Dependencies
Start by installing the libraries you’ll need:
npm install @modelcontextprotocol/sdk @civic/auth-mcp zod
Step 2: Set Up the MCP Server
Let’s create the MCP server instance. Think of this as the central controller where you’ll register tools for your LLM to use:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
const mcpServer = new McpServer({
name: "todo-mcp-server",
version: "0.0.1",
});
That’s it! The server exists. But right now, it doesn’t do anything. Let’s fix that by registering a tool.
Step 3: Register an MCP Tool
A “tool” in MCP is just a function your LLM can call. Let’s wire up a tool to list todos:
mcpServer.tool(
"list-todos",
"List your current todos",
{},
async (_, extra) => {
const userId = "placeholder-user"; // We'll hook up auth later
const todos = service.getTodos(userId);
return {
content: [{ type: "text", text: JSON.stringify(todos) }],
};
}
);
This registers a tool with a name, a short description, some input parameters (we don’t need any yet), and an async handler function.
Let’s register another tool to add todos:
mcpServer.tool(
"add-todo",
"Add a new todo item",
{
text: z.string().describe("The content of the todo item"),
},
async (input, extra) => {
const userId = "placeholder-user";
const newTodo = service.createTodo(userId, { text: input.text });
return {
content: [{ type: "text", text: `Created todo: ${newTodo.text}` }],
};
}
);
This tool demonstrates how to define typed input parameters and use them in your handler.
A note on names and descriptions
The names and descriptions that you give to your tool and its parameters are important! This helps the LLM to understand when and how to call your tool. The clearer and more descriptive the better. Think of this as a part of the prompt that you send to the LLM. However, just like with a prompt, more is not always better. Avoid piling huge amounts of information into the descriptions, but keep things concise and clear, with plenty of examples.