moltbrowser-mcp 0.0.1

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.
@@ -0,0 +1,968 @@
1
+ /**
2
+ * Hub Tools
3
+ *
4
+ * Defines MCP tool schemas and handlers for:
5
+ * - hub_execute: Single static tool for running hub-sourced tools (solves Cursor/frozen-schema issue)
6
+ * - contribute_create-config: Create an empty config shell (returns ID)
7
+ * - contribute_add-tool: Add a single tool to a config with flat execution fields
8
+ * - contribute_vote-on-tool: Upvote or downvote a tool in a config
9
+ *
10
+ * hub_execute and contribute_* tools are always-available (not dynamic per-page tools).
11
+ */
12
+
13
+ const hub = require('./hub-client.js');
14
+ const { translate } = require('./execution-translator.js');
15
+
16
+ // --- hub_execute: static tool for running hub-sourced tools ---
17
+
18
+ /**
19
+ * Get the MCP tool definition for hub_execute.
20
+ * This is always present in tools/list (when hub is enabled).
21
+ */
22
+ function getHubExecuteToolDefinition() {
23
+ return {
24
+ name: 'hub_execute',
25
+ description: 'Execute a pre-configured hub tool for the current site. After navigating, the response will list available tool names and their arguments. Use this tool to run them.',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ toolName: {
30
+ type: 'string',
31
+ description: 'The hub tool name to run (e.g. "get-rows", "search-products"). Shown in the navigation response.',
32
+ },
33
+ arguments: {
34
+ type: 'object',
35
+ description: 'Arguments to pass to the hub tool. See the navigation response for required/optional arguments per tool.',
36
+ additionalProperties: true,
37
+ },
38
+ },
39
+ required: ['toolName'],
40
+ },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Handle a hub_execute call.
46
+ * Looks up the tool in hubToolMap, translates execution metadata to Playwright code,
47
+ * and runs it via browser_run_code on the upstream.
48
+ *
49
+ * @param {object} args - { toolName: string, arguments?: object }
50
+ * @param {Map} hubToolMap - The dynamic tool registry
51
+ * @param {Client} upstreamClient - The upstream Playwright MCP client
52
+ * @returns {Promise<object>} MCP tool response
53
+ */
54
+ async function handleHubExecute(args, hubToolMap, upstreamClient) {
55
+ const { toolName, arguments: toolArgs = {} } = args;
56
+
57
+ if (!toolName) {
58
+ return {
59
+ content: [{ type: 'text', text: 'Error: toolName is required. Specify which hub tool to run.' }],
60
+ isError: true,
61
+ };
62
+ }
63
+
64
+ // Tolerate both "get-rows" and "hub_get-rows"
65
+ const lookupName = toolName.startsWith('hub_') ? toolName : `hub_${toolName}`;
66
+ const hubEntry = hubToolMap.get(lookupName);
67
+
68
+ if (!hubEntry) {
69
+ if (hubToolMap.size === 0) {
70
+ return {
71
+ content: [{ type: 'text', text: `No hub tools available. Navigate to a page first with browser_navigate — hub tools are discovered automatically after navigation.` }],
72
+ isError: true,
73
+ };
74
+ }
75
+
76
+ const available = Array.from(hubToolMap.keys()).map(k => k.replace(/^hub_/, '')).join(', ');
77
+ return {
78
+ content: [{ type: 'text', text: `Hub tool "${toolName}" not found. Available tools: ${available}` }],
79
+ isError: true,
80
+ };
81
+ }
82
+
83
+ return await executeHubTool(upstreamClient, hubEntry, toolArgs);
84
+ }
85
+
86
+ /**
87
+ * Execute a hub-sourced tool by translating its execution metadata
88
+ * to Playwright code and running it via browser_run_code on the upstream.
89
+ *
90
+ * @param {Client} upstreamClient
91
+ * @param {{ tool: object, execution: object, configId: string }} hubEntry
92
+ * @param {object} args - The arguments the agent provided
93
+ * @returns {Promise<object>} MCP tool response
94
+ */
95
+ async function executeHubTool(upstreamClient, hubEntry, args) {
96
+ // Translate once, reuse if needed
97
+ const code = translate(hubEntry.execution, args);
98
+
99
+ if (!code || code.trim() === '') {
100
+ return {
101
+ content: [{ type: 'text', text: 'No actions to execute for this tool (empty execution metadata).' }],
102
+ };
103
+ }
104
+
105
+ try {
106
+ const result = await upstreamClient.callTool({
107
+ name: 'browser_run_code',
108
+ arguments: { code },
109
+ });
110
+
111
+ if (result.isError) {
112
+ const errorText = result.content?.map(c => c.text || '').join('\n') || 'Unknown error';
113
+ return {
114
+ content: [{
115
+ type: 'text',
116
+ text: `Hub tool "${hubEntry.tool.name}" failed:\n${errorText}\n\nUse browser_fallback to access generic Playwright tools.`,
117
+ }],
118
+ isError: true,
119
+ };
120
+ }
121
+
122
+ return result;
123
+ } catch (err) {
124
+ return {
125
+ content: [{
126
+ type: 'text',
127
+ text: `Hub tool "${hubEntry.tool.name}" failed: ${err.message}\n\nUse browser_fallback to access generic Playwright tools.`,
128
+ }],
129
+ isError: true,
130
+ };
131
+ }
132
+ }
133
+
134
+ // --- contribute_* tools: hub write operations ---
135
+
136
+ /**
137
+ * Tool definitions for hub write operations.
138
+ * Each entry has: name, description, inputSchema, handler.
139
+ */
140
+ const hubWriteTools = [
141
+ {
142
+ name: 'contribute_create-config',
143
+ description: [
144
+ 'Create a new WebMCP config shell on the hub. Returns a config ID.',
145
+ 'After creating, use contribute_add-tool to add tools with CSS selectors.',
146
+ '',
147
+ 'Example:',
148
+ 'contribute_create-config({ domain: "example.com", urlPattern: "example.com/products", title: "Example Store", description: "Search and browse products" })',
149
+ '→ "Config created! ID: abc123. Use contribute_add-tool to add tools."',
150
+ ].join('\n'),
151
+ inputSchema: {
152
+ type: 'object',
153
+ properties: {
154
+ domain: { type: 'string', description: "Bare domain without protocol. Example: 'github.com', NOT 'https://github.com'" },
155
+ urlPattern: { type: 'string', description: "URL scope in 'domain/path' format. Use the current page path from browser_navigate. Patterns: 'example.com' (all pages — only for site-wide tools like nav/search), 'example.com/dashboard' (exact page), 'example.com/users/:id' (dynamic segment — matches /users/alice, /users/123), 'example.com/admin/**' (wildcard — matches /admin and everything under it). IMPORTANT: scope to the specific page or section, not the bare domain. SPA OVERLAY WARNING: On SPAs, clicking a button (e.g. Reply) may change the URL to an overlay path (e.g. /compose/post). The extension re-discovers tools on every URL change, so the new URL's config completely replaces the previous one. If a tool action causes a URL change, the destination URL's config MUST include ALL tools needed to complete the workflow (e.g. fill-text + submit), not just the triggering action." },
156
+ title: { type: 'string', description: 'Name the page or section, not the task you performed. GOOD: "X Home Feed", "GitHub Repo Page", "Reddit Community Feed", "YouTube Watch Page". BAD: "X Home - Post Composer", "GitHub Create Issue", "Reddit Submit Post". Keep it short and page-centric.' },
157
+ description: { type: 'string', description: 'Describe the site or page in general terms — what it IS, not what you are currently doing on it. Think of it as a caption for the page type. GOOD: "YouTube - a platform for sharing and discovering video content", "GitHub Issues - track and manage project issues and bug reports", "Reddit feed - a community platform for posts, discussions, and voting". BAD: "Search for videos on youtube.com", "Like a post", "Open the compose dialog". Avoid task-specific or session-specific phrases.' },
158
+ tags: {
159
+ type: 'array',
160
+ items: { type: 'string' },
161
+ description: 'Optional tags for categorization',
162
+ },
163
+ },
164
+ required: ['domain', 'urlPattern', 'title', 'description'],
165
+ },
166
+ },
167
+ {
168
+ name: 'contribute_add-tool',
169
+ description: [
170
+ 'Add a single tool to an existing config. Provide flat execution fields — inputSchema and execution objects are built automatically.',
171
+ '',
172
+ 'IMPORTANT: Always create read-only extraction tools first (get-posts, get-content, list-items).',
173
+ 'These are the most useful tools for other agents. Then add action tools (search, click, fill) if relevant.',
174
+ '',
175
+ 'Prefer small, single-action tools over multi-step workflows. For complex interactions (e.g. posting a tweet),',
176
+ 'create one tool per action (click-compose, fill-tweet-text, click-post-button) — the calling agent will chain them.',
177
+ '',
178
+ 'EXAMPLE — read-only list extraction (most common, start here):',
179
+ 'contribute_add-tool({',
180
+ ' configId: "abc123",',
181
+ ' name: "get-posts",',
182
+ ' description: "Get all visible posts on the page",',
183
+ ' selector: ".feed",',
184
+ ' resultSelector: ".feed .post",',
185
+ ' resultExtract: "list"',
186
+ '})',
187
+ '',
188
+ 'EXAMPLE — read-only page content:',
189
+ 'contribute_add-tool({',
190
+ ' configId: "abc123",',
191
+ ' name: "get-article",',
192
+ ' description: "Get the main article text",',
193
+ ' selector: "article",',
194
+ ' resultSelector: "article",',
195
+ ' resultExtract: "text"',
196
+ '})',
197
+ '',
198
+ 'EXAMPLE — single click action (e.g. open compose dialog):',
199
+ 'contribute_add-tool({',
200
+ ' configId: "abc123",',
201
+ ' name: "click-compose-button",',
202
+ ' description: "Click the compose/new tweet button to open the tweet editor",',
203
+ ' steps: [{ action: "click", selector: "[data-testid=SideNav_NewTweet_Button]" }]',
204
+ '})',
205
+ '',
206
+ 'EXAMPLE — single fill action (e.g. type into a text field):',
207
+ 'contribute_add-tool({',
208
+ ' configId: "abc123",',
209
+ ' name: "fill-tweet-text",',
210
+ ' description: "Fill the tweet text area with content",',
211
+ ' selector: "[data-testid=tweetTextarea_0]",',
212
+ ' fields: [{ type: "textarea", selector: "[data-testid=tweetTextarea_0]", name: "text", description: "The tweet text to type" }]',
213
+ '})',
214
+ '',
215
+ 'EXAMPLE — single click to submit:',
216
+ 'contribute_add-tool({',
217
+ ' configId: "abc123",',
218
+ ' name: "click-post-button",',
219
+ ' description: "Click the Post button to submit the tweet",',
220
+ ' steps: [{ action: "click", selector: "[data-testid=tweetButtonInline]" }]',
221
+ '})',
222
+ '',
223
+ 'EXAMPLE — search form (fill + submit is still atomic enough):',
224
+ 'contribute_add-tool({',
225
+ ' configId: "abc123",',
226
+ ' name: "search-products",',
227
+ ' description: "Search products by keyword",',
228
+ ' selector: "#searchForm",',
229
+ ' autosubmit: true,',
230
+ ' submitSelector: "#searchBtn",',
231
+ ' submitAction: "click",',
232
+ ' fields: [{ type: "text", selector: "#searchInput", name: "query", description: "Search term" }],',
233
+ ' resultSelector: ".results li",',
234
+ ' resultExtract: "list"',
235
+ '})',
236
+ '',
237
+ 'KEY RULES:',
238
+ '- Tools must be GENERAL, not hardcoded to a specific instance or position. WRONG: "like-first-post" (hardcoded to first). RIGHT: "like-post" with a parameter that identifies which post (e.g. postIndex: number, or postText: string used in a :has-text selector). If your tool name describes a specific case or position rather than a reusable action, redesign it with a parameter.',
239
+ '- Prefer small, single-action tools over multi-step workflows',
240
+ '- For multi-step interactions, create one tool per action (click-compose, fill-text, click-submit) — the calling agent will chain them',
241
+ '- Click tools use steps: [{ action: "click", selector: "..." }] — do NOT use autosubmit: true for standalone buttons',
242
+ '- Fill tools need: selector + one field entry',
243
+ '- Tool names must be kebab-case with a verb: "get-posts", "click-compose-button", "fill-tweet-text", "search-products"',
244
+ '- Read-only tools only need: selector, resultSelector, resultExtract. No autosubmit, no fields.',
245
+ '- Use fields[] for form inputs — each field\'s name becomes a tool parameter automatically',
246
+ '- resultExtract options: text, html, attribute, list, table',
247
+ '- Advanced: steps[] and inputProperties are available for complex cases, but prefer atomic tools instead',
248
+ '- Use evaluate steps sparingly: { action: "evaluate", value: "document.querySelector(\'...\').click()" } runs raw JS — useful for force-clicking elements blocked by overlays',
249
+ '- If you use a condition step, verify the selector in EACH branch with browser_snapshot — never assume',
250
+ ' two contexts (e.g. a dialog vs. an inline composer) share the same test IDs or element structure',
251
+ '- SPA OVERLAY TRAP: If a tool click changes the URL (e.g. reply button → /compose/post), the extension',
252
+ ' re-discovers tools at the NEW URL. That new config replaces all previous tools. You MUST:',
253
+ ' 1. Create a config for the destination URL',
254
+ ' 2. Include ALL tools needed there (fill + submit, not just submit)',
255
+ ' 3. Verify every selector with browser_snapshot at the destination URL — never copy selectors',
256
+ ' from a different URL\'s config (e.g. tweetButtonInline on /home ≠ tweetButton in reply dialog)',
257
+ ].join('\n'),
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ configId: { type: 'string', description: 'The config ID from contribute_create-config' },
262
+ name: { type: 'string', description: 'Kebab-case tool name with verb: "search-products", "get-rows"' },
263
+ description: { type: 'string', description: 'What the tool does' },
264
+
265
+ // Flat execution fields:
266
+ selector: { type: 'string', description: 'Container CSS selector' },
267
+ autosubmit: { type: 'boolean', description: 'Whether to click a submit button after filling fields. Only for form tools with fields[]. For standalone button clicks, use steps: [{action: "click", selector: "..."}] instead.' },
268
+ submitSelector: { type: 'string', description: 'CSS selector for the submit button (only used with autosubmit + fields[])' },
269
+ submitAction: { type: 'string', description: 'How to submit: "click" or "enter" (only used with autosubmit + fields[])' },
270
+ resultSelector: { type: 'string', description: 'CSS selector for the result area' },
271
+ resultExtract: { type: 'string', description: 'How to extract results: text, html, attribute, list, table' },
272
+ resultAttribute: { type: 'string', description: 'HTML attribute to read when resultExtract is "attribute" (e.g. "href", "data-id")' },
273
+ resultRequired: { type: 'boolean', description: 'If true, the tool throws an error when no results are found instead of silently returning empty' },
274
+ resultWaitSelector: { type: 'string', description: 'CSS selector to wait for before extracting results' },
275
+ resultDelay: { type: 'number', description: 'Milliseconds to wait before extracting results' },
276
+
277
+ // Input fields (for form-based tools):
278
+ fields: {
279
+ type: 'array',
280
+ description: 'Form input fields. Each field becomes a tool parameter automatically.',
281
+ items: {
282
+ type: 'object',
283
+ properties: {
284
+ type: { type: 'string', description: 'Field type: text, number, textarea, select, checkbox, radio, date, file, hidden' },
285
+ selector: { type: 'string', description: 'CSS selector for the field' },
286
+ name: { type: 'string', description: 'Parameter name (used in inputSchema)' },
287
+ description: { type: 'string', description: 'What this field is for' },
288
+ required: { type: 'boolean', description: 'Whether the field is required (default: true)' },
289
+ defaultValue: { description: 'Default value pre-filled into the field' },
290
+ options: {
291
+ type: 'array',
292
+ description: 'Explicit options for select/radio fields',
293
+ items: {
294
+ type: 'object',
295
+ properties: {
296
+ value: { type: 'string' },
297
+ label: { type: 'string' },
298
+ selector: { type: 'string', description: 'Optional per-option CSS selector' },
299
+ },
300
+ required: ['value', 'label'],
301
+ },
302
+ },
303
+ dynamicOptions: { type: 'boolean', description: 'True for select fields whose options are populated at runtime' },
304
+ },
305
+ required: ['type', 'selector', 'name', 'description'],
306
+ },
307
+ },
308
+
309
+ // Multi-step tools:
310
+ steps: {
311
+ type: 'array',
312
+ description: 'Steps for multi-step tools. Use {{paramName}} for parameter interpolation.',
313
+ items: {
314
+ type: 'object',
315
+ properties: {
316
+ action: { type: 'string', description: 'Action: click, fill, select, wait, extract, navigate, scroll, condition, evaluate' },
317
+ selector: { type: 'string', description: 'CSS selector for the action target (required for: click, fill, select, wait, extract, scroll, condition)' },
318
+ value: { type: 'string', description: 'Value for fill/select/evaluate actions. Use {{paramName}} for params.' },
319
+ url: { type: 'string', description: 'URL for navigate action' },
320
+ state: { type: 'string', description: 'State to check for condition action (e.g. "visible", "hidden")' },
321
+ },
322
+ required: ['action'],
323
+ },
324
+ },
325
+
326
+ // Explicit input parameters (for steps-based tools):
327
+ inputProperties: {
328
+ type: 'object',
329
+ description: 'Tool input parameters. Format: { paramName: "type|description" } or { paramName: "description" }. Used when steps[] reference {{paramName}} templates.',
330
+ additionalProperties: { type: 'string' },
331
+ },
332
+ },
333
+ required: ['configId', 'name', 'description'],
334
+ },
335
+ },
336
+ {
337
+ name: 'contribute_update-tool',
338
+ description: [
339
+ 'Update an existing tool in a config. Finds the tool by name and replaces it with the new fields you provide.',
340
+ 'Use this to fix broken selectors, update execution metadata, or change a tool\'s description.',
341
+ 'Same flat fields as contribute_add-tool — inputSchema and execution are rebuilt automatically.',
342
+ '',
343
+ 'EXAMPLE — fix a broken click-post-button tool with an updated selector:',
344
+ 'contribute_update-tool({',
345
+ ' configId: "abc123",',
346
+ ' name: "click-post-button",',
347
+ ' description: "Click the Post button to submit the tweet",',
348
+ ' steps: [{ action: "click", selector: "[data-testid=tweetButtonInline]" }]',
349
+ '})',
350
+ ].join('\n'),
351
+ inputSchema: {
352
+ type: 'object',
353
+ properties: {
354
+ configId: { type: 'string', description: 'The config ID containing the tool to update' },
355
+ name: { type: 'string', description: 'The name of the existing tool to update (must already exist in the config)' },
356
+ description: { type: 'string', description: 'Updated description of what the tool does' },
357
+
358
+ // Flat execution fields (same as contribute_add-tool):
359
+ selector: { type: 'string', description: 'Container CSS selector' },
360
+ autosubmit: { type: 'boolean', description: 'Whether to click a submit button after filling fields. Omit for read-only extraction tools.' },
361
+ submitSelector: { type: 'string', description: 'CSS selector for the submit button' },
362
+ submitAction: { type: 'string', description: 'How to submit: "click" or "enter"' },
363
+ resultSelector: { type: 'string', description: 'CSS selector for the result area' },
364
+ resultExtract: { type: 'string', description: 'How to extract results: text, html, attribute, list, table' },
365
+ resultAttribute: { type: 'string', description: 'HTML attribute to read when resultExtract is "attribute" (e.g. "href", "data-id")' },
366
+ resultRequired: { type: 'boolean', description: 'If true, the tool throws an error when no results are found instead of silently returning empty' },
367
+ resultWaitSelector: { type: 'string', description: 'CSS selector to wait for before extracting results' },
368
+ resultDelay: { type: 'number', description: 'Milliseconds to wait before extracting results' },
369
+ fields: {
370
+ type: 'array',
371
+ description: 'Form input fields. Each field becomes a tool parameter automatically.',
372
+ items: {
373
+ type: 'object',
374
+ properties: {
375
+ type: { type: 'string', description: 'Field type: text, number, textarea, select, checkbox, radio, date, file, hidden' },
376
+ selector: { type: 'string', description: 'CSS selector for the field' },
377
+ name: { type: 'string', description: 'Parameter name (used in inputSchema)' },
378
+ description: { type: 'string', description: 'What this field is for' },
379
+ required: { type: 'boolean', description: 'Whether the field is required (default: true)' },
380
+ defaultValue: { description: 'Default value pre-filled into the field' },
381
+ options: {
382
+ type: 'array',
383
+ description: 'Explicit options for select/radio fields',
384
+ items: {
385
+ type: 'object',
386
+ properties: {
387
+ value: { type: 'string' },
388
+ label: { type: 'string' },
389
+ selector: { type: 'string', description: 'Optional per-option CSS selector' },
390
+ },
391
+ required: ['value', 'label'],
392
+ },
393
+ },
394
+ dynamicOptions: { type: 'boolean', description: 'True for select fields whose options are populated at runtime' },
395
+ },
396
+ required: ['type', 'selector', 'name', 'description'],
397
+ },
398
+ },
399
+ steps: {
400
+ type: 'array',
401
+ description: 'Steps for multi-step tools. Use {{paramName}} for parameter interpolation.',
402
+ items: {
403
+ type: 'object',
404
+ properties: {
405
+ action: { type: 'string', description: 'Action: click, fill, select, wait, extract, navigate, scroll, condition, evaluate' },
406
+ selector: { type: 'string', description: 'CSS selector for the action target (required for: click, fill, select, wait, extract, scroll, condition)' },
407
+ value: { type: 'string', description: 'Value for fill/select/evaluate actions. Use {{paramName}} for params.' },
408
+ url: { type: 'string', description: 'URL for navigate action' },
409
+ state: { type: 'string', description: 'State to check for condition action (e.g. "visible", "hidden")' },
410
+ },
411
+ required: ['action'],
412
+ },
413
+ },
414
+ inputProperties: {
415
+ type: 'object',
416
+ description: 'Tool input parameters. Format: { paramName: "type|description" } or { paramName: "description" }.',
417
+ additionalProperties: { type: 'string' },
418
+ },
419
+ },
420
+ required: ['configId', 'name'],
421
+ },
422
+ },
423
+ {
424
+ name: 'contribute_delete-tool',
425
+ description: 'Delete a specific tool from a WebMCP Hub config. The config owner or the tool\'s own contributor can delete tools. Use this to remove a tool that is broken, incorrect, or no longer needed.',
426
+ inputSchema: {
427
+ type: 'object',
428
+ properties: {
429
+ configId: { type: 'string', description: 'The config ID containing the tool to delete' },
430
+ toolName: { type: 'string', description: "The name of the tool to delete, e.g. 'search-products'" },
431
+ },
432
+ required: ['configId', 'toolName'],
433
+ },
434
+ },
435
+ {
436
+ name: 'contribute_vote-on-tool',
437
+ description: 'Upvote or downvote a tool within a WebMCP Hub config. Each user gets one vote per tool — voting the same direction again removes the vote. Use this to signal quality: upvote tools that work, downvote broken ones.',
438
+ inputSchema: {
439
+ type: 'object',
440
+ properties: {
441
+ configId: { type: 'string', description: 'The config ID (from navigation tool list or hub lookup)' },
442
+ toolName: { type: 'string', description: "The tool name to vote on, e.g. 'search-repos'" },
443
+ vote: { type: 'number', description: '1 for upvote, -1 for downvote' },
444
+ },
445
+ required: ['configId', 'toolName', 'vote'],
446
+ },
447
+ },
448
+ ];
449
+
450
+ // --- Local validation ---
451
+
452
+ const VALID_RESULT_EXTRACTS = new Set(['text', 'html', 'attribute', 'list', 'table']);
453
+ const VALID_STEP_ACTIONS = new Set(['navigate', 'click', 'fill', 'select', 'wait', 'extract', 'scroll', 'condition', 'evaluate']);
454
+
455
+ /**
456
+ * Validate that each step has the fields required for its action type.
457
+ * Returns an array of human-readable error strings with exact paths.
458
+ *
459
+ * @param {Array} steps
460
+ * @param {string} [prefix] - e.g. "steps" or "execution.steps"
461
+ * @returns {string[]}
462
+ */
463
+ function validateStepFields(steps, prefix = 'steps') {
464
+ const errors = [];
465
+ for (let j = 0; j < steps.length; j++) {
466
+ const s = steps[j];
467
+ const p = `${prefix}[${j}]`;
468
+ if (['click', 'fill', 'select', 'wait', 'extract', 'scroll'].includes(s.action) && !s.selector) {
469
+ errors.push(`${p}.action "${s.action}" requires a "selector" field`);
470
+ }
471
+ if (['fill', 'select', 'evaluate'].includes(s.action) && !s.value) {
472
+ errors.push(`${p}.action "${s.action}" requires a "value" field`);
473
+ }
474
+ if (s.action === 'navigate' && !s.url) {
475
+ errors.push(`${p}.action "navigate" requires a "url" field`);
476
+ }
477
+ if (s.action === 'condition' && !s.selector) {
478
+ errors.push(`${p}.action "condition" requires a "selector" field`);
479
+ }
480
+ if (s.action === 'condition' && !s.state) {
481
+ errors.push(`${p}.action "condition" requires a "state" field`);
482
+ }
483
+ }
484
+ return errors;
485
+ }
486
+
487
+ /**
488
+ * Validate a config's tools array before sending to the hub.
489
+ * Returns an array of human-readable error strings with exact paths.
490
+ * Returns empty array if valid.
491
+ */
492
+ function validateTools(tools) {
493
+ const errors = [];
494
+
495
+ if (!Array.isArray(tools)) {
496
+ errors.push('tools: must be an array');
497
+ return errors;
498
+ }
499
+
500
+ for (let i = 0; i < tools.length; i++) {
501
+ const t = tools[i];
502
+ const p = `tools[${i}]`;
503
+
504
+ if (!t || typeof t !== 'object') {
505
+ errors.push(`${p}: must be an object, got ${typeof t}`);
506
+ continue;
507
+ }
508
+
509
+ // name — only hard requirement we still block on
510
+ if (!t.name || typeof t.name !== 'string') {
511
+ errors.push(`${p}.name: required, must be a string`);
512
+ } else if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(t.name)) {
513
+ errors.push(`${p}.name: "${t.name}" is not kebab-case with a verb. Use format like "search-products", "get-rows", "add-item"`);
514
+ }
515
+
516
+ // description
517
+ if (!t.description || typeof t.description !== 'string') {
518
+ errors.push(`${p}.description: required, must be a string`);
519
+ }
520
+
521
+ // If execution is present and is an object, validate its internals.
522
+ if (t.execution && typeof t.execution === 'object') {
523
+ const ex = t.execution;
524
+ const ep = `${p}.execution`;
525
+
526
+ if (ex.resultExtract && !VALID_RESULT_EXTRACTS.has(ex.resultExtract)) {
527
+ errors.push(`${ep}.resultExtract: "${ex.resultExtract}" is invalid. Must be one of: ${[...VALID_RESULT_EXTRACTS].join(', ')}`);
528
+ }
529
+
530
+ if (ex.steps && Array.isArray(ex.steps)) {
531
+ for (let j = 0; j < ex.steps.length; j++) {
532
+ const s = ex.steps[j];
533
+ if (s.action && !VALID_STEP_ACTIONS.has(s.action)) {
534
+ errors.push(`${ep}.steps[${j}].action: "${s.action}" is invalid. Must be one of: ${[...VALID_STEP_ACTIONS].join(', ')}`);
535
+ }
536
+ }
537
+ errors.push(...validateStepFields(ex.steps, `${ep}.steps`));
538
+ }
539
+ }
540
+ }
541
+
542
+ return errors;
543
+ }
544
+
545
+ /**
546
+ * Validate top-level config fields. Returns error strings with exact paths.
547
+ */
548
+ function validateConfig(args) {
549
+ const errors = [];
550
+
551
+ if (args.domain && /^https?:\/\//.test(args.domain)) {
552
+ errors.push(`domain: "${args.domain}" includes a protocol. Use bare domain like "github.com", not "https://github.com"`);
553
+ }
554
+
555
+ if (args.urlPattern && /^https?:\/\//.test(args.urlPattern)) {
556
+ errors.push(`urlPattern: "${args.urlPattern}" includes a protocol. Use "domain/path" format like "github.com/search"`);
557
+ }
558
+
559
+ if (args.tools) {
560
+ errors.push(...validateTools(args.tools));
561
+ }
562
+
563
+ return errors;
564
+ }
565
+
566
+ /**
567
+ * Build an inputSchema object from flat fields.
568
+ * - If fields[] is provided, each field's name becomes a property (type defaults to "string").
569
+ * - If inputProperties is provided, parse "type|description" or just "description" format.
570
+ * - If neither is provided, returns an empty schema.
571
+ */
572
+ function buildInputSchema(args) {
573
+ const properties = {};
574
+ const required = [];
575
+
576
+ // From fields[] — each field becomes a parameter
577
+ if (Array.isArray(args.fields)) {
578
+ for (const field of args.fields) {
579
+ if (!field.name) continue;
580
+ properties[field.name] = {
581
+ type: field.type === 'number' ? 'number' : field.type === 'checkbox' ? 'boolean' : 'string',
582
+ description: field.description || field.name,
583
+ };
584
+ if (field.required !== false) {
585
+ required.push(field.name);
586
+ }
587
+ }
588
+ }
589
+
590
+ // From inputProperties — explicit parameter definitions
591
+ if (args.inputProperties && typeof args.inputProperties === 'object') {
592
+ for (const [paramName, spec] of Object.entries(args.inputProperties)) {
593
+ if (typeof spec !== 'string') continue;
594
+ const parts = spec.split('|');
595
+ if (parts.length >= 2) {
596
+ properties[paramName] = { type: parts[0], description: parts.slice(1).join('|') };
597
+ } else {
598
+ properties[paramName] = { type: 'string', description: parts[0] };
599
+ }
600
+ if (!required.includes(paramName)) {
601
+ required.push(paramName);
602
+ }
603
+ }
604
+ }
605
+
606
+ const schema = { type: 'object', properties };
607
+ if (required.length > 0) schema.required = required;
608
+ return schema;
609
+ }
610
+
611
+ /**
612
+ * Build an execution object from flat args.
613
+ * Returns null if no execution-relevant fields are provided.
614
+ */
615
+ function buildExecution(args) {
616
+ const hasExecFields = args.selector || args.resultSelector || args.submitSelector ||
617
+ args.fields || args.steps;
618
+
619
+ if (!hasExecFields) return null;
620
+
621
+ const execution = {
622
+ selector: args.selector || 'body',
623
+ autosubmit: args.autosubmit ?? false,
624
+ };
625
+
626
+ if (args.submitSelector) execution.submitSelector = args.submitSelector;
627
+ if (args.submitAction) execution.submitAction = args.submitAction;
628
+ if (args.resultSelector) execution.resultSelector = args.resultSelector;
629
+ if (args.resultExtract) execution.resultExtract = args.resultExtract;
630
+ if (args.resultAttribute) execution.resultAttribute = args.resultAttribute;
631
+ if (args.resultRequired !== undefined) execution.resultRequired = args.resultRequired;
632
+ if (args.resultWaitSelector) execution.resultWaitSelector = args.resultWaitSelector;
633
+ if (args.resultDelay) execution.resultDelay = args.resultDelay;
634
+ if (Array.isArray(args.fields)) execution.fields = args.fields;
635
+ if (Array.isArray(args.steps)) execution.steps = args.steps;
636
+
637
+ return execution;
638
+ }
639
+
640
+ /**
641
+ * Handle a hub write tool call.
642
+ *
643
+ * @param {string} toolName
644
+ * @param {object} args
645
+ * @returns {Promise<{ content: Array<{ type: string, text: string }>, isError?: boolean }>}
646
+ */
647
+ async function handleHubWriteTool(toolName, args) {
648
+ try {
649
+ if (toolName === 'contribute_create-config') {
650
+ // Validate flat fields
651
+ const validationErrors = validateConfig(args);
652
+ if (validationErrors.length > 0) {
653
+ return {
654
+ content: [{ type: 'text', text: `Config validation failed:\n\n${validationErrors.map(e => `- ${e}`).join('\n')}` }],
655
+ isError: true,
656
+ };
657
+ }
658
+
659
+ // Explicitly ignore any tools the agent passes — tools are added via contribute_add-tool
660
+ const result = await hub.uploadConfig({
661
+ domain: args.domain,
662
+ urlPattern: args.urlPattern,
663
+ title: args.title,
664
+ description: args.description,
665
+ tools: [],
666
+ tags: args.tags,
667
+ });
668
+
669
+ if (result.status === 409) {
670
+ return {
671
+ content: [{ type: 'text', text: `A config already exists for this domain+urlPattern. Existing config ID: ${result.existingId}. You can add tools to it directly with contribute_add-tool using that ID.` }],
672
+ isError: true,
673
+ };
674
+ }
675
+
676
+ if (result.error) {
677
+ return {
678
+ content: [{ type: 'text', text: `Error creating config: ${result.error}` }],
679
+ isError: true,
680
+ };
681
+ }
682
+
683
+ const configId = result.config?.id || 'unknown';
684
+ return {
685
+ content: [{ type: 'text', text: `Config created! ID: ${configId}. Now use contribute_add-tool to add tools with CSS selectors.` }],
686
+ };
687
+ }
688
+
689
+ if (toolName === 'contribute_add-tool') {
690
+ const { configId, name, description } = args;
691
+
692
+ if (!configId) {
693
+ return {
694
+ content: [{ type: 'text', text: 'Error: configId is required. Create a config first with contribute_create-config.' }],
695
+ isError: true,
696
+ };
697
+ }
698
+ if (!name || typeof name !== 'string') {
699
+ return {
700
+ content: [{ type: 'text', text: 'Error: name is required (kebab-case with verb, e.g. "search-products").' }],
701
+ isError: true,
702
+ };
703
+ }
704
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(name)) {
705
+ return {
706
+ content: [{ type: 'text', text: `Error: name "${name}" is not kebab-case with a verb. Use format like "search-products", "get-rows", "add-item".` }],
707
+ isError: true,
708
+ };
709
+ }
710
+ if (!description || typeof description !== 'string') {
711
+ return {
712
+ content: [{ type: 'text', text: 'Error: description is required.' }],
713
+ isError: true,
714
+ };
715
+ }
716
+
717
+ // Build inputSchema and execution from flat fields
718
+ const inputSchema = buildInputSchema(args);
719
+ const execution = buildExecution(args);
720
+
721
+ // Validate execution internals if present
722
+ if (execution) {
723
+ if (execution.resultExtract && !VALID_RESULT_EXTRACTS.has(execution.resultExtract)) {
724
+ return {
725
+ content: [{ type: 'text', text: `Error: resultExtract "${execution.resultExtract}" is invalid. Must be one of: ${[...VALID_RESULT_EXTRACTS].join(', ')}` }],
726
+ isError: true,
727
+ };
728
+ }
729
+ if (execution.steps && Array.isArray(execution.steps)) {
730
+ for (let j = 0; j < execution.steps.length; j++) {
731
+ const s = execution.steps[j];
732
+ if (s.action && !VALID_STEP_ACTIONS.has(s.action)) {
733
+ return {
734
+ content: [{ type: 'text', text: `Error: steps[${j}].action "${s.action}" is invalid. Must be one of: ${[...VALID_STEP_ACTIONS].join(', ')}` }],
735
+ isError: true,
736
+ };
737
+ }
738
+ }
739
+ const stepErrors = validateStepFields(execution.steps);
740
+ if (stepErrors.length > 0) {
741
+ return {
742
+ content: [{ type: 'text', text: `Error: invalid step fields:\n\n${stepErrors.map(e => `- ${e}`).join('\n')}` }],
743
+ isError: true,
744
+ };
745
+ }
746
+ }
747
+ }
748
+
749
+ // Build the tool object
750
+ const newTool = { name, description, inputSchema };
751
+ if (execution) newTool.execution = execution;
752
+
753
+ // POST the single tool directly — no need to fetch the full config first
754
+ const result = await hub.addTool(configId, newTool);
755
+ if (result.status === 409) {
756
+ return {
757
+ content: [{ type: 'text', text: `A tool named "${name}" already exists in config ${configId}. Use contribute_update-tool to update it, or choose a different name.` }],
758
+ isError: true,
759
+ };
760
+ }
761
+ if (result.error) {
762
+ return {
763
+ content: [{ type: 'text', text: `Error adding tool to config: ${result.error}` }],
764
+ isError: true,
765
+ };
766
+ }
767
+
768
+ const warnings = [];
769
+ if (!execution) {
770
+ warnings.push('Warning: No execution fields provided — this tool won\'t be executable by other agents. Consider adding selector, resultSelector, fields, or steps.');
771
+ }
772
+
773
+ hub.clearCache();
774
+ const msg = `Tool "${name}" added to config ${configId}!${warnings.length > 0 ? '\n\n' + warnings.join('\n') : ''}`;
775
+ return {
776
+ content: [{ type: 'text', text: msg }],
777
+ };
778
+ }
779
+
780
+ if (toolName === 'contribute_update-tool') {
781
+ const { configId, name } = args;
782
+
783
+ if (!configId) {
784
+ return {
785
+ content: [{ type: 'text', text: 'Error: configId is required.' }],
786
+ isError: true,
787
+ };
788
+ }
789
+ if (!name || typeof name !== 'string') {
790
+ return {
791
+ content: [{ type: 'text', text: 'Error: name is required (the name of the existing tool to update).' }],
792
+ isError: true,
793
+ };
794
+ }
795
+
796
+ // Fetch existing config
797
+ const existing = await hub.getConfig(configId);
798
+ if (existing.error) {
799
+ return {
800
+ content: [{ type: 'text', text: `Error fetching config ${configId}: ${existing.error}` }],
801
+ isError: true,
802
+ };
803
+ }
804
+
805
+ const existingTools = existing.config?.tools || [];
806
+ const toolIndex = existingTools.findIndex(t => t.name === name);
807
+ if (toolIndex === -1) {
808
+ const available = existingTools.map(t => `"${t.name}"`).join(', ');
809
+ return {
810
+ content: [{ type: 'text', text: `Error: tool "${name}" not found in config ${configId}. Available tools: ${available || '(none)'}` }],
811
+ isError: true,
812
+ };
813
+ }
814
+
815
+ // Build new inputSchema and execution from flat fields
816
+ const inputSchema = buildInputSchema(args);
817
+ const execution = buildExecution(args);
818
+
819
+ // Validate execution internals if present
820
+ if (execution) {
821
+ if (execution.resultExtract && !VALID_RESULT_EXTRACTS.has(execution.resultExtract)) {
822
+ return {
823
+ content: [{ type: 'text', text: `Error: resultExtract "${execution.resultExtract}" is invalid. Must be one of: ${[...VALID_RESULT_EXTRACTS].join(', ')}` }],
824
+ isError: true,
825
+ };
826
+ }
827
+ if (execution.steps && Array.isArray(execution.steps)) {
828
+ for (let j = 0; j < execution.steps.length; j++) {
829
+ const s = execution.steps[j];
830
+ if (s.action && !VALID_STEP_ACTIONS.has(s.action)) {
831
+ return {
832
+ content: [{ type: 'text', text: `Error: steps[${j}].action "${s.action}" is invalid. Must be one of: ${[...VALID_STEP_ACTIONS].join(', ')}` }],
833
+ isError: true,
834
+ };
835
+ }
836
+ }
837
+ const stepErrors = validateStepFields(execution.steps);
838
+ if (stepErrors.length > 0) {
839
+ return {
840
+ content: [{ type: 'text', text: `Error: invalid step fields:\n\n${stepErrors.map(e => `- ${e}`).join('\n')}` }],
841
+ isError: true,
842
+ };
843
+ }
844
+ }
845
+ }
846
+
847
+ // Build the replacement tool — keep existing description/inputSchema/execution if not provided
848
+ const existingTool = existingTools[toolIndex];
849
+ const updatedTool = {
850
+ name,
851
+ description: args.description || existingTool.description,
852
+ inputSchema: Object.keys(inputSchema.properties || {}).length > 0 ? inputSchema : existingTool.inputSchema,
853
+ };
854
+ if (execution) {
855
+ updatedTool.execution = execution;
856
+ } else if (existingTool.execution) {
857
+ updatedTool.execution = existingTool.execution;
858
+ }
859
+
860
+ // Update the tool in-place via PATCH (atomic — no delete-then-add risk)
861
+ const { name: _toolName, ...toolUpdates } = updatedTool;
862
+ const updateResult = await hub.updateTool(configId, name, toolUpdates);
863
+ if (updateResult.error) {
864
+ return {
865
+ content: [{ type: 'text', text: `Error updating tool: ${updateResult.error}` }],
866
+ isError: true,
867
+ };
868
+ }
869
+
870
+ hub.clearCache();
871
+ return {
872
+ content: [{ type: 'text', text: `Tool "${name}" updated in config ${configId}!` }],
873
+ };
874
+ }
875
+
876
+ if (toolName === 'contribute_delete-tool') {
877
+ const { configId, toolName: name } = args;
878
+
879
+ if (!configId) {
880
+ return {
881
+ content: [{ type: 'text', text: 'Error: configId is required.' }],
882
+ isError: true,
883
+ };
884
+ }
885
+ if (!name) {
886
+ return {
887
+ content: [{ type: 'text', text: 'Error: toolName is required.' }],
888
+ isError: true,
889
+ };
890
+ }
891
+
892
+ const result = await hub.deleteTool(configId, name);
893
+
894
+ if (result.error) {
895
+ return {
896
+ content: [{ type: 'text', text: `Error deleting tool: ${result.error}` }],
897
+ isError: true,
898
+ };
899
+ }
900
+
901
+ hub.clearCache();
902
+ return {
903
+ content: [{ type: 'text', text: `Tool "${name}" deleted from config ${configId}. Config now has ${result.config?.tools?.length ?? 0} tool(s).` }],
904
+ };
905
+ }
906
+
907
+ if (toolName === 'contribute_vote-on-tool') {
908
+ if (args.vote !== 1 && args.vote !== -1) {
909
+ return {
910
+ content: [{ type: 'text', text: 'Error: vote must be 1 or -1' }],
911
+ isError: true,
912
+ };
913
+ }
914
+
915
+ const result = await hub.voteOnTool(args.configId, args.toolName, args.vote);
916
+
917
+ if (result.error) {
918
+ return {
919
+ content: [{ type: 'text', text: `Error voting: ${result.error}` }],
920
+ isError: true,
921
+ };
922
+ }
923
+
924
+ const r = result.result;
925
+ const label = r.userVote === 1 ? 'upvoted' : r.userVote === -1 ? 'downvoted' : 'removed vote';
926
+ return {
927
+ content: [{ type: 'text', text: `Vote recorded: ${label} tool "${r.toolName}" in config ${r.configId}. Current score: ${r.score}` }],
928
+ };
929
+ }
930
+
931
+ return {
932
+ content: [{ type: 'text', text: `Unknown hub tool: ${toolName}` }],
933
+ isError: true,
934
+ };
935
+ } catch (err) {
936
+ return {
937
+ content: [{ type: 'text', text: `Hub unreachable: ${err.message}` }],
938
+ isError: true,
939
+ };
940
+ }
941
+ }
942
+
943
+ /**
944
+ * Get the list of hub write tool definitions (for inclusion in listTools).
945
+ * Returns MCP Tool objects (name, description, inputSchema).
946
+ */
947
+ function getHubWriteToolDefinitions() {
948
+ return hubWriteTools.map(t => ({
949
+ name: t.name,
950
+ description: t.description,
951
+ inputSchema: t.inputSchema,
952
+ }));
953
+ }
954
+
955
+ /**
956
+ * Check if a tool name is a hub write tool.
957
+ */
958
+ function isHubWriteTool(name) {
959
+ return hubWriteTools.some(t => t.name === name);
960
+ }
961
+
962
+ module.exports = {
963
+ getHubExecuteToolDefinition,
964
+ handleHubExecute,
965
+ getHubWriteToolDefinitions,
966
+ handleHubWriteTool,
967
+ isHubWriteTool,
968
+ };