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
MCPScriptOp variants in one workflow — one needs session continuity, the other parallelises per-URLlibrary.MCPScriptOp[In, Out] in a named struct to add per-vertex params via custom SetupMapOver + CollectInto fan-out pattern for per-item parallel workpool_size, pool_prewarm) and library.ShutdownMCPPool shutdownOne 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.
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.
Three patterns to study in main.go.
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.
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
}
MCPScriptOp with a custom paramThe 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.
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
}
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.
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()
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.
# 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.
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.
# 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:
{
"mcpServers": {
"local-mcp-server": {
"command": "go",
"args": ["run", "./examples/local-mcp-server", "--mcp"]
}
}
}