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 +1 -1
- package/src/actions/click-element.js +9 -6
- package/src/actions/execute-javascript.js +9 -6
- package/src/actions/fetch-page.js +10 -6
- package/src/actions/get-current-html.js +10 -6
- package/src/core/plugin-loader.js +26 -5
- package/src/plugins/_example/index.js +1 -0
- package/src/plugins/gcal/actions/check-availability.js +185 -0
- package/src/plugins/gcal/actions/create-event.js +238 -0
- package/src/plugins/gcal/actions/delete-event.js +138 -0
- package/src/plugins/gcal/actions/edit-event.js +244 -0
- package/src/plugins/gcal/actions/list-events.js +96 -0
- package/src/plugins/gcal/actions/read-event.js +174 -0
- package/src/plugins/gcal/actions/rsvp-event.js +149 -0
- package/src/plugins/gcal/actions/search-events.js +121 -0
- package/src/plugins/gcal/helpers.js +415 -0
- package/src/plugins/gcal/index.js +148 -0
- package/src/plugins/gcal/selectors.js +54 -0
- package/src/plugins/gmail/index.js +1 -0
- package/src/plugins.json +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcpbrowser",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
+
}
|