Skip to main content
Glama
mcp_server_improved.js19.4 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 }); }

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