- strapi-mcp
- src
#!/usr/bin/env node
/**
* 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,
ReadResourceRequest,
CallToolRequest,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config();
// Extended error codes to include additional ones we need
enum ExtendedErrorCode {
// Original error codes from SDK
InvalidRequest = 'InvalidRequest',
MethodNotFound = 'MethodNotFound',
InvalidParams = 'InvalidParams',
InternalError = 'InternalError',
// Additional error codes
ResourceNotFound = 'ResourceNotFound',
AccessDenied = 'AccessDenied'
}
// Custom error class extending McpError to support our extended error codes
class ExtendedMcpError extends McpError {
public extendedCode: ExtendedErrorCode;
constructor(code: ExtendedErrorCode, message: string) {
// Map our extended codes to standard MCP error codes when needed
let mcpCode: ErrorCode;
// 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";
const STRAPI_ADMIN_EMAIL = process.env.STRAPI_ADMIN_EMAIL;
const STRAPI_ADMIN_PASSWORD = process.env.STRAPI_ADMIN_PASSWORD;
// Validate required environment variables
if (!STRAPI_API_TOKEN && !(STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD)) {
console.error("[Error] Missing required authentication. Please provide either STRAPI_API_TOKEN or both STRAPI_ADMIN_EMAIL and STRAPI_ADMIN_PASSWORD environment variables");
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: ${STRAPI_API_TOKEN ? "Using API token" : "Using admin credentials"}`);
// 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: string | null = null;
/**
* Log in to the Strapi admin API using provided credentials
*/
async function loginToStrapiAdmin(): Promise<boolean> {
// Use process.env directly here to ensure latest values are used
const email = process.env.STRAPI_ADMIN_EMAIL;
const password = process.env.STRAPI_ADMIN_PASSWORD;
if (!email || !password) {
console.error("[Auth] No admin credentials found in process.env, skipping admin login");
return false;
}
try {
// DEBUG: Log credentials just before use
console.error(`[Auth DEBUG] Attempting login with Email: ${email}, Password: ${password ? '******' : 'MISSING'}`);
console.error(`[Auth] Attempting to log in to Strapi admin as ${email}`);
const response = await axios.post(`${STRAPI_URL}/admin/login`, { // STRAPI_URL constant should be fine
email: email,
password: password
});
if (response.data && response.data.data && response.data.data.token) {
adminJwtToken = response.data.data.token;
console.error("[Auth] Successfully logged in to Strapi admin");
return true;
} else {
console.error("[Auth] Login response missing token");
return false;
}
} catch (error) {
console.error("[Auth] Failed to log in to Strapi admin:", error);
return false;
}
}
/**
* Make a request to the admin API using the admin JWT token
*/
async function makeAdminApiRequest(endpoint: string, method: string = 'get', data?: any, params?: Record<string, any>): Promise<any> { // Add params
if (!adminJwtToken) {
// Try to log in first
const success = await loginToStrapiAdmin();
if (!success) {
throw new Error("Not authenticated for admin API access");
}
}
try {
const response = await axios({
method,
url: `${STRAPI_URL}${endpoint}`,
headers: {
'Authorization': `Bearer ${adminJwtToken}`,
'Content-Type': 'application/json'
},
data, // Used for POST, PUT, etc.
params // Used for GET requests query parameters
});
return response.data;
} catch (error) {
console.error(`[Admin API] Request to ${endpoint} failed:`, error);
// Check if it's an auth error (e.g., token expired)
if (axios.isAxiosError(error) && error.response?.status === 401 && adminJwtToken) {
console.error("[Admin API] Admin token might be expired. Attempting re-login...");
adminJwtToken = null; // Clear expired token
const loginSuccess = await loginToStrapiAdmin();
if (loginSuccess) {
console.error("[Admin API] Re-login successful. Retrying original request...");
// Retry the request once after successful re-login
const retryResponse = await axios({
method,
url: `${STRAPI_URL}${endpoint}`,
headers: {
'Authorization': `Bearer ${adminJwtToken}`,
'Content-Type': 'application/json'
},
data,
params
});
return retryResponse.data;
} else {
console.error("[Admin API] Re-login failed. Throwing original error.");
throw new Error("Admin re-authentication failed after token expiry.");
}
}
// If not a 401 or re-login failed, throw the original error
throw error;
}
}
// Cache for content types
let contentTypesCache: any[] = [];
/**
* 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(): Promise<any[]> {
try {
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: any[], source: string): any[] => {
console.error(`[API] Successfully fetched collection types from ${source}`);
const contentTypes = data.map((item: any) => {
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: any) =>
!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 1: Use Admin Credentials if available ---
// DEBUG: Log the values the function sees
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');
// 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) {
return processAndCacheContentTypes(adminData, "Admin API (/content-manager/collection-types)");
} else {
console.error("[API] Admin API response did not contain expected data array.", adminResponse);
}
} catch (adminError) {
console.error(`[API] Failed to fetch content types using admin credentials:`, adminError);
// Don't throw, proceed to next method
}
} else {
console.error("[API] Admin credentials not provided, skipping admin API attempt.");
}
// --- Attempt 2: 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)");
// Transform to our expected format
const contentTypes = response.data.map((item: any) => {
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: any) =>
!ct.uid.startsWith("admin::") &&
!ct.uid.startsWith("plugin::")
);
console.error(`[API] Found ${filteredTypes.length} content types`);
contentTypesCache = filteredTypes;
return filteredTypes;
}
} catch (apiError) {
console.error(`[API] Failed to fetch from content manager API:`, apiError);
}
// Try to check what's available at the /api endpoint
try {
const response = await strapiClient.get('/api');
if (response.data && typeof response.data === 'object') {
console.error(`[API] Found API endpoint with available collections`);
// Get collection names from the root API
const collections = Object.keys(response.data);
console.error(`[API] Collections available: ${collections.join(', ')}`);
if (collections.length > 0) {
// Convert to content types
const contentTypes = collections.map(name => ({
uid: `api::${name}.${name}`,
apiID: name,
info: {
displayName: name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' '),
description: `${name} content type`,
},
attributes: {}
}));
contentTypesCache = contentTypes;
return contentTypes;
}
}
} catch (apiError) {
console.error(`[API] Failed to fetch from API root:`, apiError);
}
// Try to directly check for the collection types we see in the screenshot
try {
const knownTypes = [
'order', 'order-item', 'speaker', 'sponsor', 'talk',
'talk-tags', 'ticket', 'training', 'user', 'settings'
];
console.error(`[API] Directly checking for known collection types`);
const verifiedTypes = [];
for (const name of knownTypes) {
try {
// Check if this collection exists by trying to access it
await strapiClient.get(`/api/${name}`);
console.error(`[API] Found collection: ${name}`);
verifiedTypes.push({
uid: `api::${name}.${name}`,
apiID: name,
info: {
displayName: name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' '),
description: `${name} content type`,
},
attributes: {}
});
} catch (err) {
// Skip collections that return 404
if (axios.isAxiosError(err) && err.response?.status === 404) {
continue;
}
// If we got a different error (like 401/403), the endpoint probably exists
if (axios.isAxiosError(err)) {
console.error(`[API] Collection ${name} exists but returned ${err.response?.status}`);
verifiedTypes.push({
uid: `api::${name}.${name}`,
apiID: name,
info: {
displayName: name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' '),
description: `${name} content type`,
},
attributes: {}
});
}
}
}
if (verifiedTypes.length > 0) {
console.error(`[API] Found ${verifiedTypes.length} known collection types`);
contentTypesCache = verifiedTypes;
return verifiedTypes;
}
} catch (err) {
console.error(`[API] Error checking known types:`, err);
}
// Return empty array if all attempts failed
console.error(`[API] All attempts to find content types failed`);
return [];
} catch (error: any) {
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);
}
}
/**
* Interface for query parameters
*/
interface QueryParams {
filters?: Record<string, any>;
pagination?: {
page?: number;
pageSize?: number;
};
sort?: string[];
populate?: string | string[] | Record<string, any>;
fields?: string[];
}
/**
* Fetch entries for a specific content type with optional filtering, pagination, and sorting
*/
async function fetchEntries(contentType: string, queryParams?: QueryParams): Promise<any> {
let response;
let success = false;
let fetchedData: any[] = [];
let fetchedMeta: any = {};
const collection = contentType.split(".")[1]; // Keep this for potential path variations if needed
// --- Attempt 1: Use API Token via strapiClient ---
console.error(`[API] Attempt 1: Fetching entries for ${contentType} using strapiClient (API Token)`);
try {
const params: Record<string, any> = {};
// ... 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: any) => !item?.error);
break; // Exit loop on success
} catch (err: any) {
if (axios.isAxiosError(err) && (err.response?.status === 404 || err.response?.status === 403 || err.response?.status === 401)) {
// 404: Try next path. 403/401: Permissions issue, definitely try admin fallback later.
console.error(`[API] Path ${path} failed with ${err.response?.status}, trying next or fallback...`);
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] strapiClient succeeded for ${contentType} but returned no entries. Proceeding to admin fallback.`);
} else {
console.error(`[API] strapiClient failed to fetch entries for ${contentType}. Proceeding to admin fallback.`);
}
} catch (error) {
// Catch errors from the strapiClient attempts (excluding 404/403/401 handled above)
console.error(`[API] Error during strapiClient fetch for ${contentType}:`, error);
// Proceed to admin fallback even if strapiClient threw an unexpected error
}
// --- Attempt 2: Use Admin Credentials via makeAdminApiRequest ---
// Only attempt if admin credentials are provided
if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) {
console.error(`[API] Attempt 2: 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: Record<string, any> = {};
// 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: any) => !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.`);
}
} else {
console.error(`[API] Admin fetch for ${contentType} did not return expected 'results' array. Response:`, adminResponse);
}
} catch (adminError) {
console.error(`[API] Failed to fetch entries using admin credentials for ${contentType}:`, adminError);
// Don't throw, just proceed to return empty dataset
}
} else {
console.error("[API] Admin credentials not provided, skipping admin fallback.");
}
// --- Final Fallback: Return Empty Dataset ---
console.error(`[API] All attempts failed or returned no data for ${contentType}. Returning empty dataset.`);
return {
data: [],
meta: { pagination: { page: 1, pageSize: 10, pageCount: 0, total: 0 } }
};
}
/**
* Fetch a specific entry by ID
*/
async function fetchEntry(contentType: string, id: string, queryParams?: QueryParams): Promise<any> {
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];
// Build query parameters only for populate and fields
const params: Record<string, any> = {};
if (queryParams?.populate) {
params.populate = queryParams.populate;
}
if (queryParams?.fields) {
params.fields = queryParams.fields;
}
// Get the entry from Strapi
const response = await strapiClient.get(`/api/${collection}/${id}`, { params });
return response.data.data;
} catch (error: any) {
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: string, data: any): Promise<any> {
try {
console.error(`[API] Creating new entry for content type: ${contentType}`);
// Extract the collection name from the content type UID
const collection = contentType.split(".")[1];
// Create the entry in Strapi
const response = await strapiClient.post(`/api/${collection}`, {
data: data
});
return response.data.data;
} 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: string, id: string, data: any): Promise<any> {
const collection = contentType.split(".")[1];
const apiPath = `/api/${collection}/${id}`;
let responseData: any = null;
// --- Attempt 1: Use API Token via strapiClient ---
console.error(`[API] Attempt 1: Updating entry ${id} for ${contentType} using strapiClient`);
try {
const response = await strapiClient.put(apiPath, { data: data });
// Check if data was returned
if (response.data && response.data.data) {
console.error(`[API] Successfully updated entry ${id} via strapiClient.`);
return response.data.data; // Success with data returned
} else {
// Update might have succeeded but didn't return data, proceed to verify/fallback
console.warn(`[API] Update via strapiClient for ${id} completed, but no updated data returned. Will attempt admin fallback if configured.`);
// Don't return yet, let it fall through to admin attempt if needed
}
} catch (error) {
console.error(`[API] Failed to update entry ${id} via strapiClient:`, error);
// If it's an auth/permission error, definitely try admin fallback
if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 403)) {
console.error(`[API] Auth/Permission error (${error.response?.status}) with strapiClient, proceeding to admin fallback.`);
} else {
// For other errors (like 404 Not Found), rethrow immediately
console.error(`[API] Non-auth error during strapiClient update for ${id}. Rethrowing.`);
throw new McpError(
ErrorCode.InternalError,
`Failed to update entry ${id} for ${contentType} via strapiClient: ${error instanceof Error ? error.message : String(error)}`
);
}
// If it was 401/403, proceed to admin fallback
}
// --- Attempt 2: Use Admin Credentials via makeAdminApiRequest ---
if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) {
console.error(`[API] Attempt 2: 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'
// Let's try sending the 'data' object directly first. Adjust if needed based on Strapi version/config.
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);
// If admin fallback also fails, throw an error
throw new McpError(
ErrorCode.InternalError,
`Failed to update entry ${id} for ${contentType} using admin credentials: ${adminError instanceof Error ? adminError.message : String(adminError)}`
);
}
} else {
console.error("[API] Admin credentials not provided, skipping admin fallback for update.");
// If we reached here, strapiClient failed or returned no data, and admin creds aren't available
throw new ExtendedMcpError( // Use the extended error class
ExtendedErrorCode.AccessDenied, // Use the extended error code
`Failed to update entry ${id} for ${contentType}. API token may lack permissions, and admin credentials were not available for fallback.`
);
}
}
/**
* Delete an entry
*/
async function deleteEntry(contentType: string, id: string): Promise<void> {
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)}`
);
}
}
/**
* Upload a media file to Strapi
*/
async function uploadMedia(fileData: string, fileName: string, fileType: string): Promise<any> {
try {
console.error(`[API] Uploading media file: ${fileName}`);
const buffer = Buffer.from(fileData, 'base64');
// Use FormData for file upload
const formData = new FormData();
// Convert Buffer to Blob with the correct content type
const blob = new Blob([buffer], { type: fileType });
formData.append('files', blob, fileName);
const response = await strapiClient.post('/api/upload', formData, {
headers: {
// Let axios set the correct multipart/form-data content-type with boundary
'Content-Type': 'multipart/form-data'
}
});
return response.data;
} catch (error) {
console.error(`[Error] Failed to upload media file ${fileName}:`, error);
throw new McpError(
ErrorCode.InternalError,
`Failed to upload media file ${fileName}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Fetch the schema for a specific content type
*/
async function fetchContentTypeSchema(contentType: string): Promise<any> {
try {
console.error(`[API] Fetching schema for content type: ${contentType}`);
// --- Attempt 1: Use Admin Credentials if available ---
if (STRAPI_ADMIN_EMAIL && STRAPI_ADMIN_PASSWORD) {
console.error("[API] Attempting to fetch schema using admin credentials");
try {
const endpoint = `/content-type-builder/content-types/${contentType}`;
console.error(`[API] Trying admin endpoint: ${endpoint}`);
const adminResponse = await makeAdminApiRequest(endpoint);
// Check for schema data (often nested under 'data')
if (adminResponse && adminResponse.data) {
console.error("[API] Successfully fetched schema via Admin API");
return adminResponse.data; // Return the schema data
} else {
console.error("[API] Admin API response for schema did not contain expected data.", adminResponse);
}
} catch (adminError) {
console.error(`[API] Failed to fetch schema using admin credentials:`, adminError);
// Don't throw, proceed to next method if it was a 404 or auth error
if (!(axios.isAxiosError(adminError) && (adminError.response?.status === 401 || adminError.response?.status === 403 || adminError.response?.status === 404))) {
throw adminError; // Rethrow unexpected errors
}
}
} else {
console.error("[API] Admin credentials not provided, skipping admin API attempt for schema.");
}
// --- Attempt 2: Infer schema from public API (Fallback) ---
console.error("[API] Attempting to infer schema from public API");
// Extract the collection name from the content type UID
const collection = contentType.split(".")[1];
// Try to get a sample entry to infer the schema
try {
// Try multiple possible API paths
const possiblePaths = [
`/api/${collection}`,
`/api/${collection.toLowerCase()}`,
`/api/v1/${collection}`,
`/${collection}`,
`/${collection.toLowerCase()}`
];
let response;
let success = false;
// Try each path until one works
for (const path of possiblePaths) {
try {
console.error(`[API] Trying path for schema inference: ${path}`);
// Request with small limit to minimize data transfer
response = await strapiClient.get(`${path}?pagination[limit]=1&pagination[page]=1`);
console.error(`[API] Successfully fetched sample data from: ${path}`);
success = true;
break;
} catch (err: any) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
// Continue to try the next path if not found
continue;
}
// For other errors, throw immediately
throw err;
}
}
if (!success || !response) {
throw new Error(`Could not find any valid API path for ${collection}`);
}
// Extract a sample entry to infer schema
let sampleEntry;
if (response.data.data && Array.isArray(response.data.data) && response.data.data.length > 0) {
// Standard Strapi v4 response
sampleEntry = response.data.data[0];
} else if (Array.isArray(response.data) && response.data.length > 0) {
// Array response
sampleEntry = response.data[0];
} else if (response.data) {
// Object response
sampleEntry = response.data;
}
if (!sampleEntry) {
throw new Error(`No sample entries available to infer schema for ${contentType}`);
}
// Infer schema from sample entry
const attributes: Record<string, any> = {};
// Process entry to infer attribute types
Object.entries(sampleEntry.attributes || sampleEntry).forEach(([key, value]) => {
if (key === 'id') return; // Skip ID field
let type: string = typeof value;
if (type === 'object') {
if (value === null) {
type = 'string'; // Assume nullable string
} else if (Array.isArray(value)) {
type = 'relation'; // Assume array is a relation
} else if (value instanceof Date) {
type = 'datetime';
} else {
type = 'json'; // Complex object
}
}
attributes[key] = { type };
});
// Return inferred schema
return {
uid: contentType,
apiID: collection,
info: {
displayName: collection.charAt(0).toUpperCase() + collection.slice(1),
description: `Inferred schema for ${collection}`,
},
attributes
};
} catch (inferError) {
console.error(`[API] Failed to infer schema:`, inferError);
// Return a minimal schema as fallback
return {
uid: contentType,
apiID: collection,
info: {
displayName: collection.charAt(0).toUpperCase() + collection.slice(1),
description: `${collection} content type`,
},
attributes: {}
};
}
} catch (error: any) {
let errorMessage = `Failed to fetch schema 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 += ` (Content type not found)`;
} else if (error.response?.status === 403) {
errorCode = ExtendedErrorCode.AccessDenied;
errorMessage += ` (Permission denied - check API token permissions for Content-Type Builder)`;
} else if (error.response?.status === 401) {
errorCode = ExtendedErrorCode.AccessDenied;
errorMessage += ` (Unauthorized - API token may be invalid or expired)`;
} else if (error.response?.status === 400) {
errorCode = ExtendedErrorCode.InvalidRequest;
errorMessage += ` (Bad request - malformed content type ID)`;
}
} else if (error instanceof Error) {
errorMessage += `: ${error.message}`;
} else {
errorMessage += `: ${String(error)}`;
}
console.error(`[Error] ${errorMessage}`);
throw new ExtendedMcpError(errorCode, errorMessage);
}
}
/**
* Connect related entries for a specific field
*/
async function connectRelation(contentType: string, id: string, relationField: string, relatedIds: number[] | string[]): Promise<any> {
try {
console.error(`[API] Connecting relations for ${contentType} ${id}, field ${relationField}`);
const updateData = {
data: { // Strapi v4 expects relation updates within the 'data' object for PUT
[relationField]: {
connect: relatedIds.map(rid => ({ id: Number(rid) })) // Ensure IDs are numbers
}
}
};
// Reuse updateEntry logic which correctly wraps payload in { data: ... }
return await updateEntry(contentType, id, updateData.data);
} catch (error) {
// Rethrow McpError or wrap others
if (error instanceof McpError) throw error;
console.error(`[Error] Failed to connect relation ${relationField} for ${contentType} ${id}:`, error);
throw new McpError(
ErrorCode.InternalError,
`Failed to connect relation: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Disconnect related entries for a specific field
*/
async function disconnectRelation(contentType: string, id: string, relationField: string, relatedIds: number[] | string[]): Promise<any> {
try {
console.error(`[API] Disconnecting relations for ${contentType} ${id}, field ${relationField}`);
const updateData = {
data: { // Strapi v4 expects relation updates within the 'data' object for PUT
[relationField]: {
disconnect: relatedIds.map(rid => ({ id: Number(rid) })) // Ensure IDs are numbers
}
}
};
// Reuse updateEntry logic which correctly wraps payload in { data: ... }
return await updateEntry(contentType, id, updateData.data);
} catch (error) {
// Rethrow McpError or wrap others
if (error instanceof McpError) throw error;
console.error(`[Error] Failed to disconnect relation ${relationField} for ${contentType} ${id}:`, error);
throw new McpError(
ErrorCode.InternalError,
`Failed to disconnect relation: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Update an existing content type in Strapi. Requires admin privileges.
*/
async function updateContentType(contentTypeUid: string, attributesToUpdate: Record<string, any>): Promise<any> {
try {
console.error(`[API] Updating content type: ${contentTypeUid}`);
if (!contentTypeUid || !attributesToUpdate || typeof attributesToUpdate !== 'object') {
throw new Error("Missing required fields: contentTypeUid, attributesToUpdate (object)");
}
// 1. Fetch the current schema
console.error(`[API] Fetching current schema for ${contentTypeUid}`);
// Use fetchContentTypeSchema which already tries admin endpoint first
const currentSchemaData = await fetchContentTypeSchema(contentTypeUid);
// Ensure we have the schema structure (might be nested under 'schema')
let currentSchema = currentSchemaData.schema || currentSchemaData;
if (!currentSchema || !currentSchema.attributes) {
// If schema is still not found or malformed after fetchContentTypeSchema tried, error out
console.error("[API] Could not retrieve a valid current schema structure.", currentSchemaData);
throw new Error(`Could not retrieve a valid schema structure for ${contentTypeUid}`);
}
console.error(`[API] Current attributes: ${Object.keys(currentSchema.attributes).join(', ')}`);
// 2. Merge new/updated attributes into the current schema's attributes
const updatedAttributes = { ...currentSchema.attributes, ...attributesToUpdate };
console.error(`[API] Attributes after update: ${Object.keys(updatedAttributes).join(', ')}`);
// 3. Construct the payload for the PUT request
// Strapi's PUT endpoint expects the *entire* content type definition under the 'contentType' key,
// and potentially component updates under 'components'. We only update contentType here.
const payload = {
contentType: {
...currentSchema, // Spread the existing schema details
attributes: updatedAttributes // Use the merged attributes
}
// If components needed updating, add a 'components: [...]' key here
};
// Remove potentially problematic fields if they exist at the top level of currentSchema
// These are often managed internally by Strapi
delete payload.contentType.uid; // UID is usually in the URL, not body for PUT
// delete payload.contentType.schema; // If schema was nested, remove the outer key
console.error(`[API] Update Payload for PUT /content-type-builder/content-types/${contentTypeUid}: ${JSON.stringify(payload, null, 2)}`);
// 4. Make the PUT request using admin credentials
const endpoint = `/content-type-builder/content-types/${contentTypeUid}`;
const response = await makeAdminApiRequest(endpoint, 'put', payload);
console.error(`[API] Content type update response:`, response);
// Response might vary, often includes the updated UID or a success message
return response?.data || { message: `Content type ${contentTypeUid} update initiated. Strapi might be restarting.` };
} catch (error: any) {
console.error(`[Error] Failed to update content type ${contentTypeUid}:`, error);
let errorMessage = `Failed to update content type ${contentTypeUid}`;
let errorCode = ExtendedErrorCode.InternalError;
if (axios.isAxiosError(error)) {
errorMessage += `: ${error.response?.status} ${error.response?.statusText}`;
const responseData = JSON.stringify(error.response?.data);
if (error.response?.status === 400) {
errorCode = ExtendedErrorCode.InvalidParams;
errorMessage += ` (Bad Request - Check payload/attributes): ${responseData}`;
} else if (error.response?.status === 404) {
errorCode = ExtendedErrorCode.ResourceNotFound;
errorMessage += ` (Content Type Not Found)`;
} else if (error.response?.status === 403 || error.response?.status === 401) {
errorCode = ExtendedErrorCode.AccessDenied;
errorMessage += ` (Permission Denied - Admin credentials might lack permissions): ${responseData}`;
} else {
errorMessage += `: ${responseData}`;
}
} else if (error instanceof Error) {
errorMessage += `: ${error.message}`;
} else {
errorMessage += `: ${String(error)}`;
}
throw new ExtendedMcpError(errorCode, errorMessage);
}
}
/**
* Create a new content type in Strapi. Requires admin privileges.
*/
async function createContentType(contentTypeData: any): Promise<any> {
try {
const { displayName, singularName, pluralName, kind = 'collectionType', attributes, options = { draftAndPublish: true }, description = "" } = contentTypeData;
if (!displayName || !singularName || !pluralName || !attributes) {
throw new Error("Missing required fields: displayName, singularName, pluralName, attributes");
}
// Construct the payload for the Content-Type Builder API
// Ensure API IDs are Strapi-compliant (lowercase, no spaces, etc.)
const singularApiId = singularName.toLowerCase().replace(/\s+/g, '-');
const pluralApiId = pluralName.toLowerCase().replace(/\s+/g, '-');
const collectionName = pluralName.toLowerCase().replace(/\s+/g, '_'); // Table name often uses underscores
const payload = {
contentType: {
kind: kind,
collectionName: collectionName,
info: {
singularName: singularApiId,
pluralName: pluralApiId,
displayName: displayName,
description: description
},
options: options,
// Ensure attributes is an object { fieldName: { type: 'string', ... }, ... }
attributes: typeof attributes === 'object' && !Array.isArray(attributes) ? attributes : {}
}
// Potentially add 'components' key if needed later
};
console.error(`[API] Creating new content type: ${displayName}`);
console.error(`[API] Payload: ${JSON.stringify(payload, null, 2)}`);
// Use makeAdminApiRequest to ensure admin authentication
const response = await makeAdminApiRequest('/content-type-builder/content-types', 'post', payload);
console.error(`[API] Content type creation response:`, response);
// Strapi might restart after schema changes, response might vary
// Often returns { data: { uid: 'api::...' } } or similar on success
return response?.data || { message: "Content type creation initiated. Strapi might be restarting." };
} catch (error: any) {
console.error(`[Error] Failed to create content type:`, error);
let errorMessage = `Failed to create content type`;
let errorCode = ExtendedErrorCode.InternalError;
if (axios.isAxiosError(error)) {
errorMessage += `: ${error.response?.status} ${error.response?.statusText}`;
if (error.response?.status === 400) {
errorCode = ExtendedErrorCode.InvalidParams;
errorMessage += ` (Bad Request - Check payload format/names): ${JSON.stringify(error.response?.data)}`;
} else if (error.response?.status === 403 || error.response?.status === 401) {
errorCode = ExtendedErrorCode.AccessDenied;
errorMessage += ` (Permission Denied - Admin credentials might lack permissions)`;
}
} else if (error instanceof Error) {
errorMessage += `: ${error.message}`;
} else {
errorMessage += `: ${String(error)}`;
}
throw new ExtendedMcpError(errorCode, errorMessage);
}
}
/**
* Handler for listing available Strapi content as resources.
* Each content type and entry is exposed as a resource with:
* - A strapi:// URI scheme
* - JSON MIME type
* - Human readable name and description
*/
server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
// Fetch all content types
const contentTypes = await fetchContentTypes();
// Create a resource for each content type
const contentTypeResources = contentTypes.map(ct => ({
uri: `strapi://content-type/${ct.uid}`,
mimeType: "application/json",
name: ct.info.displayName,
description: `Strapi content type: ${ct.info.displayName}`
}));
// Return the resources
return {
resources: contentTypeResources
};
} catch (error) {
console.error("[Error] Failed to list resources:", error);
throw new McpError(
ErrorCode.InternalError,
`Failed to list resources: ${error instanceof Error ? error.message : String(error)}`
);
}
});
/**
* Handler for reading the contents of a specific resource.
* Takes a strapi:// URI and returns the content as JSON.
*
* Supports URIs in the following formats:
* - strapi://content-type/[contentTypeUid] - Get all entries for a content type
* - strapi://content-type/[contentTypeUid]/[entryId] - Get a specific entry
* - strapi://content-type/[contentTypeUid]?[queryParams] - Get filtered entries
*/
server.setRequestHandler(ReadResourceRequestSchema, async (request: ReadResourceRequest) => {
try {
const uri = request.params.uri;
// Parse the URI for content type
const contentTypeMatch = uri.match(/^strapi:\/\/content-type\/([^\/\?]+)(?:\/([^\/\?]+))?(?:\?(.+))?$/);
if (!contentTypeMatch) {
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid URI format: ${uri}`
);
}
const contentTypeUid = contentTypeMatch[1];
const entryId = contentTypeMatch[2];
const queryString = contentTypeMatch[3];
// Parse query parameters if present
let queryParams: QueryParams = {};
if (queryString) {
try {
// Parse the query string into an object
const parsedParams = new URLSearchParams(queryString);
// Extract filters
const filtersParam = parsedParams.get('filters');
if (filtersParam) {
queryParams.filters = JSON.parse(filtersParam);
}
// Extract pagination
const pageParam = parsedParams.get('page');
const pageSizeParam = parsedParams.get('pageSize');
if (pageParam || pageSizeParam) {
queryParams.pagination = {};
if (pageParam) queryParams.pagination.page = parseInt(pageParam, 10);
if (pageSizeParam) queryParams.pagination.pageSize = parseInt(pageSizeParam, 10);
}
// Extract sort
const sortParam = parsedParams.get('sort');
if (sortParam) {
queryParams.sort = sortParam.split(',');
}
// Extract populate
const populateParam = parsedParams.get('populate');
if (populateParam) {
try {
// Try to parse as JSON
queryParams.populate = JSON.parse(populateParam);
} catch {
// If not valid JSON, treat as comma-separated string
queryParams.populate = populateParam.split(',');
}
}
// Extract fields
const fieldsParam = parsedParams.get('fields');
if (fieldsParam) {
queryParams.fields = fieldsParam.split(',');
}
} catch (parseError) {
console.error("[Error] Failed to parse query parameters:", parseError);
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid query parameters: ${parseError instanceof Error ? parseError.message : String(parseError)}`
);
}
}
// If an entry ID is provided, fetch that specific entry
if (entryId) {
const entry = await fetchEntry(contentTypeUid, entryId, queryParams);
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(entry, null, 2)
}]
};
}
// Otherwise, fetch entries with query parameters
const entries = await fetchEntries(contentTypeUid, queryParams);
// Return the entries as JSON
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(entries, null, 2)
}]
};
} catch (error) {
console.error("[Error] Failed to read resource:", error);
throw new McpError(
ErrorCode.InternalError,
`Failed to read resource: ${error instanceof Error ? error.message : String(error)}`
);
}
});
/**
* Handler that lists available tools.
* Exposes tools for working with Strapi content.
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "list_content_types",
description: "List all available content types in Strapi",
inputSchema: {
type: "object",
properties: {}
}
},
{
name: "get_entries",
description: "Get entries for a specific content type with optional filtering, pagination, sorting, and population of relations",
inputSchema: {
type: "object",
properties: {
contentType: {
type: "string",
description: "The content type UID (e.g., 'api::article.article')"
},
options: {
type: "string",
description: "JSON string with query options including filters, pagination, sort, populate, and fields. Example: '{\"filters\":{\"title\":{\"$contains\":\"hello\"}},\"pagination\":{\"page\":1,\"pageSize\":10},\"sort\":[\"title:asc\"],\"populate\":[\"author\",\"categories\"],\"fields\":[\"title\",\"content\"]}'"
}
},
required: ["contentType"]
}
},
{
name: "get_entry",
description: "Get a specific entry by ID",
inputSchema: {
type: "object",
properties: {
contentType: {
type: "string",
description: "The content type UID (e.g., 'api::article.article')"
},
id: {
type: "string",
description: "The ID of the entry"
},
options: {
type: "string",
description: "JSON string with query options including populate and fields. Example: '{\"populate\":[\"author\",\"categories\"],\"fields\":[\"title\",\"content\"]}'"
}
},
required: ["contentType", "id"]
}
},
{
name: "create_entry",
description: "Create a new entry for a content type",
inputSchema: {
type: "object",
properties: {
contentType: {
type: "string",
description: "The content type UID (e.g., 'api::article.article')"
},
data: {
type: "object",
description: "The data for the new entry"
}
},
required: ["contentType", "data"]
}
},
{
name: "update_entry",
description: "Update an existing entry",
inputSchema: {
type: "object",
properties: {
contentType: {
type: "string",
description: "The content type UID (e.g., 'api::article.article')"
},
id: {
type: "string",
description: "The ID of the entry to update"
},
data: {
type: "object",
description: "The updated data for the entry"
}
},
required: ["contentType", "id", "data"]
}
},
{
name: "delete_entry",
description: "Delete an entry for a specific content type.",
inputSchema: {
type: "object",
properties: {
contentType: {
type: "string",
description: "The API ID of the content type (e.g., 'api::article.article').",
required: true,
},
id: {
type: "string",
description: "The ID of the entry to delete.",
required: true,
},
},
required: ["contentType", "id"]
}
},
{
name: "upload_media",
description: "Upload a media file to the Strapi Media Library.",
inputSchema: {
type: "object",
properties: {
fileData: {
type: "string",
description: "Base64 encoded string of the file data.",
required: true,
},
fileName: {
type: "string",
description: "The desired name for the file.",
required: true,
},
fileType: {
type: "string",
description: "The MIME type of the file (e.g., 'image/jpeg', 'application/pdf').",
required: true,
},
},
required: ["fileData", "fileName", "fileType"]
}
},
{
name: "get_content_type_schema",
description: "Get the schema (fields, types, relations) for a specific content type.",
inputSchema: {
type: "object",
properties: {
contentType: {
type: "string",
description: "The API ID of the content type (e.g., 'api::article.article').",
required: true,
},
},
required: ["contentType"]
}
},
{
name: "connect_relation",
description: "Connect one or more related entries to a specific entry's relation field.",
inputSchema: {
type: "object",
properties: {
contentType: { type: "string", description: "The API ID of the main entry's content type (e.g., 'api::article.article')." },
id: { type: "string", description: "The ID of the main entry to update." },
relationField: { type: "string", description: "The name of the relation field to modify." },
relatedIds: { type: "array", items: { type: ["string", "number"] }, description: "An array of IDs of the entries to connect." }
},
required: ["contentType", "id", "relationField", "relatedIds"]
}
},
{
name: "disconnect_relation",
description: "Disconnect one or more related entries from a specific entry's relation field.",
inputSchema: {
type: "object",
properties: {
contentType: { type: "string", description: "The API ID of the main entry's content type (e.g., 'api::article.article')." },
id: { type: "string", description: "The ID of the main entry to update." },
relationField: { type: "string", description: "The name of the relation field to modify." },
relatedIds: { type: "array", items: { type: ["string", "number"] }, description: "An array of IDs of the entries to disconnect." }
},
required: ["contentType", "id", "relationField", "relatedIds"]
}
},
{
name: "create_content_type",
description: "Create a new content type using the Content-Type Builder API (Requires Admin privileges).",
inputSchema: {
type: "object",
properties: {
displayName: { type: "string", description: "Display name for the content type (e.g., 'My Product')." },
singularName: { type: "string", description: "Singular name (e.g., 'Product'). Used for API ID generation." },
pluralName: { type: "string", description: "Plural name (e.g., 'Products'). Used for API ID and collection name generation." },
kind: { type: "string", enum: ["collectionType", "singleType"], default: "collectionType", description: "Kind of content type." },
description: { type: "string", description: "Optional description for the content type." },
draftAndPublish: { type: "boolean", default: true, description: "Enable draft and publish system?" },
attributes: {
type: "object",
description: "Object defining the fields (attributes) for the content type. Example: { \"title\": { \"type\": \"string\", \"required\": true }, \"price\": { \"type\": \"decimal\" } }",
additionalProperties: {
type: "object",
properties: {
type: { type: "string", description: "Field type (e.g., string, text, richtext, email, password, number, decimal, float, date, time, datetime, boolean, json, relation, component, media, enumeration, uid)" },
required: { type: "boolean" },
// Add other common attribute properties as needed
},
required: ["type"]
}
}
},
required: ["displayName", "singularName", "pluralName", "attributes"]
}
},
{
name: "update_content_type",
description: "Update an existing content type by adding or modifying attributes (Requires Admin privileges).",
inputSchema: {
type: "object",
properties: {
contentType: { type: "string", description: "The UID of the content type to update (e.g., 'api::speaker.speaker')." },
attributes: {
type: "object",
description: "Object defining the attributes to add or update. Example: { \"new_field\": { \"type\": \"boolean\", \"default\": false } }",
additionalProperties: {
type: "object",
properties: {
type: { type: "string", description: "Field type (e.g., string, boolean, relation, etc.)" },
// Include other relevant attribute properties like 'required', 'default', 'relation', 'target', etc.
},
required: ["type"]
}
}
},
required: ["contentType", "attributes"]
}
},
]
};
});
/**
* Handler for tool calls.
* Implements various tools for working with Strapi content.
*/
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
try {
switch (request.params.name) {
case "list_content_types": {
const contentTypes = await fetchContentTypes();
return {
content: [{
type: "text",
text: JSON.stringify(contentTypes.map(ct => ({
uid: ct.uid,
displayName: ct.info.displayName,
description: ct.info.description
})), null, 2)
}]
};
}
case "get_entries": {
const { contentType, options } = request.params.arguments as any;
if (!contentType) {
throw new McpError(
ErrorCode.InvalidParams,
"Content type is required"
);
}
// Parse the options string into a queryParams object
let queryParams: QueryParams = {};
if (options) {
try {
queryParams = JSON.parse(options);
} catch (parseError) {
console.error("[Error] Failed to parse query options:", parseError);
throw new McpError(
ErrorCode.InvalidParams,
`Invalid query options: ${parseError instanceof Error ? parseError.message : String(parseError)}`
);
}
}
// Fetch entries with query parameters
const entries = await fetchEntries(String(contentType), queryParams);
return {
content: [{
type: "text",
text: JSON.stringify(entries, null, 2)
}]
};
}
case "get_entry": {
const { contentType, id, options } = request.params.arguments as any;
if (!contentType || !id) {
throw new McpError(
ErrorCode.InvalidParams,
"Content type and ID are required"
);
}
// Parse the options string into a queryParams object
let queryParams: QueryParams = {};
if (options) {
try {
queryParams = JSON.parse(options);
} catch (parseError) {
console.error("[Error] Failed to parse query options:", parseError);
throw new McpError(
ErrorCode.InvalidParams,
`Invalid query options: ${parseError instanceof Error ? parseError.message : String(parseError)}`
);
}
}
const entry = await fetchEntry(String(contentType), String(id), queryParams);
return {
content: [{
type: "text",
text: JSON.stringify(entry, null, 2)
}]
};
}
case "create_entry": {
const contentType = String(request.params.arguments?.contentType);
const data = request.params.arguments?.data;
if (!contentType || !data) {
throw new McpError(
ErrorCode.InvalidParams,
"Content type and data are required"
);
}
const entry = await createEntry(contentType, data);
return {
content: [{
type: "text",
text: JSON.stringify(entry, null, 2)
}]
};
}
case "update_entry": {
const contentType = String(request.params.arguments?.contentType);
const id = String(request.params.arguments?.id);
const data = request.params.arguments?.data;
if (!contentType || !id || !data) {
throw new McpError(
ErrorCode.InvalidParams,
"Content type, ID, and data are required"
);
}
const entry = await updateEntry(contentType, id, data);
if (entry) {
return {
content: [{
type: "text",
text: JSON.stringify(entry, null, 2)
}]
};
} else {
// Handle cases where update might succeed but not return the entry
console.warn(`[API] Update for ${contentType} ${id} completed, but no updated entry data was returned by the API.`);
return {
content: [{
type: "text",
text: `Successfully updated entry ${id} for ${contentType}, but no updated data was returned by the API.`
}]
};
}
}
case "delete_entry": {
const contentType = String(request.params.arguments?.contentType);
const id = String(request.params.arguments?.id);
if (!contentType || !id) {
throw new McpError(
ErrorCode.InvalidParams,
"Content type and ID are required"
);
}
await deleteEntry(contentType, id);
return {
content: [{
type: "text",
text: `Successfully deleted entry ${id} from ${contentType}`
}]
};
}
case "upload_media": {
const fileData = String(request.params.arguments?.fileData);
const fileName = String(request.params.arguments?.fileName);
const fileType = String(request.params.arguments?.fileType);
if (!fileData || !fileName || !fileType) {
throw new McpError(
ErrorCode.InvalidParams,
"File data, file name, and file type are required"
);
}
const media = await uploadMedia(fileData, fileName, fileType);
return {
content: [{
type: "text",
text: JSON.stringify(media, null, 2)
}]
};
}
case "get_content_type_schema": {
const contentType = String(request.params.arguments?.contentType);
if (!contentType) {
throw new McpError(
ErrorCode.InvalidParams,
"Content type is required"
);
}
const schema = await fetchContentTypeSchema(contentType);
return {
content: [{
type: "text",
text: JSON.stringify(schema, null, 2)
}]
};
}
case "connect_relation": {
const { contentType, id, relationField, relatedIds } = request.params.arguments as any;
if (!contentType || !id || !relationField || !Array.isArray(relatedIds)) {
throw new McpError(ErrorCode.InvalidParams, "contentType, id, relationField, and relatedIds (array) are required.");
}
const result = await connectRelation(String(contentType), String(id), String(relationField), relatedIds);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "disconnect_relation": {
const { contentType, id, relationField, relatedIds } = request.params.arguments as any;
if (!contentType || !id || !relationField || !Array.isArray(relatedIds)) {
throw new McpError(ErrorCode.InvalidParams, "contentType, id, relationField, and relatedIds (array) are required.");
}
const result = await disconnectRelation(String(contentType), String(id), String(relationField), relatedIds);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "create_content_type": {
const contentTypeData = request.params.arguments;
if (!contentTypeData || typeof contentTypeData !== 'object') {
throw new McpError(ErrorCode.InvalidParams, "Content type data object is required.");
}
// We pass the whole arguments object to the function
const creationResult = await createContentType(contentTypeData);
return {
content: [{
type: "text",
text: JSON.stringify(creationResult, null, 2)
}]
};
}
case "update_content_type": {
const { contentType, attributes } = request.params.arguments as any;
if (!contentType || !attributes || typeof attributes !== 'object') {
throw new McpError(ErrorCode.InvalidParams, "contentType (string) and attributes (object) are required.");
}
const updateResult = await updateContentType(String(contentType), attributes);
return {
content: [{
type: "text",
text: JSON.stringify(updateResult, null, 2)
}]
};
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
console.error(`[Error] Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
if (error instanceof McpError) {
throw error;
}
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
/**
* Start the server using stdio transport.
*/
async function main() {
console.error("[Setup] Starting Strapi MCP server");
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[Setup] Strapi MCP server running");
}
main().catch((error) => {
console.error("[Error] Server error:", error);
process.exit(1);
});