Handbook
MCP client (LCDL)
This document describes forge-lcdl as an MCP client: governed connections to external MCP servers (for example Playwright MCP for navigates, accessibility snapshots, network logs, screenshots), with deny-by-default tool…
| If you need… | Read |
|---|---|
LCDL as MCP tools inside Cursor (lcdl.verify, …) |
MCP-SIDECAR.md |
| From Python, call Playwright (or other) MCP servers | This page |
Install
The core package has no mandatory third-party dependencies. For live MCP sessions:
cd forge-lcdl
pip install -e ".[dev,mcp]"
The [mcp] extra pulls the Python mcp SDK compatible with this hub (see pyproject.toml).
Playwright MCP prerequisites
- Node.js 18+ (
node --version) npxonPATH(sanity check:npx -y @playwright/mcp@latest --help)
Playwright MCP downloads browsers on first use; allow outbound network and disk for the download cache.
Configuration
JSON file
Minimal hub config lives beside this doc under examples/mcp/:
examples/mcp/playwright.local.json—stdioserverplaywrightwrappingnpx -y @playwright/mcp@latest --headless --isolated.
Load in code:
from pathlib import Path
from forge_lcdl.mcp_client import McpHub, McpHubConfig
config = McpHubConfig.from_json_file(Path("examples/mcp/playwright.local.json"))
Default in-process preset
For the common case (single Playwright server, read-mostly policy), PlaywrightAdapter.default_config() builds the same shape without a file:
from forge_lcdl.mcp_client import McpHub
from forge_lcdl.mcp_client.adapters.playwright import PlaywrightAdapter
config = PlaywrightAdapter.default_config(headless=True, isolated=True)
Policy is McpPolicy.readmostly_playwright() — see Policy and safety defaults.
PlaywrightAdapter API (thin wrappers)
All methods return Result[…, McpError] (see forge_lcdl.result). Use isinstance(x, Ok) / isinstance(x, Err).
| Method | Playwright MCP tool | Notes |
|---|---|---|
navigate(url) |
browser_navigate |
URL validated against policy allow/deny lists. |
snapshot(...) |
browser_snapshot |
Current page only (after you navigated separately). |
fetch_snapshot(url, depth=…, boxes=…) |
browser_navigate + browser_snapshot |
One-shot: go to URL, return structured PlaywrightSnapshot. |
network_requests(...) |
browser_network_requests |
Optional filter / include static assets. |
take_screenshot(...) |
browser_take_screenshot |
|
close() |
browser_close |
|
wait_for(arguments) |
browser_wait_for |
Pass through MCP tool args (e.g. selector / time). |
console_messages(arguments) |
browser_console_messages |
Lower-level access: hub.call_tool("playwright", "<tool_name>", {...}) for any allowed tool name.
Minimal Python: McpHub + list tools + snapshot
from forge_lcdl.mcp_client import McpHub, McpHubConfig
from forge_lcdl.mcp_client.adapters.playwright import PlaywrightAdapter
from forge_lcdl.result import Err, Ok
config = McpHubConfig.from_json_file("examples/mcp/playwright.local.json")
with McpHub(config) as hub:
tools = hub.list_tools("playwright")
if isinstance(tools, Err):
raise RuntimeError(tools.error.message)
result = hub.call_tool("playwright", "browser_snapshot", {"depth": 6})
if isinstance(result, Err):
raise RuntimeError(result.error.message)
Ok / Err are consistent with the rest of forge-lcdl.
Example: fetch_snapshot + typed adapter
from forge_lcdl.mcp_client import McpHub
from forge_lcdl.mcp_client.adapters.playwright import PlaywrightAdapter
from forge_lcdl.result import Err
config = PlaywrightAdapter.default_config(headless=True)
with McpHub(config) as hub:
browser = PlaywrightAdapter(hub)
page = browser.fetch_snapshot("https://example.com", depth=6)
if isinstance(page, Err):
raise RuntimeError(f"{page.error.kind}: {page.error.message}")
print(page.value.snapshot_text[:2000])
McpPlaywrightSnapshotSource (evidence helper)
When downstream code only needs (url, snapshot_text) tuples (no raw tool handles), use forge_lcdl.mcp_client.evidence.McpPlaywrightSnapshotSource:
from forge_lcdl.mcp_client import McpHub
from forge_lcdl.mcp_client.adapters.playwright import PlaywrightAdapter
from forge_lcdl.mcp_client.evidence import McpPlaywrightSnapshotSource
from forge_lcdl.result import Err
with McpHub(PlaywrightAdapter.default_config(headless=True)) as hub:
adapter = PlaywrightAdapter(hub)
source = McpPlaywrightSnapshotSource(adapter, snapshot_depth=8)
got = source.fetch_snapshot("https://example.com")
if isinstance(got, Err):
raise SystemExit(got.error.message)
url, snapshot_text = got.value
Consumers (for example forge-certificators Phase A/B MCP paths) map snapshot_text into their own page_probe-shaped dicts; LCDL only performs MCP I/O here.
Combining with run_task (LLM on snapshot text)
from forge_lcdl import read_certificator_profile, run_task
from forge_lcdl.mcp_client import McpHub
from forge_lcdl.mcp_client.adapters.playwright import PlaywrightAdapter
from forge_lcdl.result import Err
profile = read_certificator_profile()
config = PlaywrightAdapter.default_config(headless=True)
with McpHub(config) as hub:
browser = PlaywrightAdapter(hub)
snap = browser.fetch_snapshot("https://example.com", depth=6)
if isinstance(snap, Err):
raise RuntimeError(snap.error.message)
out = run_task(
"extract_schema_from_text",
"v1",
{
"text": snap.value.snapshot_text,
"schema_description": "Extract title, headings, links, and concise summary as JSON.",
},
profile=profile,
)
There is no default CI test that calls a live LLM gateway.
Example script (CLI)
From the forge-lcdl repo root with PYTHONPATH=src or an editable install:
python3 examples/mcp/playwright_fetch_snapshot.py https://example.com --depth 4
Optional --config points at a McpHubConfig JSON (defaults next to the script).
Consumer: forge-certificators (browser backend)
forge-certificators can drive Phase A/B fixture runners and monolithic LLM discovery with --browser-backend mcp (environment SOURCE_INGEST_BROWSER_BACKEND). That path uses the same McpHub + PlaywrightAdapter stack; chunked discovery and pipeline test stages still require in-process Playwright Page.
Cross-repo playbook (chunked vs MCP): forge-certificators docs/PLAYWRIGHT_LLM_CHUNKED_DISCOVERY.md.
Policy and safety defaults
- Deny by default for tools, resources, and prompts unless the active
McpPolicyallows them. - Qualified tool names are
server_id.tool_name(e.g.playwright.browser_snapshot). browser_run_code_unsafeandbrowser_evaluateare denied inreadmostly_playwright— they run arbitrary JavaScript and are treated as RCE-equivalent for governance.approval_requiredtools returnErrwithkind="approval_required"; there is no interactive approval UI in this MVP.
To widen policy for a dedicated operator host, construct McpHubConfig(…, policy=…) explicitly and document the risk; keep readmostly_playwright() as the default preset in examples.
Async usage
McpHub uses asyncio.Runner internally and must not be nested inside an already-running event loop in the same thread. In async code, use AsyncMcpHub with async with (see package forge_lcdl.mcp_client.async_hub).
Optional live tests
Requires mcp installed, FORGE_LCDL_MCP_PLAYWRIGHT=1, and node / npx:
FORGE_LCDL_MCP_PLAYWRIGHT=1 python3 -m pytest -m mcp_live tests/integration/test_mcp_playwright_live.py -q
Hygiene
Do not commit .env, bearer tokens, browser storage state, screenshots, or traces with sensitive page text or live gateway URLs.
Known limitations (MVP)
- No sampling support.
- No elicitation support.
- No automatic approval UI.
- No long-running agent loop that discovers tools from free-form prompts.
- Remote streamable HTTP servers: supply a configured
httpx.AsyncClient(or equivalent) via MCP SDK customization if stdio defaults are insufficient.