cd /news/ai-agents/build-a-minimal-webmcp-agent-with-pl… · home topics ai-agents article
[ARTICLE · art-46111] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Build a Minimal WebMCP Agent with Playwright and Gemini

A developer built a minimal WebMCP agent using Playwright and the Gemini API to test WebMCP tools with stronger AI models. The agent uses Playwright to control a real Chrome browser, enabling tool discovery and execution beyond the limitations of the Model Context Tool Inspector. The project demonstrates how to wire Gemini's tool-calling capabilities with WebMCP in a Node.js environment.

read11 min views1 publishedJul 1, 2026

WebMCP lets a web page expose tools that AI agents can discover and execute inside the browser. That sounds simple until you want to test those tools with a model outside the Model Context Tool Inspector Chrome extension.

A while ago, I built a small puzzle game that exposes WebMCP tools. I tested and debugged those tools using the Model Context Tool Inspector, which is great for quick experiments, the limitation is that it only gives access to a small set of lightweight Gemini models and I wanted to test the same WebMCP tools with stronger ones.

My first idea was to build another Chrome extension, but that felt like overkill. WebMCP tools need a real browser context: the browser must open the page directly, discover the tools and execute them inside the page. So instead of building another extension, I looked for something that could simply open Chrome and control the page.

And that is where Playwright fits nicely.

So in this article, I will show how to create a simple agent that wires up the Gemini API with WebMCP through Playwright. Gemini requests a tool call and Playwright executes the matching WebMCP tool inside a real Chrome browser.

For this example, you need:

The first thing we need to do is enable WebMCP in Chrome. WebMCP is still experimental, so for local development it must be enabled through a Chrome flag:

Open Chrome and navigate to chrome://flags/#enable-webmcp-testing
Set the flag to Enabled.
Relaunch Chrome to apply the changes.

After that, we can create a small Node.js project:

mkdir custom-agent
cd custom-agent
npm init -y

Next, install Playwright as a development dependency. I also use tsx

to run TypeScript files directly and dotenv

to read environment variables from a .env

file:

npm install -D playwright tsx dotenv typescript @types/node

This gives us everything we need to run TypeScript code, open Chrome and access environment variables.

Because the agent will also call an AI model, we need to install the Gemini SDK. For this example, I use @google/genai

:

npm install @google/genai

The last preparation step is to add a script to package.json

:

{
  "scripts": {
    "agent": "tsx agent.ts"
  }
}

This command will run the agent.ts

file, where we will put the main logic.

Now that the project is prepared, let’s create the first version of agent.ts

. At this stage, I only want to check whether modelContext

is available inside the browser page.

import { chromium } from "playwright";

const gameUrl = process.argv[2] ?? "http://localhost:5173";

