UNPKG

@wh1teee/strapi-mcp

Version:

High-performance MCP server for Strapi CMS with 95% smaller API responses, intelligent field selection, and Strapi v5+ compatibility

981 lines 158 kB
#!/usr/bin/env node process.on('unhandledRejection', (reason, promise) => { console.error('[FATAL] Unhandled Rejection at:', promise, 'reason:', reason); }); process.on('uncaughtException', (error) => { console.error('[FATAL] Uncaught Exception:', error); process.exit(1); // Mandatory exit after uncaught exception }); /** * Strapi MCP Server * * This MCP server integrates with any Strapi CMS instance to provide: * - Access to Strapi content types as resources * - Tools to create and update content types in Strapi * - Tools to manage content entries (create, read, update, delete) * - Support for Strapi in development mode * * This server is designed to be generic and work with any Strapi instance, * regardless of the content types defined in that instance. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import dotenv from 'dotenv'; import * as path from 'path'; import { createReadStream, promises as fs } from 'fs'; import * as os from 'os'; // Load environment variables from .env file dotenv.config(); // Extended error codes to include additional ones we need var ExtendedErrorCode; (function (ExtendedErrorCode) { // Original error codes from SDK ExtendedErrorCode["InvalidRequest"] = "InvalidRequest"; ExtendedErrorCode["MethodNotFound"] = "MethodNotFound"; ExtendedErrorCode["InvalidParams"] = "InvalidParams"; ExtendedErrorCode["InternalError"] = "InternalError"; // Additional error codes ExtendedErrorCode["ResourceNotFound"] = "ResourceNotFound"; ExtendedErrorCode["AccessDenied"] = "AccessDenied"; })(ExtendedErrorCode || (ExtendedErrorCode = {})); // Custom error class extending McpError to support our extended error codes class ExtendedMcpError extends McpError { extendedCode; constructor(code, message) { // Map our extended codes to standard MCP error codes when needed let mcpCode; // Map custom error codes to standard MCP error codes switch (code) { case ExtendedErrorCode.ResourceNotFound: case ExtendedErrorCode.AccessDenied: // Map custom codes to InternalError for SDK compatibility mcpCode = ErrorCode.InternalError; break; case ExtendedErrorCode.InvalidRequest: mcpCode = ErrorCode.InvalidRequest; break; case ExtendedErrorCode.MethodNotFound: mcpCode = ErrorCode.MethodNotFound; break; case ExtendedErrorCode.InvalidParams: mcpCode = ErrorCode.InvalidParams; break; case ExtendedErrorCode.InternalError: default: mcpCode = ErrorCode.InternalError; break; } // Call super before accessing 'this' super(mcpCode, message); // Store the extended code for reference this.extendedCode = code; } } // Configuration from environment variables const STRAPI_URL = process.env.STRAPI_URL || "http://localhost:1337"; const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN; const STRAPI_DEV_MODE = process.env.STRAPI_DEV_MODE === "true"; // Admin authentication removed - these are set to undefined to disable admin auth const STRAPI_ADMIN_EMAIL = undefined; const STRAPI_ADMIN_PASSWORD = undefined; // Validate required environment variables if (!STRAPI_API_TOKEN) { console.error("[Error] Missing required authentication. Please provide STRAPI_API_TOKEN environment variable"); process.exit(1); } // Validate API token format if (STRAPI_API_TOKEN && (STRAPI_API_TOKEN === "strapi_token" || STRAPI_API_TOKEN === "your-api-token-here" || STRAPI_API_TOKEN.includes("placeholder"))) { console.error("[Error] STRAPI_API_TOKEN appears to be a placeholder value. Please provide a real API token from your Strapi admin panel."); process.exit(1); } console.error(`[Setup] Connecting to Strapi at ${STRAPI_URL}`); console.error(`[Setup] Development mode: ${STRAPI_DEV_MODE ? "enabled" : "disabled"}`); console.error(`[Setup] Authentication: Using API token`); // Axios instance for Strapi API const strapiClient = axios.create({ baseURL: STRAPI_URL, headers: { "Content-Type": "application/json", }, validateStatus: function (status) { // Consider only 5xx as errors - for more robust error handling return status < 500; } }); // If API token is provided, use it if (STRAPI_API_TOKEN) { strapiClient.defaults.headers.common['Authorization'] = `Bearer ${STRAPI_API_TOKEN}`; } // Store admin JWT token if we log in let adminJwtToken = null; // Store user JWT token for API endpoints let userJwtToken = null; // Upload configuration const UPLOAD_CONFIG = { maxFileSize: parseInt(process.env.STRAPI_MAX_FILE_SIZE || '104857600'), // 100MB default allowedPaths: process.env.STRAPI_ALLOWED_PATHS?.split(',') || [], uploadTimeout: parseInt(process.env.STRAPI_UPLOAD_TIMEOUT || '30000'), // 30s default }; /** * Log in to get User JWT token for API endpoints * NOTE: Admin credentials are typically NOT the same as user credentials * This function is for completeness but will likely fail with admin credentials */ async function loginToStrapiUser() { const email = process.env.STRAPI_ADMIN_EMAIL; const password = process.env.STRAPI_ADMIN_PASSWORD; if (!email || !password) { console.error("[Auth] No user credentials found, skipping user login"); return false; } try { console.error(`[Auth] Attempting user login to ${STRAPI_URL}/api/auth/local as ${email}`); console.error(`[Auth] NOTE: Admin credentials typically don't work for user login`); const response = await axios.post(`${STRAPI_URL}/api/auth/local`, { identifier: email, password }); console.error(`[Auth] User login response status: ${response.status}`); if (response.data && response.data.jwt) { userJwtToken = response.data.jwt; console.error("[Auth] Successfully logged in to Strapi user API"); console.error(`[Auth] User token received (first 20 chars): ${userJwtToken?.substring(0, 20)}...`); return true; } else { console.error("[Auth] User login response missing JWT token"); console.error(`[Auth] Response data:`, JSON.stringify(response.data)); return false; } } catch (error) { console.error("[Auth] Expected failure - admin credentials don't work for user login"); if (axios.isAxiosError(error)) { console.error(`[Auth] Status: ${error.response?.status}`); console.error(`[Auth] Response data:`, error.response?.data); // Don't log this as an error since it's expected if (error.response?.status === 400 && error.response?.data?.error?.message === 'Invalid identifier or password') { console.error(`[Auth] This is expected - admin credentials are different from user credentials`); } } else { console.error(error); } return false; } } /** * Log in to the Strapi admin API using provided credentials */ async function loginToStrapiAdmin() { // Admin authentication removed - always return false console.error("[Auth] Admin authentication not supported - using API token only"); return false; } /** * Make a request to the admin API using the admin JWT token */ async function makeAdminApiRequest(endpoint, method = 'get', data, params) { // Admin API access removed - throw error console.error(`[Admin API] Admin API access not supported - endpoint: ${endpoint}`); throw new Error("Admin API access not supported. Please use API token-based endpoints instead."); } // Cache for content types let contentTypesCache = []; /** * Create an MCP server with capabilities for resources and tools */ const server = new Server({ name: "strapi-mcp", version: "0.2.0", }, { capabilities: { resources: {}, tools: {}, }, }); /** * Fetch all content types from Strapi */ async function fetchContentTypes() { try { // Validate connection before attempting to fetch await validateStrapiConnection(); console.error("[API] Fetching content types from Strapi"); // If we have cached content types, return them // --- DEBUG: Temporarily disable cache --- // if (contentTypesCache.length > 0) { // console.error("[API] Returning cached content types"); // return contentTypesCache; // } // --- END DEBUG --- // Helper function to process and cache content types const processAndCacheContentTypes = (data, source) => { console.error(`[API] Successfully fetched collection types from ${source}`); const contentTypes = data.map((item) => { const uid = item.uid; const apiID = uid.split('.').pop() || ''; return { uid: uid, apiID: apiID, info: { displayName: item.info?.displayName || apiID.charAt(0).toUpperCase() + apiID.slice(1).replace(/-/g, ' '), description: item.info?.description || `${apiID} content type`, }, attributes: item.attributes || {} }; }); // Filter out internal types const filteredTypes = contentTypes.filter((ct) => !ct.uid.startsWith("admin::") && !ct.uid.startsWith("plugin::")); console.error(`[API] Found ${filteredTypes.length} content types via ${source}`); contentTypesCache = filteredTypes; // Update cache return filteredTypes; }; // --- Attempt 0: Discovery first (most reliable for API tokens) --- console.error("[API] Attempt 0: Trying content type discovery via API endpoints (most reliable)"); // Expanded list of common content types to try const commonTypes = [ 'article', 'articles', 'page', 'pages', 'post', 'posts', 'user', 'users', 'category', 'categories', 'tag', 'tags', 'blog', 'blogs', 'news', 'product', 'products', 'service', 'services', 'gallery', 'galleries', 'event', 'events', 'testimonial', 'testimonials', 'faq', 'faqs', 'contact', 'contacts', 'team', 'about' ]; const discoveredTypes = []; // Test each potential content type for (const type of commonTypes) { try { console.error(`[API] Testing content type: ${type}`); const testResponse = await strapiClient.get(`/api/${type}?pagination[limit]=1`); if (testResponse.status === 200) { console.error(`[API] ✓ Discovered content type: api::${type}.${type}`); // Try to infer attributes from the response structure let attributes = {}; if (testResponse.data?.data?.length > 0) { const sampleEntry = testResponse.data.data[0]; if (sampleEntry.attributes) { // Extract field names and try to guess types Object.keys(sampleEntry.attributes).forEach(key => { const value = sampleEntry.attributes[key]; let fieldType = 'string'; // default if (typeof value === 'number') fieldType = 'number'; else if (typeof value === 'boolean') fieldType = 'boolean'; else if (Array.isArray(value)) fieldType = 'relation'; else if (value && typeof value === 'object' && value.data) fieldType = 'relation'; else if (typeof value === 'string' && value.length > 255) fieldType = 'text'; attributes[key] = { type: fieldType }; }); } } discoveredTypes.push({ uid: `api::${type}.${type}`, apiID: type, info: { displayName: type.charAt(0).toUpperCase() + type.slice(1).replace(/s$/, '') + (type.endsWith('s') && type !== 'news' ? '' : ''), description: `${type} content type (discovered via API)`, }, attributes: attributes }); } } catch (e) { // Log details for debugging but continue if (axios.isAxiosError(e) && e.response?.status === 404) { // Expected 404, just continue continue; } else if (axios.isAxiosError(e) && e.response?.status === 403) { console.error(`[API] Access denied for ${type}, continuing...`); continue; } else { console.error(`[API] Error testing ${type}: ${e.message}`); continue; } } } if (discoveredTypes.length > 0) { console.error(`[API] Found ${discoveredTypes.length} content types via discovery: ${discoveredTypes.map(t => t.apiID).join(', ')}`); contentTypesCache = discoveredTypes; return discoveredTypes; } // --- Attempt 1: Use API Token via strapiClient (for admin endpoints) --- if (STRAPI_API_TOKEN) { console.error("[API] Attempt 1: Trying to fetch content types via API token admin endpoints"); try { const tokenResponse = await strapiClient.get('/content-manager/collection-types'); if (tokenResponse.data && Array.isArray(tokenResponse.data)) { return processAndCacheContentTypes(tokenResponse.data, "Content Manager API (/content-manager/collection-types) [token]"); } } catch (tokenErr) { console.error("[API] API token admin endpoints failed, continuing to next method."); } } // --- Attempt 1: Use Admin Credentials if available --- console.error(`[DEBUG] Checking admin creds: EMAIL=${Boolean(STRAPI_ADMIN_EMAIL)}, PASSWORD=${Boolean(STRAPI_ADMIN_PASSWORD)}`); if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) { console.error("[API] Attempting to fetch content types using admin credentials"); try { // Use makeAdminApiRequest which handles login // Try the content-type-builder endpoint first, as it's more common for schema listing console.error("[API] Trying admin endpoint: /content-type-builder/content-types"); const adminResponse = await makeAdminApiRequest('/content-type-builder/content-types'); console.error("[API] Admin response structure:", Object.keys(adminResponse || {})); // Strapi's admin API often wraps data, check common structures let adminData = null; if (adminResponse && adminResponse.data && Array.isArray(adminResponse.data)) { adminData = adminResponse.data; // Direct array in response.data } else if (adminResponse && Array.isArray(adminResponse)) { adminData = adminResponse; // Direct array response } if (adminData && adminData.length > 0) { return processAndCacheContentTypes(adminData, "Admin API (/content-type-builder/content-types)"); } else { console.error("[API] Admin API response did not contain expected data array or was empty.", adminResponse); } } catch (adminError) { console.error(`[API] Failed to fetch content types using admin credentials:`, adminError); if (axios.isAxiosError(adminError)) { console.error(`[API] Admin API Error Status: ${adminError.response?.status}`); console.error(`[API] Admin API Error Data:`, adminError.response?.data); } // Don't throw, proceed to next method } } else { console.error("[API] Admin credentials not provided, skipping admin API attempt."); } // --- Attempt 2: Try different admin endpoints --- if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) { console.error("[API] Trying alternative admin endpoint: /content-manager/content-types"); try { const adminResponse2 = await makeAdminApiRequest('/content-manager/content-types'); console.error("[API] Admin response 2 structure:", Object.keys(adminResponse2 || {})); let adminData2 = null; if (adminResponse2 && adminResponse2.data && Array.isArray(adminResponse2.data)) { adminData2 = adminResponse2.data; } else if (adminResponse2 && Array.isArray(adminResponse2)) { adminData2 = adminResponse2; } if (adminData2 && adminData2.length > 0) { return processAndCacheContentTypes(adminData2, "Admin API (/content-manager/content-types)"); } } catch (adminError2) { console.error(`[API] Alternative admin endpoint also failed:`, adminError2); } } // --- Attempt 3: Use API Token via strapiClient (Original Primary Method) --- console.error("[API] Attempting to fetch content types using API token (strapiClient)"); try { // This is the most reliable way *if* the token has permissions const response = await strapiClient.get('/content-manager/collection-types'); if (response.data && Array.isArray(response.data)) { // Note: This path might require admin permissions, often fails with API token return processAndCacheContentTypes(response.data, "Content Manager API (/content-manager/collection-types)"); } } catch (apiError) { console.error(`[API] Failed to fetch from content manager API:`, apiError); if (axios.isAxiosError(apiError)) { console.error(`[API] API Error Status: ${apiError.response?.status}`); console.error(`[API] API Error Data:`, apiError.response?.data); } } // Final attempt: Try to discover content types by checking for common endpoint patterns // If all proper API methods failed, provide a helpful error message instead of silent failure let errorMessage = "Unable to fetch content types from Strapi. This could be due to:\n"; errorMessage += "1. Strapi server not running or unreachable\n"; errorMessage += "2. Invalid API token or insufficient permissions\n"; errorMessage += "3. Admin credentials not working\n"; errorMessage += "4. Database connectivity issues\n"; errorMessage += "5. Strapi instance configuration problems\n\n"; errorMessage += "Please check:\n"; errorMessage += `- Strapi is running at ${STRAPI_URL}\n`; errorMessage += "- Your API token has proper permissions\n"; errorMessage += "- Admin credentials are correct\n"; errorMessage += "- Database is accessible and running\n"; errorMessage += "- Try creating a test content type in your Strapi admin panel"; throw new ExtendedMcpError(ExtendedErrorCode.InternalError, errorMessage); } catch (error) { console.error("[Error] Failed to fetch content types:", error); let errorMessage = "Failed to fetch content types"; let errorCode = ExtendedErrorCode.InternalError; if (axios.isAxiosError(error)) { errorMessage += `: ${error.response?.status} ${error.response?.statusText}`; if (error.response?.status === 403) { errorCode = ExtendedErrorCode.AccessDenied; errorMessage += ` (Permission denied - check API token permissions)`; } else if (error.response?.status === 401) { errorCode = ExtendedErrorCode.AccessDenied; errorMessage += ` (Unauthorized - API token may be invalid or expired)`; } } else if (error instanceof Error) { errorMessage += `: ${error.message}`; } else { errorMessage += `: ${String(error)}`; } throw new ExtendedMcpError(errorCode, errorMessage); } } /** * Fetch entries for a specific content type with optional filtering, pagination, and sorting */ async function fetchEntries(contentType, queryParams) { // Validate connection before attempting to fetch await validateStrapiConnection(); let response; let success = false; let fetchedData = []; let fetchedMeta = {}; const collection = contentType.split(".")[1]; // Keep this for potential path variations if needed // --- Attempt 1: Use Admin Credentials via makeAdminApiRequest --- // Only attempt if admin credentials are provided if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) { console.error(`[API] Attempt 1: Fetching entries for ${contentType} using makeAdminApiRequest (Admin Credentials)`); try { // Use the full content type UID for the content-manager endpoint const adminEndpoint = `/content-manager/collection-types/${contentType}`; // Prepare query params for admin request (might need adjustment based on API) // Let's assume makeAdminApiRequest handles params correctly const adminParams = {}; // Convert nested Strapi v4 params to flat query params if needed, or pass as is // Example: filters[field][$eq]=value, pagination[page]=1, sort=field:asc, populate=*, fields=field1,field2 // For simplicity, let's pass the original structure first and modify makeAdminApiRequest if (queryParams?.filters) adminParams.filters = queryParams.filters; if (queryParams?.pagination) adminParams.pagination = queryParams.pagination; if (queryParams?.sort) adminParams.sort = queryParams.sort; if (queryParams?.populate) adminParams.populate = queryParams.populate; if (queryParams?.fields) adminParams.fields = queryParams.fields; // Make the request using admin credentials (modify makeAdminApiRequest to handle params) const adminResponse = await makeAdminApiRequest(adminEndpoint, 'get', undefined, adminParams); // Pass params here // Process admin response (structure might differ, e.g., response.data.results) if (adminResponse && adminResponse.results && Array.isArray(adminResponse.results)) { console.error(`[API] Successfully fetched data via admin credentials for ${contentType}`); // Admin API often returns data in 'results' and pagination info separately fetchedData = adminResponse.results; fetchedMeta = adminResponse.pagination || {}; // Adjust based on actual admin API response structure // Filter out potential errors within items fetchedData = fetchedData.filter((item) => !item?.error); if (fetchedData.length > 0) { console.error(`[API] Returning data fetched via admin credentials for ${contentType}`); return { data: fetchedData, meta: fetchedMeta }; } else { console.error(`[API] Admin fetch succeeded for ${contentType} but returned no entries. Trying API token.`); } } else { console.error(`[API] Admin fetch for ${contentType} did not return expected 'results' array. Response:`, adminResponse); console.error(`[API] Falling back to API token.`); } } catch (adminError) { console.error(`[API] Failed to fetch entries using admin credentials for ${contentType}:`, adminError); console.error(`[API] Falling back to API token.`); } } else { console.error("[API] Admin credentials not provided, using API token instead."); } // --- Attempt 2: Use API Token via strapiClient (as fallback) --- console.error(`[API] Attempt 2: Fetching entries for ${contentType} using strapiClient (API Token)`); try { const params = {}; // ... build params from queryParams ... (existing code) if (queryParams?.filters) params.filters = queryParams.filters; if (queryParams?.pagination) params.pagination = queryParams.pagination; if (queryParams?.sort) params.sort = queryParams.sort; if (queryParams?.populate) params.populate = queryParams.populate; if (queryParams?.fields) params.fields = queryParams.fields; // Try multiple possible API paths (keep this flexibility) const possiblePaths = [ `/api/${collection}`, `/api/${collection.toLowerCase()}`, // Add more variations if necessary ]; for (const path of possiblePaths) { try { console.error(`[API] Trying path with strapiClient: ${path}`); response = await strapiClient.get(path, { params }); if (response.data && response.data.error) { console.error(`[API] Path ${path} returned an error:`, response.data.error); continue; // Try next path } console.error(`[API] Successfully fetched data from: ${path} using strapiClient`); success = true; // Process response data if (response.data.data) { fetchedData = Array.isArray(response.data.data) ? response.data.data : [response.data.data]; fetchedMeta = response.data.meta || {}; } else if (Array.isArray(response.data)) { fetchedData = response.data; fetchedMeta = { pagination: { page: 1, pageSize: fetchedData.length, pageCount: 1, total: fetchedData.length } }; } else { // Handle unexpected format, maybe log it console.warn(`[API] Unexpected response format from ${path} using strapiClient:`, response.data); fetchedData = response.data ? [response.data] : []; // Wrap if not null/undefined fetchedMeta = {}; } // Filter out potential errors within items if any structure allows it fetchedData = fetchedData.filter((item) => !item?.error); break; // Exit loop on success } catch (err) { if (axios.isAxiosError(err) && (err.response?.status === 404 || err.response?.status === 403 || err.response?.status === 401)) { // 404: Try next path. 403/401: Permissions issue console.error(`[API] Path ${path} failed with ${err.response?.status}, trying next path...`); continue; } // For other errors, rethrow to be caught by the outer try-catch console.error(`[API] Unexpected error on path ${path} with strapiClient:`, err); throw err; } } // If strapiClient succeeded AND returned data, return it if (success && fetchedData.length > 0) { console.error(`[API] Returning data fetched via strapiClient for ${contentType}`); return { data: fetchedData, meta: fetchedMeta }; } else if (success && fetchedData.length === 0) { console.error(`[API] Content type ${contentType} exists but has no entries (empty collection)`); // Return empty result for legitimate empty collections (not an error) return { data: [], meta: fetchedMeta }; } else { console.error(`[API] strapiClient failed to fetch entries for ${contentType}.`); } } catch (error) { // Catch errors from the strapiClient attempts (excluding 404/403/401 handled above) console.error(`[API] Error during strapiClient fetch for ${contentType}:`, error); } // --- All attempts failed: Provide helpful error instead of silent empty return --- console.error(`[API] All attempts failed to fetch entries for ${contentType}`); let errorMessage = `Failed to fetch entries for content type ${contentType}. This could be due to:\n`; errorMessage += "1. Content type doesn't exist in your Strapi instance\n"; errorMessage += "2. API token lacks permissions to access this content type\n"; errorMessage += "3. Admin credentials don't have access to this content type\n"; errorMessage += "4. Content type exists but has no published entries\n"; errorMessage += "5. Database connectivity issues\n\n"; errorMessage += "Troubleshooting:\n"; errorMessage += `- Verify ${contentType} exists in your Strapi admin panel\n`; errorMessage += "- Check your API token permissions\n"; errorMessage += "- Ensure the content type has published entries\n"; errorMessage += "- Verify admin credentials if using admin authentication"; throw new ExtendedMcpError(ExtendedErrorCode.ResourceNotFound, errorMessage); } /** * Fetch a specific entry by ID */ async function fetchEntry(contentType, id, queryParams) { try { console.error(`[API] Fetching entry ${id} for content type: ${contentType}`); // Extract the collection name from the content type UID const collection = contentType.split(".")[1]; // --- Attempt 1: Use Admin Credentials --- if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) { console.error(`[API] Attempt 1: Fetching entry ${id} for ${contentType} using admin credentials`); try { // Admin API for content management uses a different path structure const adminEndpoint = `/content-manager/collection-types/${contentType}/${id}`; // Prepare admin params const adminParams = {}; if (queryParams?.populate) adminParams.populate = queryParams.populate; if (queryParams?.fields) adminParams.fields = queryParams.fields; // Make the request const adminResponse = await makeAdminApiRequest(adminEndpoint, 'get', undefined, adminParams); if (adminResponse) { console.error(`[API] Successfully fetched entry ${id} via admin credentials`); return adminResponse; } } catch (adminError) { console.error(`[API] Failed to fetch entry ${id} using admin credentials:`, adminError); console.error(`[API] Falling back to API token...`); } } else { console.error(`[API] Admin credentials not provided, falling back to API token`); } // --- Attempt 2: Use API Token as fallback --- // Build query parameters only for populate and fields const params = {}; if (queryParams?.populate) { params.populate = queryParams.populate; } if (queryParams?.fields) { params.fields = queryParams.fields; } console.error(`[API] Attempt 2: Fetching entry ${id} for ${contentType} using API token`); // Get the entry from Strapi const response = await strapiClient.get(`/api/${collection}/${id}`, { params }); return response.data.data; } catch (error) { console.error(`[Error] Failed to fetch entry ${id} for ${contentType}:`, error); let errorMessage = `Failed to fetch entry ${id} for ${contentType}`; let errorCode = ExtendedErrorCode.InternalError; if (axios.isAxiosError(error)) { errorMessage += `: ${error.response?.status} ${error.response?.statusText}`; if (error.response?.status === 404) { errorCode = ExtendedErrorCode.ResourceNotFound; errorMessage += ` (Entry not found)`; } else if (error.response?.status === 403) { errorCode = ExtendedErrorCode.AccessDenied; errorMessage += ` (Permission denied - check API token permissions)`; } else if (error.response?.status === 401) { errorCode = ExtendedErrorCode.AccessDenied; errorMessage += ` (Unauthorized - API token may be invalid or expired)`; } } else if (error instanceof Error) { errorMessage += `: ${error.message}`; } else { errorMessage += `: ${String(error)}`; } throw new ExtendedMcpError(errorCode, errorMessage); } } /** * Create a new entry */ async function createEntry(contentType, data) { try { console.error(`[API] Creating new entry for content type: ${contentType}`); // Add specific validation for articles if (contentType === 'api::articles.articles') { console.error('[API] Validating article data structure...'); // Check for common mistakes if (data.coverImage !== undefined) { throw new McpError(ErrorCode.InvalidParams, `Invalid field 'coverImage' for articles. Use 'cover' field instead with media ID as integer. Example: "cover": 14`); } if (data.description && data.description.length > 80) { throw new McpError(ErrorCode.InvalidParams, `Article description too long (${data.description.length} chars). Maximum 80 characters allowed.`); } if (data.metaTitle !== undefined || data.metaDescription !== undefined || data.keywords !== undefined) { throw new McpError(ErrorCode.InvalidParams, `SEO fields (metaTitle, metaDescription, keywords) should be in 'blocks' array with __component: 'shared.seo'. Use get_article_structure_example for correct format.`); } console.error('[API] Article data validation passed'); } // Extract the collection name from the content type UID const collection = contentType.split(".")[1]; // --- Attempt 1: Use Admin Credentials via makeAdminApiRequest --- if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) { console.error(`[API] Attempt 1: Creating entry for ${contentType} using makeAdminApiRequest`); try { // Admin API for content management often uses a different path structure const adminEndpoint = `/content-manager/collection-types/${contentType}`; console.error(`[API] Trying admin create endpoint: ${adminEndpoint}`); // Admin API might need the data directly, not nested under 'data' const adminResponse = await makeAdminApiRequest(adminEndpoint, 'post', data); // Check response from admin API (structure might differ) if (adminResponse) { console.error(`[API] Successfully created entry via makeAdminApiRequest.`); // Admin API might return the created entry directly or nested under 'data' return adminResponse.data || adminResponse; } else { // Should not happen if makeAdminApiRequest resolves, but handle defensively console.warn(`[API] Admin create completed but returned no data.`); // Return a success indicator even without data, as the operation likely succeeded return { message: "Create via admin succeeded, no data returned." }; } } catch (adminError) { console.error(`[API] Failed to create entry using admin credentials:`, adminError); // Only try API token if admin credentials fail console.error(`[API] Admin credentials failed, attempting to use API token as fallback.`); } } else { console.error("[API] Admin credentials not provided, falling back to API token."); } // --- Attempt 2: Use API Token via strapiClient (as fallback) --- console.error(`[API] Attempt 2: Creating entry for ${contentType} using strapiClient`); try { // Create the entry in Strapi const response = await strapiClient.post(`/api/${collection}`, { data: data }); console.error(`[API] Raw response structure:`, JSON.stringify({ status: response.status, hasData: !!response.data, dataKeys: response.data ? Object.keys(response.data) : [], hasDataData: !!(response.data && response.data.data) })); // More flexible response handling if (response.data) { // Case 1: Standard Strapi v4 format with nested data if (response.data.data) { console.error(`[API] Successfully created entry via strapiClient (format: data.data).`); return response.data.data; } // Case 2: Direct data format (some Strapi configurations) else if (response.data.id) { console.error(`[API] Successfully created entry via strapiClient (format: direct data).`); return response.data; } // Case 3: Other successful response formats else if (response.status === 200 || response.status === 201) { console.error(`[API] Successfully created entry via strapiClient (format: custom).`); return response.data; } } // If we get here, the response was unexpected console.warn(`[API] Create via strapiClient completed with unexpected response format.`); console.warn(`[API] Response data:`, JSON.stringify(response.data, null, 2)); // Instead of throwing error, return success indicator with available data return { success: true, message: "Entry created successfully but with unexpected response format", responseData: response.data, status: response.status }; } catch (error) { console.error(`[API] Failed to create entry via strapiClient:`, error); throw new McpError(ErrorCode.InternalError, `Failed to create entry for ${contentType} via strapiClient: ${error instanceof Error ? error.message : String(error)}`); } } catch (error) { console.error(`[Error] Failed to create entry for ${contentType}:`, error); throw new McpError(ErrorCode.InternalError, `Failed to create entry for ${contentType}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update an existing entry */ async function updateEntry(contentType, id, data) { const collection = contentType.split(".")[1]; const apiPath = `/api/${collection}/${id}`; let responseData = null; // --- Attempt 1: Use Admin Credentials via makeAdminApiRequest --- if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) { console.error(`[API] Attempt 1: Updating entry ${id} for ${contentType} using makeAdminApiRequest`); try { // Admin API for content management often uses a different path structure const adminEndpoint = `/content-manager/collection-types/${contentType}/${id}`; console.error(`[API] Trying admin update endpoint: ${adminEndpoint}`); // Admin API PUT might just need the data directly, not nested under 'data' const adminResponse = await makeAdminApiRequest(adminEndpoint, 'put', data); // Send 'data' directly // Check response from admin API (structure might differ) if (adminResponse) { console.error(`[API] Successfully updated entry ${id} via makeAdminApiRequest.`); // Admin API might return the updated entry directly or nested under 'data' return adminResponse.data || adminResponse; } else { // Should not happen if makeAdminApiRequest resolves, but handle defensively console.warn(`[API] Admin update for ${id} completed but returned no data.`); // Return a success indicator even without data, as the operation likely succeeded return { id: id, message: "Update via admin succeeded, no data returned." }; } } catch (adminError) { console.error(`[API] Failed to update entry ${id} using admin credentials:`, adminError); console.error(`[API] Admin credentials failed, attempting to use API token as fallback.`); } } else { console.error("[API] Admin credentials not provided, falling back to API token."); } // --- Attempt 2: Use API Token via strapiClient (as fallback) --- console.error(`[API] Attempt 2: Updating entry ${id} for ${contentType} using strapiClient`); try { const response = await strapiClient.put(apiPath, { data: data }); console.error(`[API] Update response structure:`, JSON.stringify({ status: response.status, hasData: !!response.data, dataKeys: response.data ? Object.keys(response.data) : [], hasDataData: !!(response.data && response.data.data) })); // More flexible response handling for updates if (response.data) { // Case 1: Standard Strapi v4 format with nested data if (response.data.data) { console.error(`[API] Successfully updated entry ${id} via strapiClient (format: data.data).`); return response.data.data; } // Case 2: Direct data format (some Strapi configurations) else if (response.data.id) { console.error(`[API] Successfully updated entry ${id} via strapiClient (format: direct data).`); return response.data; } // Case 3: Other successful response formats else if (response.status === 200 || response.status === 201) { console.error(`[API] Successfully updated entry ${id} via strapiClient (format: custom).`); return response.data; } } // If we get here, the response was unexpected but update likely succeeded console.warn(`[API] Update via strapiClient for ${id} completed with unexpected response format.`); console.warn(`[API] Response data:`, JSON.stringify(response.data, null, 2)); // Return a success indicator even without standard data format return { id: id, success: true, message: "Update completed successfully but with unexpected response format", responseData: response.data, status: response.status }; } catch (error) { console.error(`[API] Failed to update entry ${id} via strapiClient:`, error); throw new McpError(ErrorCode.InternalError, `Failed to update entry ${id} for ${contentType} via strapiClient: ${error instanceof Error ? error.message : String(error)}`); } } /** * Delete an entry */ async function deleteEntry(contentType, id) { try { console.error(`[API] Deleting entry ${id} for content type: ${contentType}`); // Extract the collection name from the content type UID const collection = contentType.split(".")[1]; // Delete the entry from Strapi await strapiClient.delete(`/api/${collection}/${id}`); } catch (error) { console.error(`[Error] Failed to delete entry ${id} for ${contentType}:`, error); throw new McpError(ErrorCode.InternalError, `Failed to delete entry ${id} for ${contentType}: ${error instanceof Error ? error.message : String(error)}`); } } /** * Validate file path for security */ async function validateFilePath(filePath) { try { // Check if path is absolute if (!path.isAbsolute(filePath)) { throw new Error(`File path must be absolute. Received: ${filePath}. Please provide an absolute path like '/home/user/image.jpg' or 'C:\\Users\\user\\image.jpg'`); } // Normalize path to prevent directory traversal const normalizedPath = path.resolve(filePath); // Check if file exists and is readable await fs.access(normalizedPath, fs.constants.R_OK); // Get file stats const stats = await fs.stat(normalizedPath); // Ensure it's a file, not a directory if (!stats.isFile()) { throw new Error(`Path is not a file: ${filePath}`); } // Check file size limit if (stats.size > UPLOAD_CONFIG.maxFileSize) { throw new Error(`File size ${stats.size} exceeds maximum allowed size ${UPLOAD_CONFIG.maxFileSize}`); } // Check allowed paths if configured if (UPLOAD_CONFIG.allowedPaths.length > 0) { const isAllowed = UPLOAD_CONFIG.allowedPaths.some(allowedPath => normalizedPath.startsWith(path.resolve(allowedPath))); if (!isAllowed) { throw new Error(`File path not in allowed directories: ${filePath}`); } } } catch (error) { throw new Error(`File path validation failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get MIME type based on file extension */ function getMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); const mimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.txt': 'text/plain', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }; return mimeTypes[ext] || 'application/octet-stream'; } /** * Upload media file from local file path */ async function uploadMediaFromPath(filePath, customName, fileInfo) { try { console.error(`[API] Uploading media from path: ${filePath}`); // Validate file path await validateFilePath(filePath); // Get file info const fileName = fileInfo?.name || customName || path.basename(filePath); const mimeType = getMimeType(filePath); // Create FormData with file stream const FormData = (await import('form-data')).default; const formData = new FormData(); const fileStream = createReadStream(filePath); formData.append('files', fileStream, { filename: fileName, contentType: mimeType }); // Add fileInfo metadata if provided if (fileInfo && (fileInfo.alternativeText || fileInfo.caption || fileInfo.name)) { const metadata = { name: fileInfo.name || fileName, ...(fileInfo.alternativeText && { alternativeText: fileInfo.alternativeText }), ...(fileInfo.caption && { caption: fileInfo.caption }) }; console.error(`[API] Adding file metadata: ${JSON.stringify(metadata)}`); formData.append('fileInfo', JSON.stringify(metadata)); } let response; // --- Attempt 0: Use API Token directly if provided --- if (STRAPI_API_TOKEN) { console.error(`[API] Attempt 0: Uploading via API Token`); try { response = await axios.post(`${STRAPI_URL}/api/upload`, formData, { headers: { 'Authorization': `Bearer ${STRAPI_API_TOKEN}`, ...formData.getHeaders() }, timeout: UPLOAD_CONFIG.uploadTimeout }); console.error(`[API] Successfully uploaded media via API Token: ${filePath}`); return response.data; } catch (tokenErr) { console.error(`[API] Failed to upload via API Token:`, tokenErr); console.error(`[API] Falling back to credential-based m