Skip to content

Commit 178024a

Browse files
committed
2 parents 44506ac + ec52e8a commit 178024a

19 files changed

+1053
-54
lines changed

.github/workflows/npm-publish.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: "Release New Version"
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: write
8+
9+
jobs:
10+
publish:
11+
name: "Publish to NPM"
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: "Checkout source code"
16+
uses: actions/checkout@v2
17+
18+
- name: "Set up Node.js"
19+
uses: actions/setup-node@v3
20+
with:
21+
node-version: 22.x
22+
registry-url: "https://registry.npmjs.org/"
23+
24+
- name: "Install dependencies"
25+
run: npm ci
26+
27+
- name: "Add node_modules/.bin to PATH"
28+
run: echo "$(npm bin)" >> $GITHUB_PATH
29+
30+
- name: "Build package"
31+
run: npm run build
32+
33+
- name: "Set Git user name and email"
34+
run: |
35+
git config --global user.name "github-actions"
36+
git config --global user.email "[email protected]"
37+
38+
- name: "Bump patch version"
39+
run: npm version patch
40+
41+
- name: "Get new version"
42+
id: get_version
43+
run: echo "version=v$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
44+
45+
- name: "Push version bump commit and tag"
46+
run: |
47+
git push origin HEAD
48+
git push origin --tags
49+
50+
- name: "Publish to NPM"
51+
run: npm publish --access public
52+
env:
53+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
54+
55+
- name: "Create GitHub Release"
56+
uses: actions/create-release@v1
57+
with:
58+
tag_name: ${{ steps.get_version.outputs.version }}
59+
release_name: Release ${{ steps.get_version.outputs.version }}
60+
body: |
61+
```
62+
• See CHANGELOG.md for details
63+
• Published by ${{ github.actor }}
64+
```
65+
env:
66+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Dockerfile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2+
# syntax=docker/dockerfile:1.4
3+
4+
# Build stage
5+
FROM node:lts-alpine AS builder
6+
WORKDIR /app
7+
8+
# Install dependencies
9+
COPY package.json package-lock.json ./
10+
RUN npm ci --ignore-scripts
11+
12+
# Copy source and build
13+
COPY . .
14+
RUN npm run build
15+
16+
# Production stage
17+
FROM node:lts-alpine AS runtime
18+
WORKDIR /app
19+
20+
# Copy built artifacts and production dependencies
21+
COPY --from=builder /app/dist ./dist
22+
COPY package.json package-lock.json ./
23+
RUN npm ci --only=production --ignore-scripts
24+
25+
# Default command to start the MCP server
26+
ENTRYPOINT ["node", "dist/index.js"]

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ Use the following prompts to run/debug/fix your **automated tests** on BrowserSt
154154
}
155155
```
156156

157+
### Installing via Smithery
158+
159+
To install BrowserStack Test Platform Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@browserstack/mcp-server):
160+
161+
```bash
162+
npx -y @smithery/cli install @browserstack/mcp-server --client claude
163+
```
164+
157165
## 🤝 Recommended MCP Clients
158166

159167
- We recommend using **Github Copilot or Cursor** for automated testing + debugging use cases.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@browserstack/mcp-server",
3-
"version": "1.0.5",
3+
"version": "1.0.6",
44
"description": "BrowserStack's Official MCP Server",
55
"main": "dist/index.js",
66
"repository": {

smithery.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2+
3+
startCommand:
4+
type: stdio
5+
configSchema:
6+
# JSON Schema defining the configuration options for the MCP.
7+
type: object
8+
required:
9+
- browserstackUsername
10+
- browserstackAccessKey
11+
properties:
12+
browserstackUsername:
13+
type: string
14+
description: BrowserStack username
15+
browserstackAccessKey:
16+
type: string
17+
description: BrowserStack access key
18+
commandFunction:
19+
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
20+
|-
21+
(config) => ({ command: 'node', args: ['dist/index.js'], env: { BROWSERSTACK_USERNAME: config.browserstackUsername, BROWSERSTACK_ACCESS_KEY: config.browserstackAccessKey } })
22+
exampleConfig:
23+
browserstackUsername: myuser
24+
browserstackAccessKey: myaccesskey

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import addObservabilityTools from "./tools/observability";
1111
import addBrowserLiveTools from "./tools/live";
1212
import addAccessibilityTools from "./tools/accessibility";
1313
import addAutomateTools from "./tools/automate";
14+
import addTestManagementTools from "./tools/testmanagement";
1415

1516
function registerTools(server: McpServer) {
1617
addSDKTools(server);
@@ -19,6 +20,7 @@ function registerTools(server: McpServer) {
1920
addObservabilityTools(server);
2021
addAccessibilityTools(server);
2122
addAutomateTools(server);
23+
addTestManagementTools(server);
2224
}
2325

2426
// Create an MCP server

src/lib/error.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AxiosError } from "axios";
2+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3+
4+
/**
5+
* Formats an AxiosError into a CallToolResult with an appropriate message.
6+
* @param err - The error object to format
7+
* @param defaultText - The fallback error message
8+
*/
9+
export function formatAxiosError(
10+
err: unknown,
11+
defaultText: string,
12+
): CallToolResult {
13+
let text = defaultText;
14+
15+
if (err instanceof AxiosError && err.response?.data) {
16+
const message =
17+
err.response.data.message ||
18+
err.response.data.error ||
19+
err.message ||
20+
defaultText;
21+
text = message;
22+
} else if (err instanceof Error) {
23+
text = err.message;
24+
}
25+
26+
return {
27+
content: [{ type: "text", text }],
28+
isError: true,
29+
};
30+
}

src/lib/fuzzy.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// 1. Compute Levenshtein distance between two strings
2+
function levenshtein(a: string, b: string): number {
3+
const dp: number[][] = Array(a.length + 1)
4+
.fill(0)
5+
.map(() => Array(b.length + 1).fill(0));
6+
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
7+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
8+
9+
for (let i = 1; i <= a.length; i++) {
10+
for (let j = 1; j <= b.length; j++) {
11+
dp[i][j] = Math.min(
12+
dp[i - 1][j] + 1, // deletion
13+
dp[i][j - 1] + 1, // insertion
14+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), // substitution
15+
);
16+
}
17+
}
18+
return dp[a.length][b.length];
19+
}
20+
21+
// 2. Score one item against the query (normalized score 0–1)
22+
function scoreItem<T>(
23+
item: T,
24+
keys: Array<keyof T | string>,
25+
queryTokens: string[],
26+
): number {
27+
let best = Infinity;
28+
for (const key of keys) {
29+
const field = String(item[key as keyof T] ?? "").toLowerCase();
30+
const fieldTokens = field.split(/\s+/);
31+
const tokenScores = queryTokens.map((qt) => {
32+
const minNormalized = Math.min(
33+
...fieldTokens.map((ft) => {
34+
const rawDist = levenshtein(ft, qt);
35+
const maxLen = Math.max(ft.length, qt.length);
36+
return maxLen === 0 ? 0 : rawDist / maxLen; // normalized 0–1
37+
}),
38+
);
39+
return minNormalized;
40+
});
41+
const avg = tokenScores.reduce((a, b) => a + b, 0) / tokenScores.length;
42+
best = Math.min(best, avg);
43+
}
44+
return best;
45+
}
46+
47+
// 3. The search entrypoint
48+
export function customFuzzySearch<T>(
49+
list: T[],
50+
keys: Array<keyof T | string>,
51+
query: string,
52+
limit: number = 5,
53+
maxDistance: number = 0.6,
54+
): T[] {
55+
const q = query.toLowerCase().trim();
56+
const queryTokens = q.split(/\s+/);
57+
58+
return list
59+
.map((item) => ({ item, score: scoreItem(item, keys, queryTokens) }))
60+
.filter((x) => x.score <= maxDistance)
61+
.sort((a, b) => a.score - b.score)
62+
.slice(0, limit)
63+
.map((x) => x.item);
64+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
5+
const CACHE_DIR = path.join(os.homedir(), ".browserstack", "app_live_cache");
6+
const CACHE_FILE = path.join(CACHE_DIR, "app_live.json");
7+
const TTL_MS = 24 * 60 * 60 * 1000; // 1 day
8+
9+
/**
10+
* Fetches and caches the App Live devices JSON with a 1-day TTL.
11+
*/
12+
export async function getAppLiveData(): Promise<any> {
13+
if (!fs.existsSync(CACHE_DIR)) {
14+
fs.mkdirSync(CACHE_DIR, { recursive: true });
15+
}
16+
if (fs.existsSync(CACHE_FILE)) {
17+
const stats = fs.statSync(CACHE_FILE);
18+
if (Date.now() - stats.mtimeMs < TTL_MS) {
19+
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
20+
}
21+
}
22+
const response = await fetch(
23+
"https://www.browserstack.com/list-of-browsers-and-platforms/app_live.json",
24+
);
25+
if (!response.ok) {
26+
throw new Error(`Failed to fetch app live list: ${response.statusText}`);
27+
}
28+
const data = await response.json();
29+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data), "utf8");
30+
return data;
31+
}

0 commit comments

Comments
 (0)