mcpbrowser 0.3.35 → 0.3.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpbrowser",
3
- "version": "0.3.35",
3
+ "version": "0.3.36",
4
4
  "mcpName": "io.github.cherchyk/mcpbrowser",
5
5
  "type": "module",
6
6
  "description": "MCP browser server - fetch web pages using real Chrome/Edge/Brave browser. Handles authentication, SSO, CAPTCHAs, and anti-bot protection. Browser automation for AI assistants.",
@@ -28,7 +28,7 @@ import { getBrowser, getValidatedPage } from '../core/browser.js';
28
28
  import { extractAndProcessHtml, waitForPageReady } from '../core/page.js';
29
29
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
30
30
  import logger from '../core/logger.js';
31
- import { getPluginNextSteps } from '../core/plugin-loader.js';
31
+ import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
32
32
 
33
33
  /**
34
34
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -38,7 +38,7 @@ import { getPluginNextSteps } from '../core/plugin-loader.js';
38
38
  * Structured response for click_element with JS fallback metadata
39
39
  */
40
40
  export class ClickWithFallbackResponse extends MCPResponse {
41
- constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [] }) {
41
+ constructor({ status, fallbackUsed = false, nativeAttempt, fallbackAttempt, postClickWait, currentUrl, html = null, message, nextSteps = [], recommendedPlugins = [] }) {
42
42
  super(nextSteps);
43
43
  this.status = status;
44
44
  this.fallbackUsed = fallbackUsed;
@@ -48,6 +48,7 @@ export class ClickWithFallbackResponse extends MCPResponse {
48
48
  this.currentUrl = currentUrl;
49
49
  this.html = html;
50
50
  this.message = message;
51
+ this.recommendedPlugins = recommendedPlugins;
51
52
  }
52
53
 
53
54
  _getAdditionalFields() {
@@ -59,7 +60,8 @@ export class ClickWithFallbackResponse extends MCPResponse {
59
60
  postClickWait: this.postClickWait,
60
61
  currentUrl: this.currentUrl,
61
62
  html: this.html,
62
- message: this.message
63
+ message: this.message,
64
+ recommendedPlugins: this.recommendedPlugins
63
65
  };
64
66
  }
65
67
 
@@ -329,12 +331,12 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
329
331
 
330
332
  const nextSteps = returnHtml
331
333
  ? [
334
+ ...(html ? getPluginNextSteps(currentUrl, html) : []),
332
335
  "Use MCPBrowser's click_element again to navigate further",
333
336
  "Use MCPBrowser's type_text to fill forms if needed",
334
337
  "Use MCPBrowser's get_current_html to refresh page state",
335
338
  "Use MCPBrowser's take_screenshot if page has popups or visual content that's hard to parse from HTML",
336
- "Use MCPBrowser's close_tab when finished",
337
- ...(html ? getPluginNextSteps(currentUrl, html) : [])
339
+ "Use MCPBrowser's close_tab when finished"
338
340
  ]
339
341
  : [
340
342
  "Use MCPBrowser's get_current_html to see updated page state",
@@ -354,7 +356,8 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
354
356
  currentUrl,
355
357
  html,
356
358
  message,
357
- nextSteps
359
+ nextSteps,
360
+ recommendedPlugins: html ? getRecommendedPlugins(currentUrl, html) : []
358
361
  });
359
362
  } catch (err) {
360
363
  logger.error(`click_element failed: ${err.message}`);
@@ -7,7 +7,7 @@ import { waitForPageReady } from '../core/page.js';
7
7
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
8
8
  import logger from '../core/logger.js';
9
9
  import { serializeExecutionResult } from '../utils.js';
10
- import { getPluginNextSteps } from '../core/plugin-loader.js';
10
+ import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
11
11
 
12
12
  // Shared execution defaults for script actions
13
13
  export const EXECUTION_TIMEOUT_DEFAULT_MS = 30_000;
@@ -22,7 +22,7 @@ export const EXECUTION_RESULT_MAX_BYTES = 100_000;
22
22
  * Structured response for execute_javascript action
23
23
  */
24
24
  export class ExecuteJavascriptResponse extends MCPResponse {
25
- constructor({ result, type, executionTimeMs, truncated = false, urlChanged = false, currentUrl = '', error = null, nextSteps = [] }) {
25
+ constructor({ result, type, executionTimeMs, truncated = false, urlChanged = false, currentUrl = '', error = null, nextSteps = [], recommendedPlugins = [] }) {
26
26
  super(nextSteps);
27
27
 
28
28
  this.result = result;
@@ -32,6 +32,7 @@ export class ExecuteJavascriptResponse extends MCPResponse {
32
32
  this.urlChanged = urlChanged;
33
33
  this.currentUrl = currentUrl;
34
34
  this.error = error;
35
+ this.recommendedPlugins = recommendedPlugins;
35
36
  }
36
37
 
37
38
  _getAdditionalFields() {
@@ -42,7 +43,8 @@ export class ExecuteJavascriptResponse extends MCPResponse {
42
43
  truncated: this.truncated,
43
44
  urlChanged: this.urlChanged,
44
45
  currentUrl: this.currentUrl,
45
- error: this.error || undefined
46
+ error: this.error || undefined,
47
+ recommendedPlugins: this.recommendedPlugins
46
48
  };
47
49
  }
48
50
 
@@ -224,11 +226,12 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
224
226
  urlChanged,
225
227
  currentUrl,
226
228
  nextSteps: [
229
+ ...getPluginNextSteps(currentUrl, ''),
227
230
  'Use click_element or type_text for follow-up actions',
228
231
  'Inspect urlChanged to decide if navigation occurred',
229
- serialization.truncated ? 'Narrow your selector or reduce returned fields to avoid truncation' : 'Proceed with the returned data',
230
- ...getPluginNextSteps(currentUrl, '')
231
- ]
232
+ serialization.truncated ? 'Narrow your selector or reduce returned fields to avoid truncation' : 'Proceed with the returned data'
233
+ ],
234
+ recommendedPlugins: getRecommendedPlugins(currentUrl, '')
232
235
  });
233
236
  }
