forge-lcdl

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

  1. Node.js 18+ (node --version)
  2. npx on PATH (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/:

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 McpPolicy allows them.
  • Qualified tool names are server_id.tool_name (e.g. playwright.browser_snapshot).
  • browser_run_code_unsafe and browser_evaluate are denied in readmostly_playwright — they run arbitrary JavaScript and are treated as RCE-equivalent for governance.
  • approval_required tools return Err with kind="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.