Skip to main content
Glama
mcp_server.js16.4 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