mcp_server_improved.js•19.8 kB
import {
MCPServer,
Resource,
Tool,
} from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { WebSocketServerTransport } from "@modelcontextprotocol/sdk/server/websocket.js";
import dotenv from "dotenv";
import { createPostService } from "./services/postService.js";
import {
uploadImage as uploadGhostImage,
getTags as getGhostTags,
createTag as createGhostTag,
} from "./services/ghostService.js";
import { processImage } from "./services/imageProcessingService.js";
import axios from "axios";
import { validateImageUrl, createSecureAxiosConfig } from "./utils/urlValidator.js";
import fs from "fs";
import path from "path";
import os from "os";
import { v4 as uuidv4 } from "uuid";
import express from "express";
import { WebSocketServer } from "ws";
// Load environment variables
dotenv.config();
console.log("Initializing MCP Server...");
// Define the server instance
const mcpServer = new MCPServer({
metadata: {
name: "Ghost CMS Manager",
description:
"MCP Server to manage a Ghost CMS instance using the Admin API.",
version: "1.0.0",
},
});
// --- Error Response Standardization ---
class MCPError extends Error {
constructor(message, code = "UNKNOWN_ERROR", details = {}) {
super(message);
this.code = code;
this.details = details;
}
}
const handleToolError = (error, toolName) => {
console.error(`Error in tool ${toolName}:`, error);
// Standardized error response
return {
error: {
code: error.code || "TOOL_EXECUTION_ERROR",
message: error.message || "An unexpected error occurred",
tool: toolName,
details: error.details || {},
timestamp: new Date().toISOString(),
}
};
};
// --- Define Resources ---
console.log("Defining MCP Resources...");
// Ghost Tag Resource
const ghostTagResource = new Resource({
name: "ghost/tag",
description: "Represents a tag in Ghost CMS.",
schema: {
type: "object",
properties: {
id: { type: "string", description: "Unique ID of the tag" },
name: { type: "string", description: "The name of the tag" },
slug: { type: "string", description: "URL-friendly version of the name" },
description: {
type: ["string", "null"],
description: "Optional description for the tag",
},
},
required: ["id", "name", "slug"],
},
// Resource fetching handler
async fetch(uri) {
try {
// Extract tag ID from URI (e.g., "ghost/tag/123")
const tagId = uri.split("/").pop();
const tags = await getGhostTags();
const tag = tags.find(t => t.id === tagId || t.slug === tagId);
if (!tag) {
throw new MCPError(`Tag not found: ${tagId}`, "RESOURCE_NOT_FOUND");
}
return tag;
} catch (error) {
return handleToolError(error, "ghost_tag_fetch");
}
}
});
mcpServer.addResource(ghostTagResource);
console.log(`Added Resource: ${ghostTagResource.name}`);
// Ghost Post Resource
const ghostPostResource = new Resource({
name: "ghost/post",
description: "Represents a post in Ghost CMS.",
schema: {
type: "object",
properties: {
id: { type: "string", description: "Unique ID of the post" },
uuid: { type: "string", description: "UUID of the post" },
title: { type: "string", description: "The title of the post" },
slug: {
type: "string",
description: "URL-friendly version of the title",
},
html: {
type: ["string", "null"],
description: "The post content as HTML",
},
plaintext: {
type: ["string", "null"],
description: "The post content as plain text",
},
feature_image: {
type: ["string", "null"],
description: "URL of the featured image",
},
feature_image_alt: {
type: ["string", "null"],
description: "Alt text for the featured image",
},
feature_image_caption: {
type: ["string", "null"],
description: "Caption for the featured image",
},
featured: {
type: "boolean",
description: "Whether the post is featured",
},
status: {
type: "string",
enum: ["draft", "published", "scheduled"],
description: "The status of the post",
},
visibility: {
type: "string",
enum: ["public", "members", "paid", "tiers"],
description: "The visibility level of the post",
},
created_at: {
type: "string",
format: "date-time",
description: "Date/time when the post was created",
},
updated_at: {
type: "string",
format: "date-time",
description: "Date/time when the post was last updated",
},
published_at: {
type: ["string", "null"],
format: "date-time",
description: "Date/time when the post was published",
},
custom_excerpt: {
type: ["string", "null"],
description: "Custom excerpt for the post",
},
tags: {
type: "array",
items: { $ref: "ghost/tag#/schema" },
description: "Associated tags",
},
meta_title: {
type: ["string", "null"],
description: "Custom meta title for SEO",
},
meta_description: {
type: ["string", "null"],
description: "Custom meta description for SEO",
},
},
required: ["id", "uuid", "title", "slug", "status"],
},
// Resource fetching handler
async fetch(uri) {
try {
// Extract post ID from URI (e.g., "ghost/post/123")
const postId = uri.split("/").pop();
// You'll need to implement a getPost service method
// For now, returning an error as this would require adding to ghostService.js
throw new MCPError(
"Post fetching not yet implemented",
"NOT_IMPLEMENTED",
{ postId }
);
} catch (error) {
return handleToolError(error, "ghost_post_fetch");
}
}
});
mcpServer.addResource(ghostPostResource);
console.log(`Added Resource: ${ghostPostResource.name}`);
// --- Define Tools (with improved error handling) ---
console.log("Defining MCP Tools...");
// Create Post Tool
const createPostTool = new Tool({
name: "ghost_create_post",
description: "Creates a new post in Ghost CMS.",
inputSchema: {
type: "object",
properties: {
title: {
type: "string",
description: "The title of the post.",
},
html: {
type: "string",
description: "The HTML content of the post.",
},
status: {
type: "string",
enum: ["draft", "published", "scheduled"],
default: "draft",
description:
"The status of the post. Use 'scheduled' with a future published_at date.",
},
tags: {
type: "array",
items: { type: "string" },
description:
"Optional: List of tag names to associate with the post. Tags will be created if they don't exist.",
},
published_at: {
type: "string",
format: "date-time",
description:
"Optional: ISO 8601 date/time to publish the post. Required if status is 'scheduled'.",
},
custom_excerpt: {
type: "string",
description: "Optional: A custom short summary for the post.",
},
feature_image: {
type: "string",
format: "url",
description:
"Optional: URL of the image (e.g., from ghost_upload_image tool) to use as the featured image.",
},
feature_image_alt: {
type: "string",
description: "Optional: Alt text for the featured image.",
},
feature_image_caption: {
type: "string",
description: "Optional: Caption for the featured image.",
},
meta_title: {
type: "string",
description:
"Optional: Custom title for SEO (max 300 chars). Defaults to post title if omitted.",
},
meta_description: {
type: "string",
description:
"Optional: Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted.",
},
},
required: ["title", "html"],
},
outputSchema: {
$ref: "ghost/post#/schema",
},
implementation: async (input) => {
console.log(
`Executing tool: ${createPostTool.name} with input keys:`,
Object.keys(input)
);
try {
const createdPost = await createPostService(input);
console.log(
`Tool ${createPostTool.name} executed successfully. Post ID: ${createdPost.id}`
);
return createdPost;
} catch (error) {
return handleToolError(error, createPostTool.name);
}
},
});
mcpServer.addTool(createPostTool);
console.log(`Added Tool: ${createPostTool.name}`);
// Upload Image Tool
const uploadImageTool = new Tool({
name: "ghost_upload_image",
description:
"Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.",
inputSchema: {
type: "object",
properties: {
imageUrl: {
type: "string",
format: "url",
description: "The publicly accessible URL of the image to upload.",
},
alt: {
type: "string",
description:
"Optional: Alt text for the image. If omitted, a default will be generated from the filename.",
},
},
required: ["imageUrl"],
},
outputSchema: {
type: "object",
properties: {
url: {
type: "string",
format: "url",
description: "The final URL of the image hosted on Ghost.",
},
alt: {
type: "string",
description: "The alt text determined for the image.",
},
},
required: ["url", "alt"],
},
implementation: async (input) => {
console.log(
`Executing tool: ${uploadImageTool.name} for URL:`,
input.imageUrl
);
const { imageUrl, alt } = input;
let downloadedPath = null;
let processedPath = null;
try {
// --- 1. Validate URL for SSRF protection ---
const urlValidation = validateImageUrl(imageUrl);
if (!urlValidation.isValid) {
throw new Error(`Invalid image URL: ${urlValidation.error}`);
}
// --- 2. Download the image with security controls ---
const axiosConfig = createSecureAxiosConfig(urlValidation.sanitizedUrl);
const response = await axios(axiosConfig);
const tempDir = os.tmpdir();
const extension = path.extname(imageUrl.split("?")[0]) || ".tmp";
const originalFilenameHint =
path.basename(imageUrl.split("?")[0]) ||
`image-${uuidv4()}${extension}`;
downloadedPath = path.join(
tempDir,
`mcp-download-${uuidv4()}${extension}`
);
const writer = fs.createWriteStream(downloadedPath);
response.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
});
console.log(`Downloaded image to temporary path: ${downloadedPath}`);
// --- 2. Process the image ---
processedPath = await processImage(downloadedPath, tempDir);
console.log(`Processed image path: ${processedPath}`);
// --- 3. Determine Alt Text ---
const defaultAlt = getDefaultAltText(originalFilenameHint);
const finalAltText = alt || defaultAlt;
console.log(`Using alt text: "${finalAltText}"`);
// --- 4. Upload processed image to Ghost ---
const uploadResult = await uploadGhostImage(processedPath);
console.log(`Uploaded processed image to Ghost: ${uploadResult.url}`);
// --- 5. Return result ---
return {
url: uploadResult.url,
alt: finalAltText,
};
} catch (error) {
return handleToolError(
new MCPError(
`Failed to upload image from URL ${imageUrl}`,
"IMAGE_UPLOAD_ERROR",
{ imageUrl, originalError: error.message }
),
uploadImageTool.name
);
} finally {
// --- 6. Cleanup temporary files ---
if (downloadedPath) {
fs.unlink(downloadedPath, (err) => {
if (err)
console.error(
"Error deleting temporary downloaded file:",
downloadedPath,
err
);
});
}
if (processedPath && processedPath !== downloadedPath) {
fs.unlink(processedPath, (err) => {
if (err)
console.error(
"Error deleting temporary processed file:",
processedPath,
err
);
});
}
}
},
});
// Helper function for default alt text
const getDefaultAltText = (filePath) => {
try {
const originalFilename = path
.basename(filePath)
.split(".")
.slice(0, -1)
.join(".");
const nameWithoutIds = originalFilename
.replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, "")
.replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, "");
return nameWithoutIds.replace(/[-_]/g, " ").trim() || "Uploaded image";
} catch (e) {
return "Uploaded image";
}
};
mcpServer.addTool(uploadImageTool);
console.log(`Added Tool: ${uploadImageTool.name}`);
// Get Tags Tool
const getTagsTool = new Tool({
name: "ghost_get_tags",
description:
"Retrieves a list of tags from Ghost CMS. Can optionally filter by tag name.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description:
"Optional: Filter tags by exact name. If omitted, all tags are returned.",
},
},
},
outputSchema: {
type: "array",
items: { $ref: "ghost/tag#/schema" },
},
implementation: async (input) => {
console.log(`Executing tool: ${getTagsTool.name}`);
try {
const tags = await getGhostTags();
if (input.name) {
const filteredTags = tags.filter(
(tag) => tag.name.toLowerCase() === input.name.toLowerCase()
);
console.log(
`Filtered tags by name "${input.name}". Found ${filteredTags.length} match(es).`
);
return filteredTags;
}
console.log(`Retrieved ${tags.length} tags from Ghost.`);
return tags;
} catch (error) {
return handleToolError(error, getTagsTool.name);
}
},
});
mcpServer.addTool(getTagsTool);
console.log(`Added Tool: ${getTagsTool.name}`);
// Create Tag Tool
const createTagTool = new Tool({
name: "ghost_create_tag",
description: "Creates a new tag in Ghost CMS.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "The name of the tag.",
},
description: {
type: "string",
description: "Optional: A description for the tag.",
},
slug: {
type: "string",
pattern: "^[a-z0-9\\-]+$",
description:
"Optional: A URL-friendly slug for the tag. Will be auto-generated from the name if omitted.",
},
},
required: ["name"],
},
outputSchema: {
$ref: "ghost/tag#/schema",
},
implementation: async (input) => {
console.log(
`Executing tool: ${createTagTool.name} with name:`,
input.name
);
try {
const createdTag = await createGhostTag(input);
console.log(
`Tool ${createTagTool.name} executed successfully. Tag ID: ${createdTag.id}`
);
return createdTag;
} catch (error) {
return handleToolError(error, createTagTool.name);
}
},
});
mcpServer.addTool(createTagTool);
console.log(`Added Tool: ${createTagTool.name}`);
// --- Transport Configuration ---
/**
* Start MCP Server with specified transport
* @param {string} transport - Transport type: 'stdio', 'http', 'websocket'
* @param {object} options - Transport-specific options
*/
const startMCPServer = async (transport = 'http', options = {}) => {
try {
console.log(`Starting MCP Server with ${transport} transport...`);
switch (transport) {
case 'stdio':
// Standard I/O transport - best for CLI tools
const stdioTransport = new StdioServerTransport();
await mcpServer.connect(stdioTransport);
console.log("MCP Server running on stdio transport");
break;
case 'http':
case 'sse':
// HTTP with Server-Sent Events - good for web clients
const port = options.port || 3001;
const app = express();
// CORS configuration for web clients
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', options.cors || '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});
// SSE endpoint
const sseTransport = new SSEServerTransport();
app.get('/mcp/sse', sseTransport.handler());
// Health check
app.get('/mcp/health', (req, res) => {
res.json({
status: 'ok',
transport: 'sse',
resources: mcpServer.listResources().map(r => r.name),
tools: mcpServer.listTools().map(t => t.name),
});
});
await mcpServer.connect(sseTransport);
const server = app.listen(port, () => {
console.log(`MCP Server (SSE) listening on port ${port}`);
console.log(`SSE endpoint: http://localhost:${port}/mcp/sse`);
console.log(`Health check: http://localhost:${port}/mcp/health`);
});
// Store server instance for cleanup
mcpServer._httpServer = server;
break;
case 'websocket':
// WebSocket transport - best for real-time bidirectional communication
const wsPort = options.port || 3001;
const wss = new WebSocketServer({ port: wsPort });
wss.on('connection', async (ws) => {
console.log('New WebSocket connection');
const wsTransport = new WebSocketServerTransport(ws);
await mcpServer.connect(wsTransport);
});
console.log(`MCP Server (WebSocket) listening on port ${wsPort}`);
console.log(`WebSocket URL: ws://localhost:${wsPort}`);
// Store WebSocket server instance for cleanup
mcpServer._wss = wss;
break;
default:
throw new Error(`Unknown transport type: ${transport}`);
}
console.log("Available Resources:", mcpServer.listResources().map(r => r.name));
console.log("Available Tools:", mcpServer.listTools().map(t => t.name));
} catch (error) {
console.error("Failed to start MCP Server:", error);
process.exit(1);
}
};
// Graceful shutdown handler
const shutdown = async () => {
console.log("\nShutting down MCP Server...");
if (mcpServer._httpServer) {
mcpServer._httpServer.close();
}
if (mcpServer._wss) {
mcpServer._wss.close();
}
await mcpServer.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Export the server instance and start function
export { mcpServer, startMCPServer, MCPError };
// If running directly, start with transport from environment or default to HTTP
if (import.meta.url === `file://${process.argv[1]}`) {
const transport = process.env.MCP_TRANSPORT || 'http';
const port = parseInt(process.env.MCP_PORT || '3001');
const cors = process.env.MCP_CORS || '*';
startMCPServer(transport, { port, cors });
}