If you've been following the AI tooling space, you've probably heard about the Model Context Protocol (MCP) — Anthropic's open standard that lets Claude connect to external tools, APIs, and services.
In this guide, I'll walk you through building a real MCP server from scratch, deploying it to Railway, and connecting it to Claude Desktop — so your users can just chat with Claude and have it interact with your backend automatically.
By the end, you'll have:
A working MCP server deployed on Railway
Claude Desktop configured to connect to it
Users who can upload an invoice photo and have Claude extract the data and save it — all without writing a single line of frontend code
Let's get into it.
What Is MCP and Why Should You Care?
MCP (Model Context Protocol) is a standard that lets you expose your API as tools that Claude can call. Instead of building a custom chat UI, form, or integration — you describe what your API does, and Claude handles the rest.
The flow looks like this:
User chats with Claude Desktop
↓
Claude understands the intent
↓
Claude calls your MCP tool
↓
Your MCP server calls your API
↓
Result is returned to the user
No frontend. No custom UI. Claude is the interface.
Two Types of MCP Servers
Before we build anything, understand the two transport options:
Type Transport Best For Local stdio Personal use, Claude Desktop only Remote HTTP (Streamable HTTP) Multiple users, production, shared teams
We're building a remote MCP server — it lives on Railway, always online, and any user with Claude Desktop can connect to it.
Step 1 — Scaffold the Project
Create a new Node.js project:
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod express form-data node-fetch
Update package.json to use ES modules:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"engines": {
"node": ">=18.0.0"
}
}
Step 2 — Build the MCP Server
Create server.js. The key things to understand:
Every request to
/mcpcreates a freshMcpServerinstance (stateless — required for serverless/Railway)Tools are registered with a name, description, a Zod schema for inputs, and an async handler
StreamableHTTPServerTransporthandles the MCP protocol over HTTP
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json({ limit: "20mb" }));
const MCP_SECRET = process.env.MCP_SECRET;
// Health check — useful for Railway and load balancers
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.all("/mcp", async (req, res) => {
// Protect your endpoint with a shared secret
const secret = req.headers["x-mcp-secret"];
if (MCP_SECRET && secret !== MCP_SECRET) {
return res.status(401).json({ error: "Unauthorized" });
}
const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" });
// Register a tool
server.tool(
"my_tool", // tool name (snake_case)
"What this tool does", // description Claude uses to decide when to call it
{
field_one: z.string().describe("What this field is"),
field_two: z.number().optional().describe("Optional number field"),
},
async ({ field_one, field_two }) => {
// Call your API here
const response = await fetch("https://your-api.com/endpoint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ field_one, field_two }),
});
const result = await response.json();
return {
content: [{
type: "text",
text: response.ok
? `✅ Success! Result: ${JSON.stringify(result)}`
: `❌ Error: ${result.message}`,
}],
};
}
);
// Connect and handle the request — always stateless for remote servers
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`MCP server running on port ${PORT}`));
Writing Good Tool Descriptions
Claude decides when to call your tool based on its name and description. Write them like you're explaining it to a smart colleague:
// ❌ Bad — too vague
server.tool("save", "Save data", { ... })
// ✅ Good — tells Claude exactly when and why to use it
server.tool(
"create_invoice",
"Generate and submit a BIR-compliant sales invoice. Call this after extracting invoice data from an image and confirming it with the user.",
{ ... }
)
Step 3 — Handle Authentication
Remote MCP servers need two layers of auth:
Layer 1 — App secret (X-MCP-Secret) Proves the request is coming from your app, not a random caller. This is a shared secret you generate once and hardcode into Claude Desktop configs.
Layer 2 — User token (X-User-Token) Identifies which user is making the request. Each user gets their own token from your system.
app.all("/mcp", async (req, res) => {
// Layer 1: Verify this is your app
const secret = req.headers["x-mcp-secret"];
if (secret !== process.env.MCP_SECRET) {
return res.status(401).json({ error: "Unauthorized" });
}
// Layer 2: Get the user's token
const userToken = req.headers["x-user-token"];
if (!userToken) {
return res.status(401).json({ error: "Missing user token" });
}
// Now use userToken when calling your API
// This ensures data is scoped to the correct user
});
Generate a strong MCP_SECRET:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Step 4 — Deploy to Railway
Railway is the easiest platform for hosting Node.js MCP servers — no execution time limits, always-on, and free tier available.
4.1 Add a railway.toml
[build]
builder = "nixpacks"
[deploy]
startCommand = "node server.js"
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3
[[deploy.healthchecks]]
type = "http"
path = "/health"
intervalSeconds = 30
timeoutSeconds = 5
4.2 Push to GitHub
echo "node_modules/" > .gitignore
echo ".env" >> .gitignore
git init
git add .
git commit -m "Initial MCP server"
git remote add origin https://github.com/yourname/my-mcp-server.git
git push -u origin main
4.3 Deploy on Railway
Go to railway.app → New Project
Click Deploy from GitHub repo and select your repo
Railway auto-detects Node.js and deploys
4.4 Set environment variables
In Railway → your service → Variables tab:
Variable Value MCP_SECRET Your generated secret Any API keys your server needs Their values
4.5 Get your Railway URL
Go to Settings → Networking → Generate Domain. You'll get a URL like:
https://my-mcp-server-production.up.railway.app
Verify it's running:
curl https://my-mcp-server-production.up.railway.app/health
# Should return: {"status":"ok"}
Step 5 — Connect Claude Desktop
Claude Desktop is your UI. No frontend to build.
Option A — Manual config (for technical users)
Open the config file:
Mac:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server:
{
"mcpServers": {
"my-server": {
"type": "http",
"url": "https://my-mcp-server-production.up.railway.app/mcp",
"headers": {
"X-MCP-Secret": "your-mcp-secret-here",
"X-User-Token": "users-personal-token-here"
}
}
}
}
Fully quit and reopen Claude Desktop. You'll see a 🔌 icon in the chat bar confirming the connection.
Option B — Desktop Extension .mcpb (for non-technical users)
This is the best option for distributing to users who shouldn't be editing JSON files. A .mcpb file is a one-click installer — users double-click it and Claude Desktop shows a friendly setup dialog.
Create a manifest.json:
{
"name": "my-mcp-server",
"display_name": "My App — Claude Integration",
"version": "1.0.0",
"description": "Connect Claude to My App for AI-powered workflows.",
"author": "Your Company",
"server": {
"type": "http",
"url": "https://my-mcp-server-production.up.railway.app/mcp",
"headers": {
"X-MCP-Secret": "your-mcp-secret-here"
}
}
}
Package it:
npm install -g @anthropic-ai/mcpb
mcpb pack .
# Produces: my-mcp-server.mcpb
Send the .mcpb file to your users. They double-click it, Claude Desktop installs it, and they're done.
Step 6 — Write a System Prompt
The system prompt tells Claude how to behave. Paste it into Claude Desktop → Settings → Custom Instructions.
A good system prompt covers:
What the tools are and when to use them
How to extract data (e.g. from images) before calling a tool
Validation rules specific to your domain
How to handle errors and what to ask the user when data is missing
Example:
You are an assistant connected to My App. When the user uploads a document:
1. Extract all relevant data from the document carefully.
2. Present the extracted data to the user for confirmation before saving.
3. Only call create_invoice after the user confirms.
4. If any required field is missing or unclear, ask the user before proceeding.
5. Report the result clearly after saving.
Putting It All Together
Here's the complete picture of what you've built:
User (Claude Desktop)
│
│ uploads image + "Save this"
▼
Claude (vision)
extracts data
confirms with user
│
│ calls tool via MCP
▼
Your MCP Server (Railway)
validates auth
calls your API
│
│ POST /your-endpoint
▼
Your Backend API
saves the record
returns result
│
▼
Claude reports back to user
"✅ Saved! Reference: #12345"
Vercel vs Railway — Which Should You Use?
Vercel Railway Best for You already host your app on Vercel Standalone MCP server Execution limit 10s (Hobby) / 60s (Pro) No limit Cold starts Yes No Always-on No Yes Setup Easy if already on Vercel Slightly more setup
My recommendation: If your main app is already on Vercel, deploy the MCP server there too. Otherwise, Railway is more reliable — no execution time ceiling, no cold starts.
Tips for Production
Add a health check endpoint — Railway uses it to verify your server is alive. A simple GET /health → 200 OK is enough.
Keep tools focused — Each tool should do one thing well. Claude will use multiple tools in sequence if needed.
Write descriptive Zod schemas — Use .describe() on every field. Claude reads these when deciding what values to pass.
Never trust user input — Your backend API is the real gatekeeper. The MCP server is just a bridge.
Use sensitive: true in manifest for secrets — Claude Desktop encrypts them using the OS keychain (Mac Keychain / Windows Credential Manager).
Final Thoughts
MCP flips the typical integration model on its head. Instead of building a UI, a form, or a chatbot from scratch — you describe what your API does, and Claude becomes the interface.
For internal tools, accounting workflows, BIR compliance systems, or any data entry-heavy process, this is genuinely transformative. Your users get a natural language interface that understands context, extracts data from images, validates inputs, and calls your API — all out of the box.
The best part? You ship zero frontend code.
Watch my demo video here: https://ianfreitz.com/projects/mcp-server