@gakim-digital/dexter-bridge 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/package.json +1 -1
- package/src/agent.js +143 -39
- package/src/agentOutput.js +6 -0
- package/src/api.js +75 -0
- package/src/cli.js +160 -110
- package/src/config.js +20 -0
- package/src/protocol.js +56 -0
- package/src/providers/codexAppServer.js +50 -34
- package/src/taskEvidence.js +245 -0
- package/src/webflowTools.js +1045 -0
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
|
|
4
|
+
import { createEvidenceLedger } from './taskEvidence.js';
|
|
5
|
+
|
|
6
|
+
export const WEBFLOW_TEXT_BUDGET_BYTES = 24 * 1024;
|
|
7
|
+
export const WEBFLOW_GUIDANCE_BUDGET_BYTES = 120 * 1024;
|
|
8
|
+
export const WEBFLOW_IDENTICAL_FAILURE_LIMIT = 5;
|
|
9
|
+
export const WEBFLOW_SERVER_FAILURE_LIMIT = 5;
|
|
10
|
+
export const WEBFLOW_WRITE_UNKNOWN = {
|
|
11
|
+
status: 'unknown',
|
|
12
|
+
message: 'The write may have landed. Re-read the target before retrying.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const WEBFLOW_DOMAINS = [
|
|
16
|
+
'visual',
|
|
17
|
+
'content',
|
|
18
|
+
'links',
|
|
19
|
+
'interactions',
|
|
20
|
+
'responsive',
|
|
21
|
+
'data',
|
|
22
|
+
'structure',
|
|
23
|
+
];
|
|
24
|
+
const WEBFLOW_CHECKS = ['structural', 'links', 'responsive', 'data'];
|
|
25
|
+
const CHECK_LABELS = {
|
|
26
|
+
'task-plan': 'a harness task and verification plan',
|
|
27
|
+
structural: 'a final structural read',
|
|
28
|
+
links: 'a webflow_call read tagged with verifies:["links"]',
|
|
29
|
+
responsive: 'a webflow_call read tagged with verifies:["responsive"]',
|
|
30
|
+
data: 'a webflow_call read tagged with verifies:["data"]',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const DROPPED_TOOL_PATTERNS = [
|
|
34
|
+
/publish/i,
|
|
35
|
+
/settings/i,
|
|
36
|
+
/enterprise/i,
|
|
37
|
+
/member/i,
|
|
38
|
+
/webflow_cloud|cloud_deploy|\bdeploy\b/i,
|
|
39
|
+
/campaign/i,
|
|
40
|
+
/^ask_webflow_ai$/i,
|
|
41
|
+
];
|
|
42
|
+
// These need Webflow's own MCP Bridge App open in the Designer. Dexter's panel
|
|
43
|
+
// replaces them: webflow_context (selection, page, mode) and webflow_snapshot.
|
|
44
|
+
const DESIGNER_SESSION_TOOLS = ['designer_tool', 'element_snapshot_tool'];
|
|
45
|
+
const DESIGNER_SESSION_TOOL_PATTERN = new RegExp(`\\b(${DESIGNER_SESSION_TOOLS.join('|')})\\b`);
|
|
46
|
+
const DESIGNER_SESSION_FAILURE = /unable to connect to webflow designer|designer mcp app/i;
|
|
47
|
+
const DESIGNER_SESSION_MESSAGE = "This Webflow action needs Webflow's own Designer app, which Dexter does not use. Do not ask the user to open a Webflow app or link. Use webflow_context for the current page and selection (each selected element's id is the { component, element } object Webflow's data tools take), webflow_snapshot for screenshots, and the data_* tools to read and edit.";
|
|
48
|
+
|
|
49
|
+
// Webflow MCP tools reach the harness through webflow_call instead of as native
|
|
50
|
+
// tools: their schemas are ~170k tokens, re-read on every model request.
|
|
51
|
+
// Dexter fills these arguments itself on every call.
|
|
52
|
+
const DEXTER_FILLED_ARGUMENTS = ['siteId', 'session_id', 'agent_id', 'context'];
|
|
53
|
+
const WEBFLOW_CALL_CONTEXT = "Dexter applies the designer's requested change to this Webflow site, reading and editing its elements, styles, and assets in the Designer.";
|
|
54
|
+
const READ_ACTION_PATTERN = /^(get|query|list|search|read|check)_|^guide$/;
|
|
55
|
+
const READ_ONLY_TOOLS = new Set(['webflow_guide_tool', 'get_asset_preview']);
|
|
56
|
+
const PROMPT_GUIDE_SECTIONS = [/^General Rules:/, /^### Important rules for creating elements/];
|
|
57
|
+
// Guide sections for the live-Designer tools Dexter replaces.
|
|
58
|
+
const DESIGNER_SESSION_GUIDE_SECTION = /^(Element Snapshot Tool Usage|Canvas Navigation \(Designer\)):/;
|
|
59
|
+
const SESSION_ISSUANCE = /\[session_id issued[^\]]*\]\s*session_id:\s*(ses_[0-9A-Za-z]+)\s*(?:This is the first-call issuance[^.]*\.)?\s*/g;
|
|
60
|
+
const CATALOG_DESCRIPTION_CHARS = 160;
|
|
61
|
+
const WEBFLOW_HELP_ACTION_LIMIT = 8;
|
|
62
|
+
|
|
63
|
+
const guidanceCache = new Map();
|
|
64
|
+
|
|
65
|
+
function toolError(code, message, statusCode = 400) {
|
|
66
|
+
return Object.assign(new Error(message), { code, statusCode });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function designerSessionError() {
|
|
70
|
+
return toolError('WEBFLOW_DESIGNER_SESSION_UNAVAILABLE', DESIGNER_SESSION_MESSAGE, 409);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isBlockedWebflowMcpTool(name) {
|
|
78
|
+
const tool = String(name || '');
|
|
79
|
+
if (!tool || tool === 'get_more_tools' || DESIGNER_SESSION_TOOLS.includes(tool)) return true;
|
|
80
|
+
return DROPPED_TOOL_PATTERNS.some((pattern) => pattern.test(tool));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isBlockedWebflowAction(action) {
|
|
84
|
+
return /publish/i.test(action);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function callActions(args) {
|
|
88
|
+
const items = Array.isArray(args?.actions) ? args.actions : [];
|
|
89
|
+
return items.flatMap((item) => (isRecord(item) ? Object.keys(item).filter((key) => key !== 'label') : []));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Webflow calls carry their actions as keys: { actions: [{ query_styles: {...} }] }.
|
|
93
|
+
export function classifyWebflowToolCall(tool, args = {}) {
|
|
94
|
+
if (READ_ONLY_TOOLS.has(tool)) return 'read';
|
|
95
|
+
if (/_builder$/.test(tool)) return 'mutation';
|
|
96
|
+
const actions = callActions(args);
|
|
97
|
+
if (actions.length === 0) return 'mutation';
|
|
98
|
+
return actions.every((action) => READ_ACTION_PATTERN.test(action)) ? 'read' : 'mutation';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function budgetWebflowText(value, maximum = WEBFLOW_TEXT_BUDGET_BYTES) {
|
|
102
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
|
|
103
|
+
const bytes = Buffer.byteLength(text);
|
|
104
|
+
if (bytes <= maximum) return text;
|
|
105
|
+
let end = text.length;
|
|
106
|
+
while (end > 0 && Buffer.byteLength(text.slice(0, end)) > maximum - 80) {
|
|
107
|
+
end = Math.floor(end * 0.9);
|
|
108
|
+
}
|
|
109
|
+
const kept = text.slice(0, end);
|
|
110
|
+
const omitted = bytes - Buffer.byteLength(kept);
|
|
111
|
+
return `${kept}…truncated ${omitted} bytes; narrow the query`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function clipBytes(text, maximum) {
|
|
115
|
+
const value = String(text || '');
|
|
116
|
+
if (Buffer.byteLength(value) <= maximum) return value;
|
|
117
|
+
let end = value.length;
|
|
118
|
+
while (end > 0 && Buffer.byteLength(value.slice(0, end)) > maximum) {
|
|
119
|
+
end -= Math.max(1, Math.floor(end * 0.1));
|
|
120
|
+
}
|
|
121
|
+
return value.slice(0, end);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function assertWebflowHarnessAgent(agentId) {
|
|
125
|
+
if (agentId === 'opencode') {
|
|
126
|
+
throw toolError(
|
|
127
|
+
'WEBFLOW_AGENT_UNSUPPORTED',
|
|
128
|
+
'OpenCode is not available for Webflow runs. Use Claude Code.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dexterTool(name, description, inputSchema, required = []) {
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
description,
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: inputSchema,
|
|
140
|
+
...(required.length ? { required } : {}),
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function dexterDefinitions() {
|
|
147
|
+
return [
|
|
148
|
+
dexterTool(
|
|
149
|
+
'webflow_context',
|
|
150
|
+
"Read the current Webflow page, selection, and site context from the open Designer. Each selected element has `id`, the { component, element } object Webflow's data tools take, and `nodeId`, which webflow_snapshot takes.",
|
|
151
|
+
{},
|
|
152
|
+
),
|
|
153
|
+
dexterTool(
|
|
154
|
+
'webflow_snapshot',
|
|
155
|
+
"Screenshot specific elements, by the nodeId webflow_context or the data tools report. Webflow renders these screenshots itself, so letter-spacing and word spacing can look tighter than on the live site.",
|
|
156
|
+
{
|
|
157
|
+
elementIds: {
|
|
158
|
+
type: 'array',
|
|
159
|
+
minItems: 1,
|
|
160
|
+
maxItems: 4,
|
|
161
|
+
items: { type: 'string', minLength: 1, maxLength: 200 },
|
|
162
|
+
},
|
|
163
|
+
pageId: { type: 'string', maxLength: 200 },
|
|
164
|
+
},
|
|
165
|
+
['elementIds'],
|
|
166
|
+
),
|
|
167
|
+
dexterTool(
|
|
168
|
+
'webflow_plan_task',
|
|
169
|
+
'After inspecting the live site and before the first change, declare the task and the evidence you will produce.',
|
|
170
|
+
{
|
|
171
|
+
summary: { type: 'string', minLength: 10, maxLength: 1000 },
|
|
172
|
+
domains: {
|
|
173
|
+
type: 'array',
|
|
174
|
+
minItems: 1,
|
|
175
|
+
uniqueItems: true,
|
|
176
|
+
items: { type: 'string', enum: WEBFLOW_DOMAINS },
|
|
177
|
+
},
|
|
178
|
+
verification: {
|
|
179
|
+
type: 'array',
|
|
180
|
+
minItems: 1,
|
|
181
|
+
uniqueItems: true,
|
|
182
|
+
items: { type: 'string', enum: WEBFLOW_CHECKS },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
['summary', 'domains', 'verification'],
|
|
186
|
+
),
|
|
187
|
+
dexterTool(
|
|
188
|
+
'webflow_read_website',
|
|
189
|
+
'Read one page of a website the user named. Only sites the user named can be read.',
|
|
190
|
+
{
|
|
191
|
+
url: { type: 'string', minLength: 1, maxLength: 2048 },
|
|
192
|
+
screenshotTop: { type: 'integer', minimum: 0 },
|
|
193
|
+
},
|
|
194
|
+
['url'],
|
|
195
|
+
),
|
|
196
|
+
dexterTool(
|
|
197
|
+
'webflow_find_images',
|
|
198
|
+
'Search for real stock photos. Use an attachment or an existing site asset first. Never invent an image URL.',
|
|
199
|
+
{
|
|
200
|
+
query: { type: 'string', minLength: 2, maxLength: 200 },
|
|
201
|
+
count: { type: 'integer', minimum: 1, maximum: 8 },
|
|
202
|
+
orientation: { type: 'string', enum: ['landscape', 'portrait', 'squarish'] },
|
|
203
|
+
},
|
|
204
|
+
['query'],
|
|
205
|
+
),
|
|
206
|
+
dexterTool(
|
|
207
|
+
'webflow_upload_image',
|
|
208
|
+
'Upload an attachment or a stock-image URL into the Webflow site through the open Designer, then use the returned asset.',
|
|
209
|
+
{
|
|
210
|
+
source: { type: 'string', enum: ['attachment', 'url'] },
|
|
211
|
+
attachmentId: { type: 'string', maxLength: 180 },
|
|
212
|
+
url: { type: 'string', maxLength: 2048 },
|
|
213
|
+
alt: { type: 'string', maxLength: 300 },
|
|
214
|
+
},
|
|
215
|
+
['source'],
|
|
216
|
+
),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function gatewayDefinitions(tools) {
|
|
221
|
+
const tool = { type: 'string', enum: tools };
|
|
222
|
+
return [
|
|
223
|
+
dexterTool(
|
|
224
|
+
'webflow_tool_help',
|
|
225
|
+
'Show the exact arguments of Webflow tool actions and Webflow\'s usage notes for that tool. Before using an action for the first time, ask for every action you plan to use in one call.',
|
|
226
|
+
{
|
|
227
|
+
tool,
|
|
228
|
+
actions: {
|
|
229
|
+
type: 'array',
|
|
230
|
+
maxItems: WEBFLOW_HELP_ACTION_LIMIT,
|
|
231
|
+
uniqueItems: true,
|
|
232
|
+
items: { type: 'string', minLength: 1, maxLength: 80 },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
['tool'],
|
|
236
|
+
),
|
|
237
|
+
dexterTool(
|
|
238
|
+
'webflow_call',
|
|
239
|
+
'Run a tool from the Webflow tool catalog with the arguments webflow_tool_help showed. Batch several actions in one call. Dexter fills siteId, session_id, agent_id, context, and action labels, and fills pageId with the current Designer page when you leave it out.',
|
|
240
|
+
{
|
|
241
|
+
tool,
|
|
242
|
+
arguments: { type: 'object', description: 'The tool\'s arguments, such as { "actions": [{ "query_elements": { … } }] }.' },
|
|
243
|
+
verifies: {
|
|
244
|
+
type: 'array',
|
|
245
|
+
uniqueItems: true,
|
|
246
|
+
items: { type: 'string', enum: ['links', 'responsive', 'data'] },
|
|
247
|
+
description: 'For a verification read, the checks it provides.',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
['tool', 'arguments'],
|
|
251
|
+
),
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Webflow declares draft-07 `$schema`; the Claude API then rejects some tool
|
|
256
|
+
// schemas (data_optimize_tool) that are valid as its required draft 2020-12.
|
|
257
|
+
function schemaOf(tool) {
|
|
258
|
+
const schema = isRecord(tool.inputSchema)
|
|
259
|
+
? tool.inputSchema
|
|
260
|
+
: isRecord(tool.parameters)
|
|
261
|
+
? tool.parameters
|
|
262
|
+
: { type: 'object', additionalProperties: true };
|
|
263
|
+
const { $schema, ...rest } = schema;
|
|
264
|
+
return rest;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function clipSentence(text, maximum) {
|
|
268
|
+
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
|
269
|
+
if (value.length <= maximum) return value;
|
|
270
|
+
const cut = value.slice(0, maximum);
|
|
271
|
+
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 1))}…`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The schema the harness's arguments must match: Webflow's, minus what Dexter fills.
|
|
275
|
+
// pageId stays optional: Dexter fills the current page when it is left out.
|
|
276
|
+
function harnessSchema(schema) {
|
|
277
|
+
const properties = { ...(schema.properties || {}) };
|
|
278
|
+
for (const field of DEXTER_FILLED_ARGUMENTS) delete properties[field];
|
|
279
|
+
return {
|
|
280
|
+
...schema,
|
|
281
|
+
properties,
|
|
282
|
+
required: (schema.required || []).filter((field) => !DEXTER_FILLED_ARGUMENTS.includes(field) && field !== 'pageId'),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Action items are validated against their own action's branch, so the
|
|
287
|
+
// top level sees `actions` as a plain array.
|
|
288
|
+
function topLevelSchema(entry) {
|
|
289
|
+
const schema = harnessSchema(entry.schema);
|
|
290
|
+
if (!entry.branches.size) return schema;
|
|
291
|
+
const { items: _items, ...actions } = schema.properties.actions;
|
|
292
|
+
return { ...schema, properties: { ...schema.properties, actions } };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function catalogEntry(tool) {
|
|
296
|
+
const schema = schemaOf(tool);
|
|
297
|
+
const variants = schema.properties?.actions?.items?.anyOf;
|
|
298
|
+
const actions = new Map();
|
|
299
|
+
const branches = new Map();
|
|
300
|
+
for (const variant of Array.isArray(variants) ? variants : []) {
|
|
301
|
+
const action = Object.keys(variant?.properties || {}).find((key) => key !== 'label');
|
|
302
|
+
if (!action || isBlockedWebflowAction(action)) continue;
|
|
303
|
+
actions.set(action, variant.properties[action]);
|
|
304
|
+
branches.set(action, variant);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
name: tool.name,
|
|
308
|
+
description: typeof tool.description === 'string' ? tool.description.trim() : '',
|
|
309
|
+
schema,
|
|
310
|
+
actions,
|
|
311
|
+
branches,
|
|
312
|
+
guide: [],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Ajv reports `data/update_style must …`; name the argument path instead.
|
|
317
|
+
function validationText(errorMessage, root) {
|
|
318
|
+
return String(errorMessage || 'invalid arguments')
|
|
319
|
+
.split(', data')
|
|
320
|
+
.map((part, index) => (index ? `data${part}` : part))
|
|
321
|
+
.map((part) => part.replace(/^data(\S*) /, (_match, path) => `${root}${path.replace(/\//g, '.')}: `))
|
|
322
|
+
.join('; ');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function catalogFromList(payload) {
|
|
326
|
+
const tools = Array.isArray(payload?.tools) ? payload.tools : [];
|
|
327
|
+
return new Map(
|
|
328
|
+
tools
|
|
329
|
+
.filter((tool) => typeof tool?.name === 'string' && !isBlockedWebflowMcpTool(tool.name))
|
|
330
|
+
.map((tool) => [tool.name, catalogEntry(tool)]),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function catalogText(catalog) {
|
|
335
|
+
return [...catalog.values()].map((entry) => {
|
|
336
|
+
const actions = entry.actions.size ? ` Actions: ${[...entry.actions.keys()].join(', ')}.` : '';
|
|
337
|
+
// Some descriptions list their actions too; the catalog line already does.
|
|
338
|
+
const description = entry.description.replace(/\s*\bActions?:[\s\S]*$/, '');
|
|
339
|
+
return `- ${entry.name}: ${clipSentence(description, CATALOG_DESCRIPTION_CHARS)}${actions}`;
|
|
340
|
+
}).join('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function guideSections(text) {
|
|
344
|
+
const sections = [];
|
|
345
|
+
for (const line of String(text || '').split('\n')) {
|
|
346
|
+
const trimmed = line.trim();
|
|
347
|
+
const header = trimmed
|
|
348
|
+
&& !trimmed.startsWith('--')
|
|
349
|
+
&& trimmed.length < 140
|
|
350
|
+
&& (trimmed.endsWith(':') || trimmed.startsWith('###'));
|
|
351
|
+
if (header || sections.length === 0) sections.push({ header: header ? trimmed : '', lines: [] });
|
|
352
|
+
// Advice to use the live-Designer tools Dexter replaces would send the model to them.
|
|
353
|
+
if (!header && DESIGNER_SESSION_TOOL_PATTERN.test(line)) continue;
|
|
354
|
+
sections[sections.length - 1].lines.push(line);
|
|
355
|
+
}
|
|
356
|
+
return sections
|
|
357
|
+
.map((section) => ({ header: section.header, text: section.lines.join('\n').trim() }))
|
|
358
|
+
.filter((section) => section.text !== section.header);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Tool sections of Webflow's guide go to the tools they describe (shown by
|
|
362
|
+
// webflow_tool_help); only the general rules stay in the prompt.
|
|
363
|
+
function splitGuide(text, catalog) {
|
|
364
|
+
const names = [...catalog.keys()].sort((a, b) => b.length - a.length);
|
|
365
|
+
const pattern = names.length
|
|
366
|
+
? new RegExp(`\\b(${names.map((name) => name.replace(/[^a-z0-9_]/gi, '')).join('|')})\\b`, 'g')
|
|
367
|
+
: null;
|
|
368
|
+
const general = [];
|
|
369
|
+
for (const section of guideSections(text)) {
|
|
370
|
+
if (!section.text || DESIGNER_SESSION_GUIDE_SECTION.test(section.header)) continue;
|
|
371
|
+
const inHeader = pattern ? [...new Set(section.header.match(pattern) || [])] : [];
|
|
372
|
+
const inBody = pattern ? section.text.match(pattern) || [] : [];
|
|
373
|
+
const owners = inHeader.length ? inHeader : inBody.slice(0, 1);
|
|
374
|
+
for (const owner of owners) catalog.get(owner)?.guide.push(section.text);
|
|
375
|
+
if (!owners.length || PROMPT_GUIDE_SECTIONS.some((rule) => rule.test(section.header))) {
|
|
376
|
+
general.push(section.text);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return general.join('\n\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function toolHelp(entry, requested) {
|
|
383
|
+
const schema = harnessSchema(entry.schema);
|
|
384
|
+
const unknown = requested.filter((action) => !entry.actions.has(action));
|
|
385
|
+
if (entry.actions.size && unknown.length) {
|
|
386
|
+
throw toolError(
|
|
387
|
+
'WEBFLOW_ACTION_UNKNOWN',
|
|
388
|
+
`${entry.name} has no action ${unknown.join(', ')}. Actions: ${[...entry.actions.keys()].join(', ')}.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const help = { tool: entry.name, description: entry.description };
|
|
392
|
+
if (entry.actions.size) {
|
|
393
|
+
const { actions: _actions, ...rest } = schema.properties;
|
|
394
|
+
help.arguments = rest;
|
|
395
|
+
help.required = schema.required;
|
|
396
|
+
help.actionItem = 'Each actions item is { "<action>": { ...that action\'s arguments } }; Dexter adds the label.';
|
|
397
|
+
if (requested.length) {
|
|
398
|
+
help.actions = Object.fromEntries(requested.map((action) => [action, entry.actions.get(action)]));
|
|
399
|
+
} else {
|
|
400
|
+
help.availableActions = [...entry.actions.keys()];
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
help.arguments = schema.properties;
|
|
404
|
+
help.required = schema.required;
|
|
405
|
+
}
|
|
406
|
+
if (schema.properties.pageId) help.pageId = 'Optional. Dexter uses the current Designer page when you leave it out.';
|
|
407
|
+
if (entry.guide.length) help.guide = entry.guide.join('\n\n');
|
|
408
|
+
return help;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Webflow requires a label on every action item and a build_label on builder items.
|
|
412
|
+
function withLabels(tool, args) {
|
|
413
|
+
if (!Array.isArray(args.actions)) return args;
|
|
414
|
+
const builder = /_builder$/.test(tool);
|
|
415
|
+
return {
|
|
416
|
+
...args,
|
|
417
|
+
actions: args.actions.map((item, index) => {
|
|
418
|
+
if (!isRecord(item)) return item;
|
|
419
|
+
if (builder) return item.build_label ? item : { build_label: `build_${index + 1}`, ...item };
|
|
420
|
+
if (item.label) return item;
|
|
421
|
+
const action = Object.keys(item).find((key) => key !== 'label');
|
|
422
|
+
return { label: action || `action_${index + 1}`, ...item };
|
|
423
|
+
}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function contentItems(result) {
|
|
428
|
+
if (Array.isArray(result?.content)) return result.content;
|
|
429
|
+
if (Array.isArray(result)) return result;
|
|
430
|
+
return [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function textFromContent(items) {
|
|
434
|
+
return items
|
|
435
|
+
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
|
436
|
+
.map((item) => item.text)
|
|
437
|
+
.join('\n');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function imagesFromContent(items) {
|
|
441
|
+
return items
|
|
442
|
+
.filter((item) => item?.type === 'image' && typeof item.data === 'string' && item.data)
|
|
443
|
+
.map((item) => ({
|
|
444
|
+
mimeType: item.mimeType || 'image/png',
|
|
445
|
+
data: item.data,
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Images reach the model as image blocks; their base64 in the text would be read twice.
|
|
450
|
+
function withoutImageData(result) {
|
|
451
|
+
if (!isRecord(result)) return result;
|
|
452
|
+
const stripped = { ...result };
|
|
453
|
+
for (const field of ['images', 'modelImages']) {
|
|
454
|
+
if (!Array.isArray(result[field])) continue;
|
|
455
|
+
stripped[field] = result[field].map((image) => {
|
|
456
|
+
if (!isRecord(image)) return image;
|
|
457
|
+
const { data: _data, ...rest } = image;
|
|
458
|
+
return rest;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return stripped;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function parseToolPayload(result) {
|
|
465
|
+
const items = contentItems(result);
|
|
466
|
+
if (items.length > 0) {
|
|
467
|
+
return {
|
|
468
|
+
text: textFromContent(items),
|
|
469
|
+
images: imagesFromContent(items),
|
|
470
|
+
isError: result?.isError === true,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (typeof result === 'string') return { text: result, images: [], isError: false };
|
|
474
|
+
return { text: JSON.stringify(result ?? {}), images: [], isError: false };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// The MCP SDK reports an HTTP failure as StreamableHTTPError with the status in `code`.
|
|
478
|
+
function statusOf(error) {
|
|
479
|
+
const status = Number(error?.status || error?.statusCode || (typeof error?.code === 'number' ? error.code : 0));
|
|
480
|
+
return Number.isFinite(status) ? status : 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Dexter's API holds the Webflow token and answers 403/409 with { error, code } when it refuses a call.
|
|
484
|
+
function proxyRefusal(error) {
|
|
485
|
+
const status = statusOf(error);
|
|
486
|
+
if (status !== 403 && status !== 409) return null;
|
|
487
|
+
let body = null;
|
|
488
|
+
try {
|
|
489
|
+
body = JSON.parse(/\{[\s\S]*\}/.exec(String(error?.message || ''))?.[0] || 'null');
|
|
490
|
+
} catch {
|
|
491
|
+
body = null;
|
|
492
|
+
}
|
|
493
|
+
return toolError(
|
|
494
|
+
body?.code || 'WEBFLOW_MCP_REFUSED',
|
|
495
|
+
body?.error || 'Dexter could not reach Webflow for this site.',
|
|
496
|
+
status,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function isTimeout(error) {
|
|
501
|
+
return error?.code === 'TIMEOUT'
|
|
502
|
+
|| error?.code === 'ETIMEDOUT'
|
|
503
|
+
|| error?.name === 'TimeoutError'
|
|
504
|
+
|| /timed out|timeout/i.test(String(error?.message || ''));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isNetwork(error) {
|
|
508
|
+
return ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'].includes(error?.code)
|
|
509
|
+
|| /network|fetch failed|socket/i.test(String(error?.message || ''));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isServerFailure(error) {
|
|
513
|
+
const status = statusOf(error);
|
|
514
|
+
if (status === 406 || status >= 500) return true;
|
|
515
|
+
return /unable to connect/i.test(String(error?.message || ''));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Webflow's MCP is reached through Dexter's API, which adds the site's Webflow token server-side.
|
|
519
|
+
async function connectWebflowMcp(endpoint) {
|
|
520
|
+
const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
|
|
521
|
+
import('@modelcontextprotocol/sdk/client/index.js'),
|
|
522
|
+
import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
|
|
523
|
+
]);
|
|
524
|
+
const client = new Client({ name: 'dexter-bridge', version: '0.12.0' });
|
|
525
|
+
const transport = new StreamableHTTPClientTransport(new URL(endpoint.url), {
|
|
526
|
+
requestInit: { headers: endpoint.headers || {} },
|
|
527
|
+
});
|
|
528
|
+
try {
|
|
529
|
+
await client.connect(transport);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
throw proxyRefusal(error) || error;
|
|
532
|
+
}
|
|
533
|
+
return client;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function createWebflowToolRuntime({
|
|
537
|
+
assignment,
|
|
538
|
+
trace,
|
|
539
|
+
onActivity,
|
|
540
|
+
mcpEndpoint,
|
|
541
|
+
callPanelTool,
|
|
542
|
+
findImages,
|
|
543
|
+
readWebsite,
|
|
544
|
+
mcpClient = null,
|
|
545
|
+
} = {}) {
|
|
546
|
+
const siteId = String(assignment?.context?.webflowSiteId || '').trim();
|
|
547
|
+
const ledger = createEvidenceLedger({
|
|
548
|
+
checks: WEBFLOW_CHECKS,
|
|
549
|
+
domains: WEBFLOW_DOMAINS,
|
|
550
|
+
labels: CHECK_LABELS,
|
|
551
|
+
});
|
|
552
|
+
const requestedTools = Array.isArray(assignment?.toolProtocol?.tools)
|
|
553
|
+
? new Set(assignment.toolProtocol.tools)
|
|
554
|
+
: null;
|
|
555
|
+
const initialTools = dexterDefinitions().filter((tool) => !requestedTools || requestedTools.has(tool.name));
|
|
556
|
+
let definitions = initialTools;
|
|
557
|
+
let catalog = new Map();
|
|
558
|
+
let webflowSession = 'start';
|
|
559
|
+
const agentId = `dexter|dexter-bridge|${crypto.randomBytes(4).toString('hex').slice(0, 5)}`;
|
|
560
|
+
const schemaValidator = new AjvJsonSchemaValidator();
|
|
561
|
+
const validators = new Map();
|
|
562
|
+
let connected = mcpClient;
|
|
563
|
+
let mcpServerVersion = mcpClient?.serverVersion || null;
|
|
564
|
+
let guidance = { content: '', hash: null };
|
|
565
|
+
let closed = false;
|
|
566
|
+
let serial = Promise.resolve();
|
|
567
|
+
let server = null;
|
|
568
|
+
let gateway = null;
|
|
569
|
+
let repeatedFailureFingerprint = null;
|
|
570
|
+
let repeatedFailureCount = 0;
|
|
571
|
+
let repeatedFailureStops = 0;
|
|
572
|
+
let serverFailures = 0;
|
|
573
|
+
let readCalls = 0;
|
|
574
|
+
let snapshotCalls = 0;
|
|
575
|
+
let currentPageId = '';
|
|
576
|
+
const abortController = new AbortController();
|
|
577
|
+
|
|
578
|
+
const reportActivity = (event) => {
|
|
579
|
+
try {
|
|
580
|
+
void Promise.resolve(onActivity?.(event)).catch(() => undefined);
|
|
581
|
+
} catch {
|
|
582
|
+
// Observability must never interrupt a Webflow operation.
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
async function ensureClient() {
|
|
587
|
+
if (connected) return connected;
|
|
588
|
+
if (!mcpEndpoint?.url) {
|
|
589
|
+
throw toolError(
|
|
590
|
+
'WEBFLOW_AUTHORIZATION_REQUIRED',
|
|
591
|
+
'Connect Webflow before Dexter can edit this site.',
|
|
592
|
+
409,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
connected = await connectWebflowMcp(mcpEndpoint);
|
|
596
|
+
mcpServerVersion = connected.getServerVersion?.()?.version || mcpServerVersion;
|
|
597
|
+
return connected;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function callMcp(name, args, mutation) {
|
|
601
|
+
const invoke = async () => {
|
|
602
|
+
const client = await ensureClient();
|
|
603
|
+
try {
|
|
604
|
+
return await client.callTool({ name, arguments: args });
|
|
605
|
+
} catch (error) {
|
|
606
|
+
throw proxyRefusal(error) || error;
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
try {
|
|
610
|
+
return await invoke();
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (!mutation && isNetwork(error) && error?.webflowRetried !== true) {
|
|
613
|
+
error.webflowRetried = true;
|
|
614
|
+
try {
|
|
615
|
+
return await invoke();
|
|
616
|
+
} catch (retried) {
|
|
617
|
+
retried.webflowRetried = true;
|
|
618
|
+
throw retried;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Dexter owns the run's site and Webflow's per-call bookkeeping arguments.
|
|
626
|
+
function filledArguments(entry, args) {
|
|
627
|
+
const properties = entry.schema.properties || {};
|
|
628
|
+
const filled = { ...args };
|
|
629
|
+
if ('siteId' in properties && siteId) filled.siteId = siteId;
|
|
630
|
+
if ('session_id' in properties) filled.session_id = webflowSession;
|
|
631
|
+
if ('agent_id' in properties) filled.agent_id = agentId;
|
|
632
|
+
if ('context' in properties) filled.context = WEBFLOW_CALL_CONTEXT;
|
|
633
|
+
return filled;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Webflow issues a session id in the first result's text; reuse it and keep it out of the model's view.
|
|
637
|
+
function withoutSessionIssuance(text) {
|
|
638
|
+
return String(text || '').replace(SESSION_ISSUANCE, (_match, issued) => {
|
|
639
|
+
if (webflowSession === 'start') webflowSession = issued;
|
|
640
|
+
return '';
|
|
641
|
+
}).trim();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function callWebflowTool(entry, args, mutation) {
|
|
645
|
+
const parsed = parseToolPayload(await callMcp(entry.name, filledArguments(entry, args), mutation));
|
|
646
|
+
return { ...parsed, text: withoutSessionIssuance(parsed.text) };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function withCurrentPage(entry, args) {
|
|
650
|
+
if (!('pageId' in (entry.schema.properties || {})) || args.pageId) return args;
|
|
651
|
+
if (currentPageId) return { ...args, pageId: currentPageId };
|
|
652
|
+
if (!(entry.schema.required || []).includes('pageId')) return args;
|
|
653
|
+
throw toolError(
|
|
654
|
+
'WEBFLOW_PAGE_UNKNOWN',
|
|
655
|
+
`${entry.name} needs a page. Call webflow_context so Dexter knows the current page, or pass pageId.`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function validatorFor(key, schema) {
|
|
660
|
+
if (!validators.has(key)) validators.set(key, schemaValidator.getValidator(schema));
|
|
661
|
+
return validators.get(key);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function invalidCall(entry, problem) {
|
|
665
|
+
return toolError(
|
|
666
|
+
'WEBFLOW_CALL_INVALID',
|
|
667
|
+
`Invalid arguments for ${entry.name}: ${problem}. Call webflow_tool_help with this tool and its actions for the exact arguments.`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function validateCall(entry, args) {
|
|
672
|
+
const unknown = entry.actions.size
|
|
673
|
+
? callActions(args).filter((action) => !entry.actions.has(action))
|
|
674
|
+
: [];
|
|
675
|
+
if (unknown.length) {
|
|
676
|
+
throw toolError(
|
|
677
|
+
'WEBFLOW_ACTION_UNKNOWN',
|
|
678
|
+
`${entry.name} cannot run ${unknown.join(', ')}. Actions: ${[...entry.actions.keys()].join(', ')}.`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
const validated = validatorFor(entry.name, topLevelSchema(entry))(args);
|
|
682
|
+
if (!validated.valid) throw invalidCall(entry, validationText(validated.errorMessage, 'arguments'));
|
|
683
|
+
if (!entry.branches.size || !Array.isArray(args.actions)) return;
|
|
684
|
+
args.actions.forEach((item, index) => {
|
|
685
|
+
const named = isRecord(item) ? Object.keys(item).filter((key) => key !== 'label') : [];
|
|
686
|
+
if (named.length !== 1) throw invalidCall(entry, `actions[${index}]: must hold exactly one action`);
|
|
687
|
+
const [action] = named;
|
|
688
|
+
const result = validatorFor(`${entry.name}:${action}`, entry.branches.get(action))(item);
|
|
689
|
+
if (!result.valid) throw invalidCall(entry, validationText(result.errorMessage, `actions[${index}]`));
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function panelImages(result) {
|
|
694
|
+
const images = [
|
|
695
|
+
...(Array.isArray(result?.images) ? result.images : []),
|
|
696
|
+
...(Array.isArray(result?.modelImages) ? result.modelImages : []),
|
|
697
|
+
];
|
|
698
|
+
return images
|
|
699
|
+
.filter((image) => typeof image?.data === 'string' && image.data)
|
|
700
|
+
.map((image) => ({
|
|
701
|
+
mimeType: image.mimeType || 'image/png',
|
|
702
|
+
data: image.data,
|
|
703
|
+
}));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function catalogTool(name) {
|
|
707
|
+
const entry = catalog.get(String(name || ''));
|
|
708
|
+
if (!entry) {
|
|
709
|
+
throw toolError(
|
|
710
|
+
'WEBFLOW_TOOL_UNKNOWN',
|
|
711
|
+
`Unknown Webflow tool: ${name}. Tools: ${[...catalog.keys()].join(', ')}.`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
return entry;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function invokeUnsafe(name, rawArguments = {}) {
|
|
718
|
+
if (closed) {
|
|
719
|
+
throw toolError('WEBFLOW_RUNTIME_CLOSED', 'The Webflow runtime is closed.');
|
|
720
|
+
}
|
|
721
|
+
if (!isRecord(rawArguments)) {
|
|
722
|
+
throw toolError('WEBFLOW_TOOL_INPUT_INVALID', 'Webflow tool arguments must be an object.');
|
|
723
|
+
}
|
|
724
|
+
const known = definitions.some((tool) => tool.name === name);
|
|
725
|
+
if (!known) {
|
|
726
|
+
throw toolError('WEBFLOW_TOOL_UNKNOWN', `Unknown Webflow tool: ${name}`);
|
|
727
|
+
}
|
|
728
|
+
const call = name === 'webflow_call' ? catalogTool(rawArguments.tool) : null;
|
|
729
|
+
const callArguments = call
|
|
730
|
+
? withLabels(call.name, isRecord(rawArguments.arguments) ? rawArguments.arguments : {})
|
|
731
|
+
: null;
|
|
732
|
+
const mutation = name === 'webflow_upload_image'
|
|
733
|
+
|| (call ? classifyWebflowToolCall(call.name, callArguments) === 'mutation' : false);
|
|
734
|
+
const category = name === 'webflow_plan_task'
|
|
735
|
+
? 'plan'
|
|
736
|
+
: mutation
|
|
737
|
+
? 'mutation'
|
|
738
|
+
: 'read';
|
|
739
|
+
const fingerprint = crypto
|
|
740
|
+
.createHash('sha256')
|
|
741
|
+
.update(`${name}\n${JSON.stringify(rawArguments)}`)
|
|
742
|
+
.digest('hex');
|
|
743
|
+
if (
|
|
744
|
+
repeatedFailureFingerprint === fingerprint
|
|
745
|
+
&& repeatedFailureCount >= WEBFLOW_IDENTICAL_FAILURE_LIMIT
|
|
746
|
+
) {
|
|
747
|
+
repeatedFailureStops += 1;
|
|
748
|
+
throw toolError(
|
|
749
|
+
'WEBFLOW_REPEATED_CALL_LOOP',
|
|
750
|
+
'The same Webflow tool call has failed five times in a row. Change the approach or report the concrete blocker.',
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
const startedAt = Date.now();
|
|
754
|
+
const toolCall = {
|
|
755
|
+
name,
|
|
756
|
+
...(call ? { webflowTool: call.name, actions: callActions(callArguments) } : {}),
|
|
757
|
+
category,
|
|
758
|
+
status: 'failed',
|
|
759
|
+
durationMs: 0,
|
|
760
|
+
};
|
|
761
|
+
ledger.noteCall(toolCall);
|
|
762
|
+
if (category === 'read') readCalls += 1;
|
|
763
|
+
reportActivity({
|
|
764
|
+
kind: 'tool_active',
|
|
765
|
+
source: 'webflow',
|
|
766
|
+
name,
|
|
767
|
+
phase: 'started',
|
|
768
|
+
message: mutation ? 'Updating the Webflow site' : 'Inspecting the Webflow site',
|
|
769
|
+
});
|
|
770
|
+
try {
|
|
771
|
+
let text = '';
|
|
772
|
+
let modelImages = [];
|
|
773
|
+
if (call) {
|
|
774
|
+
const pagedArguments = withCurrentPage(call, callArguments);
|
|
775
|
+
validateCall(call, pagedArguments);
|
|
776
|
+
if (mutation) ledger.requirePlan();
|
|
777
|
+
let parsed;
|
|
778
|
+
try {
|
|
779
|
+
parsed = await callWebflowTool(call, pagedArguments, mutation);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (DESIGNER_SESSION_FAILURE.test(String(error?.message || ''))) throw designerSessionError();
|
|
782
|
+
if (!mutation || !isTimeout(error)) throw error;
|
|
783
|
+
parsed = { text: JSON.stringify(WEBFLOW_WRITE_UNKNOWN), images: [], isError: false };
|
|
784
|
+
}
|
|
785
|
+
// Webflow reports a missing Bridge App as a successful result with status:false.
|
|
786
|
+
if (DESIGNER_SESSION_FAILURE.test(parsed.text)) throw designerSessionError();
|
|
787
|
+
if (parsed.isError) {
|
|
788
|
+
throw toolError('WEBFLOW_MCP_TOOL_FAILED', budgetWebflowText(parsed.text || 'Webflow MCP tool failed.'));
|
|
789
|
+
}
|
|
790
|
+
text = budgetWebflowText(parsed.text);
|
|
791
|
+
modelImages = parsed.images;
|
|
792
|
+
if (mutation) ledger.markMutation();
|
|
793
|
+
else ledger.markInspection({ verifies: rawArguments.verifies });
|
|
794
|
+
} else if (name === 'webflow_tool_help') {
|
|
795
|
+
const requested = Array.isArray(rawArguments.actions) ? rawArguments.actions.map(String) : [];
|
|
796
|
+
text = JSON.stringify(toolHelp(catalogTool(rawArguments.tool), requested));
|
|
797
|
+
} else if (name === 'webflow_plan_task') {
|
|
798
|
+
text = JSON.stringify(ledger.declarePlan(rawArguments));
|
|
799
|
+
} else if (name === 'webflow_context') {
|
|
800
|
+
const context = await relayPanel(name, rawArguments);
|
|
801
|
+
if (context?.page?.id) currentPageId = String(context.page.id);
|
|
802
|
+
text = JSON.stringify(context);
|
|
803
|
+
ledger.markInspection();
|
|
804
|
+
} else if (name === 'webflow_snapshot') {
|
|
805
|
+
const relayed = await relayPanel(name, rawArguments);
|
|
806
|
+
modelImages = panelImages(relayed);
|
|
807
|
+
snapshotCalls += 1;
|
|
808
|
+
text = JSON.stringify({ ...withoutImageData(relayed), imageCount: modelImages.length });
|
|
809
|
+
ledger.markInspection();
|
|
810
|
+
} else if (name === 'webflow_upload_image') {
|
|
811
|
+
ledger.requirePlan();
|
|
812
|
+
text = JSON.stringify(await relayPanel(name, rawArguments));
|
|
813
|
+
ledger.markMutation();
|
|
814
|
+
} else if (name === 'webflow_find_images') {
|
|
815
|
+
if (typeof findImages !== 'function') {
|
|
816
|
+
throw toolError('WEBFLOW_IMAGE_SEARCH_UNAVAILABLE', 'Image search is not available in this run.');
|
|
817
|
+
}
|
|
818
|
+
text = JSON.stringify(await findImages({
|
|
819
|
+
query: rawArguments.query,
|
|
820
|
+
orientation: rawArguments.orientation,
|
|
821
|
+
count: rawArguments.count,
|
|
822
|
+
signal: abortController.signal,
|
|
823
|
+
}));
|
|
824
|
+
ledger.markInspection();
|
|
825
|
+
} else if (name === 'webflow_read_website') {
|
|
826
|
+
if (typeof readWebsite !== 'function') {
|
|
827
|
+
throw toolError('WEBFLOW_WEBSITE_READ_UNAVAILABLE', 'Reading websites is not available in this run.');
|
|
828
|
+
}
|
|
829
|
+
const website = await readWebsite({
|
|
830
|
+
url: String(rawArguments.url || '').trim(),
|
|
831
|
+
screenshotTop: Number.isInteger(rawArguments.screenshotTop)
|
|
832
|
+
? rawArguments.screenshotTop
|
|
833
|
+
: undefined,
|
|
834
|
+
signal: abortController.signal,
|
|
835
|
+
});
|
|
836
|
+
modelImages = panelImages(website);
|
|
837
|
+
text = JSON.stringify(website?.result ?? website ?? {});
|
|
838
|
+
ledger.markInspection();
|
|
839
|
+
}
|
|
840
|
+
repeatedFailureFingerprint = null;
|
|
841
|
+
repeatedFailureCount = 0;
|
|
842
|
+
serverFailures = 0;
|
|
843
|
+
toolCall.status = 'succeeded';
|
|
844
|
+
toolCall.durationMs = Date.now() - startedAt;
|
|
845
|
+
const due = ledger.evidenceForRead(category === 'read' || name === 'webflow_snapshot');
|
|
846
|
+
return {
|
|
847
|
+
result: text,
|
|
848
|
+
...(modelImages.length ? { modelImages } : {}),
|
|
849
|
+
...(due.length ? { evidenceStillDue: due } : {}),
|
|
850
|
+
};
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (repeatedFailureFingerprint === fingerprint) repeatedFailureCount += 1;
|
|
853
|
+
else {
|
|
854
|
+
repeatedFailureFingerprint = fingerprint;
|
|
855
|
+
repeatedFailureCount = 1;
|
|
856
|
+
}
|
|
857
|
+
if (isServerFailure(error)) {
|
|
858
|
+
serverFailures += 1;
|
|
859
|
+
if (serverFailures >= WEBFLOW_SERVER_FAILURE_LIMIT) {
|
|
860
|
+
throw toolError(
|
|
861
|
+
'WEBFLOW_MCP_UNAVAILABLE',
|
|
862
|
+
'Webflow MCP kept failing (server error or connection). Stop and report the blocker instead of retrying the same call.',
|
|
863
|
+
503,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
toolCall.durationMs = Date.now() - startedAt;
|
|
868
|
+
toolCall.errorCode = error?.code || 'WEBFLOW_TOOL_FAILED';
|
|
869
|
+
toolCall.errorMessage = budgetWebflowText(String(error?.message || 'Webflow tool failed.'), 300);
|
|
870
|
+
trace?.warn?.('webflow_tool_failed', {
|
|
871
|
+
name,
|
|
872
|
+
code: error?.code,
|
|
873
|
+
message: error?.message,
|
|
874
|
+
});
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function relayPanel(name, args) {
|
|
880
|
+
if (typeof callPanelTool !== 'function') {
|
|
881
|
+
throw toolError('PANEL_NOT_CONNECTED', 'The Webflow panel is not connected to this run.', 409);
|
|
882
|
+
}
|
|
883
|
+
const payload = await callPanelTool({
|
|
884
|
+
callId: crypto.randomUUID(),
|
|
885
|
+
name,
|
|
886
|
+
arguments: args,
|
|
887
|
+
signal: abortController.signal,
|
|
888
|
+
});
|
|
889
|
+
// The relay's own failure is top-level; the panel's failure is nested in result.
|
|
890
|
+
const result = payload?.ok === false ? payload : payload?.result ?? payload;
|
|
891
|
+
if (result?.ok === false) {
|
|
892
|
+
throw toolError(
|
|
893
|
+
result.error?.code || 'PANEL_TOOL_FAILED',
|
|
894
|
+
result.error?.message || 'The Webflow panel could not run that tool.',
|
|
895
|
+
409,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function invoke(name, args = {}) {
|
|
902
|
+
const operation = serial.then(() => invokeUnsafe(name, args));
|
|
903
|
+
serial = operation.catch(() => undefined);
|
|
904
|
+
return operation;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function preflight() {
|
|
908
|
+
const client = await ensureClient();
|
|
909
|
+
catalog = catalogFromList(await client.listTools());
|
|
910
|
+
const guide = catalog.get('webflow_guide_tool');
|
|
911
|
+
// The guide is loaded here, split between the prompt and webflow_tool_help.
|
|
912
|
+
catalog.delete('webflow_guide_tool');
|
|
913
|
+
const parts = [];
|
|
914
|
+
if (guide) parts.push(splitGuide((await callWebflowTool(guide, {}, false)).text, catalog));
|
|
915
|
+
definitions = [...initialTools, ...(catalog.size ? gatewayDefinitions([...catalog.keys()]) : [])];
|
|
916
|
+
if (catalog.size) {
|
|
917
|
+
parts.push(`Webflow tool catalog. Call these through webflow_call; get exact arguments from webflow_tool_help first:\n${catalogText(catalog)}`);
|
|
918
|
+
}
|
|
919
|
+
const content = clipBytes(parts.filter(Boolean).join('\n\n'), WEBFLOW_GUIDANCE_BUDGET_BYTES);
|
|
920
|
+
const hash = content
|
|
921
|
+
? crypto.createHash('sha256').update(content).digest('hex')
|
|
922
|
+
: null;
|
|
923
|
+
const cached = guidanceCache.get(siteId);
|
|
924
|
+
guidance = cached?.hash === hash && hash
|
|
925
|
+
? cached
|
|
926
|
+
: { content, hash };
|
|
927
|
+
if (siteId && hash) guidanceCache.set(siteId, guidance);
|
|
928
|
+
return { unresolvedTargets: [] };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function startGateway() {
|
|
932
|
+
if (gateway) return gateway;
|
|
933
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
934
|
+
server = createServer((request, response) => {
|
|
935
|
+
const fail = (status, code, message) => {
|
|
936
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
937
|
+
response.end(JSON.stringify({ ok: false, error: { code, message } }));
|
|
938
|
+
};
|
|
939
|
+
if (request.method !== 'POST' || request.url !== '/tool') {
|
|
940
|
+
fail(404, 'WEBFLOW_GATEWAY_NOT_FOUND', 'Webflow tool endpoint not found.');
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
944
|
+
fail(401, 'WEBFLOW_GATEWAY_UNAUTHORIZED', 'Invalid Webflow tool token.');
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
let body = '';
|
|
948
|
+
request.on('data', (chunk) => {
|
|
949
|
+
body += chunk.toString('utf8');
|
|
950
|
+
if (body.length > 512 * 1024) request.destroy();
|
|
951
|
+
});
|
|
952
|
+
request.on('end', async () => {
|
|
953
|
+
try {
|
|
954
|
+
const payload = JSON.parse(body || '{}');
|
|
955
|
+
const result = await invoke(String(payload.name || ''), payload.arguments || {});
|
|
956
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
957
|
+
response.end(JSON.stringify({ ok: true, result }));
|
|
958
|
+
} catch (error) {
|
|
959
|
+
fail(
|
|
960
|
+
Number(error?.statusCode) || 400,
|
|
961
|
+
error?.code || 'WEBFLOW_TOOL_FAILED',
|
|
962
|
+
error?.message || 'Webflow tool failed.',
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
await new Promise((resolve, reject) => {
|
|
968
|
+
server.once('error', reject);
|
|
969
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
970
|
+
});
|
|
971
|
+
const address = server.address();
|
|
972
|
+
gateway = { url: `http://127.0.0.1:${address.port}/tool`, token };
|
|
973
|
+
return gateway;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
get definitions() {
|
|
978
|
+
return definitions;
|
|
979
|
+
},
|
|
980
|
+
get toolNames() {
|
|
981
|
+
return definitions.map((tool) => tool.name);
|
|
982
|
+
},
|
|
983
|
+
preflight,
|
|
984
|
+
loadProjectGuidance: async () => ({ content: guidance.content, inventory: '' }),
|
|
985
|
+
configurePromptContext() {},
|
|
986
|
+
invoke,
|
|
987
|
+
startGateway,
|
|
988
|
+
directWorkspace: true,
|
|
989
|
+
hasPendingChanges: () => false,
|
|
990
|
+
sourceHash: () => null,
|
|
991
|
+
inspect: async () => {
|
|
992
|
+
await serial;
|
|
993
|
+
return {
|
|
994
|
+
synchronized: ledger.verification().structuralCompleted,
|
|
995
|
+
changed: ledger.mutationCount() > 0,
|
|
996
|
+
verification: ledger.verification(),
|
|
997
|
+
};
|
|
998
|
+
},
|
|
999
|
+
synchronize: async () => ({ synchronized: true }),
|
|
1000
|
+
finalizeCompletion: (completion) => ledger.finalize(completion),
|
|
1001
|
+
verificationRepairPrompt: () => ledger.repairPrompt(),
|
|
1002
|
+
unblockedFailurePrompt: (completion) => {
|
|
1003
|
+
const lastGate = [...ledger.calls()].reverse().find((call) => call.category === 'plan' || call.category === 'mutation');
|
|
1004
|
+
return ledger.unblockedFailurePrompt(completion, lastGate?.status === 'failed');
|
|
1005
|
+
},
|
|
1006
|
+
summary: () => ({
|
|
1007
|
+
siteId,
|
|
1008
|
+
toolCalls: ledger.calls(),
|
|
1009
|
+
mutationCalls: ledger.mutationCount(),
|
|
1010
|
+
readCalls,
|
|
1011
|
+
snapshotCalls,
|
|
1012
|
+
snapshotsTaken: snapshotCalls,
|
|
1013
|
+
guidanceHash: guidance.hash,
|
|
1014
|
+
mcpToolCount: catalog.size,
|
|
1015
|
+
mcpServerVersion,
|
|
1016
|
+
toolCallCount: ledger.calls().length,
|
|
1017
|
+
verification: ledger.verification(),
|
|
1018
|
+
loopProtection: {
|
|
1019
|
+
identicalFailureLimit: WEBFLOW_IDENTICAL_FAILURE_LIMIT,
|
|
1020
|
+
blockedCalls: repeatedFailureStops,
|
|
1021
|
+
serverFailureLimit: WEBFLOW_SERVER_FAILURE_LIMIT,
|
|
1022
|
+
serverFailures,
|
|
1023
|
+
},
|
|
1024
|
+
}),
|
|
1025
|
+
fatalInfrastructureError: () => null,
|
|
1026
|
+
waitForFatalInfrastructure: () => new Promise(() => undefined),
|
|
1027
|
+
close: async ({ waitForPending = true } = {}) => {
|
|
1028
|
+
closed = true;
|
|
1029
|
+
abortController.abort();
|
|
1030
|
+
if (waitForPending) await serial.catch(() => undefined);
|
|
1031
|
+
if (connected?.close && connected !== mcpClient) {
|
|
1032
|
+
await connected.close().catch(() => undefined);
|
|
1033
|
+
}
|
|
1034
|
+
if (server) {
|
|
1035
|
+
if (!waitForPending) server.closeAllConnections?.();
|
|
1036
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1037
|
+
server = null;
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export function __resetWebflowToolRuntimeForTests() {
|
|
1044
|
+
guidanceCache.clear();
|
|
1045
|
+
}
|