import { MCPServer, Resource, Tool } from '@modelcontextprotocol/sdk/server/index.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 fs from 'fs';
import path from 'path';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { validateImageUrl, createSecureAxiosConfig } from './utils/urlValidator.js';
import { createContextLogger } from './utils/logger.js';
// Load environment variables (might be redundant if loaded elsewhere, but safe)
dotenv.config();
// Initialize logger for MCP server
const logger = createContextLogger('mcp-server');
logger.info('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.',
// iconUrl: '...',
},
});
// --- Define Resources ---
logger.info('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',
},
// Add other relevant tag fields if needed (e.g., feature_image, visibility)
},
required: ['id', 'name', 'slug'],
},
});
mcpServer.addResource(ghostTagResource);
logger.info('Added MCP Resource', { resourceName: 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: ['published', 'draft', 'scheduled'],
description: 'Publication status',
},
visibility: {
type: 'string',
enum: ['public', 'members', 'paid'],
description: 'Access level',
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Date/time post was created',
},
updated_at: {
type: 'string',
format: 'date-time',
description: 'Date/time post was last updated',
},
published_at: {
type: ['string', 'null'],
format: 'date-time',
description: 'Date/time post was published or scheduled',
},
custom_excerpt: {
type: ['string', 'null'],
description: 'Custom excerpt for the post',
},
meta_title: { type: ['string', 'null'], description: 'Custom SEO title' },
meta_description: {
type: ['string', 'null'],
description: 'Custom SEO description',
},
tags: {
type: 'array',
description: 'Tags associated with the post',
items: { $ref: '#/definitions/ghost/tag' }, // Reference the ghost/tag resource
},
// Add authors or other relevant fields if needed
},
required: ['id', 'uuid', 'title', 'slug', 'status', 'visibility', 'created_at', 'updated_at'],
definitions: {
// Make the referenced tag resource available within this schema's scope
'ghost/tag': ghostTagResource.schema,
},
},
});
mcpServer.addResource(ghostPostResource);
logger.info('Added MCP Resource', { resourceName: ghostPostResource.name });
// --- Define Tools (Subtasks 8.4 - 8.7) ---
// Placeholder comments for where tools will be added
// --- End Resource/Tool Definitions ---
logger.info('Defining MCP Tools');
// Create Post Tool (Adding this missing tool)
const createPostTool = new Tool({
name: 'ghost_create_post',
description:
'Creates a new post in Ghost CMS. Handles tag creation/lookup. Returns the created post data.',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'The title for the new post.' },
html: {
type: 'string',
description: 'The HTML content for the new post.',
},
status: {
type: 'string',
enum: ['published', 'draft', 'scheduled'],
default: 'draft',
description: 'The status for the post (published, draft, scheduled). Defaults to draft.',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Optional: An array of tag names (strings) to associate with the post.',
},
published_at: {
type: 'string',
format: 'date-time',
description:
'Optional: The ISO 8601 date/time for publishing or scheduling. 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) => {
logger.toolExecution(createPostTool.name, input);
try {
const createdPost = await createPostService(input);
logger.toolSuccess(createPostTool.name, createdPost, { postId: createdPost.id });
return createdPost;
} catch (error) {
logger.toolError(createPostTool.name, error);
throw new Error(`Failed to create Ghost post: ${error.message}`);
}
},
});
mcpServer.addTool(createPostTool);
logger.info('Added MCP Tool', { toolName: 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.',
},
// filenameHint: { type: 'string', description: 'Optional: A hint for the original filename, used for default alt text generation.' }
},
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) => {
logger.toolExecution(uploadImageTool.name, { imageUrl: 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);
// Generate a unique temporary filename
const tempDir = os.tmpdir();
const extension = path.extname(imageUrl.split('?')[0]) || '.tmp'; // Basic extension extraction
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);
});
logger.fileOperation('download', downloadedPath);
// --- 3. Process the image (Optional) ---
// Using the service from subtask 4.2
processedPath = await processImage(downloadedPath, tempDir);
logger.fileOperation('process', processedPath);
// --- 4. Determine Alt Text ---
// Using similar logic from subtask 4.4
const defaultAlt = getDefaultAltText(originalFilenameHint);
const finalAltText = alt || defaultAlt;
logger.debug('Generated alt text', { altText: finalAltText });
// --- 5. Upload processed image to Ghost ---
const uploadResult = await uploadGhostImage(processedPath);
logger.info('Image uploaded to Ghost', { ghostUrl: uploadResult.url });
// --- 6. Return result ---
return {
url: uploadResult.url,
alt: finalAltText,
};
} catch (error) {
logger.toolError(uploadImageTool.name, error, { imageUrl });
// Add more specific error handling (download failed, processing failed, upload failed)
throw new Error(`Failed to upload image from URL ${imageUrl}: ${error.message}`);
} finally {
// --- 7. Cleanup temporary files ---
if (downloadedPath) {
fs.unlink(downloadedPath, (err) => {
if (err)
logger.warn('Failed to delete temporary downloaded file', {
file: path.basename(downloadedPath),
error: err.message,
});
});
}
if (processedPath && processedPath !== downloadedPath) {
fs.unlink(processedPath, (err) => {
if (err)
logger.warn('Failed to delete temporary processed file', {
file: path.basename(processedPath),
error: err.message,
});
});
}
}
},
});
// Helper function for default alt text (similar to imageController)
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}-?/, ''); // Remove UUIDs too
return nameWithoutIds.replace(/[-_]/g, ' ').trim() || 'Uploaded image';
} catch (e) {
return 'Uploaded image';
}
};
mcpServer.addTool(uploadImageTool);
logger.info('Added MCP Tool', { toolName: 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: The exact name of the tag to search for.',
},
},
},
outputSchema: {
type: 'array',
items: { $ref: 'ghost/tag#/schema' }, // Output is an array of ghost/tag resources
},
implementation: async (input) => {
logger.toolExecution(getTagsTool.name, input);
try {
const tags = await getGhostTags(input?.name); // Pass name if provided
logger.toolSuccess(getTagsTool.name, tags, { tagCount: tags.length });
// TODO: Validate/map output against schema if necessary
return tags;
} catch (error) {
logger.toolError(getTagsTool.name, error);
throw new Error(`Failed to get Ghost tags: ${error.message}`);
}
},
});
mcpServer.addTool(getTagsTool);
logger.info('Added MCP Tool', { toolName: getTagsTool.name });
// Create Tag Tool
const createTagTool = new Tool({
name: 'ghost_create_tag',
description: 'Creates a new tag in Ghost CMS. Returns the created tag.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'The name for the new tag.' },
description: {
type: 'string',
description: 'Optional: A description for the tag (max 500 chars).',
},
slug: {
type: 'string',
description:
'Optional: A URL-friendly slug. If omitted, Ghost generates one from the name.',
},
// Add other createable fields like color, feature_image etc. if needed
},
required: ['name'],
},
outputSchema: {
$ref: 'ghost/tag#/schema', // Output is a single ghost/tag resource
},
implementation: async (input) => {
logger.toolExecution(createTagTool.name, input);
try {
// Basic validation happens via inputSchema, more specific validation (like slug format) could be added here if not in service
const newTag = await createGhostTag(input);
logger.toolSuccess(createTagTool.name, newTag, { tagId: newTag.id });
// TODO: Validate/map output against schema if necessary
return newTag;
} catch (error) {
logger.toolError(createTagTool.name, error);
throw new Error(`Failed to create Ghost tag: ${error.message}`);
}
},
});
mcpServer.addTool(createTagTool);
logger.info('Added MCP Tool', { toolName: createTagTool.name });
// --- End Tool Definitions ---
// Function to start the MCP server
// We might integrate this with the Express server later or run separately
const startMCPServer = async (port = 3001) => {
try {
// Ensure resources/tools are added before starting
logger.info('Starting MCP Server', { port });
await mcpServer.listen({ port });
const resources = mcpServer.listResources().map((r) => r.name);
const tools = mcpServer.listTools().map((t) => t.name);
logger.info('MCP Server started successfully', {
port,
resourceCount: resources.length,
toolCount: tools.length,
resources,
tools,
type: 'server_start',
});
} catch (error) {
logger.error('Failed to start MCP Server', {
port,
error: error.message,
stack: error.stack,
type: 'server_start_error',
});
process.exit(1);
}
};
// Export the server instance and start function if needed elsewhere
export { mcpServer, startMCPServer };
// Optional: Automatically start if this file is run directly
// This might conflict if we integrate with Express later
// if (require.main === module) {
// startMCPServer();
// }