Examples Local MCP server — Playwright fan-out
MCP example

Local MCP — Playwright fan-out

Two MCPScriptOp variants composed via dagor's MapOver. One playwright-mcp session searches Google and extracts the first three off-Google result URLs. A downstream MapOver spawns one playwright-mcp subprocess per URL in parallel, each opening the page and saving a screenshot. Warm-replenish pool sized at 8 keeps cold-start off the critical path.

What this example teaches

  • Two MCPScriptOp variants in one workflow — one needs session continuity, the other parallelises per-URL
  • Embedding library.MCPScriptOp[In, Out] in a named struct to add per-vertex params via custom Setup
  • The MapOver + CollectInto fan-out pattern for per-item parallel work
  • Best-effort sub-vertex failure handling — one URL's screenshot can fail without aborting the workflow
  • Warm-replenish pool (pool_size, pool_prewarm) and library.ShutdownMCPPool shutdown
View source on GitHub
The workflow

Pipeline structure

One stateful MCP session produces a list of URLs; MapOver turns that list into N parallel per-URL screenshot sub-vertices, each on its own playwright-mcp subprocess.

ContextValOp
SearchInput
MCPScriptOp
search + extract URLs
MapOver
3× MCPScriptOp · screenshot
CollectInto
[]ShotResult

The search subprocess maintains a live browser session — navigate, click consent, type the query, wait for results, extract URLs. All five tool calls run against the same playwright-mcp instance. The per-URL screenshot vertices get fresh subprocesses from the pool; each Run owns its session and tears it down when done.

Implementation

Walkthrough

Three patterns to study in main.go.

1. A multi-tool script over one session

The search op embeds library.MCPScriptOp[SearchInput, []string] and assigns its Script in the factory closure. Inside the script, every sess.CallTool invocation reuses the same playwright session — the browser page persists across browser_navigate, browser_evaluate, and browser_type calls.

main.go — MCPGoogleSearchURLsOp
type MCPGoogleSearchURLsOp struct {
    library.MCPScriptOp[SearchInput, []string]
}

func newMCPGoogleSearchURLsOp() operator.IOperator {
    op := &MCPGoogleSearchURLsOp{}
    op.Script = func(ctx context.Context, sess library.MCPSession,
                                   in *SearchInput, out *[]string) error {
        // Navigate, dismiss consent, type query, wait, extract URLs.
        if _, _, err := sess.CallTool(ctx, "browser_navigate",
            map[string]any{"url": "https://www.google.com/?hl=en"}); err != nil {
            return fmt.Errorf("browser_navigate: %w", err)
        }
        // ... browser_evaluate (consent), browser_type (query), browser_evaluate (extract URLs)
        *out = urls
        return nil
    }
    return op
}

2. Extending MCPScriptOp with a custom param

The per-URL screenshot op needs an out_dir param that the base MCPScriptOp.Setup doesn't know about. Override Setup, call the embedded base first, then read your own params.

main.go — MCPScreenshotURLOp
type MCPScreenshotURLOp struct {
    library.MCPScriptOp[string, ShotResult]
    outDir string
}

func (op *MCPScreenshotURLOp) Setup(p *config.Params) error {
    if err := op.MCPScriptOp.Setup(p); err != nil {
        return err
    }
    op.outDir = p.GetString("out_dir", "")
    if op.outDir == "" || !filepath.IsAbs(op.outDir) {
        return fmt.Errorf("out_dir must be absolute")
    }
    return nil
}

3. MapOver + pool_size

The fan-out vertex declares its sub-graph with SubVertex, binds each item to the url wire, and collects per-URL results into a typed slice. The pool keeps 8 playwright-mcp subprocesses warm so a 3-way fan-out runs with zero cold-start in steady state.

main.go — buildGraph()
shootParams["out_dir"] = outDir
shootParams["pool_size"] = "8"

graph.NewBuilder("mcp_google_search_screenshot").
    Vertex("search_input").Op("search_input_const").Output("Result", "search_input").
    Vertex("find_results").Op("MCPGoogleSearchURLsOp").
        Params(playwrightParams).
        Input("Input", "search_input").
        Output("Result", "result_urls").
    Vertex("shoot_each").
        Input("Items", "result_urls").
        MapOver("url").
        SubVertex("shoot").
            Op("MCPScreenshotURLOp").
            Params(shootParams).
            Input("Input", "url").
            Output("Result", "shot_result").
        CollectInto("shot_result", "screenshot_results").
    Build()

Run it

First run downloads @playwright/mcp@latest plus a browser binary; subsequent runs reuse the cache. No CLAUDE_API_KEY needed. Don't forget the pool shutdown defer.

shell
# default: query "Shizuoka", screenshots under ./.playwright-mcp/
go run ./examples/local-mcp-server

# custom query, custom out dir
go run ./examples/local-mcp-server --query "Kyoto autumn" --out-dir /tmp/shots

In main(): defer library.ShutdownMCPPool(context.Background()) so warm subprocesses drain at exit.

Run it as an MCP server

The same binary is dual-mode. Pass --mcp and instead of running once and exiting it speaks the Model Context Protocol over stdin/stdout, exposing the entire workflow as a single MCP tool, search_and_screenshot. The result is an MCP server that is itself an MCP client: each tool call fans out over MapOver to one playwright-mcp subprocess per URL. An agent gets "search and screenshot N pages" as one composed tool call.

shell
# Speak MCP over stdin/stdout instead of running once
go run ./examples/local-mcp-server --mcp

Register it with any MCP client by pointing the client at that command:

.mcp.json
{
  "mcpServers": {
    "local-mcp-server": {
      "command": "go",
      "args": ["run", "./examples/local-mcp-server", "--mcp"]
    }
  }
}
← All Examples Next: Remote MCP →