Skip to main content
Glama
mcp_server.js16.2 kB
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(); // }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jgardner04/Ghost-MCP-Server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server