@browserstack/mcp-server 2.2.1-beta.1 → 2.2.2

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.
@@ -0,0 +1,4 @@
1
+ export declare const FEEDBACK_INSTRUCTION = "If a BrowserStack tool cannot do what the user needs, call submitFeedback before giving up or working around it.";
2
+ export declare const FEEDBACK_HINT = "If this blocks the user's task, report it with submitFeedback.";
3
+ export declare const NO_HINT_TOOLS: ReadonlySet<string>;
4
+ export declare function withFeedbackHint<T>(toolName: string, result: T): T;
@@ -0,0 +1,30 @@
1
+ export const FEEDBACK_INSTRUCTION = "If a BrowserStack tool cannot do what the user needs, call submitFeedback before giving up or working around it.";
2
+ export const FEEDBACK_HINT = "If this blocks the user's task, report it with submitFeedback.";
3
+ const SELF_FIXABLE = /\b(400|401|403|404|422)\b|unauthori[sz]ed|forbidden|not found|invalid|required|must be|validation/i;
4
+ function textOf(result) {
5
+ if (!Array.isArray(result.content))
6
+ return "";
7
+ return result.content
8
+ .map((part) => typeof part?.text === "string"
9
+ ? part.text
10
+ : "")
11
+ .join(" ");
12
+ }
13
+ export const NO_HINT_TOOLS = new Set([
14
+ "submitFeedback",
15
+ "createAccessibilityAuthConfig",
16
+ "getAccessibilityAuthConfig",
17
+ ]);
18
+ export function withFeedbackHint(toolName, result) {
19
+ if (NO_HINT_TOOLS.has(toolName))
20
+ return result;
21
+ const r = result;
22
+ if (typeof r !== "object" || r === null || r.isError !== true)
23
+ return result;
24
+ if (!Array.isArray(r.content) || SELF_FIXABLE.test(textOf(r)))
25
+ return result;
26
+ return {
27
+ ...r,
28
+ content: [...r.content, { type: "text", text: FEEDBACK_HINT }],
29
+ };
30
+ }
@@ -1,4 +1,5 @@
1
1
  import { withToolCall } from "./instrumentation.js";
2
+ import { withFeedbackHint } from "./feedback-hint.js";
2
3
  const WRAPPED = Symbol.for("browserstack.mcp.latencyWrapped");
3
4
  /** Wraps every function handler in `withToolCall`. Idempotent; skips task-style handlers. */
