Skip to content

Network Log Retrieval for BrowserStack Automate Sessions #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 🚀 Contributing to the Browserstack MCP Server

This guide will help you set up your environment and contribute effectively to the MCP (Model Context Protocol) Server.

## ✅ Prerequisites

Make sure you have the following installed:

- 🟢 [Node.js](https://nodejs.org/) (Recommended: LTS v22.15.0)
- 🤖 GitHub Copilot (for VS Code or Cursor)
- 🧠 Optionally, [Claude desktop app](https://www.anthropic.com/index/claude-desktop) for additional AI assistance

## 🛠 Getting Started

1. **Clone the repository:**

```bash
git clone https://github.com/browserstack/mcp-server.git
cd mcp-server
```

2. **Build the project:**

```bash
npm run build
```

This compiles the TypeScript source code and generates `dist/index.js`.

3. **Configure MCP for your editor:**

### 💻 VS Code: `.vscode/mcp.json`

```json
{
"servers": {
"browserstack": {
"command": "node",
"args": ["FULL PATH TO dist/index.js"],
"env": {
"BROWSERSTACK_USERNAME": "",
"BROWSERSTACK_ACCESS_KEY": ""
}
}
}
}
```

### 🖱 Cursor: `.cursor/mcp.json`

```json
{
"mcpServers": {
"browserstack": {
"command": "node",
"args": ["FULL PATH TO dist/index.js"],
"env": {
"BROWSERSTACK_USERNAME": "",
"BROWSERSTACK_ACCESS_KEY": ""
}
}
}
}
```

### 🔨 Quick Start from VS Code or Cursor

When you open your `.vscode/mcp.json` or `.cursor/mcp.json` file,
you'll see a **"play" icon** (Start ▶️) next to the server configuration.
**Click it to instantly start your MCP server!**


## 🧪 How to Test with MCP Inspector

**MCP Inspector** is a lightweight tool for launching, testing, and validating MCP server implementations easily.

### 🔹 Run with Config

If you've configured `.cursor/mcp.json` or `.vscode/mcp.json`, you can start testing by running:

```bash
npx @modelcontextprotocol/inspector --config /PATH_TO_CONFIG/.cursor/mcp.json --server browserstack
```

This will spin up your MCP server and open the Inspector at:
[http://127.0.0.1:6274](http://127.0.0.1:6274)

MCP Inspector UI

Inside the Inspector:

- View and manage your server connection (restart, disconnect, etc.)
- Validate your server credentials and environment variables
- Access available tools under the **"Middle Tab"**, and run tests to see results in the **Right Panel**
- Review past interactions easily via the **History Panel**

Additionally, for every MCP server session, a log file is automatically generated at:
`~/Library/Logs/Claude/` — you can check detailed logs there if needed.

---

## ✨ Next Steps

🌀 Fork the repository to your GitHub account

🧩 Add tests to verify your contributions

🤖 Explore and interact with the server using Copilot, Cursor, or Claude

📬 Raise a pull request from your fork once you're ready!
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Use the following prompts to run/debug/fix your **automated tests** on BrowserSt
## 📝 Contributing

We welcome contributions! Please open an issue to discuss any changes you'd like to make.
👉 [**Click here to view our Contributing Guidelines**](https://github.com/browserstack/mcp-server/blob/main/CONTRIBUTING.md)

## 📞 Support

Expand Down
Binary file added assets/mcp-inspector.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import addAppLiveTools from "./tools/applive";
import addObservabilityTools from "./tools/observability";
import addBrowserLiveTools from "./tools/live";
import addAccessibilityTools from "./tools/accessibility";
import addAutomateTools from "./tools/automate";

function registerTools(server: McpServer) {
addSDKTools(server);
addAppLiveTools(server);
addBrowserLiveTools(server);
addObservabilityTools(server);
addAccessibilityTools(server);
addAutomateTools(server);
}

// Create an MCP server
Expand Down
60 changes: 60 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import config from "../config";
import { HarEntry, HarFile } from "./utils";

export async function getLatestO11YBuildInfo(
buildName: string,
Expand Down Expand Up @@ -27,3 +28,62 @@ export async function getLatestO11YBuildInfo(

return buildsResponse.json();
}

// Fetches network logs for a given session ID and returns only failure logs
export async function retrieveNetworkFailures(sessionId: string): Promise {
if (!sessionId) {
throw new Error("Session ID is required");
}
const url = `https://api.browserstack.com/automate/sessions/${sessionId}/networklogs`;
const auth = Buffer.from(
`${config.browserstackUsername}:${config.browserstackAccessKey}`,
).toString("base64");

const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${auth}`,
},
});

if (!response.ok) {
if (response.status === 404) {
throw new Error("Invalid session ID");
}
throw new Error(`Failed to fetch network logs: ${response.statusText}`);
}

const networklogs: HarFile = await response.json();

// Filter for failure logs
const failureEntries: HarEntry[] = networklogs.log.entries.filter(
(entry: HarEntry) => {
return (
entry.response.status === 0 ||
entry.response.status >= 400 ||
entry.response._error !== undefined
);
},
);

// Return only the failure entries with some context
return {
failures: failureEntries.map((entry: any) => ({
startedDateTime: entry.startedDateTime,
request: {
method: entry.request?.method,
url: entry.request?.url,
queryString: entry.request?.queryString,
},
response: {
status: entry.response?.status,
statusText: entry.response?.statusText,
_error: entry.response?._error,
},
serverIPAddress: entry.serverIPAddress,
time: entry.time,
})),
totalFailures: failureEntries.length,
};
}
22 changes: 22 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,25 @@ export function sanitizeUrlParam(param: string): string {
// Remove any characters that could be used for command injection
return param.replace(/[;&|`$(){}[\]<>]/g, "");
}

export interface HarFile {
log: {
entries: HarEntry[];
};
}

export interface HarEntry {
startedDateTime: string;
request: {
method: string;
url: string;
queryString?: { name: string; value: string }[];
};
response: {
status: number;
statusText?: string;
_error?: string;
};
serverIPAddress?: string;
time?: number;
}
62 changes: 62 additions & 0 deletions src/tools/automate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import logger from "../logger";
import { retrieveNetworkFailures } from "../lib/api";

/**
* Fetches failed network requests from a BrowserStack Automate session.
* Returns network requests that resulted in errors or failed to complete.
*/
export async function getNetworkFailures(args: {
sessionId: string;
}): Promise {
try {
const failureLogs = await retrieveNetworkFailures(args.sessionId);
logger.info(
"Successfully fetched failure network logs for session: %s",
args.sessionId,
);

// Check if there are any failures
const hasFailures = failureLogs.totalFailures > 0;
const text = hasFailures
? `${failureLogs.totalFailures} network failure(s) found for session :\n\n${JSON.stringify(failureLogs.failures, null, 2)}`
: `No network failures found for session`;

return {
content: [
{
type: "text",
text,
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
logger.error("Failed to fetch network logs: %s", errorMessage);

return {
content: [
{
type: "text",
text: `Failed to fetch network logs: ${errorMessage}`,
isError: true,
},
],
isError: true,
};
}
}

export default function addAutomateTools(server: McpServer) {
server.tool(
"getNetworkFailures",
"Use this tool to fetch failed network requests from a BrowserStack Automate session.",
{
sessionId: z.string().describe("The Automate session ID."),
},
getNetworkFailures,
);
}
65 changes: 65 additions & 0 deletions tests/tools/automate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getNetworkFailures } from '../../src/tools/automate';
import { retrieveNetworkFailures } from '../../src/lib/api';

jest.mock('../../src/lib/api', () => ({
retrieveNetworkFailures: jest.fn()
}));
jest.mock('../../src/logger', () => ({
error: jest.fn(),
info: jest.fn()
}));

describe('getNetworkFailures', () => {
const validSessionId = 'valid-session-123';
const mockFailures = {
failures: [
{
startedDateTime: '2024-01-01T00:00:00Z',
request: { method: 'GET', url: 'https://example.com' },
response: { status: 404, statusText: 'Not Found' },
serverIPAddress: '1.2.3.4',
time: 123
}
],
totalFailures: 1
};

beforeEach(() => {
jest.clearAllMocks();
(retrieveNetworkFailures as jest.Mock).mockResolvedValue(mockFailures);
});

it('should return failure logs when present', async () => {
const result = await getNetworkFailures({ sessionId: validSessionId });
expect(retrieveNetworkFailures).toHaveBeenCalledWith(validSessionId);
expect(result.content[0].text).toContain('network failure(s) found for session');
expect(result.content[0].text).toContain('"status": 404');
expect(result.isError).toBeFalsy();
});

it('should return message when no failure logs are found', async () => {
(retrieveNetworkFailures as jest.Mock).mockResolvedValue({ failures: [], totalFailures: 0 });
const result = await getNetworkFailures({ sessionId: validSessionId });
expect(retrieveNetworkFailures).toHaveBeenCalledWith(validSessionId);
expect(result.content[0].text).toContain('No network failures found for sessio');
expect(result.isError).toBeFalsy();
});

it('should handle errors from the API', async () => {
(retrieveNetworkFailures as jest.Mock).mockRejectedValue(new Error('Invalid session ID'));
const result = await getNetworkFailures({ sessionId: 'invalid-id' });
expect(retrieveNetworkFailures).toHaveBeenCalledWith('invalid-id');
expect(result.content[0].text).toBe('Failed to fetch network logs: Invalid session ID');
expect(result.content[0].isError).toBe(true);
expect(result.isError).toBe(true);
});

it('should handle empty session ID', async () => {
(retrieveNetworkFailures as jest.Mock).mockRejectedValue(new Error('Session ID is required'));
const result = await getNetworkFailures({ sessionId: '' });
expect(retrieveNetworkFailures).toHaveBeenCalledWith('');
expect(result.content[0].text).toBe('Failed to fetch network logs: Session ID is required');
expect(result.content[0].isError).toBe(true);
expect(result.isError).toBe(true);
});
});