Course: Build Your First MCP Server in 60 Minutes - Course Content - TheRevenue AI
Course delivery
Build Your First MCP Server in 60 Minutes
Why MCP is the protocol you cannot skip in 2026
Every serious AI surface speaks Model Context Protocol now. Claude Desktop, Cursor, Windsurf, Zed, Continue, Cline, Goose all consume MCP servers as their tool layer. When you ship an MCP server, you stop building one integration per assistant and start publishing one capability that every assistant on the user's machine can call.
That shift matters. Before MCP, a "ChatGPT plugin" worked in ChatGPT and nowhere else. With MCP, the same fifty lines of Node.js are reachable from Claude on a designer's laptop, from Cursor on an engineer's workstation, and from a homemade agent on a Raspberry Pi. The user installs your server once, and every model they use gains the capability.
For technical founders this is a distribution channel. Your domain expertise, your private API, your scraping pipeline becomes a tool that an AI assistant invokes mid-conversation.
The MCP mental model
Server. A process you write that exposes capabilities. It does not call models. Client. The AI surface that hosts the model: Claude Desktop, Cursor, your own agent. Transport. How bytes move between client and server. Stdio (client spawns process, talks over stdin/stdout) or streamable HTTP. Tools. Functions the model can call. Each has a name, description, and JSON Schema for its input. Resources. Read-only data the model can pull on demand. Prompts. Reusable prompt templates the user invokes.
Architecture at runtime:
+---------------------+ stdin/stdout +---------------------+
| AI Client | <--------------------> | Your MCP Server |
| (Claude Desktop) | JSON-RPC 2.0 frames | (Node process) |
+---------------------+ +---------------------+
The model lives in the cloud. The client lives on the user's machine. Your server lives there too, spawned as a child process by the client.
Stdio vs streamable HTTP
| Concern | Stdio | HTTP |
|---|---|---|
| Where the server runs | Same machine as client | Anywhere reachable by URL |
| Auth | Inherits user's local trust | You implement |
| Install UX | Edit a JSON config file | Paste a URL |
| Best for | Filesystem, git, local APIs | SaaS APIs, hosted databases |
We are building stdio in this guide because it has zero deployment surface.
The wire format
The client opens with initialize:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "claude-desktop", "version": "0.9.3"}
}
}
You answer with what you can do:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "url-fetch-mcp", "version": "1.0.0"}
}
}
The client asks tools/list:
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
You return the menu:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [{
"name": "fetch_url",
"description": "Fetch a URL and return cleaned-up text content suitable for an LLM to summarize.",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string"},
"max_chars": {"type": "integer", "default": 8000}
},
"required": ["url"]
}
}]
}
}
When the model decides to call your tool:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "fetch_url",
"arguments": {"url": "https://therevenue-ai.com"}
}
}
You respond with content blocks:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{"type": "text", "text": "TheRevenue AI - AI Visibility..."}],
"isError": false
}
}
That is the whole protocol surface for 90% of servers.
Project setup
mkdir url-fetch-mcp
cd url-fetch-mcp
npm init -y
npm install cheerio
Edit package.json:
{
"name": "url-fetch-mcp",
"version": "1.0.0",
"description": "MCP server that fetches a URL and returns LLM-ready text",
"type": "module",
"main": "server.js",
"bin": {"url-fetch-mcp": "./server.js"},
"engines": {"node": ">=20"}
}
The server: full code
Save as server.js:
#!/usr/bin/env node
import { createInterface } from "node:readline";
import { stdin, stdout, stderr } from "node:process";
import * as cheerio from "cheerio";
const PROTOCOL_VERSION = "2024-11-05";
const SERVER_INFO = { name: "url-fetch-mcp", version: "1.0.0" };
const TOOLS = [{
name: "fetch_url",
description: "Fetch a webpage and return its main text content with scripts, styles, and navigation removed.",
inputSchema: {
type: "object",
properties: {
url: { type: "string" },
max_chars: { type: "integer", default: 8000 }
},
required: ["url"],
additionalProperties: false
}
}];
function log(...args) { stderr.write(args.map(String).join(" ") + "\n"); }
function send(message) { stdout.write(JSON.stringify(message) + "\n"); }
function reply(id, result) { send({ jsonrpc: "2.0", id, result }); }
function fail(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
async function fetchAndClean(url, maxChars) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { "user-agent": "url-fetch-mcp/1.0" }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.text();
const $ = cheerio.load(body);
$("script, style, noscript, svg, nav, footer").remove();
const title = ($("title").first().text() || "").trim();
const text = $("body").text().replace(/\s+/g, " ").trim();
const out = title ? `# ${title}\n\n${text}` : text;
return out.slice(0, maxChars);
} finally {
clearTimeout(timeout);
}
}
async function handleToolCall(name, args) {
if (name !== "fetch_url") throw new Error(`Unknown tool: ${name}`);
const url = String(args?.url || "");
const maxChars = Number.isFinite(args?.max_chars) ? args.max_chars : 8000;
if (!/^https?:\/\//i.test(url)) throw new Error("url must be absolute http(s)");
const text = await fetchAndClean(url, maxChars);
return { content: [{ type: "text", text }], isError: false };
}
async function dispatch(msg) {
const { id, method, params } = msg;
try {
if (method === "initialize") {
reply(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO
});
} else if (method === "notifications/initialized" || method === "initialized") {
// notification, no reply
} else if (method === "tools/list") {
reply(id, { tools: TOOLS });
} else if (method === "tools/call") {
const result = await handleToolCall(params?.name, params?.arguments);
reply(id, result);
} else if (method === "ping") {
reply(id, {});
} else {
fail(id, -32601, `Method not found: ${method}`);
}
} catch (err) {
if (id !== undefined) {
reply(id, { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true });
}
}
}
const rl = createInterface({ input: stdin });
rl.on("line", async (line) => {
const trimmed = line.trim();
if (!trimmed) return;
let msg;
try { msg = JSON.parse(trimmed); } catch (e) { return; }
await dispatch(msg);
});
log("url-fetch-mcp ready");
Critical callouts
-
stderr for logs, stdout only for protocol frames. New MCP authors break this rule most. One stray
console.logand the client sees malformed JSON-RPC and disconnects. -
isError: trueon tool failure, not a JSON-RPC error. If the URL 404s, return a normalresultwithisError: true. Reserve JSON-RPC errors for protocol-level problems. -
Notification handling.
initializedarrives without anid. Always checkid !== undefinedbefore responding. - AbortController timeout. Without one, a hung server hangs the entire AI conversation.
Test before wiring:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual","version":"0"}}}' | node server.js
Wiring to Claude Desktop
Locations of claude_desktop_config.json:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"url-fetch": {
"command": "node",
"args": ["/absolute/path/to/url-fetch-mcp/server.js"],
"env": {}
}
}
}
Use absolute paths. On Windows, use forward slashes which Node accepts.
Quit Claude Desktop fully and relaunch. Open a new chat and look for the tools icon. Ask "Fetch https://example.com and tell me what it is about." Watch the tool fire.
Wiring to Cursor and Windsurf
Both use the identical schema. Cursor: edit ~/.cursor/mcp.json. Windsurf: edit ~/.codeium/windsurf/mcp_config.json.
Publishing
Three surfaces matter in 2026:
- Anthropic's MCP registry - canonical index, free, permanent
- Smithery.ai - one-line install command, hosts both stdio and HTTP servers
- mcp.so - community discovery portal
For a stdio Node server, the publish path: npm publish, submit to the Anthropic registry, claim your server on Smithery and mcp.so. Total time: about 20 minutes.
Common errors
Server disconnected immediately after launch. You wrote to stdout outside of a protocol frame. Search for console.log and move all logging to stderr.
Tool calls time out at 60 seconds. Add an AbortController. If the tool genuinely takes longer than 60s, return a job ID and expose a poll tool.
Invalid params from the client. Your inputSchema is malformed. Common: forgetting "type": "object" at root, or "required": "url" instead of "required": ["url"].
Tool fires but the model never sees a result. You returned a string instead of a content array. Always wrap in { content: [{ type: "text", text: "..." }] }.
You now ship AI capability
Build one for your business. Postgres query runner, Stripe revenue inspector, custom search over your private docs. Anything callable from a function is callable from every AI assistant a user touches.
While you are thinking about what to ship first, install ours: TheRevenue AI's free AI Visibility Score is available as an MCP server. Drop it in your claude_desktop_config.json and ask Claude "How visible is my domain in AI search?" The answer is the start of a much longer conversation about owning the next decade of distribution.