diff --git a/src/index.ts b/src/index.ts index ad89e01..43884a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,7 +37,7 @@ function getAccessToken() { // Configure and run local MCP server (stdio transport) async function run() { const server = createMcpServer(); - const { callTool } = await initDesignerAppBridge(); + const { callTool } = await initDesignerAppBridge({ getClient }); registerMiscTools(server); registerTools(server, getClient, getAccessToken); registerDesignerTools(server, { diff --git a/src/modules/designerAppBridge.ts b/src/modules/designerAppBridge.ts index be460ea..7a014b8 100644 --- a/src/modules/designerAppBridge.ts +++ b/src/modules/designerAppBridge.ts @@ -2,13 +2,23 @@ import express from "express"; import http from "http"; import { Socket, Server as SocketIOServer } from "socket.io"; import cors from "cors"; +import { WebflowClient } from "webflow-api"; import { RPCType } from "../types/RPCType"; import { generateUUIDv4, getFreePort } from "../utils"; +import { uploadAssetFromUrl } from "../tools/assetUpload"; type returnType = { callTool: RPCType["callTool"]; }; +type BridgeOptions = { + /** + * Optional Webflow Data API client accessor. Required to enable the + * `/api/upload-asset` HTTP endpoint; otherwise that route returns 500. + */ + getClient?: () => WebflowClient; +}; + const START_PORT = 1338; const END_PORT = 1638; @@ -123,7 +133,11 @@ const initRPC = (io: SocketIOServer, port: number): returnType => { }; }; -export const initDesignerAppBridge = async (): Promise => { +export const initDesignerAppBridge = async ( + options: BridgeOptions = {}, +): Promise => { + const { getClient } = options; + // Initialize Express app const app = express(); // Allow Private Network Access (Chrome requires this for localhost access from public origins) @@ -141,6 +155,8 @@ export const initDesignerAppBridge = async (): Promise => { credentials: true, }), ); + // Parse JSON bodies for the HTTP proxy endpoints + app.use(express.json({ limit: "10mb" })); // Create HTTP server using the Express app const server = http.createServer(app); @@ -166,6 +182,68 @@ export const initDesignerAppBridge = async (): Promise => { const rpc = initRPC(io, port); + // ── HTTP Proxy for tool calls (hackathon) ───────────────── + // Allows external apps (e.g. Designer extensions) to call MCP tools + // via HTTP POST instead of stdio. Forwards to the Designer RPC bridge. + // POST /api/tool-call { toolName, args } + app.post("/api/tool-call", async (req, res) => { + try { + const { toolName, args } = req.body ?? {}; + if (!toolName || !args) { + res.status(400).json({ error: "toolName and args required" }); + return; + } + const result = await rpc.callTool(toolName, args); + res.json({ result }); + } catch (err: any) { + res.status(500).json({ error: err.message ?? "Tool call failed" }); + } + }); + + // GET /api/status — health check for the bridge + app.get("/api/status", (_, res) => { + res.json({ status: "ok", port }); + }); + + // POST /api/upload-asset — upload image from URL to Webflow site + // { siteId, url, fileName?, altText? } → { assetId, fileName, hostedUrl? } + // Uses the Webflow Data API directly (bypasses the Designer RPC bridge), + // so getClient must be supplied when initializing the bridge. + app.post("/api/upload-asset", async (req, res) => { + try { + if (!getClient) { + res.status(500).json({ + error: + "Asset upload endpoint is not configured — bridge was initialized without a Webflow client.", + }); + return; + } + const { siteId, url, fileName, altText } = req.body ?? {}; + if (!siteId || !url) { + res.status(400).json({ error: "siteId and url are required" }); + return; + } + const result = await uploadAssetFromUrl(getClient(), { + siteId, + url, + fileName, + altText, + }); + if (!result.success) { + res.status(500).json({ error: result.error }); + return; + } + res.json({ + assetId: result.assetId, + fileName: result.fileName, + hostedUrl: result.hostedUrl, + assetUrl: result.assetUrl, + }); + } catch (err: any) { + res.status(500).json({ error: err.message ?? "Upload failed" }); + } + }); + return rpc; } catch (e) { return { diff --git a/src/tools/assetUpload.ts b/src/tools/assetUpload.ts index f1446f2..e7c5cf6 100644 --- a/src/tools/assetUpload.ts +++ b/src/tools/assetUpload.ts @@ -5,6 +5,153 @@ import { SiteIdSchema } from "../schemas"; import { formatErrorResponse, formatResponse } from "../utils"; import { createHash } from "crypto"; +export type UploadAssetSuccess = { + success: true; + assetId: string; + fileName: string; + hostedUrl?: string; + assetUrl?: string; +}; + +export type UploadAssetFailure = { + success: false; + error: string; +}; + +export type UploadAssetResult = UploadAssetSuccess | UploadAssetFailure; + +/** + * Upload an image from a URL to a Webflow site via the Data API. + * + * Shared by: + * - the MCP tool `upload_asset_from_url` (registered below) + * - the HTTP proxy endpoint `POST /api/upload-asset` on the Designer app bridge + */ +export async function uploadAssetFromUrl( + client: WebflowClient, + params: { + siteId: string; + url: string; + fileName?: string; + altText?: string; + }, +): Promise { + const { siteId, url, altText } = params; + let fileName = params.fileName; + + try { + // Step 1: Download the image + const response = await fetch(url); + if (!response.ok) { + return { + success: false, + error: `Failed to download image: ${response.status} ${response.statusText}`, + }; + } + + const contentType = response.headers.get("content-type") || "image/jpeg"; + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Derive file name if not provided + if (!fileName) { + const urlPath = new URL(url).pathname; + fileName = urlPath.split("/").pop() || "image.jpg"; + if (!fileName.includes(".")) { + const ext = contentType.split("/")[1] || "jpg"; + fileName = `${fileName}.${ext}`; + } + } + + if (fileName.length > 99) { + const ext = fileName.split(".").pop() || "jpg"; + fileName = `${fileName.substring(0, 90)}.${ext}`; + } + + // Step 2: Compute MD5 hash + const md5Hash = createHash("md5").update(buffer).digest("hex"); + + // Step 3: Create asset in Webflow (get S3 upload URL) + const createResponse = await client.assets.create(siteId, { + fileName, + fileHash: md5Hash, + }); + + const body = createResponse as any; + + if (!body.uploadUrl || !body.uploadDetails) { + return { + success: false, + error: "Webflow did not return upload URL or details", + }; + } + + // Step 4: Upload to S3 + const formData = new FormData(); + const details = body.uploadDetails; + if (details.acl) formData.append("acl", details.acl); + if (details.bucket) formData.append("bucket", details.bucket); + if (details.xAmzAlgorithm) + formData.append("X-Amz-Algorithm", details.xAmzAlgorithm); + if (details.xAmzCredential) + formData.append("X-Amz-Credential", details.xAmzCredential); + if (details.xAmzDate) formData.append("X-Amz-Date", details.xAmzDate); + if (details.key) formData.append("key", details.key); + if (details.policy) formData.append("policy", details.policy); + if (details.xAmzSignature) + formData.append("X-Amz-Signature", details.xAmzSignature); + if (details.successActionStatus) + formData.append("success_action_status", details.successActionStatus); + if (details.contentType) + formData.append("Content-Type", details.contentType); + if (details.cacheControl) + formData.append("Cache-Control", details.cacheControl); + + const blob = new Blob([buffer], { type: contentType }); + formData.append("file", blob, fileName); + + const uploadResponse = await fetch(body.uploadUrl, { + method: "POST", + body: formData, + }); + + if ( + !uploadResponse.ok && + uploadResponse.status !== 201 && + uploadResponse.status !== 204 + ) { + const errorText = await uploadResponse.text(); + return { + success: false, + error: `S3 upload failed: ${uploadResponse.status} ${errorText}`, + }; + } + + // Step 5: Update alt text if provided (non-fatal) + if (altText && body.id) { + try { + await client.assets.update(body.id, { + displayName: fileName, + altText, + }); + } catch { + // Non-fatal — asset was uploaded, alt text update failed + } + } + + return { + success: true, + assetId: body.id, + fileName, + hostedUrl: body.hostedUrl, + assetUrl: body.assetUrl, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} + export function registerAssetUploadTools( server: McpServer, getClient: () => WebflowClient, @@ -40,124 +187,25 @@ export function registerAssetUploadTools( }, async ({ siteId, url, fileName, altText }) => { try { - // Step 1: Download the image - const response = await fetch(url); - if (!response.ok) { - return formatErrorResponse( - new Error( - `Failed to download image: ${response.status} ${response.statusText}`, - ), - ); - } - - const contentType = - response.headers.get("content-type") || "image/jpeg"; - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - // Derive file name if not provided - if (!fileName) { - const urlPath = new URL(url).pathname; - fileName = urlPath.split("/").pop() || "image.jpg"; - // Ensure it has an extension - if (!fileName.includes(".")) { - const ext = contentType.split("/")[1] || "jpg"; - fileName = `${fileName}.${ext}`; - } - } - - // Ensure file name is under 100 chars - if (fileName.length > 99) { - const ext = fileName.split(".").pop() || "jpg"; - fileName = `${fileName.substring(0, 90)}.${ext}`; - } - - // Step 2: Compute MD5 hash - const md5Hash = createHash("md5").update(buffer).digest("hex"); - - // Step 3: Create asset in Webflow (get S3 upload URL) - const client = getClient(); - const createResponse = await client.assets.create(siteId, { + const result = await uploadAssetFromUrl(getClient(), { + siteId, + url, fileName, - fileHash: md5Hash, + altText, }); - const body = createResponse as any; - - if (!body.uploadUrl || !body.uploadDetails) { - return formatErrorResponse( - new Error("Webflow did not return upload URL or details"), - ); - } - - // Step 4: Upload to S3 - const formData = new FormData(); - - // Add all upload details as form fields (order matters for S3) - const details = body.uploadDetails; - if (details.acl) formData.append("acl", details.acl); - if (details.bucket) formData.append("bucket", details.bucket); - if (details.xAmzAlgorithm) - formData.append("X-Amz-Algorithm", details.xAmzAlgorithm); - if (details.xAmzCredential) - formData.append("X-Amz-Credential", details.xAmzCredential); - if (details.xAmzDate) formData.append("X-Amz-Date", details.xAmzDate); - if (details.key) formData.append("key", details.key); - if (details.policy) formData.append("policy", details.policy); - if (details.xAmzSignature) - formData.append("X-Amz-Signature", details.xAmzSignature); - if (details.successActionStatus) - formData.append( - "success_action_status", - details.successActionStatus, - ); - if (details.contentType) - formData.append("Content-Type", details.contentType); - if (details.cacheControl) - formData.append("Cache-Control", details.cacheControl); - - // Add the file last - const blob = new Blob([buffer], { type: contentType }); - formData.append("file", blob, fileName); - - const uploadResponse = await fetch(body.uploadUrl, { - method: "POST", - body: formData, - }); - - if ( - !uploadResponse.ok && - uploadResponse.status !== 201 && - uploadResponse.status !== 204 - ) { - const errorText = await uploadResponse.text(); - return formatErrorResponse( - new Error( - `S3 upload failed: ${uploadResponse.status} ${errorText}`, - ), - ); - } - - // Step 5: Update alt text if provided - if (altText && body.id) { - try { - await client.assets.update(body.id, { - displayName: fileName, - altText, - }); - } catch { - // Non-fatal — asset was uploaded, alt text update failed - } + if (!result.success) { + return formatErrorResponse(new Error(result.error)); } return formatResponse({ status: "success", - message: `Asset uploaded successfully: ${fileName}`, + message: `Asset uploaded successfully: ${result.fileName}`, data: { - assetId: body.id, - fileName, - hostedUrl: body.hostedUrl, - assetUrl: body.assetUrl, + assetId: result.assetId, + fileName: result.fileName, + hostedUrl: result.hostedUrl, + assetUrl: result.assetUrl, }, }); } catch (error) {