Build an AI Caption Generator with MCP
Build and deploy an MCP server that generates social media captions with Claude AI.
Introduction
30 Second Summary
Coming up with the perfect social media caption for every platform takes time you could spend on something else. What if you could type a topic, pick a platform, and get a polished caption in seconds?
In this project, you will build an AI-powered caption generator using the Model Context Protocol and Claude AI, then deploy it live on AWS so anyone can use it from a browser.
What You'll Build
A social media caption generator that takes a topic and platform as input, sends it through an MCP server to Claude, and returns a ready-to-post caption.
By the end of this project, you'll have:
- A working MCP server that exposes a generate-caption tool using the Model Context Protocol specification.
- A web frontend that communicates with your server and displays generated captions on demand.
- A live AWS EC2 deployment accessible via a public URL so anyone on the internet can generate captions.
- Secret Mission: Add a tone selector that lets you choose between professional, casual, humorous, and inspirational caption styles.
Are there any prerequisites?
No prior experience with MCP, AI APIs, or AWS is required. This guide walks you through setting up everything from scratch, including creating your accounts and installing all the tools you need.
Set Up Your Accounts and Project
Before you can build anything, you need access to two services. Anthropic provides the Claude AI that generates your captions. AWS provides the cloud server that makes your app accessible on the internet.
In this step, you'll create accounts on both platforms, grab your API key, and set up a local project folder with all the packages your caption generator needs.
In this step, get ready to:
- Create Anthropic and AWS accounts.
- Initialize a Node.js project with dependencies.
- Create the project file structure.
Create your Anthropic and AWS accounts
You need an Anthropic API key so your server can send prompts to Claude and get captions back. You also need an AWS account for deploying your finished app later.
- Open your browser and navigate to console.anthropic.com.
- Click Sign Up and follow the prompts to create your account (email, verification code, password).
- Once signed in, navigate to the API Keys section in the left sidebar.
- Click Create Key.
- Give your key a name like caption-generator and click Create Key.
- Copy the key immediately and paste it somewhere safe (a temporary text file works). You will not be able to see this key again after you close the dialog.
Save your key before closing the dialog
Anthropic only shows your full API key once at creation time. If you close the modal without copying it, you'll need to create a new key.
- Now open a new tab and navigate to aws.amazon.com/free.
- Click Create a free account.
- Enter your email, create a password, and follow the sign-up wizard.
- When prompted, select the Paid account plan. This gives access to all AWS services including EC2, which you need for deployment.
- Add a payment method when prompted. You will not be charged for this project if you stay within the Free Tier.
- Choose the Basic (Free) support plan.
- Wait for the activation email (usually a few minutes, can take up to 24 hours).
Why do I need a credit card for AWS?
AWS requires a payment method to verify your identity and prevent fraud. The Free Tier gives you $100 in credits at sign-up. The EC2 instance you'll launch later is covered by these credits and won't incur charges for this project.
Having trouble creating either account?
Make sure you're using a valid email address and checking your spam folder for verification emails. AWS activation can take up to 24 hours in some cases.
help me troubleshoot my account setup for Anthropic or AWS
Initialize your project and install dependencies
Your caption generator uses several packages. The MCP SDK handles the protocol layer. The Anthropic SDK talks to Claude. Express serves your web app.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir mcp-caption-generator
cd mcp-caption-generator
- Initialize a new Node.js project by running:
npm init -y
This creates a package.json file that tracks your project's metadata and dependencies.
- Install all the required packages by running:
npm install @modelcontextprotocol/sdk @anthropic-ai/sdk express cors zod dotenv
What are these packages?
- `@modelcontextprotocol/sdk` is the official MCP TypeScript SDK for building MCP servers.
- `@anthropic-ai/sdk` is Anthropic's SDK for calling the Claude API.
- `express` is a web framework that serves your frontend and handles HTTP requests.
- `cors` enables cross-origin requests between your frontend and backend.
- `zod` defines and validates the input schema for your MCP tool.
- `dotenv` loads environment variables from a `.env` file so your API key stays out of your code.
You should see a success message listing all installed packages. A node_modules folder and package-lock.json file now exist in your project.
- Now open package.json in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the mcp-caption-generator folder on your Desktop, then click Open.
- In the Explorer sidebar, click package.json to open it.
- Replace all of the file's contents with the following:
{
"name": "mcp-caption-generator",
"version": "1.0.0",
"type": "module",
"description": "AI-powered social media caption generator using MCP",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"zod": "^3.25.0"
}
}
What did you change?
- `"type": "module"` enables ES module syntax (`import`/`export`) instead of older CommonJS (`require`). The MCP SDK uses ES modules.
- `"main": "server.js"` tells Node.js your entry point file.
- `"scripts": { "start": "node server.js" }` lets you start your server with `npm start`.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing install errors?
If npm install fails, make sure you're inside the mcp-caption-generator folder (run pwd to check). Also verify Node.js is installed by running node --version in your terminal.
help me fix npm install errors in my MCP caption generator project
Create the project file structure
Your project needs three files: a server file, a frontend HTML file inside a public folder, and an environment file for your API key.
- Create the files and folders by running these commands in the VS Code terminal (open it with Ctrl+`):
mkdir public
touch server.js public/index.html .env
- Confirm the files exist by running:
ls
You should see node_modules, package.json, package-lock.json, public, server.js, and .env in the output.
- In the VS Code Explorer sidebar, click .env to open it.
- Add your Anthropic API key by pasting this line, replacing the placeholder with the key you copied earlier:
ANTHROPIC_API_KEY=your-api-key-here
- Replace your-api-key-here with the actual API key you copied from the Anthropic console.
- Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Why use a .env file?
Storing secrets in a .env file keeps them out of your code. The dotenv package reads this file at startup and makes the values available as environment variables. This means you can share or deploy your code without accidentally exposing your key.
Don't see .env in the Explorer?
Files starting with a dot are hidden by default on some systems. In VS Code, the Explorer sidebar should still show it. If not, try clicking the refresh icon at the top of the Explorer panel. You can also open it directly with File > Open File and typing .env.
help me find or create my .env file in VS Code
✔️ Awesome, I've got everything!
Great. Double check you've saved both package.json and .env.
ⓧ I'd like to double check the full code
Here is what your package.json should look like:
{
"name": "mcp-caption-generator",
"version": "1.0.0",
"type": "module",
"description": "AI-powered social media caption generator using MCP",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"zod": "^3.25.0"
}
}
And your .env file should contain:
ANTHROPIC_API_KEY=your-api-key-here
Your server.js and public/index.html files should exist but be empty for now.
Your accounts are created, your API key is saved, and your project is ready to go. Next up, you'll write the MCP server that turns Claude into a caption-generating tool.
Build Your MCP Server with a Caption Tool
Your project folder is set up with all the dependencies installed. Now it's time to build the actual server that powers your caption generator.
In this step, you'll create an MCP server that exposes a "generate-caption" tool, wire it up to the Claude API, and serve everything over HTTP using Express.
In this step, get ready to:
- Set up the MCP server with its imports and configuration.
- Register a generate-caption tool that calls the Claude API.
- Expose the server over HTTP with Express endpoints.
Set up the MCP server imports and configuration
An MCP server is a program that exposes tools (functions with defined inputs and outputs) to any MCP-compatible client. Before you can register tools, you need to import the SDK, connect to the Claude API, and create a server instance.
- In VS Code, open server.js and add the following imports and configuration:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import cors from "cors";
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import dotenv from "dotenv";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
const server = new McpServer({
name: "caption-generator",
version: "1.0.0",
});
What does this code do?
- The first two imports bring in McpServer (the core MCP server class) and StreamableHTTPServerTransport (the transport layer that lets MCP communicate over HTTP).
- dotenv.config() loads your .env file so process.env.ANTHROPIC_API_KEY is available to the Anthropic client.
- The __filename and __dirname lines recreate the familiar directory helpers that ES modules don't include by default.
- McpServer is configured with a name and version that identify your server to any MCP client that connects.
- Save server.js.
You won't see any output yet. The next section adds the tool that makes this server useful.
Register the generate-caption tool
MCP tools are functions with a defined input schema and a handler. When an MCP client calls your tool, the server validates the input against the schema and runs the handler. Your tool will accept a topic and a platform, then ask Claude to write a caption.
- Add the following code below the McpServer creation block in server.js:
server.registerTool(
"generate-caption",
{
title: "Generate Social Media Caption",
description:
"Generates a social media caption for a given topic and platform",
inputSchema: {
topic: z.string().describe("The topic or subject for the caption"),
platform: z
.enum(["Instagram", "Twitter/X", "LinkedIn", "Facebook"])
.describe("The social media platform"),
},
},
async ({ topic, platform }) => {
const message = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Generate a social media caption for ${platform} about: ${topic}`,
},
],
system: `You are a social media expert. Generate a single, engaging caption for ${platform}. Follow platform best practices: Instagram (use emojis, hashtags, 2200 char max), Twitter/X (concise, under 280 chars), LinkedIn (professional tone, can be longer), Facebook (conversational, medium length). Return ONLY the caption text, nothing else.`,
});
const caption = message.content[0].text;
return {
content: [{ type: "text", text: caption }],
};
}
);
What does this code do?
- server.registerTool takes three arguments: a tool name ("generate-caption"), a metadata object with the input schema, and an async handler function.
- The inputSchema uses zod to define what inputs the tool accepts. Here it expects a string topic and an enum platform restricted to four social networks.
- The handler calls claude-haiku-4-5 with a system prompt that instructs Claude to follow each platform's best practices (character limits, emojis, tone).
- The return value wraps the caption in MCP's standard content array format so any MCP client can consume it.
Why Haiku 4.5 for captions?
Claude Haiku 4.5 costs $1 per million input tokens and $5 per million output tokens. A typical caption request costs a fraction of a cent. For short creative text like social media captions, Haiku's speed and low cost make it the best fit.
- Save server.js.
Your tool is registered, but the server can't receive requests yet. The next section adds the HTTP layer.
Expose your server over HTTP with Express
MCP servers need a transport to communicate with clients. Streamable HTTP is the recommended transport for remote MCP servers. You'll also add a simpler REST endpoint that your web frontend will use directly.
- Add the following Express setup and endpoints below the registerTool block in server.js:
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(join(__dirname, "public")));
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.post("/generate", async (req, res) => {
const { topic, platform } = req.body;
if (!topic || !platform) {
return res.status(400).json({ error: "Topic and platform are required" });
}
try {
const message = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Generate a social media caption for ${platform} about: ${topic}`,
},
],
system: `You are a social media expert. Generate a single, engaging caption for ${platform}. Follow platform best practices: Instagram (use emojis, hashtags, 2200 char max), Twitter/X (concise, under 280 chars), LinkedIn (professional tone, can be longer), Facebook (conversational, medium length). Return ONLY the caption text, nothing else.`,
});
const caption = message.content[0].text;
res.json({ caption });
} catch (error) {
res.status(500).json({ error: "Failed to generate caption" });
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Caption Generator MCP Server running on http://localhost:${PORT}`);
});
What do these endpoints do?
- POST /mcp is the raw MCP protocol endpoint. Any MCP client (like Claude Desktop) can connect here and call your tool using the standard protocol.
- sessionIdGenerator: undefined runs the transport in stateless mode, keeping things simple for this project.
- POST /generate is a REST wrapper that your web frontend will call. It accepts topic and platform in the request body and returns a JSON object with the caption.
- express.static serves the public/ folder so your HTML frontend will be accessible at the root URL.
Why two endpoints?
The /mcp endpoint speaks the MCP protocol. It lets you connect your server to any MCP host in the future (Claude Desktop, VS Code, other AI tools).
The /generate endpoint is a simple REST API that your HTML page can call with a standard fetch request. You get both MCP compatibility and a straightforward web interface.
- Save server.js.
- Start your server by running this command in the terminal:
node server.js
- You should see this message in the terminal:
Caption Generator MCP Server running on http://localhost:3001
Not seeing the log message?
- If you see SyntaxError, check for missing commas or mismatched brackets in your code. Template literals (backtick strings) are easy to leave unclosed.
- If you see a message about ANTHROPIC_API_KEY, make sure your .env file is in the project root (same folder as server.js) and contains your key.
- If the port is already in use, stop any other server running on port 3001 or set a PORT environment variable to a different number.
Still stuck? Help me debug why my MCP server won't start
- Stop the server by pressing Ctrl+C in the terminal. You'll restart it later after building the frontend.
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.js file at this point:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import cors from "cors";
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import dotenv from "dotenv";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
const server = new McpServer({
name: "caption-generator",
version: "1.0.0",
});
server.registerTool(
"generate-caption",
{
title: "Generate Social Media Caption",
description:
"Generates a social media caption for a given topic and platform",
inputSchema: {
topic: z.string().describe("The topic or subject for the caption"),
platform: z
.enum(["Instagram", "Twitter/X", "LinkedIn", "Facebook"])
.describe("The social media platform"),
},
},
async ({ topic, platform }) => {
const message = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Generate a social media caption for ${platform} about: ${topic}`,
},
],
system: `You are a social media expert. Generate a single, engaging caption for ${platform}. Follow platform best practices: Instagram (use emojis, hashtags, 2200 char max), Twitter/X (concise, under 280 chars), LinkedIn (professional tone, can be longer), Facebook (conversational, medium length). Return ONLY the caption text, nothing else.`,
});
const caption = message.content[0].text;
return {
content: [{ type: "text", text: caption }],
};
}
);
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(join(__dirname, "public")));
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.post("/generate", async (req, res) => {
const { topic, platform } = req.body;
if (!topic || !platform) {
return res.status(400).json({ error: "Topic and platform are required" });
}
try {
const message = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Generate a social media caption for ${platform} about: ${topic}`,
},
],
system: `You are a social media expert. Generate a single, engaging caption for ${platform}. Follow platform best practices: Instagram (use emojis, hashtags, 2200 char max), Twitter/X (concise, under 280 chars), LinkedIn (professional tone, can be longer), Facebook (conversational, medium length). Return ONLY the caption text, nothing else.`,
});
const caption = message.content[0].text;
res.json({ caption });
} catch (error) {
res.status(500).json({ error: "Failed to generate caption" });
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Caption Generator MCP Server running on http://localhost:${PORT}`);
});
Your MCP server is alive and listening for requests. Next up, you'll build the web frontend that talks to this server and lets you generate captions from your browser.
Build the Web Frontend
Your MCP server is running and ready to generate captions on demand. Now you need a way for people to actually use it.
In this step, you will build an HTML page that talks to your server's /generate endpoint. The page will have a form where users type a topic, pick a platform, and click a button to get a caption back from Claude.
In this step, get ready to:
- Create an HTML page with a styled form for topic and platform inputs.
- Add JavaScript that sends requests to your server and displays generated captions.
- Test the full application end-to-end in your browser.
Create the HTML page with a caption form
The frontend is a single HTML file that lives in the public folder. Your server already serves files from this folder automatically using Express static file serving.
- In VS Code, open public/index.html (currently empty).
- Paste the following HTML and CSS to create the page structure and form:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Caption Generator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
h1 { font-size: 24px; margin-bottom: 8px; color: #1a1a1a; }
.subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
label { display: block; font-weight: 600; margin-bottom: 6px; color: #333; font-size: 14px; }
input, select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
}
input:focus, select:focus { outline: none; border-color: #6366f1; }
button {
width: 100%;
padding: 14px;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #4f46e5; }
button:disabled { background: #a5a6f6; cursor: not-allowed; }
.output {
margin-top: 24px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
display: none;
}
.output.visible { display: block; }
.output p { line-height: 1.6; color: #333; white-space: pre-wrap; }
.copy-btn {
margin-top: 12px;
padding: 8px 16px;
background: #10b981;
font-size: 13px;
width: auto;
}
.copy-btn:hover { background: #059669; }
.error { color: #ef4444; margin-top: 12px; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>AI Caption Generator</h1>
<p class="subtitle">Powered by MCP + Claude AI</p>
<form id="captionForm">
<label for="topic">Topic</label>
<input type="text" id="topic" placeholder="e.g., Morning coffee routine, New product launch" required>
<label for="platform">Platform</label>
<select id="platform" required>
<option value="Instagram">Instagram</option>
<option value="Twitter/X">Twitter/X</option>
<option value="LinkedIn">LinkedIn</option>
<option value="Facebook">Facebook</option>
</select>
<button type="submit" id="generateBtn">Generate Caption</button>
</form>
<div class="output" id="output">
<p id="captionText"></p>
<button class="copy-btn" id="copyBtn">Copy to Clipboard</button>
</div>
<p class="error" id="error"></p>
</div>
</body>
</html>
What does this code do?
The <style> section creates a clean, centered card layout with an indigo color scheme for the button and green for the copy action.
The form collects two inputs: a text field for the topic and a dropdown for the platform. The .output div starts hidden (display: none) and only appears when it receives the visible class from JavaScript.
- Save public/index.html.
- If your server is still running from the previous step, open http://localhost:3001 in your browser. You should see a white card with the title "AI Caption Generator", a topic input, a platform dropdown, and a purple Generate Caption button.
Don't see the page?
Make sure your server is running. If you stopped it earlier, restart it by running node server.js in your terminal from the mcp-caption-generator folder.
Check that index.html is saved inside the public folder, not the project root. Express serves from public/ specifically.
Help me debug why my page isn't loading at localhost:3001
Add the JavaScript for form submission and clipboard
The form looks good, but clicking the button does nothing yet. You need JavaScript to capture the form submission, send the data to your server, and display the caption that comes back.
- In public/index.html, find the closing </div> tag (the one that closes the container) just above </body>.
- Add the following <script> block between </div> and </body>:
<script>
const form = document.getElementById("captionForm");
const generateBtn = document.getElementById("generateBtn");
const output = document.getElementById("output");
const captionText = document.getElementById("captionText");
const copyBtn = document.getElementById("copyBtn");
const errorEl = document.getElementById("error");
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.textContent = "";
output.classList.remove("visible");
generateBtn.disabled = true;
generateBtn.textContent = "Generating...";
const topic = document.getElementById("topic").value;
const platform = document.getElementById("platform").value;
try {
const response = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic, platform }),
});
if (!response.ok) {
throw new Error("Failed to generate caption");
}
const data = await response.json();
captionText.textContent = data.caption;
output.classList.add("visible");
} catch (err) {
errorEl.textContent = "Error generating caption. Please try again.";
} finally {
generateBtn.disabled = false;
generateBtn.textContent = "Generate Caption";
}
});
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(captionText.textContent);
copyBtn.textContent = "Copied!";
setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
});
</script>
What does this code do?
- The form's submit listener sends a POST request to /generate with the topic and platform as JSON. While waiting, it disables the button and shows "Generating..." to prevent double-clicks.
- When the response arrives, it shows the caption by adding the visible class to the output div. If something goes wrong, it shows an error message instead.
- The copy button uses navigator.clipboard.writeText() to copy the caption, then briefly changes its label to "Copied!" for 2 seconds as feedback.
- Save public/index.html.
Seeing a syntax error in the console?
Open your browser's DevTools (right-click the page, then click **Inspect**, then the **Console** tab). If you see a red error message, check that your <script> block is placed after the closing container </div> and before </body>. A misplaced script tag can break the page.
Help me fix a JavaScript error in my caption generator page
Test the full caption generator
Your server already has the /generate endpoint and static file serving from Step 2. The frontend you just built sends requests to that endpoint. Time to test the full flow.
- If your server is not running, start it by running this command in your terminal from the project folder:
node server.js
- Open http://localhost:3001 in your browser.
- Type a topic in the text field (for example, Morning coffee routine).
- Select a platform from the dropdown (for example, Instagram).
- Click Generate Caption.
The button text changes to "Generating..." while it waits, then a caption appears in a grey box below the form. Click Copy to Clipboard to verify the copy button changes to "Copied!" for a moment.
How does the data flow end-to-end?
- The browser sends a POST with {topic, platform} to /generate on your Express server.
- The /generate endpoint calls the Anthropic API with a system prompt tailored to the chosen platform.
- Claude returns a caption, and the server sends it back as JSON. The browser displays it in the output area.
Getting an error when you click Generate?
Check your terminal for error messages. If you see an authentication error, make sure your .env file has a valid API key (not the placeholder your-api-key-here).
If the page shows "Error generating caption. Please try again." but your terminal has no output, check that you have credit on your Anthropic account at console.anthropic.com.
Help me debug why my caption generator returns an error
✔️ Awesome, I've got everything!
Great. Double check you have saved public/index.html and that captions generate successfully in your browser.
ⓧ I'd like to double check the full code
Here is the complete public/index.html file. Make sure yours matches exactly:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Caption Generator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
h1 { font-size: 24px; margin-bottom: 8px; color: #1a1a1a; }
.subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
label { display: block; font-weight: 600; margin-bottom: 6px; color: #333; font-size: 14px; }
input, select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
}
input:focus, select:focus { outline: none; border-color: #6366f1; }
button {
width: 100%;
padding: 14px;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #4f46e5; }
button:disabled { background: #a5a6f6; cursor: not-allowed; }
.output {
margin-top: 24px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
display: none;
}
.output.visible { display: block; }
.output p { line-height: 1.6; color: #333; white-space: pre-wrap; }
.copy-btn {
margin-top: 12px;
padding: 8px 16px;
background: #10b981;
font-size: 13px;
width: auto;
}
.copy-btn:hover { background: #059669; }
.error { color: #ef4444; margin-top: 12px; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>AI Caption Generator</h1>
<p class="subtitle">Powered by MCP + Claude AI</p>
<form id="captionForm">
<label for="topic">Topic</label>
<input type="text" id="topic" placeholder="e.g., Morning coffee routine, New product launch" required>
<label for="platform">Platform</label>
<select id="platform" required>
<option value="Instagram">Instagram</option>
<option value="Twitter/X">Twitter/X</option>
<option value="LinkedIn">LinkedIn</option>
<option value="Facebook">Facebook</option>
</select>
<button type="submit" id="generateBtn">Generate Caption</button>
</form>
<div class="output" id="output">
<p id="captionText"></p>
<button class="copy-btn" id="copyBtn">Copy to Clipboard</button>
</div>
<p class="error" id="error"></p>
</div>
<script>
const form = document.getElementById("captionForm");
const generateBtn = document.getElementById("generateBtn");
const output = document.getElementById("output");
const captionText = document.getElementById("captionText");
const copyBtn = document.getElementById("copyBtn");
const errorEl = document.getElementById("error");
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.textContent = "";
output.classList.remove("visible");
generateBtn.disabled = true;
generateBtn.textContent = "Generating...";
const topic = document.getElementById("topic").value;
const platform = document.getElementById("platform").value;
try {
const response = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic, platform }),
});
if (!response.ok) {
throw new Error("Failed to generate caption");
}
const data = await response.json();
captionText.textContent = data.caption;
output.classList.add("visible");
} catch (err) {
errorEl.textContent = "Error generating caption. Please try again.";
} finally {
generateBtn.disabled = false;
generateBtn.textContent = "Generate Caption";
}
});
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(captionText.textContent);
copyBtn.textContent = "Copied!";
setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
});
</script>
</body>
</html>
Your caption generator works locally. Next up, you will deploy it to AWS EC2 so anyone on the internet can use it.
Deploy to AWS EC2
Your caption generator works perfectly on your local machine. Now it is time to make it accessible to anyone on the internet.
In this step, you will launch a virtual server on AWS EC2, upload your project files, and start your application so it runs 24/7 at a public URL.
In this step, get ready to:
- Launch an EC2 instance with the correct security group settings.
- Install Node.js and deploy your project files to the server.
- Start your application with PM2 and verify it works from a public URL.
Launch a free-tier EC2 instance
An EC2 instance is a virtual computer running in Amazon's data centers. You will launch one running Ubuntu and configure it to accept web traffic on the ports your app uses.
- Sign in to the AWS Console at console.aws.amazon.com.
- In the search bar at the top, type EC2 and select the EC2 service.
- Click Launch instance.
- Enter a name for your instance, such as caption-generator.
- Under Application and OS Images, select Ubuntu. Leave the default Ubuntu Server AMI selected.
- Under Instance type, select t2.micro (this is free-tier eligible).
- Under Key pair (login), click Create new key pair.
- Name it caption-generator-key, leave the defaults (RSA, .pem), and click Create key pair. The file downloads automatically.
What is a key pair?
A key pair is a security credential that lets you connect to your EC2 instance via SSH. The .pem file you just downloaded is your private key. Keep it safe and never share it.
Now configure the security group to allow traffic on the correct ports.
- Under Network settings, click Edit.
- Keep the default rule for SSH (port 22).
- Click Add security group rule.
- Set Type to HTTP (this adds port 80).
- Click Add security group rule again.
- Set Type to Custom TCP.
- Set Port range to 3001.
- Set Source type to Anywhere (0.0.0.0/0).
- Click Launch instance.
Why three ports?
Port 22 allows SSH access so you can connect to your server. Port 80 is the standard HTTP port for web traffic. Port 3001 is the custom port your Express app listens on.
- Wait for the instance state to show Running (this takes about 30 seconds).
- Click on your instance ID to open its details page.
- Copy the Public IPv4 address shown on the details page. You will need this shortly.
Instance not launching?
Make sure you selected t2.micro as the instance type. Other types may not be free-tier eligible and could require additional capacity approval.
If you see a limit error, try a different AWS region from the top-right dropdown.
Help me troubleshoot my EC2 instance launch. I selected t2.micro Ubuntu and got an error.
Connect to your instance and install Node.js
EC2 Instance Connect lets you open a terminal session to your server directly in the browser. No SSH client needed.
- On your instance details page, click Connect (near the top).
- Select the EC2 Instance Connect tab.
- Leave the username as ubuntu.
- Click Connect. A browser-based terminal opens.
What is EC2 Instance Connect?
EC2 Instance Connect is a browser-based SSH client built into the AWS Console. It connects you to your server without needing to install or configure SSH on your local machine.
Now install Node.js on your server using nvm (Node Version Manager).
- Install nvm by running this command in the EC2 terminal:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash
- Load nvm into your current terminal session by running:
source ~/.bashrc
- Install the latest LTS version of Node.js by running:
nvm install --lts
- Verify Node.js is installed by running:
node --version
You should see a version number printed (e.g. v24.16.0 or similar).
Now upload your project files. You will create the project directory and files manually on the server.
- Create the project directory and navigate into it by running:
mkdir caption-generator && cd caption-generator
- Create the package.json file by running:
cat > package.json << 'EOF'
{
"name": "mcp-caption-generator",
"version": "1.0.0",
"type": "module",
"description": "AI-powered social media caption generator using MCP",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2",
"zod": "^3.25.0"
}
}
EOF
- Create the server.js file by running:
cat > server.js << 'EOF'
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import cors from "cors";
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import dotenv from "dotenv";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
const server = new McpServer({
name: "caption-generator",
version: "1.0.0",
});
server.registerTool(
"generate-caption",
{
title: "Generate Social Media Caption",
description:
"Generates a social media caption for a given topic and platform",
inputSchema: {
topic: z.string().describe("The topic or subject for the caption"),
platform: z
.enum(["Instagram", "Twitter/X", "LinkedIn", "Facebook"])
.describe("The social media platform"),
},
},
async ({ topic, platform }) => {
const message = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Generate a social media caption for ${platform} about: ${topic}`,
},
],
system: `You are a social media expert. Generate a single, engaging caption for ${platform}. Follow platform best practices: Instagram (use emojis, hashtags, 2200 char max), Twitter/X (concise, under 280 chars), LinkedIn (professional tone, can be longer), Facebook (conversational, medium length). Return ONLY the caption text, nothing else.`,
});
const caption = message.content[0].text;
return {
content: [{ type: "text", text: caption }],
};
}
);
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(join(__dirname, "public")));
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.post("/generate", async (req, res) => {
const { topic, platform } = req.body;
if (!topic || !platform) {
return res.status(400).json({ error: "Topic and platform are required" });
}
try {
const message = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [
{
role: "user",
content: `Generate a social media caption for ${platform} about: ${topic}`,
},
],
system: `You are a social media expert. Generate a single, engaging caption for ${platform}. Follow platform best practices: Instagram (use emojis, hashtags, 2200 char max), Twitter/X (concise, under 280 chars), LinkedIn (professional tone, can be longer), Facebook (conversational, medium length). Return ONLY the caption text, nothing else.`,
});
const caption = message.content[0].text;
res.json({ caption });
} catch (error) {
res.status(500).json({ error: "Failed to generate caption" });
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Caption Generator MCP Server running on http://localhost:${PORT}`);
});
EOF
- Create the public directory and the index.html file by running:
mkdir public
cat > public/index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Caption Generator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
h1 { font-size: 24px; margin-bottom: 8px; color: #1a1a1a; }
.subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
label { display: block; font-weight: 600; margin-bottom: 6px; color: #333; font-size: 14px; }
input, select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
}
input:focus, select:focus { outline: none; border-color: #6366f1; }
button {
width: 100%;
padding: 14px;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #4f46e5; }
button:disabled { background: #a5a6f6; cursor: not-allowed; }
.output {
margin-top: 24px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
display: none;
}
.output.visible { display: block; }
.output p { line-height: 1.6; color: #333; white-space: pre-wrap; }
.copy-btn {
margin-top: 12px;
padding: 8px 16px;
background: #10b981;
font-size: 13px;
width: auto;
}
.copy-btn:hover { background: #059669; }
.error { color: #ef4444; margin-top: 12px; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>AI Caption Generator</h1>
<p class="subtitle">Powered by MCP + Claude AI</p>
<form id="captionForm">
<label for="topic">Topic</label>
<input type="text" id="topic" placeholder="e.g., Morning coffee routine, New product launch" required>
<label for="platform">Platform</label>
<select id="platform" required>
<option value="Instagram">Instagram</option>
<option value="Twitter/X">Twitter/X</option>
<option value="LinkedIn">LinkedIn</option>
<option value="Facebook">Facebook</option>
</select>
<button type="submit" id="generateBtn">Generate Caption</button>
</form>
<div class="output" id="output">
<p id="captionText"></p>
<button class="copy-btn" id="copyBtn">Copy to Clipboard</button>
</div>
<p class="error" id="error"></p>
</div>
<script>
const form = document.getElementById("captionForm");
const generateBtn = document.getElementById("generateBtn");
const output = document.getElementById("output");
const captionText = document.getElementById("captionText");
const copyBtn = document.getElementById("copyBtn");
const errorEl = document.getElementById("error");
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.textContent = "";
output.classList.remove("visible");
generateBtn.disabled = true;
generateBtn.textContent = "Generating...";
const topic = document.getElementById("topic").value;
const platform = document.getElementById("platform").value;
try {
const response = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic, platform }),
});
if (!response.ok) {
throw new Error("Failed to generate caption");
}
const data = await response.json();
captionText.textContent = data.caption;
output.classList.add("visible");
} catch (err) {
errorEl.textContent = "Error generating caption. Please try again.";
} finally {
generateBtn.disabled = false;
generateBtn.textContent = "Generate Caption";
}
});
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(captionText.textContent);
copyBtn.textContent = "Copied!";
setTimeout(() => { copyBtn.textContent = "Copy to Clipboard"; }, 2000);
});
</script>
</body>
</html>
EOF
- Create the .env file with your Anthropic API key by running:
cat > .env << 'EOF'
ANTHROPIC_API_KEY=your-api-key-here
EOF
Replace the placeholder with your real key
Use a text editor like nano to replace your-api-key-here with your actual Anthropic API key. Run nano .env, edit the value, then press Ctrl+X, Y, Enter to save.
- Install the npm dependencies by running:
npm install
You should see the packages download and a node_modules folder appear. This may take a minute.
npm install hanging or failing?
The t2.micro instance has limited memory. If the install hangs, wait a few minutes. If it fails with a memory error, try running it again.
Make sure node --version still shows a version number. If it does not, run source ~/.bashrc to reload nvm.
Help me fix npm install failing on my EC2 instance.
Start your application with PM2
If you simply run node server.js, your app would stop the moment you close the terminal. PM2 is a process manager that keeps your app running in the background and auto-restarts it if it crashes.
- Install PM2 globally by running:
npm install pm2 -g
- Start your server with PM2 by running:
pm2 start server.js --name caption-generator
- Verify your app is running by checking the PM2 process list:
pm2 list
You should see caption-generator listed with a status of online.
Why PM2 instead of just node?
PM2 detaches your app from the terminal session. You can close the browser tab and your server keeps running. If your app crashes for any reason, PM2 automatically restarts it.
Now test your deployed application from any browser.
- Open a new browser tab on your local machine.
- Navigate to your EC2 instance's public IP on port 3001:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:3001
- You should see the AI Caption Generator UI.
- Type a topic (like Morning coffee routine), select a platform, and click Generate Caption.
- A generated caption should appear below the form.
Page not loading?
Check that you are using http:// (not https://). Your server does not have an SSL certificate.
Confirm your security group allows inbound traffic on port 3001. In the EC2 console, select your instance, click the Security tab, and verify the inbound rules.
Check that PM2 shows your app as online (run pm2 list in the EC2 terminal). If the status is errored, check the logs with pm2 logs.
Help me debug why my caption generator is not loading on EC2.
Cost warning
The t2.micro instance is free for 750 hours/month for 12 months under the AWS Free Tier. The Anthropic API charges approximately $0.001 per caption generated using claude-haiku-4-5.
If you leave your instance running past the free tier period, AWS will charge for compute time. The Before You Go section covers how to stop or terminate your instance.
Your caption generator is now live on the internet. Anyone with your EC2 public IP can use it to generate social media captions powered by Claude AI. Next up, you will take on a secret mission to add a tone selector to your app.
Secret mission
Add a Tone Selector
Your caption generator works, but every caption sounds the same. What if you could choose between professional, casual, humorous, and inspirational tones? In this secret mission, you will add tone control that flows from the UI all the way through to the Claude prompt.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
The EC2 instance is covered under AWS Free Tier (750 hours/month of t2.micro for 12 months). The Anthropic API charges fractions of a cent per caption generated.
Cost warning
If your AWS Free Tier has expired, a running t2.micro instance costs approximately $8.50/month. Each caption you generate costs roughly $0.001 via the Anthropic API. If you are not planning to use this project again soon, choose Pause or Delete below to avoid unexpected charges.
Resources you used:
- AWS EC2 instance (t2.micro, Ubuntu).
- Anthropic API key (stored in .env on EC2 and locally).
- Local project folder (mcp-caption-generator on your Desktop).
Keep everything running
No action needed. Choose this if you are still actively building or want your caption generator to stay live.
- Your EC2 instance remains accessible at its public IP on port 3001.
- Free Tier covers 750 hours/month of t2.micro for 12 months. One instance running 24/7 fits within this allowance.
- Monitor your Anthropic API usage at console.anthropic.com to track spending.
- PM2 keeps your application running on the EC2 instance even after you close the terminal.
Pause - I'll come back to this later
Stop the EC2 instance to eliminate compute charges while keeping your files intact. You can restart anytime.
- Open the EC2 console at https://console.aws.amazon.com/ec2/.
- In the navigation pane, click Instances.
- Select your caption generator instance.
- Click Instance state, then select Stop instance.
- Confirm when prompted.
- Your instance retains all data and configuration. To resume, select the instance and choose Instance state > Start instance.
- Note: the public IP address may change when you restart. Check the instance details for the new IP.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
Terminate the EC2 instance
- Open the EC2 console at https://console.aws.amazon.com/ec2/.
- In the navigation pane, click Instances.
- Select your caption generator instance.
- Click Instance state, then select Terminate (delete) instance.
- Click Terminate (delete) when prompted for confirmation.
- The instance will show a terminated state briefly before disappearing from the console.
Delete your Anthropic API key
- Go to console.anthropic.com and sign in.
- Click API Keys in the left sidebar.
- Find the key you created for this project.
- Click the delete (trash) icon next to the key and confirm deletion.
Delete local project files
Remove the project folder from your Desktop by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf mcp-caption-generator
Windows
cd ~\Desktop
rmdir /s /q mcp-caption-generator
Nice Work!
Nice Work!
You just built and deployed a live AI-powered social media caption generator using the Model Context Protocol. Your application is running on AWS, accessible to anyone with the URL.
You've learned how to:
- Built an MCP server that exposes an AI-powered tool following the Model Context Protocol specification, complete with registered tools, input schemas, and structured responses.
- Integrated the Claude API to generate creative, platform-appropriate social media captions and built a web interface to interact with your server.
- Deployed a full-stack application to AWS EC2 with security groups, PM2 process management, and a publicly accessible URL.
- Secret Mission: Added dynamic tone control, proving you understand the full data flow from the UI through the REST endpoint into the MCP tool and down to the Claude prompt.
Ready to quiz yourself?