mcpbrowser 0.3.34 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpbrowser",
3
- "version": "0.3.34",
3
+ "version": "0.3.35",
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,6 +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
32
 
32
33
  /**
33
34
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -332,7 +333,8 @@ export async function clickElement({ url, selector, text, waitForElementTimeout
332
333
  "Use MCPBrowser's type_text to fill forms if needed",
333
334
  "Use MCPBrowser's get_current_html to refresh page state",
334
335
  "Use MCPBrowser's take_screenshot if page has popups or visual content that's hard to parse from HTML",
335
- "Use MCPBrowser's close_tab when finished"
336
+ "Use MCPBrowser's close_tab when finished",
337
+ ...(html ? getPluginNextSteps(currentUrl, html) : [])
336
338
  ]
337
339
  : [
338
340
  "Use MCPBrowser's get_current_html to see updated page state",
@@ -7,6 +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
11
 
11
12
  // Shared execution defaults for script actions
12
13
  export const EXECUTION_TIMEOUT_DEFAULT_MS = 30_000;
@@ -225,7 +226,8 @@ export async function executeJavascript({ url, script, timeoutMs = EXECUTION_TIM
225
226
  nextSteps: [
226
227
  'Use click_element or type_text for follow-up actions',
227
228
  'Inspect urlChanged to decide if navigation occurred',
228
- serialization.truncated ? 'Narrow your selector or reduce returned fields to avoid truncation' : 'Proceed with the returned data'
229
+ serialization.truncated ? 'Narrow your selector or reduce returned fields to avoid truncation' : 'Proceed with the returned data',
230
+ ...getPluginNextSteps(currentUrl, '')
229
231
  ]
230
232
  });
231
233
  }
@@ -8,6 +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
12
 
12
13
  /**
13
14
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -224,7 +225,8 @@ async function doFetchPage({ url, browser, removeUnnecessaryHTML, postLoadWait }
224
225
  "Use MCPBrowser's type_text to fill in form fields",
225
226
  "Use MCPBrowser's get_current_html to re-check page state after interactions",
226
227
  "Use MCPBrowser's take_screenshot if page has charts, images, or complex visual layout that's hard to understand from HTML",
227
- "Use MCPBrowser's close_tab when finished to free browser resources"
228
+ "Use MCPBrowser's close_tab when finished to free browser resources",
229
+ ...getPluginNextSteps(page.url(), processedHtml)
228
230
  ]
229
231
  );
230
232
  } catch (err) {
@@ -6,6 +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
10
 
10
11
  /**
11
12
  * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
@@ -160,7 +161,8 @@ export async function getCurrentHtml({ url, removeUnnecessaryHTML = true }) {
160
161
  "Use MCPBrowser's click_element to interact with elements",
161
162
  "Use MCPBrowser's type_text to fill forms",
162
163
  "Use MCPBrowser's take_screenshot if page layout or visual content is hard to understand from HTML",
163
- "Use MCPBrowser's close_tab to free resources when done"
164
+ "Use MCPBrowser's close_tab to free resources when done",
165
+ ...getPluginNextSteps(currentUrl, html)
164
166
  ]
165
167
  );
166
168
  } catch (err) {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * plugin-action.js — MCP tool that dispatches to a plugin's action.
3
+ * Looks up the plugin by name, finds the action, provides the browser
4
+ * page object, and calls the action's execute function.
5
+ * Part of the plugin dispatch pair (plugin_info + plugin_action).
6
+ */
7
+
8
+ import { MCPResponse, ErrorResponse } from '../core/responses.js';
9
+ import { getLoadedPlugins, getPlugin } from '../core/plugin-loader.js';
10
+ import { getBrowser, getValidatedPage } from '../core/browser.js';
11
+ import logger from '../core/logger.js';
12
+
13
+ /**
14
+ * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
15
+ */
16
+
17
+ // ============================================================================
18
+ // RESPONSE CLASS
19
+ // ============================================================================
20
+
21
+ /** Response wrapping a plugin action's raw result */
22
+ export class PluginActionSuccessResponse extends MCPResponse {
23
+ constructor(pluginName, actionName, data, nextSteps) {
24
+ super(nextSteps);
25
+ this.pluginName = pluginName;
26
+ this.actionName = actionName;
27
+ this.data = data;
28
+ }
29
+ _getAdditionalFields() { return { pluginName: this.pluginName, actionName: this.actionName, data: this.data }; }
30
+ getTextSummary() { return `Plugin "${this.pluginName}" action "${this.actionName}" completed`; }
31
+ }
32
+
33
+ // ============================================================================
34
+ // TOOL DEFINITION
35
+ // ============================================================================
36
+
37
+ /** @type {Tool} */
38
+ export const PLUGIN_ACTION_TOOL = {
39
+ name: "plugin_action",
40
+ title: "Plugin Action",
41
+ description: "Execute a site-specific plugin action. Use plugin_info first to discover available actions and their parameters. Plugins provide specialized automation for UI-heavy websites like Gmail, Outlook, PowerBI, AWS, and Azure — faster and more reliable than generic DOM interaction.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ plugin: {
46
+ type: "string",
47
+ description: "Plugin name (e.g., 'gmail', 'outlook', 'powerbi')"
48
+ },
49
+ action: {
50
+ type: "string",
51
+ description: "Action name within the plugin (e.g., 'list_emails', 'extract_grid')"
52
+ },
53
+ params: {
54
+ type: "object",
55
+ description: "Action parameters. Use plugin_info to discover accepted parameters.",
56
+ additionalProperties: true
57
+ }
58
+ },
59
+ required: ["plugin", "action"],
60
+ additionalProperties: false
61
+ },
62
+ outputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ nextSteps: {
66
+ type: "array",
67
+ items: { type: "string" },
68
+ description: "Suggested next actions"
69
+ }
70
+ },
71
+ required: ["nextSteps"],
72
+ additionalProperties: true
73
+ }
74
+ };
75
+
76
+ // ============================================================================
77
+ // ACTION FUNCTION
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Dispatch to a plugin action.
82
+ * @param {Object} params
83
+ * @param {string} params.plugin - Plugin name
84
+ * @param {string} params.action - Action name within the plugin
85
+ * @param {Object} [params.params] - Action parameters
86
+ * @returns {Promise<MCPResponse>}
87
+ */
88
+ export async function pluginAction({ plugin: pluginName, action: actionName, params = {} }) {
89
+ logger.info(`plugin_action called: plugin=${pluginName} action=${actionName}`);
90
+
91
+ const loadedPlugins = getLoadedPlugins();
92
+
93
+ // Validate plugin exists
94
+ const pluginInstance = getPlugin(pluginName);
95
+ if (!pluginInstance) {
96
+ const available = [...loadedPlugins.keys()].join(', ') || '(none)';
97
+ return new ErrorResponse(
98
+ `Unknown plugin: '${pluginName}'. Available plugins: ${available}`,
99
+ ["Call plugin_info() to list all loaded plugins"]
100
+ );
101
+ }
102
+
103
+ // Validate action exists
104
+ const actions = pluginInstance.getActions();
105
+ const actionDef = actions.find(a => a.name === actionName);
106
+ if (!actionDef) {
107
+ const validActions = actions.map(a => a.name).join(', ');
108
+ return new ErrorResponse(
109
+ `Unknown action '${actionName}' for plugin '${pluginName}'. Available actions: ${validActions}`,
110
+ [`Call plugin_info({ plugin: '${pluginName}' }) to see all available actions and their parameters`]
111
+ );
112
+ }
113
+
114
+ // Get browser page — check if on correct domain (US5/T032)
115
+ let page;
116
+ try {
117
+ // Try to get a validated page for any of the plugin's URL patterns
118
+ const browser = await getBrowser();
119
+ const pages = await browser.pages();
120
+
121
+ // Find a page matching any of the plugin's URL patterns
122
+ let matchedPage = null;
123
+ for (const p of pages) {
124
+ try {
125
+ const pageUrl = p.url();
126
+ for (const pattern of pluginInstance.manifest.urlPatterns) {
127
+ if (pageUrl.includes(pattern)) {
128
+ matchedPage = p;
129
+ break;
130
+ }
131
+ }
132
+ if (matchedPage) break;
133
+ } catch { /* skip closed/errored pages */ }
134
+ }
135
+
136
+ if (!matchedPage) {
137
+ const targetPatterns = pluginInstance.manifest.urlPatterns.join(', ');
138
+ return new ErrorResponse(
139
+ `Plugin '${pluginName}' requires ${targetPatterns} but no matching page is open. Use fetch_webpage to navigate to the correct site first.`,
140
+ [`Use MCPBrowser's fetch_webpage to navigate to a page matching: ${targetPatterns}`, `Then retry plugin_action`]
141
+ );
142
+ }
143
+
144
+ page = matchedPage;
145
+ } catch (err) {
146
+ logger.error(`plugin_action: browser error — ${err.message}`);
147
+ return new ErrorResponse(
148
+ `Browser connection failed: ${err.message}`,
149
+ ["Ensure the browser is running with remote debugging enabled", "Retry plugin_action after browser is connected"]
150
+ );
151
+ }
152
+
153
+ // Execute the action
154
+ try {
155
+ const result = await actionDef.execute({ page, params });
156
+
157
+ // If result is already an MCPResponse subclass, return it directly
158
+ if (result && typeof result.toMcpFormat === 'function') {
159
+ return result;
160
+ }
161
+
162
+ // Wrap raw results
163
+ return new PluginActionSuccessResponse(
164
+ pluginName,
165
+ actionName,
166
+ result,
167
+ [`Use plugin_info({ plugin: '${pluginName}' }) to see other available actions`]
168
+ );
169
+ } catch (err) {
170
+ logger.error(`plugin_action: "${pluginName}/${actionName}" failed — ${err.message}`);
171
+ return new ErrorResponse(
172
+ `Plugin '${pluginName}' action '${actionName}' failed: ${err.message}. The site structure may have changed. You can fall back to generic MCPBrowser tools (click_element, get_current_html).`,
173
+ [
174
+ "Check if the page is on the correct site",
175
+ "Try MCPBrowser's get_current_html to inspect the page state",
176
+ "Use generic MCPBrowser tools as a fallback"
177
+ ]
178
+ );
179
+ }
180
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * plugin-info.js — MCP tool that returns information about installed plugins,
3
+ * their available actions, parameters, and high-level site context.
4
+ * Part of the plugin dispatch pair (plugin_info + plugin_action).
5
+ */
6
+
7
+ import { MCPResponse, ErrorResponse } from '../core/responses.js';
8
+ import { getLoadedPlugins, getPlugin } from '../core/plugin-loader.js';
9
+ import logger from '../core/logger.js';
10
+
11
+ /**
12
+ * @typedef {import('@modelcontextprotocol/sdk/types.js').Tool} Tool
13
+ */
14
+
15
+ // ============================================================================
16
+ // RESPONSE CLASSES
17
+ // ============================================================================
18
+
19
+ /** Response listing all loaded plugins */
20
+ export class PluginListResponse extends MCPResponse {
21
+ constructor(plugins, nextSteps) {
22
+ super(nextSteps);
23
+ this.plugins = plugins;
24
+ }
25
+ _getAdditionalFields() { return { plugins: this.plugins }; }
26
+ getTextSummary() { return `${this.plugins.length} plugin(s) loaded`; }
27
+ }
28
+
29
+ /** Response with plugin detail (action catalog + site context) */
30
+ export class PluginInfoResponse extends MCPResponse {
31
+ constructor(info, nextSteps) {
32
+ super(nextSteps);
33
+ this.pluginInfo = info;
34
+ }
35
+ _getAdditionalFields() { return { ...this.pluginInfo }; }
36
+ getTextSummary() { return `Plugin "${this.pluginInfo.name}": ${this.pluginInfo.actions?.length || 0} action(s)`; }
37
+ }
38
+
39
+ /** Response with single action detail */
40
+ export class PluginActionDetailResponse extends MCPResponse {
41
+ constructor(plugin, action, nextSteps) {
42
+ super(nextSteps);
43
+ this.plugin = plugin;
44
+ this.action = action;
45
+ }
46
+ _getAdditionalFields() { return { plugin: this.plugin, action: this.action }; }
47
+ getTextSummary() { return `Action "${this.action.name}" from plugin "${this.plugin}"`; }
48
+ }
49
+
50
+ // ============================================================================
51
+ // TOOL DEFINITION
52
+ // ============================================================================
53
+
54
+ /** @type {Tool} */
55
+ export const PLUGIN_INFO_TOOL = {
56
+ name: "plugin_info",
57
+ title: "Plugin Info",
58
+ description: "Get information about an installed site plugin — its available actions, parameters, and site context. Call this after a plugin is detected (recommended in nextSteps) to discover what actions you can perform via plugin_action. You can also call with no arguments to list all loaded plugins.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ plugin: {
63
+ type: "string",
64
+ description: "Plugin name to get info for. Omit to list all loaded plugins."
65
+ },
66
+ action: {
67
+ type: "string",
68
+ description: "Optional. Specific action name to get detailed info for."
69
+ }
70
+ },
71
+ additionalProperties: false
72
+ },
73
+ outputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ nextSteps: {
77
+ type: "array",
78
+ items: { type: "string" },
79
+ description: "Suggested next actions"
80
+ }
81
+ },
82
+ required: ["nextSteps"],
83
+ additionalProperties: true
84
+ }
85
+ };
86
+
87
+ // ============================================================================
88
+ // ACTION FUNCTION
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Get plugin information — list all plugins, plugin detail, or action detail.
93
+ * @param {Object} params
94
+ * @param {string} [params.plugin] - Plugin name (omit to list all)
95
+ * @param {string} [params.action] - Action name (requires plugin)
96
+ * @returns {MCPResponse}
97
+ */
98
+ export function pluginInfo({ plugin, action } = {}) {
99
+ logger.info(`plugin_info called: plugin=${plugin || '(all)'} action=${action || '(all)'}`);
100
+
101
+ const loadedPlugins = getLoadedPlugins();
102
+
103
+ // Mode 1: List all plugins
104
+ if (!plugin) {
105
+ const plugins = [];
106
+ for (const [name, p] of loadedPlugins) {
107
+ plugins.push({
108
+ name,
109
+ description: p.manifest.description,
110
+ actionCount: p.getActions().length
111
+ });
112
+ }
113
+
114
+ const nextSteps = plugins.length > 0
115
+ ? plugins.map(p => `Call plugin_info({ plugin: '${p.name}' }) to see ${p.name}'s available actions`)
116
+ : ["No plugins are currently loaded. Add plugin names to plugins.json and restart the server."];
117
+
118
+ return new PluginListResponse(plugins, nextSteps);
119
+ }
120
+
121
+ // Validate plugin exists
122
+ const pluginInstance = getPlugin(plugin);
123
+ if (!pluginInstance) {
124
+ const available = [...loadedPlugins.keys()].join(', ') || '(none)';
125
+ return new ErrorResponse(
126
+ `Unknown plugin: '${plugin}'. Available plugins: ${available}`,
127
+ loadedPlugins.size > 0
128
+ ? [`Call plugin_info() with no arguments to list all plugins`]
129
+ : ["No plugins are currently loaded. Add plugin names to plugins.json and restart the server."]
130
+ );
131
+ }
132
+
133
+ // Mode 3: Single action detail
134
+ if (action) {
135
+ const actions = pluginInstance.getActions();
136
+ const actionDef = actions.find(a => a.name === action);
137
+ if (!actionDef) {
138
+ const validActions = actions.map(a => a.name).join(', ');
139
+ return new ErrorResponse(
140
+ `Unknown action '${action}' for plugin '${plugin}'. Available actions: ${validActions}`,
141
+ [`Call plugin_info({ plugin: '${plugin}' }) to see all available actions`]
142
+ );
143
+ }
144
+
145
+ return new PluginActionDetailResponse(
146
+ plugin,
147
+ { name: actionDef.name, description: actionDef.description, params: actionDef.params },
148
+ [`Call plugin_action({ plugin: '${plugin}', action: '${action}', params: { ... } })`]
149
+ );
150
+ }
151
+
152
+ // Mode 2: Plugin detail with full action catalog
153
+ const info = pluginInstance.getInfo();
154
+ const pluginDetail = {
155
+ name: plugin,
156
+ description: info.description,
157
+ targetPages: info.targetPages,
158
+ ...(info.authFlow ? { authFlow: info.authFlow } : {}),
159
+ actions: info.actions || []
160
+ };
161
+
162
+ const nextSteps = [
163
+ ...(info.actions || []).slice(0, 3).map(a =>
164
+ `Use plugin_action({ plugin: '${plugin}', action: '${a.name}' }) to ${a.description.toLowerCase()}`
165
+ ),
166
+ "Use fetch_webpage to navigate to the target site first if not already there"
167
+ ];
168
+
169
+ return new PluginInfoResponse(pluginDetail, nextSteps);
170
+ }
@@ -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 (err) {
42
- // Fall back to stderr without recursing through logger.
43
- try {
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