async function main() {
  const context = await chromium.launchPersistentContext(
    "./.chrome-agent-profile",
    {
      channel: "chrome",
      headless: false,
      args: ["--enable-experimental-web-platform-features"],
    },
  );

  const page = await context.newPage();

  await page.goto(gameUrl, { waitUntil: "networkidle" });

  const result = await page.evaluate(() => ({
    userAgent: navigator.userAgent,
    hasNavigatorModelContext: "modelContext" in navigator,
    hasDocumentModelContext: "modelContext" in document,
  }));

  console.log(result);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

This code opens Chrome, navigates to the game page, and checks if modelContext

exists on navigator

or document

.

One important detail is that I am not using the bundled Chromium from Playwright. Instead, I am opening the real Chrome installed on my machine by using launchPersistentContext

with channel: "chrome"

. This matters because WebMCP is still experimental. In my case, the isolated Chromium browser did not discover the WebMCP tools correctly, while real Chrome with the enabled flag worked.

Note: BecauselaunchPersistentContext

creates a local Chrome profile, do not forget to add this folder to.gitignore

:

.chrome-agent-profile/

The profile can contain local browser data such as cache, cookies, and other Chrome state. It should not be committed to the repository.

The first check only tells us whether modelContext

exists. The next step is to read the tools exposed by the page.

We can do that by calling modelContext.getTools()

inside the page.evaluate()

method:

  const result = await page.evaluate(async () => {
    const modelContext = navigator.modelContext;

    if (!modelContext) {
      return {
        hasModelContext: false,
        tools: [],
      };
    }

    const tools = await modelContext.getTools();

    return {
      hasModelContext: true,
      tools: tools.map((tool) => ({
        name: tool.name,
        description: tool.description,
        inputSchema: tool.inputSchema,
        origin: tool.origin,
      })),
    };
  });

This code returns the list of tools exposed by the current page. For each tool, I print basic metadata such as the name, description, input schema and origin.

At this point, it is useful to print the result as formatted JSON:

console.log(JSON.stringify(result, null, 2));

This makes it easier to verify that Chrome discovered the WebMCP tools correctly.

Reading tools is useful, but the real goal is to execute them. In my game, one of the exposed tools is called getGameState

. It returns the current state of the puzzle, including the map, remaining moves and collected wood. For the first test, I can find this tool by name and execute it directly:

const gameState = await page.evaluate(async () => {
    const modelContext = (navigator as any).modelContext;

  if (!modelContext) {
    throw new Error("modelContext is empty");
  }

  const tools = await modelContext.getTools();

  const getGameStateTool = tools.find((tool: any) => tool.name === "getGameState");

  if (!getGameStateTool) {
    throw new Error("getGameState tool not found");
  }

  return await modelContext.executeTool(getGameStateTool, "{}");
});

This proves that Playwright can open the page, access modelContext

, find a WebMCP tool and execute it inside the browser context.

However, hardcoding the tool execution like this is not ideal. The agent should be able to execute any tool by name, so I extracted the logic into a reusable helper function:

import type { Page } from "playwright";

export async function executeWebMcpTool<T>(
  page: Page,
  toolName: string,
  args: unknown,
): Promise<T> {
  return await page.evaluate(
    async ({ toolName, args }) => {
    const modelContext =
        (document as any).modelContext ?? (navigator as any).modelContext;
    if (!modelContext) {
        throw new Error("Model Context API is not available");
    }

    const tools = await modelContext.getTools();

    const tool = tools.find((tool: any) => tool.name === toolName);

    if (!tool) {
        throw new Error(`Tool not found: ${toolName}`);
    }

    const result = await modelContext.executeTool(
        tool,
        JSON.stringify(args),
    );

    return result;
    },
    { toolName, args },
);
}

This function receives a Playwright Page

, the tool name and arguments. It then evaluates code inside the browser page, finds the matching WebMCP tool, serializes the arguments and executes the tool. With this helper, the Node.js code does not need to know the internal implementation of the page. It only needs the tool name and arguments.

That is the important bridge: Playwright controls Chrome, Chrome sees the WebMCP tools and our Node.js code can execute them.

Note:In my setup,navigator.modelContext

worked reliably, but WebMCP is still experimental, so in the reusable helper I check bothdocument.modelContext

andnavigator.modelContext

.

Now we can connect the WebMCP tool execution with an AI model.

For this article, I want to keep the example small. The goal is not to build the full game-playing agent here. The goal is to prove the basic flow:

The full agent can build on top of this by sending the tool result back to the model and continuing the loop.

For this example, I use the @google/genai

package. We already installed it earlier, so now we can create a small service for communicating with Gemini.

Create a new file called genai.service.ts

:

import "dotenv/config";
import {
  GoogleGenAI,
  type Content,
  type GenerateContentConfig,
  type GenerateContentResponse,
} from "@google/genai";

export type GenerateRequest = {
  contents: Content[];
  config?: GenerateContentConfig;
};

export class GenaiService {
  private readonly ai: GoogleGenAI;
  private readonly model: string;

  constructor(model: string = "gemini-2.5-flash-lite") {
    this.model = model;
    const apiKey = process.env.GEMINI_API_KEY;
    if (!apiKey) {
      throw new Error("Missing GEMINI_API_KEY in .env");
    }

    this.ai = new GoogleGenAI({ apiKey });
  }

  public async generateContentAsync(
    request: GenerateRequest,
  ): Promise<GenerateContentResponse> {
    const response = await this.ai.models.generateContent({
      model: this.model,
      contents: request.contents,
      config: request.config,
    });

    return response;
  }
}

The implementation is straightforward. The service reads GEMINI_API_KEY

from the .env

file, creates an instance of GoogleGenAI

and exposes one method called generateContentAsync

.

I also created a small GenerateRequest

type. The reason is simple: I only want to expose the properties that this example needs. The original SDK request type contains more options and for this proof of concept that would make the code harder to read.

You also need to create a .env

file:

GEMINI_API_KEY=your-api-key

Do not forget to add the .env

file to .gitignore

, so you do not commit your API key to the repository.

Now we can put everything together in agent.ts

.

In this example, the tool definition is hardcoded. That keeps the proof of concept simple and easier to understand. In a more generic version, we could read WebMCP tools from the page and map them into Gemini tool declarations automatically. But that would add more code and I want this article to stay focused on the core idea.

import { chromium, type Page } from "playwright";
import { GenaiService } from "./genai.service";
import {
  FunctionCallingConfigMode,
  type Content,
  type Tool,
} from "@google/genai";

export const tools: Tool[] = [
  {
    functionDeclarations: [
      {
        name: "getGameState",
        description:
          "Get the current board. visibleMap rows run top-to-bottom; each character is x=0 onward. P=player, .=land, W=tree, ~=water, B=bridge, R=rock, and G=goal.",
        responseJsonSchema: {
          type: "object",
          properties: {
            remainingMoves: { type: "number" },
            wood: { type: "number" },
            visibleMap: {
              type: "array",
              items: { type: "string" },
            },
          },
          required: ["remainingMoves", "wood", "visibleMap"],
        },
      },
    ],
  },
];

const gameUrl = process.argv[2] ?? "https://tower-before-dusk.gramli.workers.dev";

async function main() {
  const aiService = new GenaiService();
  const context = await chromium.launchPersistentContext(
    "./.chrome-agent-profile",
    {
      channel: "chrome",
      headless: false,
      args: ["--enable-experimental-web-platform-features"],
    },
  );

  const page = await context.newPage();
  await page.goto(gameUrl, { waitUntil: "networkidle" });

  const contents: Content[] = [
    {
      role: "user",
      parts: [
        {
          text: "Inspect the current Tower Before Dusk game state.",
        },
      ],
    },
  ];

  const response = await aiService.generateContentAsync({
    contents,
    config: {
      tools,
      toolConfig: {
        functionCallingConfig: {
          mode: FunctionCallingConfigMode.ANY,
          allowedFunctionNames: ["getGameState"],
        },
      },
    },
  });

  const functionCall = response.functionCalls?.[0];
  if (!functionCall?.name) {
    throw new Error("Gemini did not return a tool call");
  }
  if (functionCall.name !== "getGameState") {
    throw new Error(`Gemini requested an unknown tool: ${functionCall.name}`);
  }

  console.log("Gemini tool call:", functionCall);

  const gameState = await executeWebMcpTool(
    page,
    functionCall.name,
    functionCall.args ?? {},
  );

  console.log("Tool result:", gameState);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

export async function executeWebMcpTool<T>(
  page: Page,
  toolName: string,
  args: unknown,
): Promise<T> {
  return await page.evaluate(
    async ({ toolName, args }) => {
      const modelContext =
        (document as any).modelContext ?? (navigator as any).modelContext;
      if (!modelContext) {
        throw new Error("Model Context API is not available");
      }

      const tools = await modelContext.getTools();

      const tool = tools.find((tool: any) => tool.name === toolName);

      if (!tool) {
        throw new Error(`Tool not found: ${toolName}`);
      }

      const result = await modelContext.executeTool(tool, JSON.stringify(args));

      return result;
    },
    { toolName, args },
  );
}

The flow is simple:

First, the script opens Chrome and navigates to the game page. Then it sends a prompt to Gemini together with the available tool definition. In this example, Gemini is allowed to call only one function: getGameState

.

After Gemini returns a function call, the script validates that the requested function is really getGameState

. This is important because the application should never blindly execute arbitrary tool names returned by the model. Then the script passes the function name and arguments to executeWebMcpTool

. The tool is executed inside the browser page through WebMCP and the result is printed to the console.

And that is the proof of concept.

Our Node.js script does not call the game directly. It opens the game in Chrome, lets Chrome discover the WebMCP tools, lets Gemini request a function call and then executes that function call against the page.

This small example proves that Playwright can be used as a bridge between an AI model and WebMCP tools.

The browser still owns the WebMCP context. The page still exposes the tools, but our external Node.js process can orchestrate the flow and connect those tools to a stronger model.

This is useful when the existing browser-based tooling is too limited, or when you want to experiment with your own agent loop.

The example in this article only executes one tool call. A real agent would need a loop:

That full implementation would make this article much longer, so I kept the article focused on the proof of concept.

You can find the source code here:

In this article, I showed how to use Playwright to create a custom proof of concept agent for WebMCP. First, I checked whether modelContext

is available, then I discovered the exposed tools, executed one of them and finally connected the flow with Gemini function calling.

Of course, this is not a fully autonomous agent yet, but it is the foundation for one.

WebMCP is still experimental and the Model Context Tool Inspector is great for debugging. However, the available models can feel limiting for some types of web apps. I hope this approach can help others test WebMCP tools with stronger models without the need to create another Chrome extension.

── more in #ai-agents 4 stories · sorted by recency
── more on @playwright 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-a-minimal-webm…] indexed:0 read:11min 2026-07-01 ·