234
237
 
@@ -8,7 +8,7 @@ import { getOrCreatePage, queueRequest, navigateToUrl, waitForPageReady, extract
8
8
  import { isLikelyAuthUrl, waitForAuth } from '../core/auth.js';
9
9
  import { MCPResponse, ErrorResponse, HttpStatusResponse, InformationalResponse } from '../core/responses.js';
10
10
  import logger from '../core/logger.js';
11
- import { getPluginNextSteps } from '../core/plugin-loader.js';
11
+ import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
12
12
 
13
13
  /**
14
14
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -26,8 +26,9 @@ export class FetchPageSuccessResponse extends MCPResponse {
26
26
  * @param {string} currentUrl - Final URL after redirects
27
27
  * @param {string} html - Page HTML content
28
28
  * @param {string[]} nextSteps - Suggested next actions
29
+ * @param {Array} [recommendedPlugins] - Detected plugin metadata
29
30
  */
30
- constructor(currentUrl, html, nextSteps) {
31
+ constructor(currentUrl, html, nextSteps, recommendedPlugins = []) {
31
32
  super(nextSteps);
32
33
 
33
34
  if (typeof currentUrl !== 'string') {
@@ -39,12 +40,14 @@ export class FetchPageSuccessResponse extends MCPResponse {
39
40
 
40
41
  this.currentUrl = currentUrl;
41
42
  this.html = html;
43
+ this.recommendedPlugins = recommendedPlugins;
42
44
  }
43
45
 
44
46
  _getAdditionalFields() {
45
47
  return {
46
48
  currentUrl: this.currentUrl,
47
- html: this.html
49
+ html: this.html,
50
+ recommendedPlugins: this.recommendedPlugins
48
51
  };
49
52
  }
50
53
 
@@ -221,13 +224,14 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, postLoadWait }
221
224
  page.url(),
222
225
  processedHtml,
223
226
  [
227
+ ...getPluginNextSteps(page.url(), processedHtml),
224
228
  "Use MCPBrowser's click_element to interact with buttons/links on the page",
225
229
  "Use MCPBrowser's type_text to fill in form fields",
226
230
  "Use MCPBrowser's get_current_html to re-check page state after interactions",
227
231
  "Use MCPBrowser's take_screenshot if page has charts, images, or complex visual layout that's hard to understand from HTML",
228
- "Use MCPBrowser's close_tab when finished to free browser resources",
229
- ...getPluginNextSteps(page.url(), processedHtml)
230
- ]
232
+ "Use MCPBrowser's close_tab when finished to free browser resources"
233
+ ],
234
+ getRecommendedPlugins(page.url(), processedHtml)
231
235
  );
232
236
  } catch (err) {
233
237
  logger.error(`fetch_webpage failed: ${err.message || String(err)}`);
@@ -6,7 +6,7 @@ import { getBrowser, getValidatedPage } from '../core/browser.js';
6
6
  import { extractAndProcessHtml } from '../core/page.js';
7
7
  import { MCPResponse, InformationalResponse } from '../core/responses.js';
8
8
  import logger from '../core/logger.js';
9
- import { getPluginNextSteps } from '../core/plugin-loader.js';
9
+ import { getPluginNextSteps, getRecommendedPlugins } from '../core/plugin-loader.js';
10
10
 
11
11
  /**
12
12
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -24,8 +24,9 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
24
24
  * @param {string} currentUrl - Current page URL
25
25
  * @param {string} html - Page HTML content
26
26
  * @param {string[]} nextSteps - Suggested next actions
27
+ * @param {Array} [recommendedPlugins] - Detected plugin metadata
27
28
  */
28
- constructor(currentUrl, html, nextSteps) {
29
+ constructor(currentUrl, html, nextSteps, recommendedPlugins = []) {
29
30
  super(nextSteps);
30
31
 
31
32
  if (typeof currentUrl !== 'string') {
@@ -37,12 +38,14 @@ export class GetCurrentHtmlSuccessResponse extends MCPResponse {
37
38
 
38
39
  this.currentUrl = currentUrl;
39
40
  this.html = html;
41
+ this.recommendedPlugins = recommendedPlugins;
40
42
  }
41
43
 
42
44
  _getAdditionalFields() {
43
45
  return {
44
46
  currentUrl: this.currentUrl,
45
- html: this.html
47
+ html: this.html,
48
+ recommendedPlugins: this.recommendedPlugins
46
49
  };
47
50
  }
48
51
 
@@ -158,12 +161,13 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true }) {
158
161
  currentUrl,
159
162
  html,
160
163
  [
164
+ ...getPluginNextSteps(currentUrl, html),
161
165
  "Use MCPBrowser's click_element to interact with elements",
162
166
  "Use MCPBrowser's type_text to fill forms",
163
167
  "Use MCPBrowser's take_screenshot if page layout or visual content is hard to understand from HTML",
164
- "Use MCPBrowser's close_tab to free resources when done",
165
- ...getPluginNextSteps(currentUrl, html)
166
- ]
168
+ "Use MCPBrowser's close_tab to free resources when done"
169
+ ],
170
+ getRecommendedPlugins(currentUrl, html)
167
171
  );
168
172
  } catch (err) {
169
173
  logger.error(`get_current_html failed: ${err.message}`);
@@ -263,14 +263,12 @@ export function detectPlugins(url, html) {
263
263
  if (match && match.matched) {
264
264
  const confidence = typeof match.confidence === 'number' ? match.confidence : 1.0;
265
265
 
266
- // Build nextSteps from plugin info
266
+ // Build nextSteps from plugin info — concise, actionable
267
267
  const info = plugin.getInfo();
268
- const topActions = (info.actions || []).slice(0, 3);
269
- const actionSummary = topActions.map(a => a.name).join(', ');
268
+ const recommendationText = info.recommendation || info.description || 'Site-specific automation available.';
270
269
 
271
270
  const nextSteps = [
272
- `Plugin "${name}" detected use plugin_info({ plugin: '${name}' }) to see all available actions`,
273
- ...(actionSummary ? [`Top actions: ${actionSummary}. Use plugin_action({ plugin: '${name}', action: '<name>' }) to execute`] : [])
271
+ `Recommended: use "${name}" plugin to ${recommendationText.charAt(0).toLowerCase() + recommendationText.slice(1).replace(/\.$/, '')}. See recommendedPlugins for available actions.`
274
272
  ];
275
273
 
276
274
  results.push({ pluginName: name, confidence, nextSteps });
@@ -301,6 +299,29 @@ export function getPluginNextSteps(url, html) {
301
299
  return steps;
302
300
  }
303
301
 
302
+ /**
303
+ * Build the recommendedPlugins payload for structuredContent.
304
+ * Returns full plugin metadata including action catalog with params,
305
+ * so agents can call actions directly without needing plugin_info.
306
+ * @param {string} url - Current page URL
307
+ * @param {string} html - Extracted page HTML
308
+ * @returns {Array<{ plugin: string, recommendation: string, actions: Array, usage: string }>}
309
+ */
310
+ export function getRecommendedPlugins(url, html) {
311
+ const detections = detectPlugins(url, html);
312
+ return detections.map(d => {
313
+ const plugin = loadedPlugins.get(d.pluginName);
314
+ const info = plugin.getInfo();
315
+ const recommendationText = info.recommendation || info.description || 'Site-specific automation available.';
316
+ return {
317
+ plugin: d.pluginName,
318
+ recommendation: recommendationText,
319
+ actions: info.actions || [],
320
+ usage: `plugin_action({ plugin: '${d.pluginName}', action: '<name>', params: {...} })`
321
+ };
322
+ });
323
+ }
324
+
304
325
  // ============================================================================
305
326
  // ACCESSORS
306
327
  // ============================================================================
@@ -131,6 +131,7 @@ export function getActions() {
131
131
  */
132
132
  export function getInfo() {
133
133
  return {
134
+ recommendation: "Interact with example.test pages — list items and view item details.",
134
135
  description: "Example stub plugin for testing MCPBrowser's plugin system. Lists items and retrieves item details from example.test pages.",
135
136
  targetPages: ["Example test page (example.test)"],
136
137
  authFlow: "No authentication required — example.test is a test domain",
@@ -0,0 +1,185 @@
1
+ /**
2
+ * check-availability.js — Check whether a time slot is free or busy.
3
+ *
4
+ * Tier usage:
5
+ * T1: calendarNavigate + buildViewPath to navigate to day view for the date
6
+ * T3: extractVisibleEvents for event data extraction
7
+ * T4: EVENT_CHIP selector via waitForCalendar
8
+ */
9
+
10
+ import { ErrorResponse } from '../../../core/responses.js';
11
+ import logger from '../../../core/logger.js';
12
+ import {
13
+ checkPrecondition,
14
+ calendarNavigate,
15
+ buildViewPath,
16
+ waitForCalendar,
17
+ extractVisibleEvents,
18
+ GCalActionResponse
19
+ } from '../helpers.js';
20
+ import { EVENT_CHIP } from '../selectors.js';
21
+
22
+ /**
23
+ * Parse HH:MM time string to total minutes since midnight.
24
+ * @param {string} timeStr - Time in HH:MM format
25
+ * @returns {number} Minutes since midnight
26
+ */
27
+ function timeToMinutes(timeStr) {
28
+ const [h, m] = timeStr.split(':').map(Number);
29
+ return h * 60 + m;
30
+ }
31
+
32
+ /**
33
+ * Format minutes since midnight back to HH:MM.
34
+ * @param {number} minutes
35
+ * @returns {string}
36
+ */
37
+ function minutesToTime(minutes) {
38
+ const h = Math.floor(minutes / 60);
39
+ const m = minutes % 60;
40
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
41
+ }
42
+
43
+ /**
44
+ * Check availability for a given date and time window.
45
+ * @param {object} opts
46
+ * @param {import('puppeteer-core').Page} opts.page
47
+ * @param {object} opts.params
48
+ * @param {string} opts.params.date - ISO date to check (required)
49
+ * @param {string} opts.params.startTime - Window start time in HH:MM (required)
50
+ * @param {string} opts.params.endTime - Window end time in HH:MM (required)
51
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
52
+ */
53
+ export async function checkAvailability({ page, params }) {
54
+ // Validate required params
55
+ if (!params.date) {
56
+ return new ErrorResponse(
57
+ 'The "date" parameter is required to check availability.',
58
+ ['Provide a date: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
59
+ );
60
+ }
61
+ if (!params.startTime) {
62
+ return new ErrorResponse(
63
+ 'The "startTime" parameter is required to check availability.',
64
+ ['Provide a start time: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
65
+ );
66
+ }
67
+ if (!params.endTime) {
68
+ return new ErrorResponse(
69
+ 'The "endTime" parameter is required to check availability.',
70
+ ['Provide an end time: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
71
+ );
72
+ }
73
+
74
+ // Validate startTime < endTime
75
+ const windowStart = timeToMinutes(params.startTime);
76
+ const windowEnd = timeToMinutes(params.endTime);
77
+ if (windowStart >= windowEnd) {
78
+ return new ErrorResponse(
79
+ `startTime (${params.startTime}) must be earlier than endTime (${params.endTime}).`,
80
+ ['Ensure the start time is before the end time, e.g. startTime: "09:00", endTime: "17:00"']
81
+ );
82
+ }
83
+
84
+ // Precondition: must be on Google Calendar
85
+ const pre = await checkPrecondition(page, 'on_calendar');
86
+ if (!pre.met) {
87
+ return new ErrorResponse(pre.error, [
88
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
89
+ ]);
90
+ }
91
+
92
+ // T1: Navigate to the day view for the specified date
93
+ const path = buildViewPath('day', params.date);
94
+ await calendarNavigate(page, path);
95
+ logger.debug(`checkAvailability: navigated to day view for ${params.date}`);
96
+
97
+ // Wait for event chips (or empty day)
98
+ let events = [];
99
+ try {
100
+ await waitForCalendar(page, EVENT_CHIP);
101
+ events = await extractVisibleEvents(page, 100);
102
+ } catch {
103
+ // No events — the entire window is free
104
+ logger.debug('checkAvailability: no events found, entire window is free');
105
+ }
106
+
107
+ // Filter events that overlap with the requested time window
108
+ const busySlots = [];
109
+ for (const event of events) {
110
+ if (event.allDay) {
111
+ // All-day events occupy the full day but don't block time slots
112
+ continue;
113
+ }
114
+
115
+ // Parse event times (best-effort from extracted data)
116
+ if (event.startTime && event.endTime) {
117
+ const eventStart = timeToMinutes(event.startTime);
118
+ const eventEnd = timeToMinutes(event.endTime);
119
+
120
+ // Check overlap: event overlaps window if event starts before window ends
121
+ // AND event ends after window starts
122
+ if (eventStart < windowEnd && eventEnd > windowStart) {
123
+ busySlots.push({
124
+ title: event.title,
125
+ startTime: event.startTime,
126
+ endTime: event.endTime
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ // Compute free slots within the window
133
+ const freeSlots = [];
134
+ // Sort busy slots by start time
135
+ busySlots.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
136
+
137
+ let cursor = windowStart;
138
+ for (const busy of busySlots) {
139
+ const busyStart = timeToMinutes(busy.startTime);
140
+ const busyEnd = timeToMinutes(busy.endTime);
141
+
142
+ if (cursor < busyStart) {
143
+ freeSlots.push({
144
+ startTime: minutesToTime(cursor),
145
+ endTime: minutesToTime(Math.min(busyStart, windowEnd)),
146
+ durationMinutes: Math.min(busyStart, windowEnd) - cursor
147
+ });
148
+ }
149
+ cursor = Math.max(cursor, busyEnd);
150
+ }
151
+
152
+ // Final free slot after all busy periods
153
+ if (cursor < windowEnd) {
154
+ freeSlots.push({
155
+ startTime: minutesToTime(cursor),
156
+ endTime: minutesToTime(windowEnd),
157
+ durationMinutes: windowEnd - cursor
158
+ });
159
+ }
160
+
161
+ const isFree = busySlots.length === 0;
162
+ const summary = isFree
163
+ ? `${params.date} from ${params.startTime} to ${params.endTime} is completely free.`
164
+ : `${params.date} from ${params.startTime} to ${params.endTime} has ${busySlots.length} conflicting event(s) and ${freeSlots.length} free slot(s).`;
165
+
166
+ return new GCalActionResponse(
167
+ {
168
+ date: params.date,
169
+ windowStart: params.startTime,
170
+ windowEnd: params.endTime,
171
+ isFree,
172
+ busySlots,
173
+ freeSlots,
174
+ conflictCount: busySlots.length
175
+ },
176
+ summary,
177
+ isFree
178
+ ? [`Use create_event({ date: "${params.date}", startTime: "${params.startTime}" }) to book this slot`]
179
+ : [
180
+ 'Use create_event to book one of the free slots',
181
+ 'Use check_availability with a different time range to find open slots',
182
+ 'Use list_events to see all events on this date'
183
+ ]
184
+ );
185
+ }