Skip to main content
Glama
stories.ts62.1 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { handleApiResponse, getManagementHeaders, buildManagementUrl, createPaginationParams, addOptionalParams } from '../utils/api'; import type { StoryFilterParams } from '../types/index'; import { getComponentSchemaByName } from '../tools/components'; export function registerStoryTools(server: McpServer) { // Fetch stories with filtering server.tool( "fetch-stories", "Fetches stories from Storyblok space with optional filtering", { page: z.number().optional().describe("Page number for pagination (default: 1)"), per_page: z.number().optional().describe("Number of stories per page (default: 25, max: 100)"), starts_with: z.string().optional().describe("Filter by story slug starting with this value"), by_slugs: z.string().optional().describe("Filter by comma-separated story slugs"), excluding_slugs: z.string().optional().describe("Exclude stories with these comma-separated slugs"), content_type: z.string().optional().describe("Filter by content type/component name"), sort_by: z.string().optional().describe("Sort field (e.g., 'created_at:desc', 'name:asc')"), search_term: z.string().optional().describe("Search term to filter stories"), include_content: z.boolean().optional().describe("Force content inclusion in results (Storyblok's 'resolve_relations' and 'resolve_links' might be relevant if 'content' is not directly returned for list views without it, or if it means fetching full story objects)."), content_status: z.enum(["draft", "published", "both"]).optional().describe("Fetch draft, published, or both versions of stories (maps to Storyblok's 'version' parameter: 'draft' or 'published'. 'both' will require two API calls)."), deep_filter: z.record(z.string()).optional().describe("Client-side filter on story content. Provide key-value pairs. Supports dot notation for nested fields, e.g., {'content.field_name': 'value'}."), validate_schema: z.string().optional().describe("Component name to validate stories against. Results added to each story or a separate metadata field."), fields: z.string().optional().describe("Comma-separated list of story fields to return (e.g., 'id,name,slug,content.component,published_at'). If provided, only these fields will be included for each story."), summary_mode: z.boolean().optional().describe("If true, returns a predefined condensed summary of each story. Overridden by the 'fields' parameter if 'fields' is also provided.") }, async (params: StoryFilterParams & { include_content?: boolean; content_status?: "draft" | "published" | "both"; deep_filter?: Record<string, string>; validate_schema?: string; fields?: string; summary_mode?: boolean; }) => { try { // Helper function to access nested properties const getValueByPath = (obj: any, path: string): any => { if (obj === null || obj === undefined || typeof path !== 'string' || path === '') { return undefined; } const parts = path.split('.'); let current = obj; for (const part of parts) { if (current === null || current === undefined) { return undefined; } const isArrayIndex = /^\d+$/.test(part); if (isArrayIndex && Array.isArray(current)) { const index = parseInt(part, 10); if (index >= current.length || index < 0) { // Out of bounds (negative index check too) return undefined; } current = current[index]; } else if (typeof current === 'object' && current !== null && Object.prototype.hasOwnProperty.call(current, part)) { // Ensures 'part' is an own property of 'current' for objects current = (current as any)[part]; } else { // Path segment not found, or trying to access property on non-object (e.g. string, number) // or part not an own property return undefined; } } return current; }; // Helper function to set nested properties (needed for summary_mode/fields) const setByPath = (obj: any, path: string, value: any): void => { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; const isNextPartArrayIndex = /^\d+$/.test(parts[i+1]); if (!current[part] || typeof current[part] !== 'object') { current[part] = isNextPartArrayIndex ? [] : {}; } current = current[part]; } const lastPart = parts[parts.length - 1]; if (Array.isArray(current) && /^\d+$/.test(lastPart)) { current[parseInt(lastPart, 10)] = value; } else { current[lastPart] = value; } }; const fetchStoriesForVersion = async (version?: "draft" | "published") => { const endpointPath = '/stories'; const urlParams = createPaginationParams(params.page, params.per_page); addOptionalParams(urlParams, { starts_with: params.starts_with, by_slugs: params.by_slugs, excluding_slugs: params.excluding_slugs, content_type: params.content_type, // This is pre-existing, for single content_type sort_by: params.sort_by, search_term: params.search_term, version: version, ...(params.include_content && { with_content: 1 }) }); const fullUrl = `${buildManagementUrl(endpointPath)}?${urlParams}`; const apiResponse = await fetch(fullUrl, { headers: getManagementHeaders() }); const responseJson = await handleApiResponse(apiResponse, fullUrl); return { stories_data: responseJson.stories || [], total_from_api: responseJson.total || 0, }; }; let stories: any[] = []; let total_items_from_api: number | null = null; const per_page_for_calc = params.per_page || 25; if (params.content_status === "both") { const [draftData, publishedData] = await Promise.all([ fetchStoriesForVersion("draft"), fetchStoriesForVersion("published") ]); const storiesMap = new Map(); (publishedData.stories_data || []).forEach((story: any) => storiesMap.set(story.id, story)); (draftData.stories_data || []).forEach((story: any) => storiesMap.set(story.id, story)); stories = Array.from(storiesMap.values()); total_items_from_api = draftData.total_from_api; } else { const singleVersionData = await fetchStoriesForVersion(params.content_status); stories = singleVersionData.stories_data || []; total_items_from_api = singleVersionData.total_from_api; } let responseMetadata: Record<string, any> = {}; if (total_items_from_api !== null) { responseMetadata.total_items_from_api = total_items_from_api; responseMetadata.total_pages_api = Math.ceil(total_items_from_api / per_page_for_calc); } if (params.page) { responseMetadata.current_page = params.page; } responseMetadata.per_page_requested = per_page_for_calc; // Apply deep_filter if (params.deep_filter && Object.keys(params.deep_filter).length > 0) { stories = stories.filter((story: any) => { return Object.entries(params.deep_filter!).every(([path, expectedValue]) => { // Adjust path to be relative to story object if it starts with 'content.' const actualPath = path.startsWith('content.') ? path : `content.${path}`; const value = getValueByPath(story, actualPath); return String(value) === expectedValue; }); }); } // Apply validate_schema if (params.validate_schema) { const componentNameToValidate = params.validate_schema; responseMetadata.validated_schema_component_name = componentNameToValidate; // Renamed for clarity const componentSchema = await getComponentSchemaByName(componentNameToValidate); if (!componentSchema) { responseMetadata.validation_schema_error = `Component schema for '${componentNameToValidate}' not found.`; // Renamed for consistency } else { stories = stories.map((story: any) => { if (story.content?.component === componentNameToValidate) { const validationResult: { isValid: boolean; errors: Array<{ field: string; type: string; message: string }>; missingFields: string[]; extraneousFields: string[]; } = { isValid: true, errors: [], missingFields: [], extraneousFields: [] }; const schemaFields = componentSchema; const contentFieldsToValidate = story.content || {}; for (const fieldName in schemaFields) { const fieldDef = schemaFields[fieldName] as any; if (fieldDef.required && contentFieldsToValidate[fieldName] === undefined) { validationResult.missingFields.push(fieldName); validationResult.errors.push({ field: fieldName, type: "missing_required", message: `Field '${fieldName}' is required.` }); } } for (const contentFieldName in contentFieldsToValidate) { if (!schemaFields.hasOwnProperty(contentFieldName)) { validationResult.extraneousFields.push(contentFieldName); validationResult.errors.push({ field: contentFieldName, type: "extraneous_field", message: `Field '${contentFieldName}' not in schema.` }); } } if (validationResult.errors.length > 0) validationResult.isValid = false; return { ...story, validation: validationResult }; } return story; // No validation object if component doesn't match }); } } // Apply summary_mode if true AND fields is not provided if (params.summary_mode && (!params.fields || params.fields.trim() === "")) { const PREDEFINED_SUMMARY_FIELDS = ['id', 'name', 'slug', 'uuid', 'content.component', 'published_at', 'updated_at', 'created_at', 'parent_id', 'full_slug']; stories = stories.map(story => { const summaryStory: Record<string, any> = {}; for (const path of PREDEFINED_SUMMARY_FIELDS) { const value = getValueByPath(story, path); if (value !== undefined) { // Only set if value exists setByPath(summaryStory, path, value); } } return summaryStory; }); } // Apply fields projection if params.fields is provided (this will run if summary_mode was false or if fields were also provided) else if (params.fields && params.fields.trim() !== "") { const requestedPaths = params.fields.split(',').map(p => p.trim()).filter(p => p); if (requestedPaths.length > 0) { stories = stories.map(story => { const newStory: Record<string, any> = {}; for (const path of requestedPaths) { const value = getValueByPath(story, path); if (value !== undefined) { // Only set if value exists setByPath(newStory, path, value); } } return newStory; }); } } const stories_count_current_response = stories.length; responseMetadata.stories_count_current_response = stories_count_current_response; const finalResponseData = { ...responseMetadata, stories: stories }; return { content: [ { type: "text", text: JSON.stringify(finalResponseData, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Get specific story server.tool( "get-story", "Gets a specific story by ID or slug", { id: z.string().describe("Story ID or slug") }, async ({ id }) => { try { const endpointPath = `/stories/${id}`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { headers: getManagementHeaders() }); const data = await handleApiResponse(response, fullUrl); return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Create story server.tool( "create-story", "Creates a new story in Storyblok", { name: z.string().describe("Story name"), slug: z.string().describe("Story slug (URL path)"), content: z.record(z.unknown()).describe("Story content object (must include a 'component' property for validation)."), parent_id: z.number().optional().describe("Parent folder ID"), is_folder: z.boolean().optional().default(false).describe("Whether this is a folder"), is_startpage: z.boolean().optional().default(false).describe("Whether this is the startpage"), tag_list: z.array(z.string()).optional().describe("Array of tag names"), validate_before_create: z.boolean().optional().describe("Validate content against component schema before creation."), auto_publish: z.boolean().optional().default(false).describe("Publish the story immediately after successful creation."), return_full_story: z.boolean().optional().default(false).describe("Return the full story object after creation (requires an additional fetch).") }, async (params) => { const { name, slug, content, parent_id, is_folder = false, is_startpage = false, tag_list, validate_before_create, auto_publish = false, return_full_story = false } = params; try { // 1. Validate Before Create (if requested) if (validate_before_create) { if (!content || !content.component || typeof content.component !== 'string') { return { isError: true, content: [{ type: "text", text: "Error: 'content.component' is required for validation." }] }; } const componentName = content.component as string; const componentSchema = await getComponentSchemaByName(componentName); if (!componentSchema) { return { isError: true, content: [{ type: "text", text: `Error: Component schema for '${componentName}' not found for validation.` }] }; } const validationErrors: Array<{ field: string; type: "missing_required" | "extraneous_field"; message: string }> = []; const validationMissingFields: string[] = []; const validationExtraneousFields: string[] = []; const schemaFields = componentSchema; // schema from getComponentSchemaByName is the actual schema definition const contentFieldsToValidate = content; // The story content itself for (const fieldName in schemaFields) { const fieldDef = schemaFields[fieldName] as any; if (fieldDef.required && contentFieldsToValidate[fieldName] === undefined) { validationMissingFields.push(fieldName); validationErrors.push({ field: fieldName, type: "missing_required", message: `Field '${fieldName}' is required.` }); } } for (const contentFieldName in contentFieldsToValidate) { if (!schemaFields.hasOwnProperty(contentFieldName)) { validationExtraneousFields.push(contentFieldName); validationErrors.push({ field: contentFieldName, type: "extraneous_field", message: `Field '${contentFieldName}' not in schema.` }); } } if (validationErrors.length > 0) { return { content: [{ type: "text", text: JSON.stringify({ validationFailed: true, isValid: false, errors: validationErrors, missingFields: validationMissingFields, extraneousFields: validationExtraneousFields, message: "Pre-creation validation failed." }, null, 2) }] }; } } // 2. Main Story Creation Logic const storyPayload = { story: { name, slug, content, parent_id, is_folder, is_startpage, tag_list } }; const createEndpointPath = '/stories'; const createFullUrl = buildManagementUrl(createEndpointPath); const createResponse = await fetch(createFullUrl, { method: 'POST', headers: getManagementHeaders(), body: JSON.stringify(storyPayload) }); let creationData = await handleApiResponse(createResponse, createFullUrl); const storyId = creationData?.story?.id; let publishStatus: any = null; // 3. Auto Publish (if requested and story created successfully) if (auto_publish && storyId) { try { const publishEndpointPath = `/stories/${storyId}/publish`; const publishFullUrl = buildManagementUrl(publishEndpointPath); const publishResponse = await fetch(publishFullUrl, { method: 'POST', headers: getManagementHeaders(), }); await handleApiResponse(publishResponse, publishFullUrl); // Throws on error publishStatus = { success: true, message: "Story published successfully." }; } catch (publishError) { publishStatus = { success: false, message: `Story created, but publishing failed: ${publishError instanceof Error ? publishError.message : String(publishError)}` }; } } // 4. Return Full Story (if requested and story created successfully) let finalData = creationData; if (return_full_story && storyId) { try { const getEndpointPath = `/stories/${storyId}`; const getFullUrl = buildManagementUrl(getEndpointPath); const getResponse = await fetch(getFullUrl, { headers: getManagementHeaders() }); finalData = await handleApiResponse(getResponse, getFullUrl); } catch (fetchError) { // If fetching the full story fails, we can still return the creation data, but add a note. finalData = creationData; // Fallback to creation data if (publishStatus) { finalData.publish_status_note = publishStatus.message + " | Additionally, fetching full story failed: " + (fetchError instanceof Error ? fetchError.message : String(fetchError)); } else { finalData.fetch_full_story_error = `Fetching full story failed: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`; } } } // Augment final data with publish status if it exists if (publishStatus) { finalData.publish_status = publishStatus; } return { content: [ { type: "text", text: JSON.stringify(finalData, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Update story server.tool( "update-story", "Updates an existing story in Storyblok", { id: z.string().describe("Story ID"), name: z.string().optional().describe("Story name"), slug: z.string().optional().describe("Story slug"), content: z.record(z.unknown()).optional().describe("Story content object"), tag_list: z.array(z.string()).optional().describe("Array of tag names"), publish: z.boolean().optional().describe("Whether to publish the story after updating") }, async ({ id, name, slug, content, tag_list, publish = false }) => { try { const updateData: Record<string, unknown> = {}; if (name !== undefined) updateData.name = name; if (slug !== undefined) updateData.slug = slug; if (content !== undefined) updateData.content = content; if (tag_list !== undefined) updateData.tag_list = tag_list; const storyData = { story: updateData }; const endpointPath = `/stories/${id}`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'PUT', headers: getManagementHeaders(), body: JSON.stringify(storyData) } ); const data = await handleApiResponse(response, fullUrl); // Publish if requested if (publish) { const publishEndpointPath = `/stories/${id}/publish`; const publishFullUrl = buildManagementUrl(publishEndpointPath); const publishResponse = await fetch(publishFullUrl, { method: 'POST', headers: getManagementHeaders() } ); await handleApiResponse(publishResponse, publishFullUrl); } return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Delete story server.tool( "delete-story", "Deletes a story from Storyblok", { id: z.string().describe("Story ID") }, async ({ id }) => { try { const endpointPath = `/stories/${id}`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'DELETE', headers: getManagementHeaders() } ); await handleApiResponse(response, fullUrl); return { content: [ { type: "text", text: `Story ${id} has been successfully deleted.` } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Publish story server.tool( "publish-story", "Publishes a story in Storyblok", { id: z.string().describe("Story ID") }, async ({ id }) => { try { const endpointPath = `/stories/${id}/publish`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'POST', headers: getManagementHeaders() } ); const data = await handleApiResponse(response, fullUrl); return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Unpublish story server.tool( "unpublish-story", "Unpublishes a story in Storyblok", { id: z.string().describe("Story ID") }, async ({ id }) => { try { const endpointPath = `/stories/${id}/unpublish`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'POST', headers: getManagementHeaders() } ); const data = await handleApiResponse(response, fullUrl); return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Get story versions server.tool( "get-story-versions", "Gets all versions of a story", { id: z.string().describe("Story ID") }, async ({ id }) => { try { const endpointPath = `/stories/${id}/versions`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { headers: getManagementHeaders() }); const data = await handleApiResponse(response, fullUrl); return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // Restore story server.tool( "restore-story", "Restores a story to a specific version", { id: z.string().describe("Story ID"), version_id: z.string().describe("Version ID to restore to") }, async ({ id, version_id }) => { try { const endpointPath = `/stories/${id}/restore/${version_id}`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'POST', headers: getManagementHeaders() } ); const data = await handleApiResponse(response, fullUrl); return { content: [ { type: "text", text: JSON.stringify(data, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // New Tool: validate-story-content server.tool( "validate-story-content", "Validates a story's content against a component schema", { story_id: z.string().optional().describe("Story ID (optional if content is provided directly)."), component_name: z.string().describe("The name of the component schema to validate against."), story_content: z.record(z.unknown()).optional().describe("Story content object to validate (fetched if not provided and story_id is)."), space_id: z.string().optional().describe("Space ID, defaults to configured space. (Currently uses default space)") }, async ({ story_id, component_name, story_content, space_id }: { story_id?: string; component_name: string; story_content?: Record<string, unknown>; space_id?: string; }) => { const errors: Array<{ field: string; type: "missing_required" | "extraneous_field" | "type_mismatch" | "general"; message: string }> = []; const missingFields: string[] = []; const extraneousFields: string[] = []; let isValid = true; try { // 1. Fetch Component Schema // Note: The space_id from input isn't fully utilized yet by getComponentSchemaByName as it defaults to primary config. const componentSchema = await getComponentSchemaByName(component_name, space_id); if (!componentSchema) { return { isError: true, content: [{ type: "text", text: `Error: Component schema for '${component_name}' not found.` }] }; } // 2. Fetch Story Content (if needed) let actualStoryContent: Record<string, any> | null = null; if (story_content) { actualStoryContent = story_content; } else if (story_id) { const storyEndpointPath = `/stories/${story_id}`; const storyFullUrl = buildManagementUrl(storyEndpointPath); const storyResponse = await fetch(storyFullUrl, { headers: getManagementHeaders() }); const storyData = await handleApiResponse(storyResponse, storyFullUrl); // Assuming storyData is { story: { content: { ... } } } if (storyData && storyData.story && storyData.story.content) { actualStoryContent = storyData.story.content; } else { return { isError: true, content: [{ type: "text", text: `Error: Could not fetch content for story_id '${story_id}'. Or content is not in expected format.` }] }; } } else { return { isError: true, content: [{ type: "text", text: "Validation failed: Either 'story_id' (to fetch the story) or 'story_content' (to validate directly) must be provided." }] }; } if (!actualStoryContent) { // Should be caught above, but as a safeguard return { isError: true, content: [{ type: "text", text: "Error: Failed to obtain story content." }] }; } // 3. Validate Content // Storyblok component schemas have a 'schema' object where keys are field names. // Each field definition can have a 'required: true' property. const schemaFields = componentSchema; // componentSchema is the schema object itself // Check for missing required fields for (const fieldName in schemaFields) { const fieldDef = schemaFields[fieldName] as any; if (fieldDef.required && actualStoryContent[fieldName] === undefined) { missingFields.push(fieldName); errors.push({ field: fieldName, type: "missing_required", message: `Field '${fieldName}' is required but missing.` }); } } // Check for extraneous fields in the content for (const contentFieldName in actualStoryContent) { if (!schemaFields.hasOwnProperty(contentFieldName)) { extraneousFields.push(contentFieldName); errors.push({ field: contentFieldName, type: "extraneous_field", message: `Field '${contentFieldName}' is present in content but not defined in component schema.` }); } } isValid = errors.length === 0; return { content: [ { type: "text", text: JSON.stringify({ isValid, errors, missingFields, extraneousFields, validatedComponentName: component_name, storyIdProcessed: story_id || "N/A (content provided directly)" }, null, 2) } ] }; } catch (error) { return { isError: true, content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }] }; } } ); // Tool: debug-story-access server.tool( "debug-story-access", "Debugs access to a specific story by trying various fetch parameters.", { story_id: z.string().describe("The ID of the story to debug.") }, async ({ story_id }: { story_id: string }) => { const apiCallAttempts: any[] = []; const issuesDetected: string[] = []; const suggestions: string[] = []; let accessibleAsDraftDetails = { accessible: false, contentPresent: false, fromScenario: "" }; let accessibleAsPublishedDetails = { accessible: false, contentPresent: false, fromScenario: "" }; const scenarios = [ { name: "Default (likely draft)", params: {} }, { name: "Published", params: { version: "published" } }, { name: "Draft explicit", params: { version: "draft" } }, { name: "Draft with content", params: { version: "draft", with_content: "1" } }, { name: "Published with content", params: { version: "published", with_content: "1" } }, ]; for (const scenario of scenarios) { const attemptResult: any = { scenarioName: scenario.name, paramsUsed: { ...scenario.params, story_id } }; try { const urlParams = new URLSearchParams(); if ((scenario.params as any).version) urlParams.append("version", (scenario.params as any).version); if ((scenario.params as any).with_content) urlParams.append("with_content", (scenario.params as any).with_content); const endpointPath = `/stories/${story_id}`; const queryString = urlParams.toString(); const fullUrl = buildManagementUrl(endpointPath) + (queryString ? `?${queryString}` : ""); attemptResult.requestUrl = fullUrl; const response = await fetch(fullUrl, { headers: getManagementHeaders() }); const data = await handleApiResponse(response, fullUrl); // handleApiResponse throws on !response.ok attemptResult.status = response.status; attemptResult.responseData = { id: data?.story?.id, name: data?.story?.name, published_at: data?.story?.published_at, full_slug: data?.story?.full_slug, content_present: !!data?.story?.content, content_component: data?.story?.content?.component, version: data?.story?.version, // if API returns it }; const story = data?.story; const contentPresent = !!story?.content; if ((scenario.params as any).version === "published") { if (story) { if (!accessibleAsPublishedDetails.accessible || (contentPresent && !accessibleAsPublishedDetails.contentPresent)) { accessibleAsPublishedDetails = { accessible: true, contentPresent: contentPresent, fromScenario: scenario.name }; } if (!story.published_at) { issuesDetected.push(`Scenario '${scenario.name}': Story fetched as published, but 'published_at' is null/missing.`); } } } else { // Draft or default if (story) { if (!accessibleAsDraftDetails.accessible || (contentPresent && !accessibleAsDraftDetails.contentPresent)) { accessibleAsDraftDetails = { accessible: true, contentPresent: contentPresent, fromScenario: scenario.name }; } } } if ((scenario.params as any).with_content && !contentPresent && story) { issuesDetected.push(`Scenario '${scenario.name}': Requested 'with_content' but content is missing.`); } } catch (error) { attemptResult.status = "ERROR"; // Placeholder, real status is in parsedError.error if (error instanceof Error && error.message.startsWith('{')) { try { const parsedError = JSON.parse(error.message); attemptResult.errorDetails = parsedError; if (parsedError.error) { // From our enhanced handler const statusMatch = parsedError.error.match(/^(\d{3})/); if (statusMatch) attemptResult.status = parseInt(statusMatch[1]); } } catch (parseErr) { attemptResult.errorDetails = error.message; } } else { attemptResult.errorDetails = error instanceof Error ? error.message : String(error); } } apiCallAttempts.push(attemptResult); } // Overall Analysis if (accessibleAsDraftDetails.accessible && !accessibleAsPublishedDetails.accessible) { suggestions.push("Story is accessible in 'draft' version but not 'published'. It might be unpublished or never published."); if (!accessibleAsDraftDetails.contentPresent && accessibleAsPublishedDetails.fromScenario.includes("with content")){ suggestions.push("Draft version was accessible but content might be missing. Ensure 'with_content=1' (or equivalent) is used if full content is needed for drafts."); } } if (!accessibleAsDraftDetails.accessible && accessibleAsPublishedDetails.accessible) { issuesDetected.push("Story is accessible as 'published' but not as 'draft'. This is unusual but could happen if there's a very specific server-side state or error with the draft version."); } if (accessibleAsDraftDetails.accessible && accessibleAsPublishedDetails.accessible) { if(accessibleAsDraftDetails.contentPresent && !accessibleAsPublishedDetails.contentPresent && accessibleAsPublishedDetails.fromScenario && !accessibleAsPublishedDetails.fromScenario.includes("with content")){ suggestions.push("Published version content was not fetched. If needed, try fetching published version with 'with_content=1'."); } if(!accessibleAsDraftDetails.contentPresent && accessibleAsPublishedDetails.contentPresent && accessibleAsDraftDetails.fromScenario && !accessibleAsDraftDetails.fromScenario.includes("with content")){ suggestions.push("Draft version content was not fetched. If needed, try fetching draft version with 'with_content=1'."); } } if (!accessibleAsDraftDetails.accessible && !accessibleAsPublishedDetails.accessible) { issuesDetected.push("Story was not accessible with any of the attempted common parameters (draft, published, with/without content)."); suggestions.push("Verify the story ID is correct and exists in the space. Check token permissions if 403 errors occurred."); } // Check for consistent 404s const all404 = apiCallAttempts.every(att => att.status === 404); if(all404){ issuesDetected.push("All attempts resulted in a 404 Not Found error."); suggestions.push("Confirm the story ID is correct and the story has not been deleted."); } // Check for permission issues const any403 = apiCallAttempts.some(att => att.status === 403); if(any403){ issuesDetected.push("At least one attempt resulted in a 403 Forbidden error."); suggestions.push("Check the API token's permissions for reading stories. It might lack access to certain story states (e.g., drafts vs published) or the stories endpoint in general."); } return { content: [{ type: "text", text: JSON.stringify({ storyId: story_id, accessibleAsDraftDetails, accessibleAsPublishedDetails, issuesDetected: [...new Set(issuesDetected)], // Deduplicate suggestions: [...new Set(suggestions)], // Deduplicate apiCallAttempts }, null, 2) }] }; } ); // Tool: bulk-publish-stories server.tool( "bulk-publish-stories", "Publishes multiple stories in Storyblok", { story_ids: z.array(z.string()).min(1).describe("Array of Story IDs to publish.") }, async ({ story_ids }: { story_ids: string[] }) => { const results: Array<{ id: string, status: "success" | "error", data?: any, error?: string }> = []; let successful_operations = 0; let failed_operations = 0; for (const id of story_ids) { try { const endpointPath = `/stories/${id}/publish`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'POST', // Publish is a POST request headers: getManagementHeaders(), }); const data = await handleApiResponse(response, fullUrl); // Publish endpoint returns the published story object results.push({ id, status: "success", data }); successful_operations++; } catch (error) { results.push({ id, status: "error", error: error instanceof Error ? error.message : String(error) }); failed_operations++; } } return { content: [{ type: "text", text: JSON.stringify({ total_processed: story_ids.length, successful_operations, failed_operations, results }, null, 2) }] }; } ); // Tool: bulk-delete-stories server.tool( "bulk-delete-stories", "Deletes multiple stories in Storyblok", { story_ids: z.array(z.string()).min(1).describe("Array of Story IDs to delete.") }, async ({ story_ids }: { story_ids: string[] }) => { const results: Array<{ id: string, status: "success" | "error", error?: string }> = []; let successful_operations = 0; let failed_operations = 0; for (const id of story_ids) { try { const endpointPath = `/stories/${id}`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'DELETE', headers: getManagementHeaders(), }); await handleApiResponse(response, fullUrl); // delete doesn't typically return content, but handleApiResponse checks response.ok results.push({ id, status: "success" }); successful_operations++; } catch (error) { results.push({ id, status: "error", error: error instanceof Error ? error.message : String(error) }); failed_operations++; } } return { content: [{ type: "text", text: JSON.stringify({ total_processed: story_ids.length, successful_operations, failed_operations, results }, null, 2) }] }; } ); // Tool: bulk-update-stories server.tool( "bulk-update-stories", "Updates multiple stories in Storyblok", { stories: z.array( z.object({ id: z.string().describe("Story ID"), name: z.string().optional().describe("Story name"), slug: z.string().optional().describe("Story slug"), content: z.record(z.unknown()).optional().describe("Story content object"), parent_id: z.number().optional().describe("Parent folder ID (cannot be used to move to root, use 0 or null for root)"), is_folder: z.boolean().optional().describe("Whether this is a folder"), is_startpage: z.boolean().optional().describe("Whether this is the startpage"), tag_list: z.array(z.string()).optional().describe("Array of tag names"), publish: z.boolean().optional().default(false).describe("Whether to publish the story after updating") }) ).min(1).describe("Array of story objects to update. Each object must have an 'id'.") }, async ({ stories }: { stories: Array<any> }) => { const results: Array<{ id: string, status: "success" | "error", data?: any, error?: string, published?: boolean }> = []; let successful_operations = 0; let failed_operations = 0; for (const storyUpdate of stories) { const { id, publish, ...updateFields } = storyUpdate; try { const storyPayload: Record<string, unknown> = {}; // Filter out undefined values explicitly, as Storyblok API might interpret them Object.keys(updateFields).forEach(key => { if ((updateFields as any)[key] !== undefined) { storyPayload[key] = (updateFields as any)[key]; } }); const endpointPath = `/stories/${id}`; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'PUT', headers: getManagementHeaders(), body: JSON.stringify({ story: storyPayload }), // Storyblok expects { story: { ... } } }); const data = await handleApiResponse(response, fullUrl); let published = false; if (publish) { try { const publishEndpointPath = `/stories/${id}/publish`; const publishFullUrl = buildManagementUrl(publishEndpointPath); const publishResponse = await fetch(publishFullUrl, { method: 'POST', headers: getManagementHeaders(), }); await handleApiResponse(publishResponse, publishFullUrl); published = true; } catch (publishError) { // Log publish error but don't fail the update operation itself // Optionally add this info to the result for this story } } results.push({ id, status: "success", data, published }); successful_operations++; } catch (error) { results.push({ id, status: "error", error: error instanceof Error ? error.message : String(error) }); failed_operations++; } } return { content: [{ type: "text", text: JSON.stringify({ total_processed: stories.length, successful_operations, failed_operations, results }, null, 2) }] }; } ); // Tool: bulk-create-stories server.tool( "bulk-create-stories", "Creates multiple stories in Storyblok", { stories: z.array( z.object({ name: z.string().describe("Story name"), slug: z.string().describe("Story slug (URL path)"), content: z.record(z.unknown()).describe("Story content object"), parent_id: z.number().optional().describe("Parent folder ID"), is_folder: z.boolean().optional().default(false).describe("Whether this is a folder"), is_startpage: z.boolean().optional().default(false).describe("Whether this is the startpage"), tag_list: z.array(z.string()).optional().describe("Array of tag names") }) ).min(1).describe("Array of story objects to create") }, async ({ stories }: { stories: Array<any> }) => { const results: Array<{ input: any, id?: number, slug?: string, status: "success" | "error", data?: any, error?: string }> = []; let successful_operations = 0; let failed_operations = 0; for (const storyInput of stories) { try { const storyPayload = { story: storyInput }; const endpointPath = '/stories'; const fullUrl = buildManagementUrl(endpointPath); const response = await fetch(fullUrl, { method: 'POST', headers: getManagementHeaders(), body: JSON.stringify(storyPayload), }); const data = await handleApiResponse(response, fullUrl); // data.story contains the created story results.push({ input: storyInput, id: data?.story?.id, slug: data?.story?.slug, status: "success", data }); successful_operations++; } catch (error) { results.push({ input: storyInput, slug: storyInput.slug, status: "error", error: error instanceof Error ? error.message : String(error) }); failed_operations++; } } return { content: [{ type: "text", text: JSON.stringify({ total_processed: stories.length, successful_operations, failed_operations, results }, null, 2) }] }; } ); // New Tool: fetch-stories-by-component (re-adding with pagination) server.tool( "fetch-stories-by-component", "Fetches stories from Storyblok space filtering by a specific component name in story content or body.", { component_name: z.string().describe("The name of the component to filter by."), content_status: z.enum(["draft", "published", "both"]).optional().default("both").describe("Fetch draft, published, or both versions of stories (default: 'both')."), page: z.number().optional().describe("Page number for pagination (default: 1)"), per_page: z.number().optional().describe("Number of stories per page (default: 25, max: 100)"), starts_with: z.string().optional().describe("Filter by story slug starting with this value (applied before component filtering)."), by_slugs: z.string().optional().describe("Filter by comma-separated story slugs (applied before component filtering)."), excluding_slugs: z.string().optional().describe("Exclude stories with these comma-separated slugs (applied before component filtering)."), sort_by: z.string().optional().describe("Sort field (e.g., 'created_at:desc', 'name:asc'). Applied by Storyblok API."), }, async (params: { component_name: string; content_status?: "draft" | "published" | "both"; page?: number; per_page?: number; starts_with?: string; by_slugs?: string; excluding_slugs?: string; sort_by?: string; }) => { try { const { component_name, content_status = "both", ...otherApiParams } = params; // fetchStoriesForVersion for this tool const fetchStoriesForVersion = async (version?: "draft" | "published") => { const endpointPath = '/stories'; const urlParams = createPaginationParams(otherApiParams.page, otherApiParams.per_page); addOptionalParams(urlParams, { starts_with: otherApiParams.starts_with, by_slugs: otherApiParams.by_slugs, excluding_slugs: otherApiParams.excluding_slugs, sort_by: otherApiParams.sort_by, version: version, with_content: 1 // Always fetch content for component filtering }); const fullUrl = `${buildManagementUrl(endpointPath)}?${urlParams}`; const apiResponse = await fetch(fullUrl, { headers: getManagementHeaders() }); const responseJson = await handleApiResponse(apiResponse, fullUrl); return { stories_data: responseJson.stories || [], total_from_api: responseJson.total || 0 }; }; let fetchedStoriesData: any[] = []; let api_total_items: number | null = null; const per_page_for_calc = otherApiParams.per_page || 25; if (content_status === "both") { const [draftResult, publishedResult] = await Promise.all([ fetchStoriesForVersion("draft"), fetchStoriesForVersion("published") ]); const storiesMap = new Map(); (publishedResult.stories_data || []).forEach((story: any) => storiesMap.set(story.id, story)); (draftResult.stories_data || []).forEach((story: any) => storiesMap.set(story.id, story)); fetchedStoriesData = Array.from(storiesMap.values()); api_total_items = draftResult.total_from_api; } else { const result = await fetchStoriesForVersion(content_status); fetchedStoriesData = result.stories_data || []; api_total_items = result.total_from_api; } const filteredStories = fetchedStoriesData.filter(story => { if (!story.content) return false; if (story.content.component === component_name) return true; if (Array.isArray(story.content.body)) { return story.content.body.some((bodyComponent: any) => bodyComponent && bodyComponent.component === component_name ); } return false; }); let responseMetadata: Record<string, any> = { component_name_filter: component_name, stories_found_after_filter: filteredStories.length, }; if (api_total_items !== null) { responseMetadata.api_total_items_before_component_filter = api_total_items; responseMetadata.api_total_pages_before_component_filter = Math.ceil(api_total_items / per_page_for_calc); } if (otherApiParams.page) { responseMetadata.current_page_requested = otherApiParams.page; } responseMetadata.per_page_requested = per_page_for_calc; const finalResponseData = { ...responseMetadata, stories: filteredStories }; return { content: [ { type: "text", text: JSON.stringify(finalResponseData, null, 2) } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error in fetch-stories-by-component: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // New Tool: search-content server.tool( "search-content", "Searches for a query string within specified fields of story content, with options for content type filtering and deep searching in nested components.", { query: z.string().describe("The text/value to search for within story content fields."), fields_to_search: z.array(z.string()).min(1).describe("Array of field paths within story.content to search the query in (e.g., ['title', 'description', 'body.0.text']). Paths are relative to the 'content' object."), content_types: z.array(z.string()).optional().describe("Array of content type names (component names) to filter stories by. If empty or undefined, all content types are searched."), content_status: z.enum(["draft", "published", "both"]).optional().default("both").describe("Fetch draft, published, or both versions of stories."), deep_search_nested_components: z.boolean().optional().default(false).describe("If true, recursively search within nested components in arrays like 'body' or any field specified in 'fields_to_search' that resolves to an array/object of components."), page: z.number().optional().describe("Page number for pagination (default: 1)."), per_page: z.number().optional().describe("Number of stories per page (default: 25, max: 100).") }, async (params: { query: string; fields_to_search: string[]; content_types?: string[]; content_status?: "draft" | "published" | "both"; deep_search_nested_components?: boolean; page?: number; per_page?: number; }) => { const { query, fields_to_search, content_types, content_status = "both", deep_search_nested_components = false, page, per_page } = params; // getValueByPath is defined in fetch-stories, ensure it's accessible // (It is, as it's in the same file scope, outside the fetch-stories handler) // If this tool were in a different file, getValueByPath would need to be imported or passed. const getValueByPath = (obj: any, path: string): any => { if (obj === null || obj === undefined || typeof path !== 'string' || path === '') { return undefined; } const parts = path.split('.'); let current = obj; for (const part of parts) { if (current === null || current === undefined) return undefined; const isArrayIndex = /^\d+$/.test(part); if (isArrayIndex && Array.isArray(current)) { const index = parseInt(part, 10); if (index >= current.length || index < 0) return undefined; current = current[index]; } else if (typeof current === 'object' && current !== null && Object.prototype.hasOwnProperty.call(current, part)) { current = (current as any)[part]; } else { return undefined; } } return current; }; const fetchStoriesForSearch = async (version?: "draft" | "published", apiContentType?: string) => { const endpointPath = '/stories'; const urlParams = createPaginationParams(page, per_page); addOptionalParams(urlParams, { version: version, content_type: apiContentType, // Use single content_type for API if applicable with_content: 1 // Always fetch content }); const fullUrl = `${buildManagementUrl(endpointPath)}?${urlParams}`; const apiResponse = await fetch(fullUrl, { headers: getManagementHeaders() }); const responseJson = await handleApiResponse(apiResponse, fullUrl); return { stories_data: responseJson.stories || [], total_from_api: responseJson.total || 0 }; }; let storiesToAnalyze: any[] = []; let total_items_from_api: number | null = null; const per_page_for_calc = per_page || 25; // Content Type Handling for API call let apiContentTypeFilter: string | undefined = undefined; if (content_types && content_types.length === 1) { apiContentTypeFilter = content_types[0]; } if (content_status === "both") { const [draftData, publishedData] = await Promise.all([ fetchStoriesForSearch("draft", apiContentTypeFilter), fetchStoriesForSearch("published", apiContentTypeFilter) ]); const storiesMap = new Map(); (publishedData.stories_data || []).forEach((story: any) => storiesMap.set(story.id, story)); (draftData.stories_data || []).forEach((story: any) => storiesMap.set(story.id, story)); storiesToAnalyze = Array.from(storiesMap.values()); total_items_from_api = draftData.total_from_api; } else { const result = await fetchStoriesForSearch(content_status, apiContentTypeFilter); storiesToAnalyze = result.stories_data; total_items_from_api = result.total_from_api; } // Client-side content_types filter if multiple were given if (content_types && content_types.length > 1) { storiesToAnalyze = storiesToAnalyze.filter((story: any) => story.content?.component && content_types.includes(story.content.component) ); // Note: total_items_from_api would reflect pre-filtering count if this happens. } const stories_analyzed_count = storiesToAnalyze.length; const matched_stories: any[] = []; const lowerCaseQuery = query.toLowerCase(); const recursiveDeepSearch = (currentValue: any, visited: Set<any>): boolean => { if (currentValue === null || currentValue === undefined) return false; if (typeof currentValue === 'string') { return currentValue.toLowerCase().includes(lowerCaseQuery); } if (typeof currentValue !== 'object' || visited.has(currentValue)) { return false; // Primitive (non-string) or already visited } visited.add(currentValue); if (Array.isArray(currentValue)) { for (const item of currentValue) { if (recursiveDeepSearch(item, visited)) { visited.delete(currentValue); return true; } } } else { // Object for (const key in currentValue) { if (Object.prototype.hasOwnProperty.call(currentValue, key)) { if (recursiveDeepSearch(currentValue[key], visited)) { visited.delete(currentValue); return true; } } } } visited.delete(currentValue); return false; }; for (const story of storiesToAnalyze) { if (!story.content) continue; let storyMatched = false; for (const fieldPath of fields_to_search) { const value = getValueByPath(story.content, fieldPath); if (value === undefined) continue; if (typeof value === 'string' && value.toLowerCase().includes(lowerCaseQuery)) { storyMatched = true; break; } if (deep_search_nested_components && (Array.isArray(value) || typeof value === 'object')) { if (recursiveDeepSearch(value, new Set())) { storyMatched = true; break; } } } if (storyMatched) { matched_stories.push(story); } } let responseMetadata: Record<string, any> = { query: query, fields_searched: fields_to_search, content_types_filter: content_types || "all", deep_search_enabled: deep_search_nested_components, stories_analyzed_count: stories_analyzed_count, matches_found_count: matched_stories.length, }; if (total_items_from_api !== null) { responseMetadata.total_items_from_api_before_search = total_items_from_api; responseMetadata.total_pages_api = Math.ceil(total_items_from_api / per_page_for_calc); } if (page) responseMetadata.current_page_requested = page; responseMetadata.per_page_requested = per_page_for_calc; return { content: [{ type: "text", text: JSON.stringify({ ...responseMetadata, matched_stories }, null, 2) }] }; } ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/arb-ec/mcp-storyblok-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server