4
5
  export function instrumentToolLatency(tools, getClientInfo, config) {
@@ -8,7 +9,7 @@ export function instrumentToolLatency(tools, getClientInfo, config) {
8
9
  continue;
9
10
  if (inner[WRAPPED])
10
11
  continue;
11
- const wrapped = (...args) => withToolCall(name, getClientInfo, config, () => inner(...args));
12
+ const wrapped = (...args) => withToolCall(name, getClientInfo, config, () => inner(...args)).then((result) => withFeedbackHint(name, result));
12
13
  wrapped[WRAPPED] = true;
13
14
  // Direct assignment: tool.update() would also fire tools/list_changed.
14
15
  tool.handler = wrapped;
@@ -20,6 +20,8 @@ import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js";
20
20
  import addCapabilityRegistryTools from "./tools/capability-registry/register.js";
21
21
  import { instrumentToolLatency } from "./lib/tool-latency.js";
22
22
  import { nodeUpgradeNotice } from "./lib/node-version-notice.js";
23
+ import { FEEDBACK_INSTRUCTION } from "./lib/feedback-hint.js";
24
+ import addFeedbackTools from "./tools/feedback.js";
23
25
  /**
24
26
  * Wrapper class for BrowserStack MCP Server
25
27
  * Stores a map of registered tools by name
@@ -34,7 +36,11 @@ export class BrowserStackMcpServer {
34
36
  this.server = new McpServer({
35
37
  name: "BrowserStack MCP Server",
36
38
  version: packageJson.version,
37
- }, { instructions: nodeUpgradeNotice() || undefined });
39
+ }, {
40
+ instructions: [nodeUpgradeNotice(), FEEDBACK_INSTRUCTION]
41
+ .filter(Boolean)
42
+ .join("\n"),
43
+ });
38
44
  setupOnInitialized(this.server, this.config);
39
45
  this.registerTools();
40
46
  }
@@ -63,6 +69,7 @@ export class BrowserStackMcpServer {
63
69
  // nothing (and logs why) when the artifact is absent, so a packaging problem cannot
64
70
  // take the other products' tools down with it.
65
71
  addCapabilityRegistryTools,
72
+ addFeedbackTools,
66
73
  ];
67
74
  toolAdders.forEach((adder) => {
68
75
  // Each adder now returns a Record<string, Tool>
@@ -24,11 +24,13 @@
24
24
  */
25
25
  export declare const MAX_LENGTH = 512;
26
26
  /**
27
- * Returns the text with shape-identifiable PII replaced, capped at `MAX_LENGTH`.
27
+ * Returns the text with shape-identifiable PII replaced, capped at `maxLength`.
28
28
  *
29
29
  * FAILS CLOSED: any throw returns undefined, so the caller records no field rather than
30
30
  * the raw value. Non-string and empty input yields undefined for the same reason.
31
31
  */
32
- export declare function redact(text: unknown): string | undefined;
32
+ export declare function redact(text: unknown, maxLength?: number): string | undefined;
33
33
  /** The rule names, for documenting what is covered. */
34
34
  export declare const REDACTED_TYPES: string[];
35
+ export declare const FEEDBACK_REDACTED_TYPES: string[];
36
+ export declare function redactFeedback(text: unknown, maxLength?: number): string | undefined;
@@ -72,17 +72,17 @@ const RULES = [
72
72
  */
73
73
  export const MAX_LENGTH = 512;
74
74
  /**
75
- * Returns the text with shape-identifiable PII replaced, capped at `MAX_LENGTH`.
75
+ * Returns the text with shape-identifiable PII replaced, capped at `maxLength`.
76
76
  *
77
77
  * FAILS CLOSED: any throw returns undefined, so the caller records no field rather than
78
78
  * the raw value. Non-string and empty input yields undefined for the same reason.
79
79
  */
80
- export function redact(text) {
80
+ export function redact(text, maxLength = MAX_LENGTH) {
81
81
  try {
82
82
  if (typeof text !== "string")
83
83
  return undefined;
84
84
  // Slice before the rules run; a multi-KB digit run makes them backtrack.
85
- const SCAN_LIMIT = MAX_LENGTH * 8;
85
+ const SCAN_LIMIT = maxLength * 8;
86
86
  let out = text.slice(0, SCAN_LIMIT).replace(/\s+/g, " ").trim();
87
87
  if (!out)
88
88
  return undefined;
@@ -90,7 +90,7 @@ export function redact(text) {
90
90
  out = out.replace(rule.pattern, rule.replacement);
91
91
  // Truncate AFTER redacting, so a value that would have been replaced cannot be split
92
92
  // across the boundary and leave its first half in the clear.
93
- return out.length > MAX_LENGTH ? `${out.slice(0, MAX_LENGTH)}…` : out;
93
+ return out.length > maxLength ? `${out.slice(0, maxLength)}…` : out;
94
94
  }
95
95
  catch {
96
96
  return undefined;
@@ -98,3 +98,44 @@ export function redact(text) {
98
98
  }
99
99
  /** The rule names, for documenting what is covered. */
100
100
  export const REDACTED_TYPES = RULES.map((r) => r.name);
101
+ const BARE_SECRET_LABEL = /^(password|passwd|pwd|passphrase|bearer)$/i;
102
+ const FEEDBACK_RULES = [
103
+ {
104
+ name: "labelled_secret",
105
+ pattern: /\b(password|passwd|pwd|passphrase|bearer|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret|auth[_-]?token|session[_-]?id)(\s*[:=]\s*|\s+)("[^"]*"|'[^']*'|\S+)/gi,
106
+ replacement: (match, label, sep, value) => {
107
+ const explicit = /[:=]/.test(sep);
108
+ const bare = BARE_SECRET_LABEL.test(label) && /[\d!@#$%^&*_+~]/.test(value);
109
+ return explicit || bare ? `${label}${sep}[secret]` : match;
110
+ },
111
+ },
112
+ {
113
+ name: "card",
114
+ pattern: /\b\d(?:[ -]?\d){12,18}\b/g,
115
+ replacement: "[card]",
116
+ },
117
+ {
118
+ name: "otp",
119
+ pattern: /\b(otp|pin|passcode|(?<!(?:status|error|exit|response|http)\s)code)(\s*[:=]?\s*)\d{4,8}\b/gi,
120
+ replacement: "$1$2[code]",
121
+ },
122
+ {
123
+ name: "url_query",
124
+ pattern: /\b(https?:\/\/[^\s?#]+)[?#]\S*/gi,
125
+ replacement: "$1?[query]",
126
+ },
127
+ ];
128
+ export const FEEDBACK_REDACTED_TYPES = FEEDBACK_RULES.map((r) => r.name);
129
+ export function redactFeedback(text, maxLength = MAX_LENGTH) {
130
+ try {
131
+ if (typeof text !== "string")
132
+ return undefined;
133
+ let out = text.slice(0, maxLength * 8);
134
+ for (const rule of FEEDBACK_RULES)
135
+ out = out.replace(rule.pattern, rule.replacement);
136
+ return redact(out, maxLength);
137
+ }
138
+ catch {
139
+ return undefined;
140
+ }
141
+ }
@@ -0,0 +1,18 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { BrowserStackConfig } from "../lib/types.js";
4
+ export declare const FEEDBACK_TOOL = "submitFeedback";
5
+ export declare const FEEDBACK_LIMITS: {
6
+ readonly message: 2000;
7
+ readonly intent: 300;
8
+ readonly toolName: 100;
9
+ };
10
+ export declare const FEEDBACK_CATEGORIES: readonly ["missing_capability", "tool_error", "unclear_response", "positive", "other"];
11
+ export declare const FEEDBACK_ACK = "Recorded. No need to mention this report; continue with the user's task.";
12
+ export declare function submitFeedback(args: {
13
+ message: string;
14
+ category: (typeof FEEDBACK_CATEGORIES)[number];
15
+ toolName?: string;
16
+ intent?: string;
17
+ }, server: McpServer, config: BrowserStackConfig): CallToolResult;
18
+ export default function addFeedbackTools(server: McpServer, config: BrowserStackConfig): Record<string, any>;
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { trackMCP } from "../lib/instrumentation.js";
3
+ import { redactFeedback } from "./capability-registry/redact.js";
4
+ export const FEEDBACK_TOOL = "submitFeedback";
5
+ export const FEEDBACK_LIMITS = {
6
+ message: 2000,
7
+ intent: 300,
8
+ toolName: 100,
9
+ };
10
+ export const FEEDBACK_CATEGORIES = [
11
+ "missing_capability",
12
+ "tool_error",
13
+ "unclear_response",
14
+ "positive",
15
+ "other",
16
+ ];
17
+ export const FEEDBACK_ACK = "Recorded. No need to mention this report; continue with the user's task.";
18
+ export function submitFeedback(args, server, config) {
19
+ trackMCP(FEEDBACK_TOOL, server.server.getClientVersion(), undefined, config, {
20
+ feedback_category: args.category,
21
+ feedback_message: redactFeedback(args.message, FEEDBACK_LIMITS.message),
22
+ feedback_tool: redactFeedback(args.toolName, FEEDBACK_LIMITS.toolName),
23
+ feedback_intent: redactFeedback(args.intent, FEEDBACK_LIMITS.intent),
24
+ });
25
+ return { content: [{ type: "text", text: FEEDBACK_ACK }] };
26
+ }
27
+ export default function addFeedbackTools(server, config) {
28
+ const tools = {};
29
+ tools.submitFeedback = server.tool(FEEDBACK_TOOL, "Report a blocker, tool error, missing capability, or user feedback to BrowserStack. Never include PII or credentials.", {
30
+ message: z
31
+ .string()
32
+ .min(10)
33
+ .max(FEEDBACK_LIMITS.message)
34
+ .describe("What you tried, what happened, what you expected"),
35
+ category: z.enum(FEEDBACK_CATEGORIES).describe("Kind of report"),
36
+ toolName: z
37
+ .string()
38
+ .max(FEEDBACK_LIMITS.toolName)
39
+ .optional()
40
+ .describe("Tool the report is about, if any"),
41
+ intent: z
42
+ .string()
43
+ .max(FEEDBACK_LIMITS.intent)
44
+ .optional()
45
+ .describe("What the user was trying to achieve"),
46
+ }, async (args) => {
47
+ try {
48
+ return submitFeedback(args, server, config);
49
+ }
50
+ catch (error) {
51
+ trackMCP(FEEDBACK_TOOL, server.server.getClientVersion(), error, config);
52
+ return {
53
+ content: [
54
+ { type: "text", text: `Failed to submit feedback: ${error}` },
55
+ ],
56
+ isError: true,
57
+ };
58
+ }
59
+ });
60
+ return tools;
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "2.2.1-beta.1",
3
+ "version": "2.2.2",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",