Skip to main content
Glama
components.ts14.5 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { handleApiResponse, getManagementHeaders, buildManagementUrl } from "../utils/api"; export function registerComponentTools(server: McpServer) { // Fetch components server.tool( "fetch-components", "Fetches components from Storyblok space, with optional filtering and response shaping.", { component_summary: z.boolean().optional().describe("If true, return only component names, IDs, and display_names."), include_schema_details: z.boolean().optional().default(true).describe("If false, exclude the detailed 'schema' field (used if component_summary is false)."), filter_by_name: z.string().optional().describe("Filter components by name (case-insensitive substring match on 'name' or 'display_name').") }, async (params) => { const { component_summary, include_schema_details, filter_by_name } = params; try { const endpointUrl = buildManagementUrl('/components'); const response = await fetch(endpointUrl, { headers: getManagementHeaders() }); const data = await handleApiResponse(response, endpointUrl); // Expects { components: [] } let components = data.components || []; // Apply filter_by_name if (filter_by_name) { const filterLower = filter_by_name.toLowerCase(); components = components.filter((comp: any) => (comp.name && comp.name.toLowerCase().includes(filterLower)) || (comp.display_name && comp.display_name.toLowerCase().includes(filterLower)) ); } // Apply response shaping if (component_summary) { components = components.map((comp: any) => ({ id: comp.id, name: comp.name, display_name: comp.display_name, })); } else if (!include_schema_details) { components = components.map((comp: any) => { const { schema, ...rest } = comp; // Destructure to omit schema return rest; }); } // The original API call for all components returns an object like { components: [...] } // To maintain consistency or if the user expects that shape, we can return { components: components } // Or, if just the array is fine (more common for a "fetch list" type tool after processing): const finalResponseData = { components_count: components.length, components: components }; 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 component server.tool( "get-component", "Gets a specific component by ID", { id: z.string().describe("Component ID") }, async ({ id }) => { try { const response = await fetch( buildManagementUrl(`/components/${id}`), { headers: getManagementHeaders() } ); const data = await handleApiResponse(response, buildManagementUrl(`/components/${id}`)); 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 component server.tool( "create-component", "Creates a new component (block) in Storyblok", { name: z.string().describe("Component name"), display_name: z.string().optional().describe("Display name for the component"), schema: z.record(z.unknown()).describe("Component schema definition"), is_root: z.boolean().optional().describe("Whether this is a root component (default: false)"), is_nestable: z.boolean().optional().describe("Whether this component can be nested (default: true)") }, async ({ name, display_name, schema, is_root = false, is_nestable = true }) => { try { const componentData = { component: { name, display_name: display_name || name, schema, is_root, is_nestable } }; const response = await fetch( buildManagementUrl('/components'), { method: 'POST', headers: getManagementHeaders(), body: JSON.stringify(componentData) } ); const data = await handleApiResponse(response, buildManagementUrl('/components')); 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)}` } ] }; } } ); // Update component server.tool( "update-component", "Updates an existing component in Storyblok", { id: z.string().describe("Component ID"), name: z.string().optional().describe("Component name"), display_name: z.string().optional().describe("Display name for the component"), schema: z.record(z.unknown()).optional().describe("Component schema definition"), is_root: z.boolean().optional().describe("Whether this is a root component"), is_nestable: z.boolean().optional().describe("Whether this component can be nested") }, async ({ id, name, display_name, schema, is_root, is_nestable }) => { try { const updateData: Record<string, unknown> = {}; if (name !== undefined) updateData.name = name; if (display_name !== undefined) updateData.display_name = display_name; if (schema !== undefined) updateData.schema = schema; if (is_root !== undefined) updateData.is_root = is_root; if (is_nestable !== undefined) updateData.is_nestable = is_nestable; const componentData = { component: updateData }; const response = await fetch( buildManagementUrl(`/components/${id}`), { method: 'PUT', headers: getManagementHeaders(), body: JSON.stringify(componentData) } ); const data = await handleApiResponse(response, buildManagementUrl(`/components/${id}`)); 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 component server.tool( "delete-component", "Deletes a component from Storyblok", { id: z.string().describe("Component ID") }, async ({ id }) => { try { const response = await fetch( buildManagementUrl(`/components/${id}`), { method: 'DELETE', headers: getManagementHeaders() } ); await handleApiResponse(response, buildManagementUrl(`/components/${id}`)); return { content: [ { type: "text", text: `Component ${id} has been successfully deleted.` } ] }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` } ] }; } } ); // New Tool: get-component-usage server.tool( "get-component-usage", "Finds all stories where a specific component is used, checking direct use and nested use within the story content.", { component_name: z.string().describe("The name of the component to search for.") }, async ({ component_name }: { component_name: string }) => { const MAX_PAGES = 10; // Limit to 10 pages (e.g., 1000 stories if per_page is 100) to prevent excessive calls const PER_PAGE = 100; let allStoriesMap = new Map<number, any>(); // Use story ID as key let pagesFetched = 0; let totalStoriesFetched = 0; let limitReached = false; // Helper to fetch a single page of stories for a given version const fetchStoriesPage = async (version: "draft" | "published", page: number) => { const endpointPath = '/stories'; const urlParams = new URLSearchParams({ page: page.toString(), per_page: PER_PAGE.toString(), with_content: "1", // Crucial for checking component usage version: version, }); const fullUrl = `${buildManagementUrl(endpointPath)}?${urlParams.toString()}`; const response = await fetch(fullUrl, { headers: getManagementHeaders() }); return handleApiResponse(response, fullUrl); // Expects { stories: [], total: number } }; // Fetch and merge stories (draft and published) for (const version of ["published", "draft"] as ("published" | "draft")[]) { let currentPage = 1; let hasMore = true; pagesFetched = 0; // Reset for each version to adhere to MAX_PAGES per version type if desired, or overall. Let's do overall. while (hasMore && pagesFetched < MAX_PAGES) { try { const data = await fetchStoriesPage(version, currentPage); const stories = data.stories || []; stories.forEach((story: any) => { // If version is draft, it overwrites. If published, it adds only if not already there from draft. // For component usage, draft version is usually more up-to-date. if (version === "draft" || !allStoriesMap.has(story.id)) { allStoriesMap.set(story.id, story); } }); totalStoriesFetched = allStoriesMap.size; // Update based on map size pagesFetched++; // Storyblok API returns 'total' which is total items, and 'per_page' // Check if more stories might exist const totalAvailable = Number(data.total) || 0; if (totalStoriesFetched >= totalAvailable || stories.length < PER_PAGE) { hasMore = false; } else { currentPage++; } } catch (error) { // Log error for this page and continue if possible, or break. // For simplicity, we break. A more robust version might retry or skip. console.error(`Error fetching page ${currentPage} for ${version} stories: ${error}`); hasMore = false; } } if (pagesFetched >= MAX_PAGES && hasMore) { limitReached = true; } } const allStories = Array.from(allStoriesMap.values()); const used_in_stories: Array<{ id: number; name: string; slug: string; full_slug: string }> = []; // Recursive function to find component in content structures const findComponentInValue = (value: any): boolean => { if (!value) return false; if (Array.isArray(value)) { return value.some(item => findComponentInValue(item)); } if (typeof value === 'object' && value !== null) { if (value.component === component_name) { return true; } // Check all object properties for nested components return Object.values(value).some(propValue => findComponentInValue(propValue)); } return false; }; allStories.forEach(story => { if (story.content) { if (findComponentInValue(story.content)) { used_in_stories.push({ id: story.id, name: story.name, slug: story.slug, full_slug: story.full_slug, }); } } }); let message = `Found ${used_in_stories.length} stories using component '${component_name}'.`; if (limitReached) { message += ` Searched up to ${MAX_PAGES * PER_PAGE * 2} potential story entries (across draft/published versions over ${MAX_PAGES} pages each if available). The actual number of unique stories processed is ${allStories.length}. The search limit was reached, so there might be more instances not listed.`; } return { content: [ { type: "text", text: JSON.stringify({ component_name, usage_count: used_in_stories.length, search_limit_reached: limitReached, stories_analyzed_count: allStories.length, message, used_in_stories, }, null, 2) } ] }; } ); } // Helper function to get a component's schema by its name export async function getComponentSchemaByName(componentName: string, spaceId?: string): Promise<Record<string, unknown> | null> { // If spaceId is provided, it implies a more complex setup, possibly different credentials. // For now, this example assumes it uses the primary configured space. // A real implementation might need to handle different Storyblok clients if spaceId implies different tokens. if (spaceId) { } const endpoint = buildManagementUrl('/components'); const response = await fetch(endpoint, { headers: getManagementHeaders() }); const data = await handleApiResponse(response, endpoint); // Assuming data is { components: [] } if (data && data.components && Array.isArray(data.components)) { const foundComponent = data.components.find((comp: any) => comp.name === componentName); if (foundComponent) { return foundComponent.schema || null; // Return the schema object } } return null; }

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