Universal MCP Client
Connect to any MCP server without bloating context with tool definitions.
⚠️ PLAYWRIGHT USERS: READ "CRITICAL: Playwright Browser Session Behavior" SECTION BELOW!
Each MCP call = new browser session. Browser CLOSES after each call.
You CANNOT navigate in one call and click in another.
Use browser_run_code for ANY multi-step operation.
If you need to return to a state (e.g., logged in), you MUST redo ALL steps from scratch.
How It Works
Instead of loading all MCP tool schemas into context, this client:
- Lists available servers from config
- Queries tool schemas on-demand
- Executes tools with JSON arguments
Configuration
Config location priority:
MCP_CONFIG_PATH environment variable
.claude/skills/mcp-client/references/mcp-config.json
.mcp.json in current directory
~/.claude.json
Commands
bash
1# List configured servers
2python scripts/mcp_client.py servers
3
4# List tools from a specific server
5python scripts/mcp_client.py tools playwright
6
7# Call a tool
8python scripts/mcp_client.py call playwright browser_navigate '{"url": "https://example.com"}'
CRITICAL: Playwright Browser Session Behavior
⚠️ The Session Problem
Each MCP call creates a NEW browser session. The browser CLOSES after each call.
This means:
bash
1# ❌ WRONG - These run in SEPARATE browser sessions!
2python scripts/mcp_client.py call playwright browser_navigate '{"url": "https://example.com"}'
3python scripts/mcp_client.py call playwright browser_click '{"element": "Accept cookies"}'
4python scripts/mcp_client.py call playwright browser_snapshot '{}'
5# ^ The snapshot captures a FRESH page, not the page after clicking!
✅ The Solution: browser_run_code
Use browser_run_code to run multiple Playwright steps in ONE browser session:
bash
1python scripts/mcp_client.py call playwright browser_run_code '{
2 "code": "
3 await page.goto(\"https://example.com\");
4
5 // Wait for and click cookie banner
6 const acceptBtn = page.getByRole(\"button\", { name: /accept/i });
7 if (await acceptBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
8 await acceptBtn.click();
9 await page.waitForTimeout(1000);
10 }
11
12 // Wait for page to stabilize
13 await page.waitForLoadState(\"networkidle\");
14
15 // Return snapshot data for analysis
16 const snapshot = await page.accessibility.snapshot();
17 return JSON.stringify(snapshot, null, 2);
18 "
19}'
When to Use Each Approach
| Scenario | Tool | Why |
|---|
| Simple page load + snapshot | browser_navigate | Returns snapshot automatically |
| Multi-step interaction | browser_run_code | Keeps session alive |
| Click then observe result | browser_run_code | Session persists |
| Fill form and submit | browser_run_code | Session persists |
| Hover to reveal menu | browser_run_code | Session persists |
Playwright Workflows for Test Discovery
1. Basic Page Exploration (Single Step)
browser_navigate returns both navigation result AND accessibility snapshot:
bash
1python scripts/mcp_client.py call playwright browser_navigate '{"url": "https://example.com"}'
Output includes:
- Page URL and title
- Full accessibility tree (all visible elements with roles, names, states)
- Element references for further interaction
Use this when: Simple page load without interactions
2. Page with Cookie Banner (Multi-Step)
bash
1python scripts/mcp_client.py call playwright browser_run_code '{
2 "code": "
3 await page.goto(\"https://www.olx.ro\");
4
5 // Handle cookie consent
6 try {
7 const cookieBtn = page.getByRole(\"button\", { name: \"Accept\" });
8 await cookieBtn.click({ timeout: 5000 });
9 await page.waitForTimeout(1000);
10 } catch (e) {
11 // No cookie banner
12 }
13
14 // Get accessibility snapshot
15 const snapshot = await page.accessibility.snapshot({ interestingOnly: false });
16 return JSON.stringify(snapshot, null, 2);
17 "
18}'
3. Navigate to Subpage (Multi-Step)
bash
1python scripts/mcp_client.py call playwright browser_run_code '{
2 "code": "
3 await page.goto(\"https://www.olx.ro\");
4
5 // Dismiss cookies
6 const acceptBtn = page.getByRole(\"button\", { name: \"Accept\" });
7 if (await acceptBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
8 await acceptBtn.click();
9 await page.waitForTimeout(500);
10 }
11
12 // Navigate to login
13 await page.goto(\"https://www.olx.ro/cont/\");
14
15 // Wait for redirect to login domain
16 await page.waitForURL(/login\\.olx\\.ro/, { timeout: 10000 });
17
18 // Get form structure
19 const snapshot = await page.accessibility.snapshot();
20 return JSON.stringify({ url: page.url(), snapshot }, null, 2);
21 "
22}'
4. Explore Element Interactions (Multi-Step)
Use this to understand how menus/dropdowns behave:
bash
1python scripts/mcp_client.py call playwright browser_run_code '{
2 "code": "
3 await page.goto(\"https://www.olx.ro\");
4
5 // Dismiss cookies
6 const acceptBtn = page.getByRole(\"button\", { name: \"Accept\" });
7 if (await acceptBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
8 await acceptBtn.click();
9 }
10
11 // Click on category to see what happens
12 const categoryLink = page.getByRole(\"link\", { name: /Auto, moto/i }).first();
13 await categoryLink.click();
14
15 // Wait to see result
16 await page.waitForTimeout(1500);
17
18 // Capture state after click
19 const snapshot = await page.accessibility.snapshot();
20 return JSON.stringify({
21 url: page.url(),
22 didNavigate: page.url().includes(\"auto\"),
23 snapshot: snapshot
24 }, null, 2);
25 "
26}'
bash
1python scripts/mcp_client.py call playwright browser_run_code '{
2 "code": "
3 await page.goto(\"https://login.olx.ro\");
4
5 // Fill login form
6 await page.locator(\"input[type=email]\").fill(\"test@example.com\");
7 await page.locator(\"input[type=password]\").fill(\"test123\");
8
9 // Click login button
10 await page.getByTestId(\"login-submit-button\").click();
11
12 // Wait for response
13 await page.waitForTimeout(3000);
14
15 // Capture any error messages
16 const errors = await page.locator(\"[class*=error], [role=alert]\").allTextContents();
17 const snapshot = await page.accessibility.snapshot();
18
19 return JSON.stringify({
20 url: page.url(),
21 errors: errors,
22 snapshot: snapshot
23 }, null, 2);
24 "
25}'
Gathering Selectors for Page Objects
Best Practices
1. Use Accessibility Tree First
The snapshot from browser_navigate or browser_run_code provides:
- Role: button, link, textbox, combobox, etc.
- Name: accessible name (from label, aria-label, text content)
- State: disabled, checked, expanded, etc.
Map these to Playwright locators:
typescript
1// From snapshot: { role: "button", name: "Căutare" }
2page.getByRole('button', { name: /Căutare/i })
3
4// From snapshot: { role: "textbox", name: "Ce anume cauți?" }
5page.getByRole('textbox', { name: /Ce anume cauți/i })
6
7// From snapshot: { role: "link", name: "Auto, moto și ambarcațiuni" }
8page.getByRole('link', { name: /Auto, moto/i })
2. Selector Priority
| Priority | Method | Use When |
|---|
| 1 | getByRole() | Element has semantic role + accessible name |
| 2 | getByTestId() | Element has data-testid attribute |
| 3 | getByText() | Unique text content |
| 4 | getByPlaceholder() | Input with placeholder |
| 5 | locator('[attr="value"]') | CSS attribute selector |
| 6 | locator('.class') | CSS class (fragile, avoid) |
3. Handling Multiple Matches
typescript
1// Use .first() when multiple match
2page.getByRole('link', { name: 'Category' }).first()
3
4// Use parent context
5page.locator('nav').getByRole('link', { name: 'Category' })
6
7// Use filter
8page.getByRole('button').filter({ hasText: /submit/i })
4. Get Full DOM for Complex Cases
When accessibility tree isn't enough, get raw HTML:
bash
1python scripts/mcp_client.py call playwright browser_run_code '{
2 "code": "
3 await page.goto(\"https://example.com\");
4
5 // Get specific element HTML
6 const formHtml = await page.locator(\"form\").first().innerHTML();
7
8 // Or get all buttons with their attributes
9 const buttons = await page.locator(\"button\").evaluateAll(btns =>
10 btns.map(b => ({
11 text: b.textContent,
12 testid: b.dataset.testid,
13 class: b.className,
14 type: b.type
15 }))
16 );
17
18 return JSON.stringify({ formHtml, buttons }, null, 2);
19 "
20}'
| Tool | Session Behavior | Use Case |
|---|
browser_navigate | New session, returns snapshot | Simple page load |
browser_run_code | Single session, custom script | Multi-step operations |
browser_click | New session | Single click (usually not useful alone) |
browser_type | New session | Single type (usually not useful alone) |
browser_snapshot | Reuses if session exists | Get current page state |
browser_screenshot | Reuses if session exists | Visual capture |
browser_navigate
json
1{"url": "https://example.com"}
browser_run_code
json
1{
2 "code": "await page.goto('https://example.com'); return await page.title();"
3}
The code must be valid JavaScript that:
- Uses
page object (Playwright Page)
- Uses
await for async operations
- Returns the data you want (use
JSON.stringify for objects)
browser_click
json
1{"element": "Submit button", "ref": "optional-element-ref"}
browser_type
json
1{"element": "Email input", "text": "user@example.com"}
Error Handling
| Error | Cause | Fix |
|---|
| "No MCP config found" | Missing config file | Create mcp-config.json |
| "Server not found" | Server not in config | Add server to config |
| "Connection failed" | Server not running | Start the MCP server |
| "Invalid JSON" | Bad tool arguments | Check argument format |
| "Timeout" | Page too slow | Increase timeout in code |
| "Element not found" | Wrong selector | Check snapshot for actual names |
Setup
-
Copy the example config:
bash
1cp .claude/skills/mcp-client/references/mcp-config.example.json \
2 .claude/skills/mcp-client/references/mcp-config.json
-
The config should contain:
json
1{
2 "mcpServers": {
3 "playwright": {
4 "command": "npx",
5 "args": ["@playwright/mcp@latest"]
6 }
7 }
8}
-
Install dependencies:
bash
1pip install mcp fastmcp
Config Example
See references/mcp-config.example.json
Available Servers
See references/mcp-servers.md for:
- Playwright (browser automation)
- GitHub (repository operations)
- Filesystem (file access)
- Sequential Thinking (reasoning)
- And more...
Dependencies
bash
1pip install mcp fastmcp