diff --git a/package.json b/package.json index 55d155f..477dc99 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "start": "concurrently \"npm run dev:local\" \"npm run inspector:local\"", "dev:local": "npm run build:watch", "inspector:local": "npx @modelcontextprotocol/inspector -- nodemon --env-file=.env -q --watch dist dist/index.js", - "build": "tsup src/index.ts --dts --clean", - "build:watch": "tsup src/index.ts --dts --watch" + "build": "tsup src/index.ts", + "build:watch": "tsup src/index.ts --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.21.1", + "@modelcontextprotocol/sdk": "1.23.0", "cors": "^2.8.5", "express": "^5.1.0", "socket.io": "^4.8.1", diff --git a/src/mcp.ts b/src/mcp.ts index 701eaaf..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,6 +53,8 @@ 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) { diff --git a/src/tools/comments.ts b/src/tools/comments.ts new file mode 100644 index 0000000..e095caf --- /dev/null +++ b/src/tools/comments.ts @@ -0,0 +1,286 @@ +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, + }, + 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/enterprise.ts b/src/tools/enterprise.ts new file mode 100644 index 0000000..9826019 --- /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"; +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/rules.ts b/src/tools/rules.ts index 3dcbcdd..9d43b8d 100644 --- a/src/tools/rules.ts +++ b/src/tools/rules.ts @@ -29,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` + diff --git a/src/tools/scripts.ts b/src/tools/scripts.ts index b40a92e..2ec8d02 100644 --- a/src/tools/scripts.ts +++ b/src/tools/scripts.ts @@ -11,6 +11,7 @@ import { toolResponse, isApiError, } from "../utils"; +import { ScriptApplyList } from "webflow-api/api"; export function registerScriptsTools( server: McpServer, @@ -104,6 +105,43 @@ export function registerScriptsTools( } }; + 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( "data_scripts_tool", { @@ -153,6 +191,58 @@ export function registerScriptsTools( .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." + ), }) ), }, @@ -185,6 +275,20 @@ export function registerScriptsTools( ); 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)); + } } return toolResponse(result); } catch (error) {