mcpbrowser 0.3.34 → 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 +8 -3
- package/src/actions/execute-javascript.js +8 -3
- package/src/actions/fetch-page.js +9 -3
- package/src/actions/get-current-html.js +9 -3
- package/src/actions/plugin-action.js +180 -0
- package/src/actions/plugin-info.js +170 -0
- package/src/core/logger.js +3 -7
- package/src/core/plugin-loader.js +344 -0
- package/src/mcp-browser.js +34 -2
- package/src/plugins/_example/index.js +140 -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/actions/archive-email.js +65 -0
- package/src/plugins/gmail/actions/compose-email.js +116 -0
- package/src/plugins/gmail/actions/delete-email.js +65 -0
- package/src/plugins/gmail/actions/forward-email.js +95 -0
- package/src/plugins/gmail/actions/label-email.js +107 -0
- package/src/plugins/gmail/actions/list-emails.js +61 -0
- package/src/plugins/gmail/actions/mark-read.js +71 -0
- package/src/plugins/gmail/actions/mark-unread.js +71 -0
- package/src/plugins/gmail/actions/read-email.js +149 -0
- package/src/plugins/gmail/actions/reply-email.js +87 -0
- package/src/plugins/gmail/actions/search-emails.js +95 -0
- package/src/plugins/gmail/helpers.js +419 -0
- package/src/plugins/gmail/index.js +195 -0
- package/src/plugins/gmail/selectors.js +82 -0
- package/src/plugins.json +4 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-events.js — Search Google Calendar for events matching a keyword query.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T2: '/' keyboard shortcut to focus search
|
|
6
|
+
* T3: extractVisibleEvents for result extraction
|
|
7
|
+
* T4: EVENT_CHIP selector for result detection
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
11
|
+
import logger from '../../../core/logger.js';
|
|
12
|
+
import {
|
|
13
|
+
checkPrecondition,
|
|
14
|
+
waitForCalendar,
|
|
15
|
+
extractVisibleEvents,
|
|
16
|
+
GCalActionResponse
|
|
17
|
+
} from '../helpers.js';
|
|
18
|
+
import { EVENT_CHIP } from '../selectors.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Search events by query string using Calendar's search UI.
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
24
|
+
* @param {object} opts.params
|
|
25
|
+
* @param {string} opts.params.query - Search keywords (required)
|
|
26
|
+
* @param {number} [opts.params.limit=25] - Maximum results to return
|
|
27
|
+
* @returns {Promise<GCalActionResponse|ErrorResponse>}
|
|
28
|
+
*/
|
|
29
|
+
export async function searchEvents({ page, params }) {
|
|
30
|
+
// Validate query
|
|
31
|
+
if (!params.query || !params.query.trim()) {
|
|
32
|
+
return new ErrorResponse(
|
|
33
|
+
'Search query is required.',
|
|
34
|
+
[
|
|
35
|
+
'Provide a query: search_events({ query: "team meeting" })',
|
|
36
|
+
'Use list_events to browse the calendar without searching'
|
|
37
|
+
]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Precondition: must be on Google Calendar
|
|
42
|
+
const pre = await checkPrecondition(page, 'on_calendar');
|
|
43
|
+
if (!pre.met) {
|
|
44
|
+
return new ErrorResponse(pre.error, [
|
|
45
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const query = params.query.trim();
|
|
50
|
+
const limit = params.limit || 25;
|
|
51
|
+
|
|
52
|
+
// Ensure nothing is focused that would swallow the keystroke
|
|
53
|
+
await page.evaluate(() => {
|
|
54
|
+
if (document.activeElement && document.activeElement.tagName !== 'BODY') {
|
|
55
|
+
document.activeElement.blur();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// T2: Press '/' to focus the search box
|
|
60
|
+
await page.keyboard.press('/');
|
|
61
|
+
await new Promise(r => setTimeout(r, 300));
|
|
62
|
+
|
|
63
|
+
// T3: Type query into the search input
|
|
64
|
+
const searchInput = await page.$('input[aria-label="Search"]') ||
|
|
65
|
+
await page.$('input[aria-label*="search" i]') ||
|
|
66
|
+
await page.$('input[type="text"][role="searchbox"]');
|
|
67
|
+
if (searchInput) {
|
|
68
|
+
await searchInput.click({ clickCount: 3 }); // clear existing text
|
|
69
|
+
await searchInput.type(query);
|
|
70
|
+
logger.debug(`searchEvents: typed query "${query}"`);
|
|
71
|
+
} else {
|
|
72
|
+
// Fallback: type directly if the search field is already focused by '/'
|
|
73
|
+
await page.keyboard.type(query);
|
|
74
|
+
logger.debug(`searchEvents: typed query via keyboard "${query}"`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Press Enter to execute search
|
|
78
|
+
await page.keyboard.press('Enter');
|
|
79
|
+
|
|
80
|
+
// Wait for search results to appear
|
|
81
|
+
let hasResults = true;
|
|
82
|
+
try {
|
|
83
|
+
await waitForCalendar(page, EVENT_CHIP);
|
|
84
|
+
} catch {
|
|
85
|
+
// Check for "no results" state
|
|
86
|
+
const noResults = await page.evaluate(() => {
|
|
87
|
+
const body = document.body?.textContent || '';
|
|
88
|
+
return body.includes('No results') || body.includes('No events found');
|
|
89
|
+
});
|
|
90
|
+
if (noResults) {
|
|
91
|
+
hasResults = false;
|
|
92
|
+
} else {
|
|
93
|
+
// Re-check — page may still be loading
|
|
94
|
+
hasResults = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!hasResults) {
|
|
99
|
+
return new GCalActionResponse(
|
|
100
|
+
{ events: [], query, resultCount: 0 },
|
|
101
|
+
`No events found for "${query}".`,
|
|
102
|
+
[
|
|
103
|
+
'Try a different or broader search query',
|
|
104
|
+
'Use list_events to browse the calendar instead'
|
|
105
|
+
]
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// T3+T4: Extract search results
|
|
110
|
+
const events = await extractVisibleEvents(page, limit);
|
|
111
|
+
|
|
112
|
+
return new GCalActionResponse(
|
|
113
|
+
{ events, query, resultCount: events.length },
|
|
114
|
+
`Found ${events.length} result(s) for "${query}".`,
|
|
115
|
+
[
|
|
116
|
+
'Use read_event({ index: N }) to open a specific result',
|
|
117
|
+
'Use list_events to return to the calendar view',
|
|
118
|
+
'Refine your search with more specific keywords'
|
|
119
|
+
]
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* helpers.js — Shared tiered utilities for the Google Calendar plugin.
|
|
3
|
+
*
|
|
4
|
+
* Provides URL-based navigation (T1), keyboard shortcut verification (T2),
|
|
5
|
+
* ARIA/data-attr DOM utilities (T3), and CSS-selector data extraction (T4).
|
|
6
|
+
*
|
|
7
|
+
* All helpers are stateless — they inspect the page at invocation time
|
|
8
|
+
* per FR-017. No internal state is maintained between calls.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { MCPResponse } from '../../core/responses.js';
|
|
12
|
+
import * as sel from './selectors.js';
|
|
13
|
+
import logger from '../../core/logger.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// CONSTANTS
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/** Maximum wait time for dynamic content (FR-012). */
|
|
20
|
+
export const DEFAULT_TIMEOUT = 10_000;
|
|
21
|
+
|
|
22
|
+
/** Calendar view states detected by detectView(). */
|
|
23
|
+
export const VIEW = Object.freeze({
|
|
24
|
+
DAY: 'day',
|
|
25
|
+
WEEK: 'week',
|
|
26
|
+
MONTH: 'month',
|
|
27
|
+
SCHEDULE: 'schedule',
|
|
28
|
+
CUSTOM: 'custom',
|
|
29
|
+
EVENT_DETAIL: 'event_detail',
|
|
30
|
+
EVENT_FORM: 'event_form',
|
|
31
|
+
SEARCH_RESULTS: 'search_results',
|
|
32
|
+
LOADING: 'loading',
|
|
33
|
+
NOT_CALENDAR: 'not_calendar',
|
|
34
|
+
NOT_READY: 'not_ready'
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Map param view names to URL path segments. */
|
|
38
|
+
const VIEW_TO_PATH = {
|
|
39
|
+
day: 'day',
|
|
40
|
+
week: 'week',
|
|
41
|
+
month: 'month',
|
|
42
|
+
schedule: 'agenda',
|
|
43
|
+
custom: 'customday'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// T1: URL-BASED NAVIGATION (FR-019)
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract the Google account index (/u/N/) from a URL.
|
|
52
|
+
* Returns '0' if not found. Shared with Gmail plugin pattern.
|
|
53
|
+
*/
|
|
54
|
+
export function getAccountIndex(url) {
|
|
55
|
+
const match = url.match(/\/u\/(\d+)\//);
|
|
56
|
+
return match ? match[1] : '0';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Navigate Google Calendar to a specific path while preserving account index.
|
|
61
|
+
* @param {import('puppeteer').Page} page
|
|
62
|
+
* @param {string} path — e.g. 'r/day/2026/4/6', 'r/week', 'r/search'
|
|
63
|
+
*/
|
|
64
|
+
export async function calendarNavigate(page, path) {
|
|
65
|
+
const currentUrl = page.url();
|
|
66
|
+
const accountIndex = getAccountIndex(currentUrl);
|
|
67
|
+
const targetUrl = `https://calendar.google.com/calendar/u/${accountIndex}/${path}`;
|
|
68
|
+
logger.debug(`calendarNavigate: ${targetUrl}`);
|
|
69
|
+
await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: DEFAULT_TIMEOUT });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build url path for a date in a given view.
|
|
74
|
+
* @param {string} view — 'day', 'week', 'month'
|
|
75
|
+
* @param {string} [date] — ISO date string, e.g. '2026-04-10'
|
|
76
|
+
* @returns {string} path segment, e.g. 'r/day/2026/4/10'
|
|
77
|
+
*/
|
|
78
|
+
export function buildViewPath(view, date) {
|
|
79
|
+
const pathView = VIEW_TO_PATH[view] || view;
|
|
80
|
+
if (date) {
|
|
81
|
+
// Parse ISO date string directly to avoid timezone issues
|
|
82
|
+
const parts = date.split('-');
|
|
83
|
+
const y = parseInt(parts[0], 10);
|
|
84
|
+
const m = parseInt(parts[1], 10);
|
|
85
|
+
const day = parseInt(parts[2], 10);
|
|
86
|
+
return `r/${pathView}/${y}/${m}/${day}`;
|
|
87
|
+
}
|
|
88
|
+
return `r/${pathView}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// VIEW DETECTION (FR-017, FR-024 — URL path primary, DOM fallback)
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Detect the current Google Calendar view from URL and DOM.
|
|
97
|
+
* @param {import('puppeteer').Page} page
|
|
98
|
+
* @returns {Promise<string>} One of the VIEW enum values.
|
|
99
|
+
*/
|
|
100
|
+
export async function detectView(page) {
|
|
101
|
+
const url = page.url();
|
|
102
|
+
|
|
103
|
+
if (!url.includes('calendar.google.com')) {
|
|
104
|
+
logger.debug('detectView: not Calendar');
|
|
105
|
+
return VIEW.NOT_CALENDAR;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for interstitials / CAPTCHAs
|
|
109
|
+
const hasInterstitial = await page.evaluate(() => {
|
|
110
|
+
const body = document.body?.textContent || '';
|
|
111
|
+
return body.includes('Confirm it') ||
|
|
112
|
+
!!document.querySelector('iframe[src*="accounts.google.com"]') ||
|
|
113
|
+
!!document.querySelector('#captcha');
|
|
114
|
+
});
|
|
115
|
+
if (hasInterstitial) {
|
|
116
|
+
logger.debug('detectView: not_ready (interstitial/CAPTCHA)');
|
|
117
|
+
return VIEW.NOT_READY;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// URL path-based detection (primary signal)
|
|
121
|
+
const pathMatch = url.match(/\/r\/([a-z]+)/i);
|
|
122
|
+
const pathSegment = pathMatch ? pathMatch[1].toLowerCase() : '';
|
|
123
|
+
|
|
124
|
+
if (pathSegment === 'eventedit') {
|
|
125
|
+
logger.debug('detectView: event_form');
|
|
126
|
+
return VIEW.EVENT_FORM;
|
|
127
|
+
}
|
|
128
|
+
if (pathSegment === 'search') {
|
|
129
|
+
logger.debug('detectView: search_results');
|
|
130
|
+
return VIEW.SEARCH_RESULTS;
|
|
131
|
+
}
|
|
132
|
+
if (pathSegment === 'day') {
|
|
133
|
+
logger.debug('detectView: day');
|
|
134
|
+
return VIEW.DAY;
|
|
135
|
+
}
|
|
136
|
+
if (pathSegment === 'week') {
|
|
137
|
+
logger.debug('detectView: week');
|
|
138
|
+
return VIEW.WEEK;
|
|
139
|
+
}
|
|
140
|
+
if (pathSegment === 'month') {
|
|
141
|
+
logger.debug('detectView: month');
|
|
142
|
+
return VIEW.MONTH;
|
|
143
|
+
}
|
|
144
|
+
if (pathSegment === 'agenda' || pathSegment === 'list') {
|
|
145
|
+
logger.debug('detectView: schedule');
|
|
146
|
+
return VIEW.SCHEDULE;
|
|
147
|
+
}
|
|
148
|
+
if (pathSegment === 'customday') {
|
|
149
|
+
logger.debug('detectView: custom');
|
|
150
|
+
return VIEW.CUSTOM;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check for event detail dialog overlay
|
|
154
|
+
const hasEventDialog = await page.evaluate(() =>
|
|
155
|
+
!!document.querySelector('div[role="dialog"]')
|
|
156
|
+
);
|
|
157
|
+
if (hasEventDialog) {
|
|
158
|
+
logger.debug('detectView: event_detail (dialog overlay)');
|
|
159
|
+
return VIEW.EVENT_DETAIL;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Default calendar view (e.g. /r or /r/)
|
|
163
|
+
const hasMain = await page.evaluate(() =>
|
|
164
|
+
!!document.querySelector('div[role="main"]')
|
|
165
|
+
);
|
|
166
|
+
if (hasMain) {
|
|
167
|
+
logger.debug('detectView: week (default fallback)');
|
|
168
|
+
return VIEW.WEEK;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
logger.debug('detectView: loading (no main container)');
|
|
172
|
+
return VIEW.LOADING;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// T2: KEYBOARD SHORTCUT VERIFICATION (FR-018)
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if Google Calendar keyboard shortcuts are enabled by sending '?'.
|
|
181
|
+
* @param {import('puppeteer').Page} page
|
|
182
|
+
* @returns {Promise<{enabled: boolean, error?: string}>}
|
|
183
|
+
*/
|
|
184
|
+
export async function checkKeyboardShortcuts(page) {
|
|
185
|
+
try {
|
|
186
|
+
// Ensure nothing is focused that would swallow the keystroke
|
|
187
|
+
await page.evaluate(() => {
|
|
188
|
+
if (document.activeElement && document.activeElement.tagName !== 'BODY') {
|
|
189
|
+
document.activeElement.blur();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await page.keyboard.down('Shift');
|
|
194
|
+
await page.keyboard.press('/');
|
|
195
|
+
await page.keyboard.up('Shift');
|
|
196
|
+
|
|
197
|
+
const dialog = await page.waitForSelector(
|
|
198
|
+
'div[role="dialog"]',
|
|
199
|
+
{ timeout: 2000 }
|
|
200
|
+
).catch(() => null);
|
|
201
|
+
|
|
202
|
+
if (dialog) {
|
|
203
|
+
await page.keyboard.press('Escape');
|
|
204
|
+
return { enabled: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
enabled: false,
|
|
209
|
+
error: 'Google Calendar keyboard shortcuts are not enabled. Enable them in Calendar Settings → Keyboard shortcuts → Enable keyboard shortcuts.'
|
|
210
|
+
};
|
|
211
|
+
} catch {
|
|
212
|
+
return {
|
|
213
|
+
enabled: false,
|
|
214
|
+
error: 'Could not verify Google Calendar keyboard shortcuts. Ensure Calendar is fully loaded.'
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// PRECONDITION CHECKING (FR-022)
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check a precondition before performing an action.
|
|
225
|
+
* @param {import('puppeteer').Page} page
|
|
226
|
+
* @param {string} requirement — 'on_calendar', 'event_visible', 'list_view'
|
|
227
|
+
* @returns {Promise<{met: boolean, error?: string, suggestion?: string}>}
|
|
228
|
+
*/
|
|
229
|
+
export async function checkPrecondition(page, requirement) {
|
|
230
|
+
const url = page.url();
|
|
231
|
+
|
|
232
|
+
switch (requirement) {
|
|
233
|
+
case 'on_calendar': {
|
|
234
|
+
if (!url.includes('calendar.google.com')) {
|
|
235
|
+
return {
|
|
236
|
+
met: false,
|
|
237
|
+
error: 'Google Calendar is not the active page.',
|
|
238
|
+
suggestion: "Use fetch_webpage({ url: 'https://calendar.google.com' }) to navigate to Google Calendar first."
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return { met: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'event_visible': {
|
|
245
|
+
const view = await detectView(page);
|
|
246
|
+
if (view === VIEW.NOT_CALENDAR || view === VIEW.NOT_READY || view === VIEW.LOADING) {
|
|
247
|
+
return {
|
|
248
|
+
met: false,
|
|
249
|
+
error: 'Google Calendar is not ready.',
|
|
250
|
+
suggestion: "Use fetch_webpage({ url: 'https://calendar.google.com' }) to navigate to Google Calendar."
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { met: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case 'list_view': {
|
|
257
|
+
const view = await detectView(page);
|
|
258
|
+
const isListView = [VIEW.DAY, VIEW.WEEK, VIEW.MONTH, VIEW.SCHEDULE, VIEW.CUSTOM, VIEW.SEARCH_RESULTS].includes(view);
|
|
259
|
+
if (!isListView) {
|
|
260
|
+
return {
|
|
261
|
+
met: false,
|
|
262
|
+
error: 'Not in a calendar view with visible events.',
|
|
263
|
+
suggestion: "Use list_events to navigate to a calendar view."
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return { met: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
default:
|
|
270
|
+
return { met: false, error: `Unknown precondition: ${requirement}` };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// CONTENT WAITING (FR-012 — 10s timeout with diagnostics)
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Wait for a selector to appear on the page.
|
|
280
|
+
* @param {import('puppeteer').Page} page
|
|
281
|
+
* @param {string} selector
|
|
282
|
+
* @param {number} [timeout=DEFAULT_TIMEOUT]
|
|
283
|
+
*/
|
|
284
|
+
export async function waitForCalendar(page, selector, timeout = DEFAULT_TIMEOUT) {
|
|
285
|
+
try {
|
|
286
|
+
await page.waitForSelector(selector, { timeout });
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Google Calendar content did not load within ${timeout}ms. ` +
|
|
290
|
+
`Selector that failed: "${selector}". ` +
|
|
291
|
+
`The page may still be loading or Calendar's UI may have changed.`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ============================================================================
|
|
297
|
+
// EVENT SELECTION — Hybrid DOM+keyboard (FR-016)
|
|
298
|
+
// ============================================================================
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Select (click) an event by index or event ID.
|
|
302
|
+
* @param {import('puppeteer').Page} page
|
|
303
|
+
* @param {{index?: number, id?: string}} options
|
|
304
|
+
* @returns {Promise<{selected: boolean, error?: string}>}
|
|
305
|
+
*/
|
|
306
|
+
export async function selectEvent(page, { index, id } = {}) {
|
|
307
|
+
// Try by event ID first (more stable)
|
|
308
|
+
if (id !== undefined && id !== null) {
|
|
309
|
+
const event = await page.$(`[data-eventid="${id}"]`);
|
|
310
|
+
if (event) {
|
|
311
|
+
await event.click();
|
|
312
|
+
logger.debug(`selectEvent: selected by ID "${id}"`);
|
|
313
|
+
return { selected: true };
|
|
314
|
+
}
|
|
315
|
+
logger.debug(`selectEvent: ID "${id}" not found, trying index fallback`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Fall back to index-based selection
|
|
319
|
+
if (index !== undefined && index !== null) {
|
|
320
|
+
const events = await page.$$(sel.EVENT_CHIP);
|
|
321
|
+
if (index >= 0 && index < events.length) {
|
|
322
|
+
await events[index].click();
|
|
323
|
+
logger.debug(`selectEvent: selected by index ${index}`);
|
|
324
|
+
return { selected: true };
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
selected: false,
|
|
328
|
+
error: `Event index ${index} is out of range. The current view has ${events.length} events (indices 0-${events.length - 1}). Use list_events to refresh.`
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { selected: false, error: 'No index or id provided for event selection.' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ============================================================================
|
|
336
|
+
// EVENT EXTRACTION — T3+T4 data extraction
|
|
337
|
+
// ============================================================================
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Extract visible events from the current Calendar view.
|
|
341
|
+
* Returns EventSummary objects per data-model.md.
|
|
342
|
+
* @param {import('puppeteer').Page} page
|
|
343
|
+
* @param {number} [limit=25]
|
|
344
|
+
* @returns {Promise<Array>}
|
|
345
|
+
*/
|
|
346
|
+
export async function extractVisibleEvents(page, limit = 25) {
|
|
347
|
+
return page.evaluate((selectors, lim) => {
|
|
348
|
+
const chips = document.querySelectorAll(selectors.eventChip);
|
|
349
|
+
const results = [];
|
|
350
|
+
const count = Math.min(chips.length, lim);
|
|
351
|
+
|
|
352
|
+
for (let i = 0; i < count; i++) {
|
|
353
|
+
const chip = chips[i];
|
|
354
|
+
|
|
355
|
+
// T3: aria-label is the primary extraction method for event data
|
|
356
|
+
const ariaLabel = chip.getAttribute('aria-label') || '';
|
|
357
|
+
|
|
358
|
+
// T3: data-eventid for stable identification
|
|
359
|
+
const eventId = chip.getAttribute('data-eventid') || null;
|
|
360
|
+
|
|
361
|
+
// Parse aria-label — typically "Title, date, time – time, calendar"
|
|
362
|
+
// This is a best-effort parse; structure varies by locale
|
|
363
|
+
const title = ariaLabel.split(',')[0]?.trim() || '';
|
|
364
|
+
|
|
365
|
+
// T4: CSS fallback for time and calendar info
|
|
366
|
+
const timeSpans = chip.querySelectorAll(selectors.timeSpan);
|
|
367
|
+
const timeText = timeSpans.length > 0 ? timeSpans[0]?.textContent?.trim() || '' : '';
|
|
368
|
+
|
|
369
|
+
// Detect all-day by checking if event lacks specific time text
|
|
370
|
+
const allDay = !timeText || timeText === '';
|
|
371
|
+
|
|
372
|
+
results.push({
|
|
373
|
+
index: i,
|
|
374
|
+
eventId,
|
|
375
|
+
title,
|
|
376
|
+
startDate: '', // Populated from date context outside evaluate
|
|
377
|
+
startTime: allDay ? null : timeText.split('–')[0]?.trim() || null,
|
|
378
|
+
endDate: '',
|
|
379
|
+
endTime: allDay ? null : timeText.split('–')[1]?.trim() || null,
|
|
380
|
+
allDay,
|
|
381
|
+
location: null, // Only available in detail view
|
|
382
|
+
calendarName: '', // Extracted from color dot or aria-label suffix
|
|
383
|
+
calendarColor: null
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return results;
|
|
387
|
+
}, {
|
|
388
|
+
eventChip: sel.EVENT_CHIP,
|
|
389
|
+
timeSpan: sel.EVENT_TIME_IN_CHIP
|
|
390
|
+
}, limit);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// RESPONSE CLASS
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Google Calendar plugin response — extends MCPResponse with data spreading.
|
|
399
|
+
* Mirrors GmailActionResponse pattern.
|
|
400
|
+
*/
|
|
401
|
+
export class GCalActionResponse extends MCPResponse {
|
|
402
|
+
constructor(data, summary, nextSteps) {
|
|
403
|
+
super(nextSteps);
|
|
404
|
+
this.data = data;
|
|
405
|
+
this._summary = summary;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_getAdditionalFields() {
|
|
409
|
+
return { ...this.data };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
getTextSummary() {
|
|
413
|
+
return this._summary;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar plugin — Site-specific automation for Google Calendar (calendar.google.com).
|
|
3
|
+
* Implements the MCPBrowser plugin interface (interfaceVersion 1).
|
|
4
|
+
*
|
|
5
|
+
* Uses a tiered interaction strategy (FR-011):
|
|
6
|
+
* T1: URL path navigation for views/dates
|
|
7
|
+
* T2: Keyboard shortcuts for actions (create, delete, view switches)
|
|
8
|
+
* T3: ARIA / data attributes for data extraction and form filling
|
|
9
|
+
* T4: CSS class selectors (last resort, centralized in selectors.js)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { listEvents } from './actions/list-events.js';
|
|
13
|
+
import { readEvent } from './actions/read-event.js';
|
|
14
|
+
import { createEvent } from './actions/create-event.js';
|
|
15
|
+
import { searchEvents } from './actions/search-events.js';
|
|
16
|
+
import { editEvent } from './actions/edit-event.js';
|
|
17
|
+
import { rsvpEvent } from './actions/rsvp-event.js';
|
|
18
|
+
import { deleteEvent } from './actions/delete-event.js';
|
|
19
|
+
import { checkAvailability } from './actions/check-availability.js';
|
|
20
|
+
|
|
21
|
+
export const manifest = {
|
|
22
|
+
name: "gcal",
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
description: "Google Calendar plugin for MCPBrowser — event management, scheduling, and availability with hybrid UI resilience (URL navigation, keyboard shortcuts, ARIA selectors, CSS fallback)",
|
|
25
|
+
interfaceVersion: 1,
|
|
26
|
+
urlPatterns: ["calendar.google.com"],
|
|
27
|
+
domPatterns: ["div[role=\"main\"]", "[data-eventchip]"]
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function matchesPage(url, html) {
|
|
31
|
+
try {
|
|
32
|
+
if (url && url.includes('calendar.google.com')) {
|
|
33
|
+
return { matched: true, confidence: 1.0 };
|
|
34
|
+
}
|
|
35
|
+
if (html && (html.includes('data-eventchip') || html.includes('data-datekey'))) {
|
|
36
|
+
return { matched: true, confidence: 0.8 };
|
|
37
|
+
}
|
|
38
|
+
return { matched: false };
|
|
39
|
+
} catch {
|
|
40
|
+
return { matched: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getActions() {
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
name: "list_events",
|
|
48
|
+
description: "List visible events from the current Google Calendar view",
|
|
49
|
+
params: [
|
|
50
|
+
{ name: "date", type: "string", description: "ISO date to navigate to (e.g., '2026-04-10'). If omitted, uses current view.", required: false },
|
|
51
|
+
{ name: "view", type: "string", description: "Calendar view: day, week, month, schedule. If omitted, uses current view.", required: false },
|
|
52
|
+
{ name: "limit", type: "number", description: "Maximum number of events to return (default: 25)", required: false, default: 25 }
|
|
53
|
+
],
|
|
54
|
+
execute: listEvents
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "read_event",
|
|
58
|
+
description: "Open an event and read its full details (attendees, description, conferencing link)",
|
|
59
|
+
params: [
|
|
60
|
+
{ name: "index", type: "number", description: "0-based position in current event list", required: false },
|
|
61
|
+
{ name: "id", type: "string", description: "Google Calendar event ID", required: false }
|
|
62
|
+
],
|
|
63
|
+
execute: readEvent
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "create_event",
|
|
67
|
+
description: "Create a new calendar event with title, time, location, description, and attendees",
|
|
68
|
+
params: [
|
|
69
|
+
{ name: "title", type: "string", description: "Event title", required: true },
|
|
70
|
+
{ name: "date", type: "string", description: "Event date (ISO format, e.g., '2026-04-07'). Default: today", required: false },
|
|
71
|
+
{ name: "startTime", type: "string", description: "Start time in HH:MM format. Ignored if allDay:true", required: false },
|
|
72
|
+
{ name: "endTime", type: "string", description: "End time in HH:MM format. Ignored if allDay:true", required: false },
|
|
73
|
+
{ name: "allDay", type: "boolean", description: "Create an all-day event", required: false, default: false },
|
|
74
|
+
{ name: "location", type: "string", description: "Event location", required: false },
|
|
75
|
+
{ name: "description", type: "string", description: "Event description/notes", required: false },
|
|
76
|
+
{ name: "attendees", type: "array", description: "Array of attendee email addresses", required: false },
|
|
77
|
+
{ name: "save", type: "boolean", description: "If true, save the event. Default: false (leave for review)", required: false, default: false }
|
|
78
|
+
],
|
|
79
|
+
execute: createEvent
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "search_events",
|
|
83
|
+
description: "Search Google Calendar for events matching a keyword query",
|
|
84
|
+
params: [
|
|
85
|
+
{ name: "query", type: "string", description: "Search keywords", required: true },
|
|
86
|
+
{ name: "limit", type: "number", description: "Maximum results to return (default: 25)", required: false, default: 25 }
|
|
87
|
+
],
|
|
88
|
+
execute: searchEvents
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "edit_event",
|
|
92
|
+
description: "Modify an existing event's fields (time, title, location, description, attendees)",
|
|
93
|
+
params: [
|
|
94
|
+
{ name: "index", type: "number", description: "0-based position in current event list", required: false },
|
|
95
|
+
{ name: "id", type: "string", description: "Google Calendar event ID", required: false },
|
|
96
|
+
{ name: "title", type: "string", description: "New title", required: false },
|
|
97
|
+
{ name: "date", type: "string", description: "New date (ISO format)", required: false },
|
|
98
|
+
{ name: "startTime", type: "string", description: "New start time (HH:MM)", required: false },
|
|
99
|
+
{ name: "endTime", type: "string", description: "New end time (HH:MM)", required: false },
|
|
100
|
+
{ name: "location", type: "string", description: "New location", required: false },
|
|
101
|
+
{ name: "description", type: "string", description: "New description", required: false },
|
|
102
|
+
{ name: "attendees", type: "array", description: "New attendee list (replaces existing)", required: false },
|
|
103
|
+
{ name: "allDay", type: "boolean", description: "Toggle all-day", required: false },
|
|
104
|
+
{ name: "save", type: "boolean", description: "If true, save changes. Default: false", required: false, default: false }
|
|
105
|
+
],
|
|
106
|
+
execute: editEvent
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "rsvp_event",
|
|
110
|
+
description: "Respond to a calendar invitation (accept, decline, or tentative)",
|
|
111
|
+
params: [
|
|
112
|
+
{ name: "index", type: "number", description: "0-based position in current event list", required: false },
|
|
113
|
+
{ name: "id", type: "string", description: "Google Calendar event ID", required: false },
|
|
114
|
+
{ name: "response", type: "string", description: "RSVP response: accept, decline, or tentative", required: true }
|
|
115
|
+
],
|
|
116
|
+
execute: rsvpEvent
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "delete_event",
|
|
120
|
+
description: "Remove an event from Google Calendar",
|
|
121
|
+
params: [
|
|
122
|
+
{ name: "index", type: "number", description: "0-based position in current event list", required: false },
|
|
123
|
+
{ name: "id", type: "string", description: "Google Calendar event ID", required: false }
|
|
124
|
+
],
|
|
125
|
+
execute: deleteEvent
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "check_availability",
|
|
129
|
+
description: "Check whether a time slot is free or busy on the calendar",
|
|
130
|
+
params: [
|
|
131
|
+
{ name: "date", type: "string", description: "ISO date to check (required)", required: true },
|
|
132
|
+
{ name: "startTime", type: "string", description: "Window start time in HH:MM format (required)", required: true },
|
|
133
|
+
{ name: "endTime", type: "string", description: "Window end time in HH:MM format (required)", required: true }
|
|
134
|
+
],
|
|
135
|
+
execute: checkAvailability
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getInfo() {
|
|
141
|
+
return {
|
|
142
|
+
recommendation: "Manage Google Calendar — list, read, create, search, edit, RSVP, delete events and check availability.",
|
|
143
|
+
description: "Google Calendar event management with hybrid UI resilience — list, read, create, search, edit, RSVP, delete events and check availability using URL navigation (T1), keyboard shortcuts (T2), ARIA selectors (T3), and CSS fallback (T4).",
|
|
144
|
+
targetPages: ["Google Calendar (calendar.google.com)"],
|
|
145
|
+
authFlow: "User must be signed into Google Calendar in the browser before using plugin actions. Keyboard shortcuts must be enabled in Calendar Settings.",
|
|
146
|
+
actions: getActions().map(({ name, description, params }) => ({ name, description, params }))
|
|
147
|
+
};
|
|
148
|
+
}
|