@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
JavaScript
#!/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