mcpbrowser 0.3.33 → 0.3.35
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 +3 -1
- package/src/actions/execute-javascript.js +3 -1
- package/src/actions/fetch-page.js +3 -1
- package/src/actions/get-current-html.js +3 -1
- package/src/actions/navigate-history.js +248 -0
- 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 +323 -0
- package/src/mcp-browser.js +41 -2
- package/src/plugins/_example/index.js +139 -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 +194 -0
- package/src/plugins/gmail/selectors.js +82 -0
- package/src/plugins.json +3 -0
package/src/core/logger.js
CHANGED
|
@@ -38,13 +38,9 @@ async function notifyAgent(level, data) {
|
|
|
38
38
|
// Skip if client requested a higher threshold.
|
|
39
39
|
if (mcpServer.isMessageIgnored?.(level)) return;
|
|
40
40
|
await mcpServer.sendLoggingMessage({ level, logger: 'mcpbrowser', data });
|
|
41
|
-
} catch
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
process.stderr.write(`${PREFIX} logging notification failed: ${err?.message || err}\n`);
|
|
45
|
-
} catch (_) {
|
|
46
|
-
/* ignore */
|
|
47
|
-
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Silently drop — this is expected during startup before the MCP
|
|
43
|
+
// transport handshake completes. Stderr already has the message.
|
|
48
44
|
}
|
|
49
45
|
}
|
|
50
46
|
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin-loader.js — Core plugin infrastructure for MCPBrowser.
|
|
3
|
+
* Reads the plugin registry, validates manifests, dynamically loads plugins,
|
|
4
|
+
* and provides detection/accessor functions for dispatch tools.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync } from 'fs';
|
|
8
|
+
import { join, resolve } from 'path';
|
|
9
|
+
import { pathToFileURL } from 'url';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { dirname } from 'path';
|
|
12
|
+
import logger from './logger.js';
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// CONSTANTS
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/** Current plugin interface version. Plugins must match this exactly. */
|
|
19
|
+
export const CURRENT_INTERFACE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** @type {Map<string, object>} Loaded plugin instances keyed by name */
|
|
22
|
+
const loadedPlugins = new Map();
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// REGISTRY
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the path to plugins.json relative to this module's package root.
|
|
30
|
+
* @returns {string} Absolute path to plugins.json
|
|
31
|
+
*/
|
|
32
|
+
function getRegistryPath() {
|
|
33
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
34
|
+
const __dirname = dirname(__filename);
|
|
35
|
+
return join(__dirname, '../plugins.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the path to the plugins/ directory.
|
|
40
|
+
* @returns {string} Absolute path to plugins/
|
|
41
|
+
*/
|
|
42
|
+
function getPluginsDir() {
|
|
43
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
44
|
+
const __dirname = dirname(__filename);
|
|
45
|
+
return join(__dirname, '../plugins');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read and parse the plugin registry file.
|
|
50
|
+
* @returns {{ enabled: string[] }} Registry data with enabled plugin names
|
|
51
|
+
*/
|
|
52
|
+
export function readRegistry() {
|
|
53
|
+
const registryPath = getRegistryPath();
|
|
54
|
+
|
|
55
|
+
if (!existsSync(registryPath)) {
|
|
56
|
+
logger.debug('plugins.json not found — no plugins to load');
|
|
57
|
+
return { enabled: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(registryPath, 'utf-8');
|
|
62
|
+
const data = JSON.parse(raw);
|
|
63
|
+
|
|
64
|
+
if (!data || !Array.isArray(data.enabled)) {
|
|
65
|
+
logger.warn('plugins.json: "enabled" must be an array — no plugins loaded');
|
|
66
|
+
return { enabled: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { enabled: data.enabled.filter(name => typeof name === 'string' && name.length > 0) };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.warn(`plugins.json: failed to parse — ${err.message}`);
|
|
72
|
+
return { enabled: [] };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// VALIDATION
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate a plugin manifest against required fields and interface version.
|
|
82
|
+
* @param {object} manifest - Plugin manifest object
|
|
83
|
+
* @param {string} folderName - Expected folder name for the plugin
|
|
84
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function validateManifest(manifest, folderName) {
|
|
87
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
88
|
+
return { valid: false, reason: 'manifest is missing or not an object' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const required = ['name', 'version', 'description', 'interfaceVersion', 'urlPatterns'];
|
|
92
|
+
for (const field of required) {
|
|
93
|
+
if (manifest[field] === undefined || manifest[field] === null || manifest[field] === '') {
|
|
94
|
+
return { valid: false, reason: `missing required field: ${field}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof manifest.name !== 'string') {
|
|
99
|
+
return { valid: false, reason: 'name must be a string' };
|
|
100
|
+
}
|
|
101
|
+
if (typeof manifest.version !== 'string') {
|
|
102
|
+
return { valid: false, reason: 'version must be a string' };
|
|
103
|
+
}
|
|
104
|
+
if (typeof manifest.description !== 'string') {
|
|
105
|
+
return { valid: false, reason: 'description must be a string' };
|
|
106
|
+
}
|
|
107
|
+
if (typeof manifest.interfaceVersion !== 'number' || !Number.isInteger(manifest.interfaceVersion)) {
|
|
108
|
+
return { valid: false, reason: 'interfaceVersion must be an integer' };
|
|
109
|
+
}
|
|
110
|
+
if (!Array.isArray(manifest.urlPatterns) || manifest.urlPatterns.length === 0) {
|
|
111
|
+
return { valid: false, reason: 'urlPatterns must be a non-empty array' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (manifest.name !== folderName) {
|
|
115
|
+
return { valid: false, reason: `manifest name "${manifest.name}" does not match folder name "${folderName}"` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (manifest.interfaceVersion !== CURRENT_INTERFACE_VERSION) {
|
|
119
|
+
return { valid: false, reason: `interfaceVersion ${manifest.interfaceVersion} is not compatible (expected ${CURRENT_INTERFACE_VERSION})` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { valid: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate that a plugin module has all required exports.
|
|
127
|
+
* @param {object} mod - Imported module namespace
|
|
128
|
+
* @param {string} pluginName - Plugin name for logging
|
|
129
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
130
|
+
*/
|
|
131
|
+
function validateExports(mod, pluginName) {
|
|
132
|
+
if (typeof mod.matchesPage !== 'function') {
|
|
133
|
+
return { valid: false, reason: 'missing required export: matchesPage (function)' };
|
|
134
|
+
}
|
|
135
|
+
if (typeof mod.getActions !== 'function') {
|
|
136
|
+
return { valid: false, reason: 'missing required export: getActions (function)' };
|
|
137
|
+
}
|
|
138
|
+
if (typeof mod.getInfo !== 'function') {
|
|
139
|
+
return { valid: false, reason: 'missing required export: getInfo (function)' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate getActions returns non-empty array with required fields
|
|
143
|
+
try {
|
|
144
|
+
const actions = mod.getActions();
|
|
145
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
146
|
+
return { valid: false, reason: 'getActions() must return a non-empty array' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const actionNames = new Set();
|
|
150
|
+
for (const action of actions) {
|
|
151
|
+
if (!action.name || !action.description || !Array.isArray(action.params) || typeof action.execute !== 'function') {
|
|
152
|
+
return { valid: false, reason: `action "${action.name || '?'}" is missing required fields (name, description, params, execute)` };
|
|
153
|
+
}
|
|
154
|
+
if (actionNames.has(action.name)) {
|
|
155
|
+
return { valid: false, reason: `duplicate action name "${action.name}" — action names must be unique within a plugin` };
|
|
156
|
+
}
|
|
157
|
+
actionNames.add(action.name);
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return { valid: false, reason: `getActions() threw: ${err.message}` };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { valid: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// LOADING
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Load all enabled plugins from the registry.
|
|
172
|
+
* Reads plugins.json, dynamically imports each plugin, validates manifest
|
|
173
|
+
* and exports, and stores valid plugins in the loaded map.
|
|
174
|
+
* @returns {Promise<number>} Number of successfully loaded plugins
|
|
175
|
+
*/
|
|
176
|
+
export async function loadPlugins() {
|
|
177
|
+
loadedPlugins.clear();
|
|
178
|
+
|
|
179
|
+
const registry = readRegistry();
|
|
180
|
+
if (registry.enabled.length === 0) {
|
|
181
|
+
logger.debug('No plugins enabled in registry');
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const pluginsDir = getPluginsDir();
|
|
186
|
+
|
|
187
|
+
// Check for duplicate names in registry
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
const uniqueEnabled = [];
|
|
190
|
+
for (const name of registry.enabled) {
|
|
191
|
+
if (seen.has(name)) {
|
|
192
|
+
logger.warn(`Plugin "${name}" is listed multiple times in registry — skipping duplicate`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
seen.add(name);
|
|
196
|
+
uniqueEnabled.push(name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const pluginName of uniqueEnabled) {
|
|
200
|
+
const pluginDir = join(pluginsDir, pluginName);
|
|
201
|
+
const entryPoint = join(pluginDir, 'index.js');
|
|
202
|
+
|
|
203
|
+
if (!existsSync(entryPoint)) {
|
|
204
|
+
logger.warn(`Plugin "${pluginName}": entry point not found at ${entryPoint} — skipping`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const moduleUrl = pathToFileURL(resolve(entryPoint)).href;
|
|
210
|
+
const mod = await import(moduleUrl);
|
|
211
|
+
|
|
212
|
+
// Validate manifest
|
|
213
|
+
const manifestCheck = validateManifest(mod.manifest, pluginName);
|
|
214
|
+
if (!manifestCheck.valid) {
|
|
215
|
+
logger.warn(`Plugin "${pluginName}": invalid manifest — ${manifestCheck.reason} — skipping`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Validate exports
|
|
220
|
+
const exportsCheck = validateExports(mod, pluginName);
|
|
221
|
+
if (!exportsCheck.valid) {
|
|
222
|
+
logger.warn(`Plugin "${pluginName}": invalid exports — ${exportsCheck.reason} — skipping`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Store the loaded plugin
|
|
227
|
+
loadedPlugins.set(pluginName, {
|
|
228
|
+
manifest: mod.manifest,
|
|
229
|
+
matchesPage: mod.matchesPage,
|
|
230
|
+
getActions: mod.getActions,
|
|
231
|
+
getInfo: mod.getInfo
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
logger.info(`Plugin "${pluginName}" v${mod.manifest.version} loaded (${mod.getActions().length} actions)`);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
logger.warn(`Plugin "${pluginName}": failed to load — ${err.message} — skipping`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
logger.info(`Plugin loader: ${loadedPlugins.size} plugin(s) loaded`);
|
|
241
|
+
return loadedPlugins.size;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// DETECTION
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Detect which loaded plugins match the given page URL and HTML.
|
|
250
|
+
* URL patterns are checked first (fast path), then DOM patterns if defined.
|
|
251
|
+
* @param {string} url - Current page URL
|
|
252
|
+
* @param {string} html - Extracted page HTML
|
|
253
|
+
* @returns {Array<{ pluginName: string, confidence: number, nextSteps: string[] }>}
|
|
254
|
+
*/
|
|
255
|
+
export function detectPlugins(url, html) {
|
|
256
|
+
if (loadedPlugins.size === 0) return [];
|
|
257
|
+
|
|
258
|
+
const results = [];
|
|
259
|
+
|
|
260
|
+
for (const [name, plugin] of loadedPlugins) {
|
|
261
|
+
try {
|
|
262
|
+
const match = plugin.matchesPage(url, html);
|
|
263
|
+
if (match && match.matched) {
|
|
264
|
+
const confidence = typeof match.confidence === 'number' ? match.confidence : 1.0;
|
|
265
|
+
|
|
266
|
+
// Build nextSteps from plugin info
|
|
267
|
+
const info = plugin.getInfo();
|
|
268
|
+
const topActions = (info.actions || []).slice(0, 3);
|
|
269
|
+
const actionSummary = topActions.map(a => a.name).join(', ');
|
|
270
|
+
|
|
271
|
+
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`] : [])
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
results.push({ pluginName: name, confidence, nextSteps });
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
// Detection must not throw — skip this plugin silently
|
|
280
|
+
logger.debug(`Plugin "${name}" detection error: ${err.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Sort by confidence descending
|
|
285
|
+
results.sort((a, b) => b.confidence - a.confidence);
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Convert detection results into a flat nextSteps string array for response augmentation.
|
|
291
|
+
* @param {string} url - Current page URL
|
|
292
|
+
* @param {string} html - Extracted page HTML
|
|
293
|
+
* @returns {string[]} Array of nextSteps strings from matching plugins
|
|
294
|
+
*/
|
|
295
|
+
export function getPluginNextSteps(url, html) {
|
|
296
|
+
const detections = detectPlugins(url, html);
|
|
297
|
+
const steps = [];
|
|
298
|
+
for (const d of detections) {
|
|
299
|
+
steps.push(...d.nextSteps);
|
|
300
|
+
}
|
|
301
|
+
return steps;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// ACCESSORS
|
|
306
|
+
// ============================================================================
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get the map of all loaded plugins.
|
|
310
|
+
* @returns {Map<string, object>}
|
|
311
|
+
*/
|
|
312
|
+
export function getLoadedPlugins() {
|
|
313
|
+
return loadedPlugins;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get a specific loaded plugin by name.
|
|
318
|
+
* @param {string} name - Plugin name
|
|
319
|
+
* @returns {object|undefined} Plugin instance or undefined
|
|
320
|
+
*/
|
|
321
|
+
export function getPlugin(name) {
|
|
322
|
+
return loadedPlugins.get(name);
|
|
323
|
+
}
|
package/src/mcp-browser.js
CHANGED
|
@@ -28,6 +28,11 @@ import { getCurrentHtml, GET_CURRENT_HTML_TOOL } from './actions/get-current-htm
|
|
|
28
28
|
import { takeScreenshot, TAKE_SCREENSHOT_TOOL } from './actions/take-screenshot.js';
|
|
29
29
|
import { scrollPage, SCROLL_PAGE_TOOL } from './actions/scroll-page.js';
|
|
30
30
|
import { executeJavascript, EXECUTE_JAVASCRIPT_TOOL } from './actions/execute-javascript.js';
|
|
31
|
+
import { navigateHistory, NAVIGATE_HISTORY_TOOL } from './actions/navigate-history.js';
|
|
32
|
+
|
|
33
|
+
// Import plugin dispatch tools
|
|
34
|
+
import { pluginAction, PLUGIN_ACTION_TOOL } from './actions/plugin-action.js';
|
|
35
|
+
import { pluginInfo, PLUGIN_INFO_TOOL } from './actions/plugin-info.js';
|
|
31
36
|
|
|
32
37
|
// Import functions for testing exports
|
|
33
38
|
import { getBrowser, closeBrowser } from './core/browser.js';
|
|
@@ -36,6 +41,9 @@ import { isLikelyAuthUrl, waitForAuth, pollUntilAuthDone, detectLoginPage } from
|
|
|
36
41
|
import { cleanHtml, enrichHtml, prepareHtml } from './core/html.js';
|
|
37
42
|
import { getBaseDomain } from './utils.js';
|
|
38
43
|
|
|
44
|
+
// Import plugin system
|
|
45
|
+
import { loadPlugins, getLoadedPlugins, getPlugin, detectPlugins, getPluginNextSteps } from './core/plugin-loader.js';
|
|
46
|
+
|
|
39
47
|
/**
|
|
40
48
|
* Main entry point for the MCP server.
|
|
41
49
|
* Sets up the Model Context Protocol server with all available tools,
|
|
@@ -67,7 +75,10 @@ async function main() {
|
|
|
67
75
|
CLOSE_TAB_TOOL,
|
|
68
76
|
GET_CURRENT_HTML_TOOL,
|
|
69
77
|
TAKE_SCREENSHOT_TOOL,
|
|
70
|
-
SCROLL_PAGE_TOOL
|
|
78
|
+
SCROLL_PAGE_TOOL,
|
|
79
|
+
NAVIGATE_HISTORY_TOOL,
|
|
80
|
+
PLUGIN_INFO_TOOL,
|
|
81
|
+
PLUGIN_ACTION_TOOL
|
|
71
82
|
];
|
|
72
83
|
|
|
73
84
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
@@ -121,6 +132,18 @@ async function main() {
|
|
|
121
132
|
case "scroll_page":
|
|
122
133
|
result = await scrollPage(safeArgs);
|
|
123
134
|
break;
|
|
135
|
+
|
|
136
|
+
case "navigate_history":
|
|
137
|
+
result = await navigateHistory(safeArgs);
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
case "plugin_info":
|
|
141
|
+
result = pluginInfo(safeArgs);
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
case "plugin_action":
|
|
145
|
+
result = await pluginAction(safeArgs);
|
|
146
|
+
break;
|
|
124
147
|
|
|
125
148
|
default:
|
|
126
149
|
throw new Error(`Unknown tool: ${name}`);
|
|
@@ -142,6 +165,13 @@ async function main() {
|
|
|
142
165
|
});
|
|
143
166
|
|
|
144
167
|
const transport = new StdioServerTransport();
|
|
168
|
+
|
|
169
|
+
// Load plugins before starting the server
|
|
170
|
+
const pluginCount = await loadPlugins();
|
|
171
|
+
if (pluginCount > 0) {
|
|
172
|
+
logger.info(`${pluginCount} plugin(s) loaded and ready`);
|
|
173
|
+
}
|
|
174
|
+
|
|
145
175
|
await server.connect(transport);
|
|
146
176
|
logger.info(`MCPBrowser server v${packageJson.version} started`);
|
|
147
177
|
}
|
|
@@ -171,7 +201,16 @@ export {
|
|
|
171
201
|
getCurrentHtml,
|
|
172
202
|
takeScreenshot,
|
|
173
203
|
scrollPage,
|
|
174
|
-
|
|
204
|
+
navigateHistory,
|
|
205
|
+
handleAcceptEula,
|
|
206
|
+
// Plugin system exports
|
|
207
|
+
loadPlugins,
|
|
208
|
+
getLoadedPlugins,
|
|
209
|
+
getPlugin,
|
|
210
|
+
detectPlugins,
|
|
211
|
+
getPluginNextSteps,
|
|
212
|
+
pluginAction,
|
|
213
|
+
pluginInfo
|
|
175
214
|
};
|
|
176
215
|
|
|
177
216
|
// Run the MCP server only if this is the main module (not imported for testing)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _example plugin — Stub plugin for testing and documentation.
|
|
3
|
+
* Implements the full plugin interface contract (interfaceVersion 1).
|
|
4
|
+
* Matches pages on "example.test" domain for unit/integration testing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { MCPResponse } from '../../core/responses.js';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// MANIFEST
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export const manifest = {
|
|
14
|
+
name: "_example",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
description: "Example stub plugin for testing the MCPBrowser plugin system",
|
|
17
|
+
interfaceVersion: 1,
|
|
18
|
+
urlPatterns: ["example.test"],
|
|
19
|
+
domPatterns: [".example-plugin-marker"]
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// DETECTION
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Detect whether this plugin is applicable for the given page.
|
|
28
|
+
* @param {string} url - Current page URL
|
|
29
|
+
* @param {string} html - Extracted page HTML
|
|
30
|
+
* @returns {{ matched: boolean, confidence?: number }}
|
|
31
|
+
*/
|
|
32
|
+
export function matchesPage(url, html) {
|
|
33
|
+
try {
|
|
34
|
+
// Fast URL check first
|
|
35
|
+
if (url && url.includes("example.test")) {
|
|
36
|
+
return { matched: true, confidence: 1.0 };
|
|
37
|
+
}
|
|
38
|
+
// DOM fallback check
|
|
39
|
+
if (html && html.includes("example-plugin-marker")) {
|
|
40
|
+
return { matched: true, confidence: 0.8 };
|
|
41
|
+
}
|
|
42
|
+
return { matched: false };
|
|
43
|
+
} catch {
|
|
44
|
+
return { matched: false };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// ACTIONS
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
class ExampleActionResponse extends MCPResponse {
|
|
53
|
+
constructor(data, nextSteps) {
|
|
54
|
+
super(nextSteps);
|
|
55
|
+
this.data = data;
|
|
56
|
+
}
|
|
57
|
+
_getAdditionalFields() { return { data: this.data }; }
|
|
58
|
+
getTextSummary() { return `Example action returned ${Array.isArray(this.data) ? this.data.length : 0} items`; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return the complete list of actions this plugin provides.
|
|
63
|
+
* @returns {import('../../specs/002-site-plugins/data-model.md').ActionDescriptor[]}
|
|
64
|
+
*/
|
|
65
|
+
export function getActions() {
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
name: "list_items",
|
|
69
|
+
description: "List items from the example page",
|
|
70
|
+
params: [
|
|
71
|
+
{ name: "limit", type: "number", description: "Maximum number of items to return", required: false, default: 10 }
|
|
72
|
+
],
|
|
73
|
+
execute: async ({ page, params }) => {
|
|
74
|
+
const limit = params?.limit ?? 10;
|
|
75
|
+
const items = await page.evaluate((lim) => {
|
|
76
|
+
const rows = document.querySelectorAll('.item-row');
|
|
77
|
+
return Array.from(rows).slice(0, lim).map(row => ({
|
|
78
|
+
title: row.querySelector('.title')?.textContent?.trim() || 'Untitled',
|
|
79
|
+
date: row.querySelector('.date')?.textContent?.trim() || ''
|
|
80
|
+
}));
|
|
81
|
+
}, limit);
|
|
82
|
+
|
|
83
|
+
return new ExampleActionResponse(items, [
|
|
84
|
+
"Call plugin_action with action 'get_item_detail' to read a specific item"
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "get_item_detail",
|
|
90
|
+
description: "Get details for a specific item by ID",
|
|
91
|
+
params: [
|
|
92
|
+
{ name: "itemId", type: "string", description: "Item identifier", required: true }
|
|
93
|
+
],
|
|
94
|
+
execute: async ({ page, params }) => {
|
|
95
|
+
if (!params?.itemId) {
|
|
96
|
+
throw new Error("itemId parameter is required");
|
|
97
|
+
}
|
|
98
|
+
const detail = await page.evaluate((id) => {
|
|
99
|
+
const el = document.querySelector(`[data-id="${id}"]`);
|
|
100
|
+
if (!el) return null;
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
title: el.querySelector('.title')?.textContent?.trim() || '',
|
|
104
|
+
body: el.querySelector('.body')?.textContent?.trim() || ''
|
|
105
|
+
};
|
|
106
|
+
}, params.itemId);
|
|
107
|
+
|
|
108
|
+
if (!detail) {
|
|
109
|
+
const { ErrorResponse } = await import('../../src/core/responses.js');
|
|
110
|
+
return new ErrorResponse(
|
|
111
|
+
`Item '${params.itemId}' not found on the page`,
|
|
112
|
+
["Verify the item ID is correct", "Use list_items to see available items"]
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return new ExampleActionResponse(detail, [
|
|
117
|
+
"Use plugin_action with action 'list_items' to see all items"
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// INFO
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Return high-level plugin context for the AI agent.
|
|
130
|
+
* @returns {import('../../specs/002-site-plugins/data-model.md').PluginInfo}
|
|
131
|
+
*/
|
|
132
|
+
export function getInfo() {
|
|
133
|
+
return {
|
|
134
|
+
description: "Example stub plugin for testing MCPBrowser's plugin system. Lists items and retrieves item details from example.test pages.",
|
|
135
|
+
targetPages: ["Example test page (example.test)"],
|
|
136
|
+
authFlow: "No authentication required — example.test is a test domain",
|
|
137
|
+
actions: getActions().map(({ name, description, params }) => ({ name, description, params }))
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archive-email.js — Archive an email, removing it from the inbox.
|
|
3
|
+
*
|
|
4
|
+
* Tier usage:
|
|
5
|
+
* T2: 'e' keyboard shortcut to archive
|
|
6
|
+
* T3: selectEmailRow for list view targeting
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ErrorResponse } from '../../../core/responses.js';
|
|
10
|
+
import logger from '../../../core/logger.js';
|
|
11
|
+
import {
|
|
12
|
+
checkPrecondition,
|
|
13
|
+
detectView,
|
|
14
|
+
selectEmailRow,
|
|
15
|
+
VIEW,
|
|
16
|
+
GmailActionResponse
|
|
17
|
+
} from '../helpers.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Archive an email from thread view or list view.
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {import('puppeteer-core').Page} opts.page
|
|
23
|
+
* @param {object} opts.params
|
|
24
|
+
* @param {number} [opts.params.index] - Email index in list view
|
|
25
|
+
* @param {string} [opts.params.id] - Email ID in list view
|
|
26
|
+
* @returns {Promise<GmailActionResponse|ErrorResponse>}
|
|
27
|
+
*/
|
|
28
|
+
export async function archiveEmail({ page, params }) {
|
|
29
|
+
// Precondition: must be on Gmail
|
|
30
|
+
const pre = await checkPrecondition(page, 'on_gmail');
|
|
31
|
+
if (!pre.met) {
|
|
32
|
+
return new ErrorResponse(pre.error, [
|
|
33
|
+
pre.suggestion || "Use fetch_webpage({ url: 'https://mail.google.com' }) to open Gmail first."
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const view = await detectView(page);
|
|
38
|
+
|
|
39
|
+
if (view === VIEW.THREAD) {
|
|
40
|
+
// In thread view, archive directly
|
|
41
|
+
await page.keyboard.press('e');
|
|
42
|
+
logger.debug('archiveEmail: pressed "e" in thread view');
|
|
43
|
+
} else if (view === VIEW.EMAIL_LIST || view === VIEW.SEARCH_RESULTS) {
|
|
44
|
+
// In list view, select the row first
|
|
45
|
+
const sel = await selectEmailRow(page, { index: params.index, id: params.id });
|
|
46
|
+
if (!sel.selected) {
|
|
47
|
+
return new ErrorResponse(sel.error || 'Could not select email to archive.', [
|
|
48
|
+
'Provide an index or id parameter to target a specific email.'
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
await page.keyboard.press('e');
|
|
52
|
+
logger.debug('archiveEmail: selected row and pressed "e"');
|
|
53
|
+
} else {
|
|
54
|
+
return new ErrorResponse(
|
|
55
|
+
'Cannot archive from the current view. Navigate to inbox or open an email first.',
|
|
56
|
+
["Use list_emails to view the inbox, or read_email to open a thread."]
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new GmailActionResponse(
|
|
61
|
+
{ archived: true },
|
|
62
|
+
'Email archived successfully.',
|
|
63
|
+
['Use list_emails to return to inbox']
|
|
64
|
+
);
|
|
65
|
+
}
|