diff --git a/package-lock.json b/package-lock.json index 3fda967..7778721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "cors": "^2.8.5", "express": "^5.1.0", "socket.io": "^4.8.1", - "webflow-api": "3.1.1", + "webflow-api": "3.2.1", "zod": "^3.24.2" }, "bin": { @@ -611,9 +611,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.0.tgz", - "integrity": "sha512-D8h5KXY2vHFW8zTuxn2vuZGN0HGrQ5No6LkHwlEA9trVgNdPL3TF1dSqKA7Dny6BbBYKSW/rOBDXdC8KJAjUCg==", + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.1.tgz", + "integrity": "sha512-UyLFcJLDvUuZbGnaQqXFT32CpPpGj7VS19roLut6gkQVhb439xUzYWbsUvdI3ZPL+2hnFosuugtYWE0Mcs1rmQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", @@ -635,36 +635,14 @@ "node": ">=18" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "@cfworker/json-schema": "^4.1.1" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true - }, - "zod": { - "optional": false } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/zod": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", - "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2981,9 +2959,9 @@ } }, "node_modules/js-base64": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", - "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==", + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", + "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", "license": "BSD-3-Clause" }, "node_modules/json-schema-traverse": { @@ -4702,35 +4680,20 @@ } }, "node_modules/webflow-api": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/webflow-api/-/webflow-api-3.1.1.tgz", - "integrity": "sha512-WypXB9Vz9fXx5cVUBtO9O702//FWFoDSvmDkw0VHh36PZ2X/XcKSYwT/uYMap2E4k242Zv8a63MUn6JkF36Rig==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/webflow-api/-/webflow-api-3.2.1.tgz", + "integrity": "sha512-NvBH15JPvVIdKHEDpzKzrJWnJytwqaBuUDVxlhW91fgK5hSr9kXrD3t67ZqIS1zzi3AnnGSVJl/sauJS0TqBDg==", "dependencies": { "crypto-browserify": "^3.12.1", "form-data": "^4.0.0", "formdata-node": "^6.0.3", - "js-base64": "3.7.2", - "node-fetch": "2.7.0", - "qs": "6.11.2", + "js-base64": "3.7.7", + "node-fetch": "^2.7.0", + "qs": "^6.13.1", "readable-stream": "^4.5.2", "url-join": "4.0.1" } }, - "node_modules/webflow-api/node_modules/qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index c371ddd..b963c25 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,11 @@ "build:watch": "tsup src/index.ts --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.24.0", + "@modelcontextprotocol/sdk": "1.25.2", "cors": "^2.8.5", "express": "^5.1.0", "socket.io": "^4.8.1", - "webflow-api": "3.1.1", + "webflow-api": "3.2.1", "zod": "^3.24.2" }, "devDependencies": { diff --git a/src/mcp.ts b/src/mcp.ts index ffb2161..a80e4ff 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -15,6 +15,8 @@ import { registerDEVariableTools, registerRulesTools, registerLocalDeMCPConnectionTools, + registerCommentsTools, + registerEnterpriseTools, } from "./tools"; import { RPCType } from "./types/RPCType"; @@ -51,12 +53,11 @@ export function registerTools( registerPagesTools(server, getClient); registerScriptsTools(server, getClient); registerSiteTools(server, getClient); + registerCommentsTools(server, getClient); + registerEnterpriseTools(server, getClient); } -export function registerDesignerTools( - server: McpServer, - rpc: RPCType -) { +export function registerDesignerTools(server: McpServer, rpc: RPCType) { registerDEAssetTools(server, rpc); registerDEComponentsTools(server, rpc); registerDEElementTools(server, rpc); @@ -72,9 +73,6 @@ export function registerMiscTools(server: McpServer) { /** * IMPORTANT: registerLocalTools is only valid for OSS MCP Version */ -export function registerLocalTools( - server: McpServer, - rpc: RPCType -) { +export function registerLocalTools(server: McpServer, rpc: RPCType) { registerLocalDeMCPConnectionTools(server, rpc); } diff --git a/src/schemas/StaticFieldSchema.ts b/src/schemas/StaticFieldSchema.ts index a36eb4a..0b256ad 100644 --- a/src/schemas/StaticFieldSchema.ts +++ b/src/schemas/StaticFieldSchema.ts @@ -24,7 +24,7 @@ export const StaticFieldSchema = z.object({ z.literal("PlainText"), z.literal("RichText"), z.literal("Switch"), - z.literal("Video"), + z.literal("VideoLink"), ]) .describe("Type of the field. Choose of these appropriate field types."), displayName: z.string().describe("Name of the field."), diff --git a/src/tools/aiChat.ts b/src/tools/aiChat.ts index dde7133..7817809 100644 --- a/src/tools/aiChat.ts +++ b/src/tools/aiChat.ts @@ -9,9 +9,15 @@ export function registerAiChatTools(server: McpServer) { server.registerTool( "ask_webflow_ai", { - title: "Ask Webflow AI", description: "Ask Webflow AI about anything related to Webflow API.", - inputSchema: z.object({ message: z.string() }), + title: "Ask Webflow AI", + annotations: { + openWorldHint: true, + readOnlyHint: true, + }, + inputSchema: { + message: z.string().describe("The message to ask Webflow AI about."), + }, }, async ({ message }) => { const result = await postChat(message); diff --git a/src/tools/cms.ts b/src/tools/cms.ts index e7231d7..701ef4d 100644 --- a/src/tools/cms.ts +++ b/src/tools/cms.ts @@ -5,448 +5,545 @@ import { requestOptions } from "../mcp"; import { OptionFieldSchema, ReferenceFieldSchema, + SiteIdSchema, StaticFieldSchema, WebflowCollectionsCreateRequestSchema, WebflowCollectionsFieldUpdateSchema, - WebflowCollectionsItemsCreateItemLiveRequestSchema, - WebflowCollectionsItemsCreateItemRequestSchema, + // WebflowCollectionsItemsCreateItemLiveRequestSchema, WebflowCollectionsItemsListItemsRequestSortBySchema, WebflowCollectionsItemsListItemsRequestSortOrderSchema, - WebflowCollectionsItemsUpdateItemsLiveRequestSchema, + // WebflowCollectionsItemsUpdateItemsLiveRequestSchema, WebflowCollectionsItemsUpdateItemsRequestSchema, } from "../schemas"; -import { formatErrorResponse, formatResponse } from "../utils"; +import { + Content, + formatErrorResponse, + formatResponse, + textContent, + toolResponse, +} from "../utils"; +import { CollectionsCreateRequest, FieldCreate } from "webflow-api/api"; +import { FieldUpdate } from "webflow-api/api/resources/collections/resources/fields"; +import { + //ItemsCreateItemLiveRequest, + //ItemsUpdateItemsLiveRequest, + ItemsDeleteItemsRequest, + ItemsListItemsRequest, + ItemsUpdateItemsRequest, +} from "webflow-api/api/resources/collections/resources/items"; export function registerCmsTools( server: McpServer, getClient: () => WebflowClient ) { - // GET https://api.webflow.com/v2/sites/:site_id/collections - server.registerTool( - "collections_list", - { - title: "List Collections", - description: - "List all CMS collections in a site. Returns collection metadata including IDs, names, and schemas.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - }), - }, - async ({ site_id }) => { - try { - const response = await getClient().collections.list( - site_id, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const getCollectionList = async (arg: { siteId: string }) => { + const response = await getClient().collections.list( + arg.siteId, + requestOptions + ); + return response; + }; + const getCollectionDetails = async (arg: { collection_id: string }) => { + const response = await getClient().collections.get( + arg.collection_id, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/collections/:collection_id - server.registerTool( - "collections_get", - { - title: "Get Collection", - description: - "Get detailed information about a specific CMS collection including its schema and field definitions.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - }), - }, - async ({ collection_id }) => { - try { - const response = await getClient().collections.get( - collection_id, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const createCollection = async (arg: { + siteId: string; + request: CollectionsCreateRequest; + }) => { + const response = await getClient().collections.create( + arg.siteId, + arg.request, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/sites/:site_id/collections - server.registerTool( - "collections_create", - { - title: "Create Collection", - description: - "Create a new CMS collection in a site with specified name and schema.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - request: WebflowCollectionsCreateRequestSchema, - }), - }, - async ({ site_id, request }) => { - try { - const response = await getClient().collections.create( - site_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const createCollectionStaticField = async (arg: { + collection_id: string; + request: FieldCreate; + }) => { + const response = await getClient().collections.fields.create( + arg.collection_id, + arg.request, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/collections/:collection_id/fields - server.registerTool( - "collection_fields_create_static", - { - title: "Create Static Field", - description: - "Create a new static field in a CMS collection (e.g., text, number, date, etc.).", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - request: StaticFieldSchema, - }), - }, - async ({ collection_id, request }) => { - try { - const response = await getClient().collections.fields.create( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const createCollectionOptionField = async (arg: { + collection_id: string; + request: FieldCreate; + }) => { + const response = await getClient().collections.fields.create( + arg.collection_id, + arg.request, + requestOptions + ); + return response; + }; + const createCollectionReferenceField = async (arg: { + collection_id: string; + request: FieldCreate; + }) => { + const response = await getClient().collections.fields.create( + arg.collection_id, + arg.request, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/collections/:collection_id/fields - server.registerTool( - "collection_fields_create_option", - { - title: "Create Option Field", - description: - "Create a new option field in a CMS collection with predefined choices.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - request: OptionFieldSchema, - }), - }, - async ({ collection_id, request }) => { - try { - const response = await getClient().collections.fields.create( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const updateCollectionField = async (arg: { + collection_id: string; + field_id: string; + request: FieldUpdate; + }) => { + const response = await getClient().collections.fields.update( + arg.collection_id, + arg.field_id, + arg.request, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/collections/:collection_id/fields - server.registerTool( - "collection_fields_create_reference", - { - title: "Create Reference Field", - description: - "Create a new reference field in a CMS collection that links to items in another collection.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - request: ReferenceFieldSchema, - }), - }, - async ({ collection_id, request }) => { - try { - const response = await getClient().collections.fields.create( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + // const createCollectionItemsLive = async (arg:{collection_id:string, request: ItemsCreateItemLiveRequest})=>{ + // const response = await getClient().collections.items.createItemLive( + // arg.collection_id, + // arg.request, + // requestOptions + // ); + // return response; + // } + // const updateCollectionItemsLive = async (arg:{collection_id:string, request: ItemsUpdateItemsLiveRequest})=>{ + // const response = await getClient().collections.items.updateItemsLive( + // arg.collection_id, + // arg.request, + // requestOptions + // ); + // return response; + // } - // PATCH https://api.webflow.com/v2/collections/:collection_id/fields/:field_id - server.registerTool( - "collection_fields_update", - { - title: "Update Collection Field", - description: - "Update properties of an existing field in a CMS collection.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - field_id: z.string().describe("Unique identifier for the Field."), - request: WebflowCollectionsFieldUpdateSchema, - }), - }, - async ({ collection_id, field_id, request }) => { - try { - const response = await getClient().collections.fields.update( - collection_id, - field_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const listCollectionItems = async (arg: { + collection_id: string; + request: ItemsListItemsRequest; + }) => { + const response = await getClient().collections.items.listItems( + arg.collection_id, + arg.request, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/collections/:collection_id/items/live - // NOTE: Cursor agent seems to struggle when provided with z.union(...), so we simplify the type here - server.registerTool( - "collections_items_create_item_live", - { - title: "Create Item Live", - description: - "Create and publish new items in a CMS collection directly to the live site.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - request: WebflowCollectionsItemsCreateItemLiveRequestSchema, - }), - }, - async ({ collection_id, request }) => { - try { - const response = await getClient().collections.items.createItemLive( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const createCollectionItems = async (arg: { + collection_id: string; + request: { + cmsLocaleIds?: string[]; + isArchived?: boolean; + isDraft?: boolean; + fieldData: { + name: string; + slug: string; + [key: string]: any; + }[]; + }; + }) => { + const response = await getClient().collections.items.createItems( + arg.collection_id, + { + cmsLocaleIds: arg.request.cmsLocaleIds, + isArchived: arg.request.isArchived, + isDraft: arg.request.isDraft, + fieldData: arg.request.fieldData, + }, + requestOptions + ); + return response; + }; - // PATCH https://api.webflow.com/v2/collections/:collection_id/items/live - server.registerTool( - "collections_items_update_items_live", - { - title: "Update Items Live", - description: - "Update and publish existing items in a CMS collection directly to the live site.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - request: WebflowCollectionsItemsUpdateItemsLiveRequestSchema, - }), - }, - async ({ collection_id, request }) => { - try { - const response = await getClient().collections.items.updateItemsLive( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const updateCollectionItems = async (arg: { + collection_id: string; + request: ItemsUpdateItemsRequest; + }) => { + const response = await getClient().collections.items.updateItems( + arg.collection_id, + arg.request, + requestOptions + ); + return response; + }; + const publishCollectionItems = async (arg: { + collection_id: string; + request: { + itemIds: string[]; + }; + }) => { + const response = await getClient().collections.items.publishItem( + arg.collection_id, + { + itemIds: arg.request.itemIds, + }, + requestOptions + ); + return response; + }; + const deleteCollectionItems = async (arg: { + collection_id: string; + request: ItemsDeleteItemsRequest; + }) => { + const response = await getClient().collections.items.deleteItems( + arg.collection_id, + arg.request, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/collections/:collection_id/items server.registerTool( - "collections_items_list_items", + "data_cms_tool", { - title: "List Collection Items", + title: "Data CMS Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: - "List items in a CMS collection with optional filtering and sorting.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - cmsLocaleId: z - .string() - .optional() - .describe("Unique identifier for the locale of the CMS Item."), - limit: z - .number() - .optional() - .describe( - "Maximum number of records to be returned (max limit: 100)" - ), - offset: z - .number() - .optional() - .describe( - "Offset used for pagination if the results have more than limit records." - ), - name: z.string().optional().describe("Name of the field."), - slug: z - .string() - .optional() - .describe( - "URL structure of the Item in your site. Note: Updates to an item slug will break all links referencing the old slug." - ), - sortBy: WebflowCollectionsItemsListItemsRequestSortBySchema, - sortOrder: WebflowCollectionsItemsListItemsRequestSortOrderSchema, - }), - }, - async ({ - collection_id, - cmsLocaleId, - offset, - limit, - name, - slug, - sortBy, - sortOrder, - }) => { - try { - const response = await getClient().collections.items.listItems( - collection_id, - { - cmsLocaleId, - offset, - limit, - name, - slug, - sortBy, - sortOrder, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); - - // POST https://api.webflow.com/v2/collections/:collection_id/items - server.registerTool( - "collections_items_create_item", - { - title: "Create Collection Item", - description: "Create new items in a CMS collection as drafts.", - inputSchema: z.object({ - collection_id: z.string(), - request: WebflowCollectionsItemsCreateItemRequestSchema, - }), + "Data tool - CMS tool to perform actions like get collection list, get collection details, create collection, create collection fields (static/option/reference), update collection field, list collection items, create collection items, update collection items, publish collection items, and delete collection items", + inputSchema: { + actions: z.array( + z.object({ + // GET https://api.webflow.com/v2/sites/:site_id/collections + get_collection_list: z + .object({ + ...SiteIdSchema, + }) + .optional() + .describe( + "List all CMS collections in a site. Returns collection metadata including IDs, names, and schemas." + ), + // GET https://api.webflow.com/v2/collections/:collection_id + get_collection_details: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + }) + .optional() + .describe( + "Get detailed information about a specific CMS collection including its schema and field definitions." + ), + // POST https://api.webflow.com/v2/sites/:site_id/collections + create_collection: z + .object({ + ...SiteIdSchema, + request: WebflowCollectionsCreateRequestSchema, + }) + .optional() + .describe( + "Create a new CMS collection in a site with specified name and schema." + ), + // POST https://api.webflow.com/v2/collections/:collection_id/fields + create_collection_static_field: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: StaticFieldSchema, + }) + .optional() + .describe( + "Create a new static field in a CMS collection (e.g., text, number, date, etc.)." + ), + // POST https://api.webflow.com/v2/collections/:collection_id/fields + create_collection_option_field: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: OptionFieldSchema, + }) + .optional() + .describe( + "Create a new option field in a CMS collection with predefined choices." + ), + // POST https://api.webflow.com/v2/collections/:collection_id/fields + create_collection_reference_field: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: ReferenceFieldSchema, + }) + .optional() + .describe( + "Create a new reference field in a CMS collection that links to items in another collection." + ), + // PATCH https://api.webflow.com/v2/collections/:collection_id/fields/:field_id + update_collection_field: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + field_id: z + .string() + .describe("Unique identifier for the Field."), + request: WebflowCollectionsFieldUpdateSchema, + }) + .optional() + .describe( + "Update properties of an existing field in a CMS collection." + ), + // // POST https://api.webflow.com/v2/collections/:collection_id/items/live + // //NOTE: Cursor agent seems to struggle when provided with z.union(...), so we simplify the type here + // create_collection_items_live:z.object({ + // collection_id: z.string().describe("Unique identifier for the Collection."), + // request: WebflowCollectionsItemsCreateItemLiveRequestSchema, + // }).optional().describe("Create and publish new items in a CMS collection directly to the live site."), + // // PATCH https://api.webflow.com/v2/collections/:collection_id/items/live + // update_collection_items_live:z.object({ + // collection_id: z.string().describe("Unique identifier for the Collection."), + // request: WebflowCollectionsItemsUpdateItemsLiveRequestSchema, + // }).optional().describe("Update and publish existing items in a CMS collection directly to the live site."), + // GET https://api.webflow.com/v2/collections/:collection_id/items + list_collection_items: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: z + .object({ + cmsLocaleId: z + .string() + .optional() + .describe( + "Unique identifier for the locale of the CMS Item." + ), + limit: z + .number() + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + name: z.string().optional().describe("Name of the field."), + slug: z + .string() + .optional() + .describe( + "URL structure of the Item in your site. Note: Updates to an item slug will break all links referencing the old slug." + ), + sortBy: WebflowCollectionsItemsListItemsRequestSortBySchema, + sortOrder: + WebflowCollectionsItemsListItemsRequestSortOrderSchema, + }) + .optional() + .describe("Filter and sort items in a CMS collection."), + }) + .optional() + .describe( + "List items in a CMS collection with optional filtering and sorting." + ), + // POST https://api.webflow.com/v2/collections/:collection_id/items/bulk + create_collection_items: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: z + .object({ + cmsLocaleIds: z + .array(z.string()) + .optional() + .describe( + "Unique identifier for the locale of the CMS Item." + ), + isArchived: z + .boolean() + .optional() + .describe("Indicates if the item is archived."), + isDraft: z + .boolean() + .optional() + .describe("Indicates if the item is a draft."), + fieldData: z + .array( + z.record(z.any()).and( + z.object({ + name: z.string().describe("Name of the field."), + slug: z + .string() + .describe( + "URL structure of the Item in your site. Note: Updates to an item slug will break all links referencing the old slug." + ), + }) + ) + ) + .describe("Data of the item."), + }) + .describe("Array of items to be created."), + }) + .optional() + .describe("Create new items in a CMS collection as drafts."), + //PATCH https://api.webflow.com/v2/collections/:collection_id/items + update_collection_items: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: + WebflowCollectionsItemsUpdateItemsRequestSchema.describe( + "Array of items to be updated." + ), + }) + .optional() + .describe("Update existing items in a CMS collection as drafts."), + // POST https://api.webflow.com/v2/collections/:collection_id/items/publish + publish_collection_items: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: z + .object({ + itemIds: z + .array(z.string()) + .describe("Array of item IDs to be published."), + }) + .describe("Array of items to be published."), + }) + .optional() + .describe( + "Publish existing items in a CMS collection as drafts." + ), + // DEL https://api.webflow.com/v2/collections/:collection_id/items + delete_collection_items: z + .object({ + collection_id: z + .string() + .describe("Unique identifier for the Collection."), + request: z + .object({ + items: z + .array( + z.object({ + id: z.string().describe("Item ID to be deleted."), + cmsLocaleIds: z + .array(z.string()) + .optional() + .describe( + "Unique identifier for the locale of the CMS Item." + ), + }) + ) + .describe("Array of items to be deleted."), + }) + .describe("Array of items to be deleted."), + }) + .optional() + .describe("Delete existing items in a CMS collection as drafts."), + }) + ), + }, }, - async ({ collection_id, request }) => { + async ({ actions }) => { + const result: Content[] = []; try { - const response = await getClient().collections.items.createItem( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + for (const action of actions) { + if (action.get_collection_list) { + const content = await getCollectionList(action.get_collection_list); + result.push(textContent(content)); + } + if (action.get_collection_details) { + const content = await getCollectionDetails( + action.get_collection_details + ); + result.push(textContent(content)); + } + if (action.create_collection) { + const content = await createCollection(action.create_collection); + result.push(textContent(content)); + } + if (action.create_collection_static_field) { + const content = await createCollectionStaticField( + action.create_collection_static_field + ); + result.push(textContent(content)); + } + if (action.create_collection_option_field) { + const content = await createCollectionOptionField( + action.create_collection_option_field + ); + result.push(textContent(content)); + } + if (action.create_collection_reference_field) { + const content = await createCollectionReferenceField( + action.create_collection_reference_field + ); + result.push(textContent(content)); + } + if (action.update_collection_field) { + const content = await updateCollectionField( + action.update_collection_field + ); + result.push(textContent(content)); + } + // else if(action.create_collection_items_live){ + // const content = await createCollectionItemsLive(action.create_collection_items_live); + // result.push(textContent(content)); + // } + // else if(action.update_collection_items_live){ + // const content = await updateCollectionItemsLive(action.update_collection_items_live); + // result.push(textContent(content)); + // } - // PATCH https://api.webflow.com/v2/collections/:collection_id/items - server.registerTool( - "collections_items_update_items", - { - title: "Update Collection Items", - description: "Update existing items in a CMS collection as drafts.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - request: WebflowCollectionsItemsUpdateItemsRequestSchema, - }), - }, - async ({ collection_id, request }) => { - try { - const response = await getClient().collections.items.updateItems( - collection_id, - request, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); - - // POST https://api.webflow.com/v2/collections/:collection_id/items/publish - server.registerTool( - "collections_items_publish_items", - { - title: "Publish Collection Items", - description: "Publish draft items in a CMS collection to make them live.", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - itemIds: z - .array(z.string()) - .describe("Array of item IDs to be published."), - }), - }, - async ({ collection_id, itemIds }) => { - try { - const response = await getClient().collections.items.publishItem( - collection_id, - { - itemIds: itemIds, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); - - // DEL https://api.webflow.com/v2/collections/:collection_id/items/ - server.registerTool( - "collections_items_delete_item", - { - title: "Delete Collection Item", - description: - "Delete an item in a CMS collection. Items will only be deleted in the primary locale unless a cmsLocaleId is included in the request. ", - inputSchema: z.object({ - collection_id: z - .string() - .describe("Unique identifier for the Collection."), - itemId: z.string().describe("Item ID to be deleted."), - cmsLocaleIds: z - .string() - .optional() - .describe("Unique identifier for the locale of the CMS Item."), - }), - }, - async ({ collection_id, itemId, cmsLocaleIds }) => { - try { - const response = await getClient().collections.items.deleteItem( - collection_id, - itemId, - { cmsLocaleId: cmsLocaleIds }, - requestOptions - ); - return formatResponse(JSON.stringify("Item deleted")); + if (action.list_collection_items) { + const content = await listCollectionItems({ + collection_id: action.list_collection_items.collection_id, + request: action.list_collection_items.request || {}, + }); + result.push(textContent(content)); + } + if (action.create_collection_items) { + const content = await createCollectionItems({ + collection_id: action.create_collection_items.collection_id, + request: action.create_collection_items.request, + }); + result.push(textContent(content)); + } + if (action.update_collection_items) { + const content = await updateCollectionItems({ + collection_id: action.update_collection_items.collection_id, + request: action.update_collection_items.request, + }); + result.push(textContent(content)); + } + if (action.publish_collection_items) { + const content = await publishCollectionItems({ + collection_id: action.publish_collection_items.collection_id, + request: action.publish_collection_items.request, + }); + result.push(textContent(content)); + } + if (action.delete_collection_items) { + const content = await deleteCollectionItems({ + collection_id: action.delete_collection_items.collection_id, + request: action.delete_collection_items.request, + }); + result.push(textContent(content)); + } + } + return toolResponse(result); } catch (error) { return formatErrorResponse(error); } diff --git a/src/tools/comments.ts b/src/tools/comments.ts new file mode 100644 index 0000000..162e102 --- /dev/null +++ b/src/tools/comments.ts @@ -0,0 +1,287 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebflowClient } from "webflow-api"; +import z from "zod"; +import { + Content, + formatErrorResponse, + textContent, + toolResponse, +} from "../utils/formatResponse"; +import { requestOptions } from "../mcp"; +import { + CommentsGetCommentThreadRequest, + CommentsListCommentRepliesRequest, + CommentsListCommentThreadsRequest, +} from "webflow-api/api/resources/sites"; + +export function registerCommentsTools( + server: McpServer, + getClient: () => WebflowClient +) { + const listCommentThreads = async (arg: { + site_id: string; + localeId?: string; + offset?: number; + limit?: number; + sortBy?: "createdOn" | "lastUpdated"; + sortOrder?: "asc" | "desc"; + }) => { + const data: CommentsListCommentThreadsRequest = {}; + if ("localeId" in arg) { + data.localeId = arg.localeId; + } + if ("offset" in arg) { + data.offset = arg.offset; + } + if ("limit" in arg) { + data.limit = arg.limit; + } + if ("sortBy" in arg) { + data.sortBy = arg.sortBy; + } + if ("sortOrder" in arg) { + data.sortOrder = arg.sortOrder; + } + const response = await getClient().sites.comments.listCommentThreads( + arg.site_id, + data, + requestOptions + ); + return response; + }; + + const getCommentThread = async (arg: { + site_id: string; + comment_thread_id: string; + localeId?: string; + offset?: number; + limit?: number; + sortBy?: "createdOn" | "lastUpdated"; + sortOrder?: "asc" | "desc"; + }) => { + const data: CommentsGetCommentThreadRequest = {}; + if ("localeId" in arg) { + data.localeId = arg.localeId; + } + if ("offset" in arg) { + data.offset = arg.offset; + } + if ("limit" in arg) { + data.limit = arg.limit; + } + if ("sortBy" in arg) { + data.sortBy = arg.sortBy; + } + if ("sortOrder" in arg) { + data.sortOrder = arg.sortOrder; + } + const response = await getClient().sites.comments.getCommentThread( + arg.site_id, + arg.comment_thread_id, + data, + requestOptions + ); + return response; + }; + + const listCommentReplies = async (arg: { + site_id: string; + comment_thread_id: string; + localeId?: string; + offset?: number; + limit?: number; + sortBy?: "createdOn" | "lastUpdated"; + sortOrder?: "asc" | "desc"; + }) => { + const data: CommentsListCommentRepliesRequest = {}; + if ("localeId" in arg) { + data.localeId = arg.localeId; + } + if ("offset" in arg) { + data.offset = arg.offset; + } + if ("limit" in arg) { + data.limit = arg.limit; + } + if ("sortBy" in arg) { + data.sortBy = arg.sortBy; + } + if ("sortOrder" in arg) { + data.sortOrder = arg.sortOrder; + } + const response = await getClient().sites.comments.listCommentReplies( + arg.site_id, + arg.comment_thread_id, + data, + requestOptions + ); + return response; + }; + + server.registerTool( + "data_comments_tool", + { + title: "Data Comments Tool", + description: `Data tool - A comment in Webflow is user feedback attached to a specific element or page inside the Designer, stored as a top-level thread with optional replies. Each comment includes author info, timestamps, content, resolved state, and design-context metadata like page location and breakpoint. Use this tool to inspect feedback discussions across the site and understand where and why they were left.`, + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + inputSchema: { + actions: z + .array( + z.object({ + list_comment_threads: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to list its comment threads." + ), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + limit: z + .number() + .max(100) + .min(1) + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + sortBy: z + .enum(["createdOn", "lastUpdated"]) + .optional() + .describe("Sort the results by the given field."), + sortOrder: z + .enum(["asc", "desc"]) + .optional() + .describe("Sort the results by the given order."), + }) + .optional() + .describe( + "List all comment threads for a specific element or page." + ), + get_comment_thread: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to get its comment thread." + ), + comment_thread_id: z + .string() + .describe( + "The comment thread's unique ID, used to get its details." + ), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + limit: z + .number() + .max(100) + .min(1) + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + sortBy: z + .enum(["createdOn", "lastUpdated"]) + .optional() + .describe("Sort the results by the given field."), + sortOrder: z + .enum(["asc", "desc"]) + .optional() + .describe("Sort the results by the given order."), + }) + .optional() + .describe("Get the details of a specific comment thread."), + list_comment_replies: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to list its comment replies." + ), + comment_thread_id: z + .string() + .describe( + "The comment thread's unique ID, used to list its replies." + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + limit: z + .number() + .max(100) + .min(1) + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + sortBy: z + .enum(["createdOn", "lastUpdated"]) + .optional() + .describe("Sort the results by the given field."), + sortOrder: z + .enum(["asc", "desc"]) + .optional() + .describe("Sort the results by the given order."), + }) + .optional() + .describe("List all replies for a specific comment thread."), + }) + ) + .min(1) + .describe("The actions to perform on the comments."), + }, + }, + async ({ actions }) => { + const result: Content[] = []; + try { + for (const action of actions) { + if (action.list_comment_threads) { + const content = await listCommentThreads( + action.list_comment_threads + ); + result.push(textContent(content)); + } + if (action.get_comment_thread) { + const content = await getCommentThread(action.get_comment_thread); + result.push(textContent(content)); + } + if (action.list_comment_replies) { + const content = await listCommentReplies( + action.list_comment_replies + ); + result.push(textContent(content)); + } + } + return toolResponse(result); + } catch (error) { + return formatErrorResponse(error); + } + } + ); +} diff --git a/src/tools/components.ts b/src/tools/components.ts index 6a584fa..5b8c851 100644 --- a/src/tools/components.ts +++ b/src/tools/components.ts @@ -6,224 +6,277 @@ import { ComponentDomWriteNodesItemSchema, ComponentPropertyUpdateSchema, } from "../schemas"; -import { formatErrorResponse, formatResponse } from "../utils"; +import { + type Content, + formatErrorResponse, + textContent, + toolResponse, +} from "../utils"; export function registerComponentsTools( server: McpServer, getClient: () => WebflowClient ) { - // GET https://api.webflow.com/v2/sites/:site_id/components - server.registerTool( - "components_list", - { - title: "List Components", - description: - "List all components in a site. Returns component metadata including IDs, names, and versions.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - limit: z - .number() - .optional() - .describe( - "Maximum number of records to be returned (max limit: 100)" - ), - offset: z - .number() - .optional() - .describe( - "Offset used for pagination if the results have more than limit records." - ), - }), - }, - async ({ site_id, limit, offset }) => { - try { - const response = await getClient().components.list( - site_id, - { - limit, - offset, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const listComponents = async (arg: { + site_id: string; + limit?: number; + offset?: number; + }) => { + const response = await getClient().components.list( + arg.site_id, + { + limit: arg.limit, + offset: arg.offset, + }, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/sites/:site_id/components/:component_id/dom - server.registerTool( - "components_get_content", - { - title: "Get Component Content", - description: - "Get the content structure and data for a specific component including text, images, and nested components.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - component_id: z - .string() - .describe("Unique identifier for the Component."), - localeId: z - .string() - .optional() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - limit: z - .number() - .optional() - .describe( - "Maximum number of records to be returned (max limit: 100)" - ), - offset: z - .number() - .optional() - .describe( - "Offset used for pagination if the results have more than limit records." - ), - }), - }, - async ({ site_id, component_id, localeId, limit, offset }) => { - try { - const response = await getClient().components.getContent( - site_id, - component_id, - { - localeId, - limit, - offset, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const getComponentContent = async (arg: { + site_id: string; + component_id: string; + localeId?: string; + limit?: number; + offset?: number; + }) => { + const response = await getClient().components.getContent( + arg.site_id, + arg.component_id, + { + localeId: arg.localeId, + limit: arg.limit, + offset: arg.offset, + }, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/sites/:site_id/components/:component_id/dom - server.registerTool( - "components_update_content", - { - title: "Update Component Content", - description: - "Update content on a component in secondary locales by modifying text nodes and property overrides.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - component_id: z - .string() - .describe("Unique identifier for the Component."), - localeId: z - .string() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - nodes: ComponentDomWriteNodesItemSchema, - }), - }, - async ({ site_id, component_id, localeId, nodes }) => { - try { - const response = await getClient().components.updateContent( - site_id, - component_id, - { - localeId, - nodes, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const updateComponentContent = async (arg: { + site_id: string; + component_id: string; + localeId: string; + nodes: any; + }) => { + const response = await getClient().components.updateContent( + arg.site_id, + arg.component_id, + { + localeId: arg.localeId, + nodes: arg.nodes, + }, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/sites/:site_id/components/:component_id/properties - server.registerTool( - "components_get_properties", - { - title: "Get Component Properties", - description: - "Get component properties including default values and configuration for a specific component.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - component_id: z - .string() - .describe("Unique identifier for the Component."), - localeId: z - .string() - .optional() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - limit: z - .number() - .optional() - .describe( - "Maximum number of records to be returned (max limit: 100)" - ), - offset: z - .number() - .optional() - .describe( - "Offset used for pagination if the results have more than limit records." - ), - }), - }, - async ({ site_id, component_id, localeId, limit, offset }) => { - try { - const response = await getClient().components.getProperties( - site_id, - component_id, - { - localeId, - limit, - offset, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const getComponentProperties = async (arg: { + site_id: string; + component_id: string; + localeId?: string; + limit?: number; + offset?: number; + }) => { + const response = await getClient().components.getProperties( + arg.site_id, + arg.component_id, + { + localeId: arg.localeId, + limit: arg.limit, + offset: arg.offset, + }, + requestOptions + ); + return response; + }; + + const updateComponentProperties = async (arg: { + site_id: string; + component_id: string; + localeId: string; + properties: any; + }) => { + const response = await getClient().components.updateProperties( + arg.site_id, + arg.component_id, + { + localeId: arg.localeId, + properties: arg.properties, + }, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/sites/:site_id/components/:component_id/properties server.registerTool( - "components_update_properties", + "data_components_tool", { - title: "Update Component Properties", + title: "Data Components Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: - "Update component properties for localization to customize behavior in different languages.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the Site."), - component_id: z - .string() - .describe("Unique identifier for the Component."), - localeId: z - .string() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - properties: ComponentPropertyUpdateSchema, - }), + "Data tool - Components tool to perform actions like list components, get component content, update component content, get component properties, and update component properties", + inputSchema: { + actions: z.array( + z.object({ + // GET https://api.webflow.com/v2/sites/:site_id/components + list_components: z + .object({ + site_id: z.string().describe("Unique identifier for the Site."), + limit: z + .number() + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + }) + .optional() + .describe( + "List all components in a site. Returns component metadata including IDs, names, and versions." + ), + // GET https://api.webflow.com/v2/sites/:site_id/components/:component_id/dom + get_component_content: z + .object({ + site_id: z.string().describe("Unique identifier for the Site."), + component_id: z + .string() + .describe("Unique identifier for the Component."), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + limit: z + .number() + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + }) + .optional() + .describe( + "Get the content structure and data for a specific component including text, images, and nested components." + ), + // POST https://api.webflow.com/v2/sites/:site_id/components/:component_id/dom + update_component_content: z + .object({ + site_id: z.string().describe("Unique identifier for the Site."), + component_id: z + .string() + .describe("Unique identifier for the Component."), + localeId: z + .string() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + nodes: ComponentDomWriteNodesItemSchema, + }) + .optional() + .describe( + "Update content on a component in secondary locales by modifying text nodes and property overrides." + ), + // GET https://api.webflow.com/v2/sites/:site_id/components/:component_id/properties + get_component_properties: z + .object({ + site_id: z.string().describe("Unique identifier for the Site."), + component_id: z + .string() + .describe("Unique identifier for the Component."), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + limit: z + .number() + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + }) + .optional() + .describe( + "Get component properties including default values and configuration for a specific component." + ), + // POST https://api.webflow.com/v2/sites/:site_id/components/:component_id/properties + update_component_properties: z + .object({ + site_id: z.string().describe("Unique identifier for the Site."), + component_id: z + .string() + .describe("Unique identifier for the Component."), + localeId: z + .string() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + properties: ComponentPropertyUpdateSchema, + }) + .optional() + .describe( + "Update component properties for localization to customize behavior in different languages." + ), + }) + ), + }, }, - async ({ site_id, component_id, localeId, properties }) => { + async ({ actions }) => { + const result: Content[] = []; try { - const response = await getClient().components.updateProperties( - site_id, - component_id, - { - localeId, - properties, - }, - requestOptions - ); - return formatResponse(response); + for (const action of actions) { + if (action.list_components) { + const content = await listComponents(action.list_components); + result.push(textContent(content)); + } + if (action.get_component_content) { + const content = await getComponentContent( + action.get_component_content + ); + result.push(textContent(content)); + } + if (action.update_component_content) { + const content = await updateComponentContent( + action.update_component_content + ); + result.push(textContent(content)); + } + if (action.get_component_properties) { + const content = await getComponentProperties( + action.get_component_properties + ); + result.push(textContent(content)); + } + if (action.update_component_properties) { + const content = await updateComponentProperties( + action.update_component_properties + ); + result.push(textContent(content)); + } + } + return toolResponse(result); } catch (error) { return formatErrorResponse(error); } diff --git a/src/tools/deAsset.ts b/src/tools/deAsset.ts index 6d40cd2..a5d7e78 100644 --- a/src/tools/deAsset.ts +++ b/src/tools/deAsset.ts @@ -30,9 +30,13 @@ export function registerDEAssetTools(server: McpServer, rpc: RPCType) { "asset_tool", { title: "Designer Asset Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: "Designer Tool - Asset tool to perform actions like create folder, get all assets and folders, update assets and folders", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -82,7 +86,7 @@ export function registerDEAssetTools(server: McpServer, rpc: RPCType) { .describe("Update an asset on the site"), }) ), - }), + }, }, async ({ siteId, actions }) => { try { @@ -95,16 +99,21 @@ export function registerDEAssetTools(server: McpServer, rpc: RPCType) { server.registerTool( "get_image_preview", + { - title: "Get Image Preview", + title: "Get Webflow Image Preview", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: - "Designer Tool - Get image preview from url. this is helpful to get image preview from url.", - inputSchema: z.object({ + "Designer Tool - Get image preview from url. this is helpful to get image preview from url. Only supports JPG, PNG, GIF, WEBP, WEBP and AVIF formats.", + inputSchema: { url: z .string() .describe("The URL of the image to get the preview from"), ...SiteIdSchema, - }), + }, }, async ({ url, siteId }) => { try { diff --git a/src/tools/deComponents.ts b/src/tools/deComponents.ts index 61ac64b..8f99acc 100644 --- a/src/tools/deComponents.ts +++ b/src/tools/deComponents.ts @@ -16,9 +16,13 @@ export function registerDEComponentsTools(server: McpServer, rpc: RPCType) { "de_component_tool", { title: "Designer Component Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: "Designer tool - Component tool to perform actions like create component instances, get all components and more.", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -80,7 +84,7 @@ export function registerDEComponentsTools(server: McpServer, rpc: RPCType) { .describe("Rename a component."), }) ), - }), + }, }, async ({ siteId, actions }) => { try { diff --git a/src/tools/deElement.ts b/src/tools/deElement.ts index 6a0fe78..2d253cd 100644 --- a/src/tools/deElement.ts +++ b/src/tools/deElement.ts @@ -19,13 +19,37 @@ export const registerDEElementTools = (server: McpServer, rpc: RPCType) => { }); }; + const elementSnapshotToolRPCCall = async ( + siteId: string, + action: any + ): Promise< + | { + status: string; + message: string; + data: null; + } + | { + status: string; + message: string; + data: string; + } + > => { + return rpc.callTool("element_snapshot_tool", { + siteId, + action: action || {}, + }); + }; + server.registerTool( "element_builder", { - title: "Designer Element Builder", + annotations: { + openWorldHint: true, + readOnlyHint: false, + }, description: "Designer Tool - Element builder to create element on current active page. only create elements upto max 3 levels deep. divide your elements into smaller elements to create complex structures. recall this tool to create more elements. but max level is upto 3 levels. you can have as many children as you want. but max level is 3 levels.", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -76,7 +100,7 @@ export const registerDEElementTools = (server: McpServer, rpc: RPCType) => { }).describe("element schema of element to create."), }) ), - }), + }, }, async ({ actions, siteId }) => { try { @@ -91,9 +115,13 @@ export const registerDEElementTools = (server: McpServer, rpc: RPCType) => { "element_tool", { title: "Designer Element Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: "Designer Tool - Element tool to perform actions like get all elements, get selected element, select element on current active page. and more", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -217,7 +245,7 @@ export const registerDEElementTools = (server: McpServer, rpc: RPCType) => { .describe("Set image asset on the image element"), }) ), - }), + }, }, async ({ actions, siteId }) => { try { @@ -227,4 +255,44 @@ export const registerDEElementTools = (server: McpServer, rpc: RPCType) => { } } ); + + server.registerTool( + "element_snapshot_tool", + { + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + description: + "Designer Tool - Element snapshot tool to perform actions like get element snapshot. helpful to get element snapshot for debugging and more and visual feedback.", + inputSchema: { + ...SiteIdSchema, + action: z.object({ + id: DEElementIDSchema.id, + }), + }, + }, + async ({ action, siteId }) => { + try { + const { status, message, data } = await elementSnapshotToolRPCCall( + siteId, + action + ); + if (status === "success" && data) { + return { + content: [ + { + type: "image", + data: data.replace("data:image/png;base64,", ""), + mimeType: "image/png", + }, + ], + }; + } + return formatErrorResponse(new Error(message)); + } catch (error) { + return formatErrorResponse(error); + } + } + ); }; diff --git a/src/tools/dePages.ts b/src/tools/dePages.ts index a79479d..7e97607 100644 --- a/src/tools/dePages.ts +++ b/src/tools/dePages.ts @@ -16,9 +16,13 @@ export function registerDEPagesTools(server: McpServer, rpc: RPCType) { "de_page_tool", { title: "Designer Page Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: "Designer Tool - Page tool to perform actions like create page, create page folder, get current page, switch page", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -70,7 +74,7 @@ export function registerDEPagesTools(server: McpServer, rpc: RPCType) { .describe("Switch to a page on webflow designer"), }) ), - }), + }, }, async ({ siteId, actions }) => { try { diff --git a/src/tools/deStyle.ts b/src/tools/deStyle.ts index 4f60813..cfebb13 100644 --- a/src/tools/deStyle.ts +++ b/src/tools/deStyle.ts @@ -16,9 +16,13 @@ export function registerDEStyleTools(server: McpServer, rpc: RPCType) { "style_tool", { title: "Designer Style Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: "Designer Tool - Style tool to perform actions like create style, get all styles, update styles", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -144,7 +148,7 @@ export function registerDEStyleTools(server: McpServer, rpc: RPCType) { .describe("Update a style"), }) ), - }), + }, }, async ({ siteId, actions }) => { try { @@ -158,12 +162,16 @@ export function registerDEStyleTools(server: McpServer, rpc: RPCType) { server.registerTool( "de_learn_more_about_styles", { - title: "Learn More About Styles", + title: "Designer Learn More About Webflow Styles", + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, description: "Designer tool - Learn more about styles supported by Webflow Designer." + "Please do not use any other styles which is not supported by Webflow Designer." + "Please use the long-form alias of a CSS property when managing styles. For example, the property row-gap has a long-form alias of grid-row-gap, margin has long-form alias of margin-top, margin-right, margin-bottom, margin-left, etc.", - inputSchema: z.object({}), + inputSchema: {}, }, async ({}) => formatResponse(supportDEStyles) ); diff --git a/src/tools/deVariable.ts b/src/tools/deVariable.ts index 14f117e..b4c9b9d 100644 --- a/src/tools/deVariable.ts +++ b/src/tools/deVariable.ts @@ -16,9 +16,13 @@ export function registerDEVariableTools(server: McpServer, rpc: RPCType) { "variable_tool", { title: "Designer Variable Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: "Designer Tool - Variable tool to perform actions like create variable, get all variables, update variable", - inputSchema: z.object({ + inputSchema: { ...SiteIdSchema, actions: z.array( z.object({ @@ -202,7 +206,7 @@ export function registerDEVariableTools(server: McpServer, rpc: RPCType) { .describe("Update a font family variable"), }) ), - }), + }, }, async ({ siteId, actions }) => { try { diff --git a/src/tools/enterprise.ts b/src/tools/enterprise.ts new file mode 100644 index 0000000..b0c0c13 --- /dev/null +++ b/src/tools/enterprise.ts @@ -0,0 +1,482 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebflowClient } from "webflow-api"; +import z from "zod/v3"; +import { requestOptions } from "../mcp"; +import { Robots } from "webflow-api/api"; +import { + Content, + formatErrorResponse, + textContent, + toolResponse, +} from "../utils/formatResponse"; + +export function registerEnterpriseTools( + server: McpServer, + getClient: () => WebflowClient +) { + const list301Redirects = async (arg: { site_id: string }) => { + const response = await getClient().sites.redirects.list( + arg.site_id, + requestOptions + ); + return response; + }; + const create301Redirect = async (arg: { + site_id: string; + fromUrl: string; + toUrl: string; + }) => { + const response = await getClient().sites.redirects.create( + arg.site_id, + { + fromUrl: arg.fromUrl, + toUrl: arg.toUrl, + }, + requestOptions + ); + return response; + }; + const update301Redirect = async (arg: { + site_id: string; + redirect_id: string; + fromUrl: string; + toUrl: string; + }) => { + const response = await getClient().sites.redirects.update( + arg.site_id, + arg.redirect_id, + { + fromUrl: arg.fromUrl, + toUrl: arg.toUrl, + }, + requestOptions + ); + return response; + }; + const delete301Redirect = async (arg: { + site_id: string; + redirect_id: string; + }) => { + const response = await getClient().sites.redirects.delete( + arg.site_id, + arg.redirect_id, + requestOptions + ); + return response; + }; + const getRobotsDotTxt = async (arg: { site_id: string }) => { + const response = await getClient().sites.robotsTxt.get( + arg.site_id, + requestOptions + ); + return response; + }; + const updateRobotsDotTxt = async (arg: { + site_id: string; + rules?: { + userAgent: string; + allow: string[]; + disallow: string[]; + }[]; + sitemap?: string; + }) => { + const data: Robots = {}; + if (arg.rules) { + data.rules = arg.rules; + } + if (arg.sitemap) { + data.sitemap = arg.sitemap; + } + const response = await getClient().sites.robotsTxt.patch( + arg.site_id, + data, + requestOptions + ); + return response; + }; + const replaceRobotsDotTxt = async (arg: { + site_id: string; + rules?: { + userAgent: string; + allow: string[]; + disallow: string[]; + }[]; + sitemap?: string; + }) => { + const data: Robots = {}; + if (arg.rules) { + data.rules = arg.rules; + } + if (arg.sitemap) { + data.sitemap = arg.sitemap; + } + const response = await getClient().sites.robotsTxt.put( + arg.site_id, + data, + requestOptions + ); + return response; + }; + const deleteRobotsDotTxt = async (arg: { + site_id: string; + rules?: { + userAgent: string; + allow: string[]; + disallow: string[]; + }[]; + sitemap?: string; + }) => { + const data: Robots = {}; + if (arg.rules) { + data.rules = arg.rules; + } + if (arg.sitemap) { + data.sitemap = arg.sitemap; + } + const response = await getClient().sites.robotsTxt.patch( + arg.site_id, + data, + requestOptions + ); + return response; + }; + + const addWellKnownFile = async (arg: { + site_id: string; + fileName: string; + fileData: string; + contentType: "application/json" | "text/plain"; + }) => { + const response = await getClient().sites.wellKnown.put( + arg.site_id, + { + fileData: arg.fileData, + fileName: arg.fileName, + contentType: arg.contentType, + }, + requestOptions + ); + return response; + }; + + const removeWellKnownFiles = async (arg: { + site_id: string; + fileNames: string[]; + }) => { + const response = await getClient().sites.wellKnown.delete( + arg.site_id, + { + fileNames: arg.fileNames, + }, + requestOptions + ); + return response; + }; + + server.registerTool( + "data_enterprise_tool", + { + title: "Data Enterprise Tool", + description: + "Data tool - Enterprise tool to perform actions like manage 301 redirects, manage robots.txt and more. This tool only works if User's workspace plan is Enterprise or higher, else tool will return an error.", + annotations: { + readOnlyHint: false, + }, + inputSchema: { + actions: z + .array( + z.object({ + list_301_redirects: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to list its 301 redirects." + ), + }) + .optional() + .describe("List all 301 redirects for a site."), + create_301_redirect: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to create a 301 redirect." + ), + fromUrl: z + .string() + .describe( + "The source URL path that will be redirected (e.g., '/old-page')." + ), + toUrl: z + .string() + .describe( + "The destination URL path where requests will be redirected to (e.g., '/new-page')." + ), + }) + .optional() + .describe("Create a new 301 redirect for a site."), + update_301_redirect: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to update a 301 redirect." + ), + redirect_id: z + .string() + .describe( + "The redirect's unique ID, used to identify which redirect to update." + ), + fromUrl: z + .string() + .describe( + "The source URL path that will be redirected (e.g., '/old-page')." + ), + toUrl: z + .string() + .describe( + "The destination URL path where requests will be redirected to (e.g., '/new-page')." + ), + }) + .optional() + .describe("Update an existing 301 redirect."), + delete_301_redirect: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to delete a 301 redirect." + ), + redirect_id: z + .string() + .describe( + "The redirect's unique ID, used to identify which redirect to delete." + ), + }) + .optional() + .describe("Delete a 301 redirect from a site."), + get_robots_txt: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to get its robots.txt configuration." + ), + }) + .optional() + .describe("Get the robots.txt configuration for a site."), + update_robots_txt: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to update its robots.txt." + ), + rules: z + .array( + z.object({ + userAgent: z + .string() + .describe( + "The user agent to apply rules to (e.g., '*', 'Googlebot')." + ), + allow: z + .array(z.string()) + .describe("Array of URL paths to allow."), + disallow: z + .array(z.string()) + .describe("Array of URL paths to disallow."), + }) + ) + .optional() + .describe( + "Array of rules to apply to the robots.txt file." + ), + sitemap: z + .string() + .optional() + .describe( + "URL to the sitemap (e.g., 'https://example.com/sitemap.xml')." + ), + }) + .optional() + .describe( + "Partially update the robots.txt file (PATCH operation)." + ), + replace_robots_txt: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to replace its robots.txt." + ), + rules: z + .array( + z.object({ + userAgent: z + .string() + .describe( + "The user agent to apply rules to (e.g., '*', 'Googlebot')." + ), + allow: z + .array(z.string()) + .describe("Array of URL paths to allow."), + disallow: z + .array(z.string()) + .describe("Array of URL paths to disallow."), + }) + ) + .optional() + .describe( + "Array of rules to apply to the robots.txt file." + ), + sitemap: z + .string() + .optional() + .describe( + "URL to the sitemap (e.g., 'https://example.com/sitemap.xml')." + ), + }) + .optional() + .describe( + "Completely replace the robots.txt file (PUT operation)." + ), + delete_robots_txt: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to delete rules from its robots.txt." + ), + rules: z + .array( + z.object({ + userAgent: z + .string() + .describe( + "The user agent to apply rules to (e.g., '*', 'Googlebot')." + ), + allow: z + .array(z.string()) + .describe("Array of URL paths to allow."), + disallow: z + .array(z.string()) + .describe("Array of URL paths to disallow."), + }) + ) + .optional() + .describe( + "Array of rules to remove from the robots.txt file." + ), + sitemap: z + .string() + .optional() + .describe("Sitemap URL to remove."), + }) + .optional() + .describe("Delete specific rules from the robots.txt file."), + add_well_known_file: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to add a well-known file." + ), + fileName: z + .string() + .describe( + `The name of the well-known file (e.g., 'apple-app-site-association', 'assetlinks.json'). ".noext" is a special file extension that removes other extensions. For example, apple-app-site-association.noext.txt will be uploaded as apple-app-site-association. Use this extension for tools that have trouble uploading extensionless files.` + ), + fileData: z + .string() + .describe( + "The content/data of the well-known file as a string." + ), + contentType: z + .enum(["application/json", "text/plain"]) + .describe( + "The MIME type of the file content (application/json or text/plain)." + ), + }) + .optional() + .describe( + "Add or update a well-known file to the site's /.well-known/ directory." + ), + remove_well_known_files: z + .object({ + site_id: z + .string() + .describe( + "The site's unique ID, used to remove well-known files." + ), + fileNames: z + .array(z.string()) + .describe( + "Array of file names to remove from the /.well-known/ directory." + ), + }) + .optional() + .describe("Remove one or more well-known files from the site."), + }) + ) + .min(1) + .describe("The actions to perform on the enterprise tool."), + }, + }, + async ({ actions }) => { + const result: Content[] = []; + try { + for (const action of actions) { + if (action.list_301_redirects) { + const content = await list301Redirects(action.list_301_redirects); + result.push(textContent(content)); + } + if (action.create_301_redirect) { + const content = await create301Redirect(action.create_301_redirect); + result.push(textContent(content)); + } + if (action.update_301_redirect) { + const content = await update301Redirect(action.update_301_redirect); + result.push(textContent(content)); + } + if (action.delete_301_redirect) { + const content = await delete301Redirect(action.delete_301_redirect); + result.push(textContent(content)); + } + if (action.get_robots_txt) { + const content = await getRobotsDotTxt(action.get_robots_txt); + result.push(textContent(content)); + } + if (action.update_robots_txt) { + const content = await updateRobotsDotTxt(action.update_robots_txt); + result.push(textContent(content)); + } + if (action.replace_robots_txt) { + const content = await replaceRobotsDotTxt( + action.replace_robots_txt + ); + result.push(textContent(content)); + } + if (action.delete_robots_txt) { + const content = await deleteRobotsDotTxt(action.delete_robots_txt); + result.push(textContent(content)); + } + if (action.add_well_known_file) { + const content = await addWellKnownFile(action.add_well_known_file); + result.push(textContent(content)); + } + if (action.remove_well_known_files) { + const content = await removeWellKnownFiles( + action.remove_well_known_files + ); + result.push(textContent(content)); + } + } + return toolResponse(result); + } catch (error) { + return formatErrorResponse(error); + } + } + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 0b39fdb..1c57d6b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,6 +5,8 @@ export { registerComponentsTools } from "./components"; export { registerPagesTools } from "./pages"; export { registerScriptsTools } from "./scripts"; export { registerSiteTools } from "./sites"; +export { registerCommentsTools } from "./comments"; +export { registerEnterpriseTools } from "./enterprise"; // Designer API Tools export { registerDEAssetTools } from "./deAsset"; export { registerDEComponentsTools } from "./deComponents"; diff --git a/src/tools/localDeMCPConnection.ts b/src/tools/localDeMCPConnection.ts index e687ff1..7a6dda1 100644 --- a/src/tools/localDeMCPConnection.ts +++ b/src/tools/localDeMCPConnection.ts @@ -1,6 +1,5 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { RPCType } from "../types/RPCType"; -import { z } from "zod/v3"; import { formatErrorResponse, formatResponse } from "../utils/formatResponse"; export function registerLocalDeMCPConnectionTools( @@ -14,10 +13,14 @@ export function registerLocalDeMCPConnectionTools( server.registerTool( "get_designer_app_connection_info", { - title: "Get Designer App Connection Info", + title: "Get Webflow MCP App Connection Info", + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, description: "Get Webflow MCP App Connection Info. if user ask to get Webflow MCP app connection info, use this tool", - inputSchema: z.object({}), + inputSchema: {}, }, async () => { try { diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 6fcdcdd..e3b0da1 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -6,204 +6,247 @@ import { WebflowPageDomWriteNodesItemSchema, WebflowPageSchema, } from "../schemas"; -import { formatErrorResponse, formatResponse } from "../utils"; +import { + type Content, + formatErrorResponse, + textContent, + toolResponse, +} from "../utils"; export function registerPagesTools( server: McpServer, getClient: () => WebflowClient ) { - // GET https://api.webflow.com/v2/sites/:site_id/pages - server.registerTool( - "pages_list", - { - title: "List Pages", - description: - "List all pages within a site. Returns page metadata including IDs, titles, and slugs.", - inputSchema: z.object({ - site_id: z - .string() - .describe("The site's unique ID, used to list its pages."), - localeId: z - .string() - .optional() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - limit: z - .number() - .optional() - .describe( - "Maximum number of records to be returned (max limit: 100)" - ), - offset: z - .number() - .optional() - .describe( - "Offset used for pagination if the results have more than limit records." - ), - }), - }, - async ({ site_id, localeId, limit, offset }) => { - try { - const response = await getClient().pages.list( - site_id, - { - localeId, - limit, - offset, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const listPages = async (arg: { + site_id: string; + localeId?: string; + limit?: number; + offset?: number; + }) => { + const response = await getClient().pages.list( + arg.site_id, + { + localeId: arg.localeId, + limit: arg.limit, + offset: arg.offset, + }, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/pages/:page_id - server.registerTool( - "pages_get_metadata", - { - title: "Get Page Metadata", - description: - "Get metadata for a specific page including SEO settings, Open Graph data, and page status (draft/published).", - inputSchema: z.object({ - page_id: z.string().describe("Unique identifier for the page."), - localeId: z - .string() - .optional() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - }), - }, - async ({ page_id, localeId }) => { - try { - const response = await getClient().pages.getMetadata( - page_id, - { - localeId, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const getPageMetadata = async (arg: { + page_id: string; + localeId?: string; + }) => { + const response = await getClient().pages.getMetadata( + arg.page_id, + { + localeId: arg.localeId, + }, + requestOptions + ); + return response; + }; - // PUT https://api.webflow.com/v2/pages/:page_id - server.registerTool( - "pages_update_page_settings", - { - title: "Update Page Settings", - description: - "Update page settings including SEO metadata, Open Graph data, slug, and publishing status.", - inputSchema: z.object({ - page_id: z.string().describe("Unique identifier for the page."), - localeId: z - .string() - .optional() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - body: WebflowPageSchema, - }), - }, - async ({ page_id, localeId, body }) => { - try { - const response = await getClient().pages.updatePageSettings( - page_id, - { - localeId, - body, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const updatePageSettings = async (arg: { + page_id: string; + localeId?: string; + body: any; + }) => { + const response = await getClient().pages.updatePageSettings( + arg.page_id, + { + localeId: arg.localeId, + body: arg.body, + }, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/pages/:page_id/dom - server.registerTool( - "pages_get_content", - { - title: "Get Page Content", - description: - "Get the content structure and data for a specific page including all elements and their properties.", - inputSchema: z.object({ - page_id: z.string().describe("Unique identifier for the page."), - localeId: z - .string() - .optional() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - limit: z - .number() - .optional() - .describe( - "Maximum number of records to be returned (max limit: 100)" - ), - offset: z - .number() - .optional() - .describe( - "Offset used for pagination if the results have more than limit records." - ), - }), - }, - async ({ page_id, localeId, limit, offset }) => { - try { - const response = await getClient().pages.getContent( - page_id, - { - localeId, - limit, - offset, - }, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const getPageContent = async (arg: { + page_id: string; + localeId?: string; + limit?: number; + offset?: number; + }) => { + const response = await getClient().pages.getContent( + arg.page_id, + { + localeId: arg.localeId, + limit: arg.limit, + offset: arg.offset, + }, + requestOptions + ); + return response; + }; + + const updateStaticContent = async (arg: { + page_id: string; + localeId: string; + nodes: any; + }) => { + const response = await getClient().pages.updateStaticContent( + arg.page_id, + { + localeId: arg.localeId, + nodes: arg.nodes, + }, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/pages/:page_id/dom server.registerTool( - "pages_update_static_content", + "data_pages_tool", { - title: "Update Page Static Content", + title: "Data Pages Tool", + annotations: { + readOnlyHint: false, + }, description: - "Update content on a static page in secondary locales by modifying text nodes and property overrides.", - inputSchema: z.object({ - page_id: z.string().describe("Unique identifier for the page."), - localeId: z - .string() - .describe( - "Unique identifier for a specific locale. Applicable when using localization." - ), - nodes: WebflowPageDomWriteNodesItemSchema, - }), + "Data tool - Pages tool to perform actions like list pages, get page metadata, update page settings, get page content, and update static content", + inputSchema: { + actions: z.array( + z.object({ + // GET https://api.webflow.com/v2/sites/:site_id/pages + list_pages: z + .object({ + site_id: z + .string() + .describe("The site's unique ID, used to list its pages."), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + limit: z + .number() + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + }) + .optional() + .describe( + "List all pages within a site. Returns page metadata including IDs, titles, and slugs." + ), + // GET https://api.webflow.com/v2/pages/:page_id + get_page_metadata: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + }) + .optional() + .describe( + "Get metadata for a specific page including SEO settings, Open Graph data, and page status (draft/published)." + ), + // PUT https://api.webflow.com/v2/pages/:page_id + update_page_settings: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + body: WebflowPageSchema, + }) + .optional() + .describe( + "Update page settings including SEO metadata, Open Graph data, slug, and publishing status." + ), + // GET https://api.webflow.com/v2/pages/:page_id/dom + get_page_content: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + localeId: z + .string() + .optional() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + limit: z + .number() + .optional() + .describe( + "Maximum number of records to be returned (max limit: 100)" + ), + offset: z + .number() + .optional() + .describe( + "Offset used for pagination if the results have more than limit records." + ), + }) + .optional() + .describe( + "Get the content structure and data for a specific page including all elements and their properties for localization." + ), + // POST https://api.webflow.com/v2/pages/:page_id/dom + update_static_content: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + localeId: z + .string() + .describe( + "Unique identifier for a specific locale. Applicable when using localization." + ), + nodes: WebflowPageDomWriteNodesItemSchema, + }) + .optional() + .describe( + "Update content on a static page in secondary locales by modifying text nodes and property overrides." + ), + }) + ), + }, }, - async ({ page_id, localeId, nodes }) => { + async ({ actions }) => { + const result: Content[] = []; try { - const response = await getClient().pages.updateStaticContent( - page_id, - { - localeId, - nodes, - }, - requestOptions - ); - return formatResponse(response); + for (const action of actions) { + if (action.list_pages) { + const content = await listPages(action.list_pages); + result.push(textContent(content)); + } + if (action.get_page_metadata) { + const content = await getPageMetadata(action.get_page_metadata); + result.push(textContent(content)); + } + if (action.update_page_settings) { + const content = await updatePageSettings( + action.update_page_settings + ); + result.push(textContent(content)); + } + if (action.get_page_content) { + const content = await getPageContent(action.get_page_content); + result.push(textContent(content)); + } + if (action.update_static_content) { + const content = await updateStaticContent( + action.update_static_content + ); + result.push(textContent(content)); + } + } + return toolResponse(result); } catch (error) { return formatErrorResponse(error); } diff --git a/src/tools/rules.ts b/src/tools/rules.ts index 8282c45..c007fdb 100644 --- a/src/tools/rules.ts +++ b/src/tools/rules.ts @@ -1,14 +1,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod/v3"; export function registerRulesTools(server: McpServer) { server.registerTool( "webflow_guide_tool", { title: "Webflow Guide Tool", + annotations: { + readOnlyHint: true, + openWorldHint: false, + }, description: "Provides essential guidelines and best practices for effectively using the Webflow tools. Call this tool to understand recommended workflows and important considerations before performing actions. ALWAYS CALL THIS TOOL FIRST BEFORE CALLING ANY OTHER TOOLS. ALWAYS CALL THIS TOOL FIRST BEFORE CALLING ANY OTHER TOOLS. ", - inputSchema: z.object({}), + inputSchema: {}, }, async ({}) => ({ content: [ @@ -26,6 +29,7 @@ export function registerRulesTools(server: McpServer) { `-- After updating or creating an element, the updated/created element is not automatically selected. If you need more information about that element, use element_tool > select_element with the appropriate element ID to select and inspect it.\n` + `-- Do not use CSS shorthand properties when updating or creating styles. Always use longhand property names like "margin-top", "padding-left", "border-width", etc.\n` + `-- When creating or updating elements, most users prefer using existing styles. You should reuse styles if they exist, unless the user explicitly wants new ones.\n` + + `-- To learn or find about localizations and locale id you get use site too and get site details to learn how many locales are supported and their details.\n` + `\n` + `Element Tool Usage:\n` + `-- To get detailed information about the currently selected element, use element_tool > get_selected_element.\n` + @@ -42,6 +46,13 @@ export function registerRulesTools(server: McpServer) { `Element Builder Tool:\n` + `-- To create a new element, use element_builder. Pass the type of element you want to create. After creation, use element_tool > select_element to select the element and gather additional details if needed.\n` + `\n` + + `Element Snapshot Tool Usage:\n` + + `-- To get a visual snapshot of an element, section, or component, use element_snapshot_tool. Pass the element ID to capture its current visual state as an image.\n` + + `-- Use this tool to verify visual changes after creating or updating elements. It provides immediate visual feedback without requiring manual inspection.\n` + + `-- This tool is helpful for debugging layout issues, verifying styling changes, or confirming that elements render as expected.\n` + + `-- The snapshot returns a PNG image of the specified element. Use it to validate your work before proceeding with additional changes.\n` + + `-- When the user asks to see or preview an element, use this tool to provide visual confirmation.\n` + + `\n` + `Asset Tool Usage:\n` + `-- To create an asset folder, use asset_tool > create_folder. Pass the name of the folder. To create a nested folder, pass parent_folder_id. Otherwise, the folder will be created in the root directory.\n` + `-- To retrieve assets and folders, use asset_tool > get_all_assets_and_folders. You can use query as "all", "folders", or "assets". To limit data, use filter_assets_by_ids or search query. Fetch only what you need to avoid context overload.\n` + diff --git a/src/tools/scripts.ts b/src/tools/scripts.ts index f278ba2..c88940f 100644 --- a/src/tools/scripts.ts +++ b/src/tools/scripts.ts @@ -4,144 +4,296 @@ import { ScriptApplyLocation } from "webflow-api/api/types/ScriptApplyLocation"; import { z } from "zod/v3"; import { requestOptions } from "../mcp"; import { RegisterInlineSiteScriptSchema } from "../schemas"; -import { formatErrorResponse, formatResponse, isApiError } from "../utils"; +import { + type Content, + formatErrorResponse, + textContent, + toolResponse, + isApiError, +} from "../utils"; +import { ScriptApplyList } from "webflow-api/api"; export function registerScriptsTools( server: McpServer, getClient: () => WebflowClient ) { - // GET https://api.webflow.com/v2/sites/:site_id/registered_scripts - server.registerTool( - "site_registered_scripts_list", - { - title: "List Registered Scripts", - description: - "List all registered scripts for a site. To apply a script to a site or page, first register it via the Register Script endpoints, then apply it using the relevant Site or Page endpoints.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the site."), - }), - }, - async ({ site_id }) => { - try { - const response = await getClient().scripts.list( - site_id, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const listRegisteredScripts = async (arg: { site_id: string }) => { + const response = await getClient().scripts.list( + arg.site_id, + requestOptions + ); + return response; + }; - // GET https://api.webflow.com/v2/sites/:site_id/custom_code - server.registerTool( - "site_applied_scripts_list", - { - title: "List Applied Scripts", - description: - "Get all scripts applied to a site by the App. To apply a script to a site or page, first register it via the Register Script endpoints, then apply it using the relevant Site or Page endpoints.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the site."), - }), - }, - async ({ site_id }) => { - try { - const response = await getClient().sites.scripts.getCustomCode( - site_id, - requestOptions - ); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const listAppliedScripts = async (arg: { site_id: string }) => { + const response = await getClient().sites.scripts.getCustomCode( + arg.site_id, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/sites/:site_id/registered_scripts/inline - server.registerTool( - "add_inline_site_script", - { - title: "Add Inline Site Script", - description: - "Register an inline script for a site. Inline scripts are limited to 2000 characters. ", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the site."), - request: RegisterInlineSiteScriptSchema, - }), - }, - async ({ site_id, request }) => { - const registerScriptResponse = await getClient().scripts.registerInline( - site_id, - { - sourceCode: request.sourceCode, - version: request.version, - displayName: request.displayName, - canCopy: request.canCopy !== undefined ? request.canCopy : true, - }, + const addInlineSiteScript = async (arg: { + site_id: string; + request: { + sourceCode: string; + version: string; + displayName: string; + location?: string; + canCopy?: boolean; + attributes?: Record; + }; + }) => { + const registerScriptResponse = await getClient().scripts.registerInline( + arg.site_id, + { + sourceCode: arg.request.sourceCode, + version: arg.request.version, + displayName: arg.request.displayName, + canCopy: arg.request.canCopy !== undefined ? arg.request.canCopy : true, + }, + requestOptions + ); + + let existingScripts: any[] = []; + try { + const allScriptsResponse = await getClient().sites.scripts.getCustomCode( + arg.site_id, requestOptions ); + existingScripts = allScriptsResponse.scripts || []; + } catch (error) { + existingScripts = []; + } - let existingScripts: any[] = []; - try { - const allScriptsResponse = - await getClient().sites.scripts.getCustomCode( - site_id, - requestOptions - ); - existingScripts = allScriptsResponse.scripts || []; - } catch (error) { - formatErrorResponse(error); - existingScripts = []; - } + const newScript = { + id: registerScriptResponse.id ?? " ", + location: + arg.request.location === "footer" + ? ScriptApplyLocation.Footer + : ScriptApplyLocation.Header, + version: registerScriptResponse.version ?? " ", + attributes: arg.request.attributes, + }; - const newScript = { - id: registerScriptResponse.id ?? " ", - location: - request.location === "footer" - ? ScriptApplyLocation.Footer - : ScriptApplyLocation.Header, - version: registerScriptResponse.version ?? " ", - attributes: request.attributes, - }; + existingScripts.push(newScript); - existingScripts.push(newScript); + await getClient().sites.scripts.upsertCustomCode( + arg.site_id, + { + scripts: existingScripts, + }, + requestOptions + ); - const addedSiteCustomCoderesponse = - await getClient().sites.scripts.upsertCustomCode( - site_id, - { - scripts: existingScripts, - }, - requestOptions - ); + return registerScriptResponse; + }; - return formatResponse(registerScriptResponse); + const deleteAllSiteScripts = async (arg: { site_id: string }) => { + try { + await getClient().sites.scripts.deleteCustomCode( + arg.site_id, + requestOptions + ); + return "Custom Code Deleted"; + } catch (error) { + // If it's a 404, we'll try to clear the scripts another way + if (isApiError(error) && error.status === 404) { + return error.message ?? "No custom code found"; + } + throw error; } - ); + }; + + const getPageScript = async (arg: { page_id: string }) => { + const response = await getClient().pages.scripts.getCustomCode( + arg.page_id, + requestOptions + ); + return response; + }; + + const upsertPageScript = async (arg: { + page_id: string; + scripts: { + id: string; + location: "header" | "footer"; + version: string; + attributes?: Record; + }[]; + }) => { + const data: ScriptApplyList = { + scripts: arg.scripts, + }; + + const response = await getClient().pages.scripts.upsertCustomCode( + arg.page_id, + data, + requestOptions + ); + return response; + }; + + const deleteAllPageScripts = async (arg: { page_id: string }) => { + const response = await getClient().pages.scripts.deleteCustomCode( + arg.page_id, + requestOptions + ); + return response; + }; server.registerTool( - "delete_all_site_scripts", + "data_scripts_tool", { - title: "Delete All Site Scripts", - description: "Delete all custom scripts from a site.", - inputSchema: z.object({ - site_id: z.string(), - }), + title: "Data Scripts Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, + description: + "Data tool - Scripts tool to perform actions like list registered scripts, list applied scripts, add inline site script, and delete all site scripts", + inputSchema: { + actions: z.array( + z.object({ + // GET https://api.webflow.com/v2/sites/:site_id/registered_scripts + list_registered_scripts: z + .object({ + site_id: z.string().describe("Unique identifier for the site."), + }) + .optional() + .describe( + "List all registered scripts for a site. To apply a script to a site or page, first register it via the Register Script endpoints, then apply it using the relevant Site or Page endpoints." + ), + // GET https://api.webflow.com/v2/sites/:site_id/custom_code + list_applied_scripts: z + .object({ + site_id: z.string().describe("Unique identifier for the site."), + }) + .optional() + .describe( + "Get all scripts applied to a site by the App. To apply a script to a site or page, first register it via the Register Script endpoints, then apply it using the relevant Site or Page endpoints." + ), + // POST https://api.webflow.com/v2/sites/:site_id/registered_scripts/inline + add_inline_site_script: z + .object({ + site_id: z.string().describe("Unique identifier for the site."), + request: RegisterInlineSiteScriptSchema, + }) + .optional() + .describe( + "Register an inline script for a site. Inline scripts are limited to 2000 characters." + ), + // DELETE https://api.webflow.com/v2/sites/:site_id/custom_code + delete_all_site_scripts: z + .object({ + site_id: z.string().describe("Unique identifier for the site."), + }) + .optional() + .describe( + "Delete all custom scripts applied to a site by the App." + ), + // GET https://api.webflow.com/v2/pages/:page_id/custom_code + get_page_script: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + }) + .optional() + .describe( + "Get all custom scripts applied to a specific page by the App." + ), + // PUT https://api.webflow.com/v2/pages/:page_id/custom_code + upsert_page_script: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + scripts: z + .array( + z.object({ + id: z + .string() + .describe( + "The unique identifier of the registered script." + ), + location: z + .enum(["header", "footer"]) + .describe( + "The location where the script should be applied (header or footer)." + ), + version: z + .string() + .describe("The version of the script to apply."), + attributes: z + .record(z.any()) + .optional() + .describe( + "Optional attributes to apply to the script element." + ), + }) + ) + .describe("Array of scripts to apply to the page."), + }) + .optional() + .describe( + "Add or update custom scripts on a specific page. This will replace all existing scripts on the page with the provided scripts." + ), + // DELETE https://api.webflow.com/v2/pages/:page_id/custom_code + delete_all_page_scripts: z + .object({ + page_id: z.string().describe("Unique identifier for the page."), + }) + .optional() + .describe( + "Delete all custom scripts applied to a specific page by the App." + ), + }) + ), + }, }, - async ({ site_id }) => { + async ({ actions }) => { + const result: Content[] = []; try { - const response = await getClient().sites.scripts.deleteCustomCode( - site_id, - requestOptions - ); - return formatResponse("Custom Code Deleted"); - } catch (error) { - // If it's a 404, we'll try to clear the scripts another way - if (isApiError(error) && error.status === 404) { - return formatResponse(error.message ?? "No custom code found"); + for (const action of actions) { + if (action.list_registered_scripts) { + const content = await listRegisteredScripts( + action.list_registered_scripts + ); + result.push(textContent(content)); + } + if (action.list_applied_scripts) { + const content = await listAppliedScripts( + action.list_applied_scripts + ); + result.push(textContent(content)); + } + if (action.add_inline_site_script) { + const content = await addInlineSiteScript( + action.add_inline_site_script + ); + result.push(textContent(content)); + } + if (action.delete_all_site_scripts) { + const content = await deleteAllSiteScripts( + action.delete_all_site_scripts + ); + result.push(textContent(content)); + } + if (action.get_page_script) { + const content = await getPageScript(action.get_page_script); + result.push(textContent(content)); + } + if (action.upsert_page_script) { + const content = await upsertPageScript(action.upsert_page_script); + result.push(textContent(content)); + } + if (action.delete_all_page_scripts) { + const content = await deleteAllPageScripts( + action.delete_all_page_scripts + ); + result.push(textContent(content)); + } } - throw error; + return toolResponse(result); + } catch (error) { + return formatErrorResponse(error); } } ); diff --git a/src/tools/sites.ts b/src/tools/sites.ts index 1d0a03b..1050e4d 100644 --- a/src/tools/sites.ts +++ b/src/tools/sites.ts @@ -2,84 +2,111 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebflowClient } from "webflow-api"; import { z } from "zod/v3"; import { requestOptions } from "../mcp"; -import { formatErrorResponse, formatResponse } from "../utils"; +import { + type Content, + formatErrorResponse, + textContent, + toolResponse, +} from "../utils"; export function registerSiteTools( server: McpServer, getClient: () => WebflowClient ) { - // GET https://api.webflow.com/v2/sites - server.registerTool( - "sites_list", - { - title: "List Sites", - description: - "List all sites accessible to the authenticated user. Returns basic site information including site ID, name, and last published date.", - inputSchema: z.object({}), - }, - async () => { - try { - const response = await getClient().sites.list(requestOptions); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const listSites = async () => { + const response = await getClient().sites.list(requestOptions); + return response; + }; - // GET https://api.webflow.com/v2/sites/:site_id - server.registerTool( - "sites_get", - { - title: "Get Site", - description: - "Get detailed information about a specific site including its settings, domains, and publishing status.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the site."), - }), - }, - async ({ site_id }) => { - try { - const response = await getClient().sites.get(site_id, requestOptions); - return formatResponse(response); - } catch (error) { - return formatErrorResponse(error); - } - } - ); + const getSite = async (arg: { site_id: string }) => { + const response = await getClient().sites.get(arg.site_id, requestOptions); + return response; + }; + + const publishSite = async (arg: { + site_id: string; + customDomains?: string[]; + publishToWebflowSubdomain?: boolean; + }) => { + const response = await getClient().sites.publish( + arg.site_id, + { + customDomains: arg.customDomains, + publishToWebflowSubdomain: arg.publishToWebflowSubdomain, + }, + requestOptions + ); + return response; + }; - // POST https://api.webflow.com/v2/sites/:site_id/publish server.registerTool( - "sites_publish", + "data_sites_tool", { - title: "Publish Site", + title: "Data Sites Tool", + annotations: { + readOnlyHint: false, + openWorldHint: true, + }, description: - "Publish a site to specified domains. This will make the latest changes live on the specified domains.", - inputSchema: z.object({ - site_id: z.string().describe("Unique identifier for the site."), - customDomains: z - .string() - .array() - .optional() - .describe("Array of custom domains to publish the site to."), - publishToWebflowSubdomain: z - .boolean() - .optional() - .default(false) - .describe("Whether to publish to the Webflow subdomain."), - }), + "Data tool - Sites tool to perform actions like list sites, get site details, and publish sites", + inputSchema: { + actions: z.array( + z.object({ + // GET https://api.webflow.com/v2/sites + list_sites: z + .object({}) + .optional() + .describe( + "List all sites accessible to the authenticated user. Returns basic site information including site ID, name, and last published date." + ), + // GET https://api.webflow.com/v2/sites/:site_id + get_site: z + .object({ + site_id: z.string().describe("Unique identifier for the site."), + }) + .optional() + .describe( + "Get detailed information about a specific site including its settings, domains, and publishing status." + ), + // POST https://api.webflow.com/v2/sites/:site_id/publish + publish_site: z + .object({ + site_id: z.string().describe("Unique identifier for the site."), + customDomains: z + .array(z.string()) + .optional() + .describe("Array of custom domains to publish the site to."), + publishToWebflowSubdomain: z + .boolean() + .optional() + .describe("Whether to publish to the Webflow subdomain."), + }) + .optional() + .describe( + "Publish a site to specified domains. This will make the latest changes live on the specified domains." + ), + }) + ), + }, }, - async ({ site_id, customDomains, publishToWebflowSubdomain }) => { + async ({ actions }) => { + const result: Content[] = []; try { - const response = await getClient().sites.publish( - site_id, - { - customDomains, - publishToWebflowSubdomain, - }, - requestOptions - ); - return formatResponse(response); + for (const action of actions) { + if (action.list_sites) { + const content = await listSites(); + result.push(textContent(content)); + } + if (action.get_site) { + const content = await getSite(action.get_site); + result.push(textContent(content)); + } + if (action.publish_site) { + const content = await publishSite(action.publish_site); + result.push(textContent(content)); + } + } + return toolResponse(result); } catch (error) { return formatErrorResponse(error); } diff --git a/src/utils/formatResponse.ts b/src/utils/formatResponse.ts index 64bd97c..3903734 100644 --- a/src/utils/formatResponse.ts +++ b/src/utils/formatResponse.ts @@ -1,9 +1,48 @@ -export function formatResponse(response: any) { +export type TextContent = { + type: "text"; + text: string; +} + +export type ImageContent = { + type: "image"; + data: string; + mimeType: string; +} + +export type Content = TextContent | ImageContent; + +export type ToolResponse = { + content: Content[]; +} + +export function formatResponse(response: any): ToolResponse { return { content: [{ type: "text" as "text", text: JSON.stringify(response) }], }; } +export function textContent(response: any): TextContent { + return { + type: "text", + text: JSON.stringify(response) + }; +} + +export function imageContent(data: string, mimeType: string): ImageContent { + return { + type: "image", + data, + mimeType, + }; +} + +export function toolResponse(contentItems: Content[]): ToolResponse { + return { + content: contentItems, + }; +} + + // https://modelcontextprotocol.io/docs/concepts/tools#error-handling-2 export function formatErrorResponse(error: any) { return {