---
title: "WebMCP - this blog now has tools"
url: "https://www.pardel.dev/2026/09/28/webmcp-this-blog-now-has-tools"
date: 2026-09-28
author: "Paul Ardeleanu"
tags: ["AI-Agents","WebMCP","JavaScript","Jekyll"]
description: "pardel.dev now declares five WebMCP tools, so an agent in the browser can search the posts, list the projects and talks, and open a page, instead of scraping the HTML. It took one JSON file and one script."
---

# WebMCP - this blog now has tools

> TLDR; WebMCP is a proposed web standard that lets a page register tools for an AI agent running in the browser: a name, a description, an input schema and a function. I added five to this blog. The pages haven't changed; they just say what they do a second time, for a reader without a cursor.

On 30 September I'm talking at apiDays London about who we're really writing docs and building tools for, and one slide in it is about WebMCP. It showed the proposal's own example, a todo app with an `add-todo` tool. That's fine, but it's somebody else's code, and I'd rather show something I've actually shipped. So I added WebMCP to this blog.

## The problem we're trying to solve

Say an agent in your browser is asked "what has Paul written about AI agents?". Today it has two options. It can read the rendered page, work out which bits are post titles and which are navigation, follow the pagination, and hope the layout doesn't change. Or it can go looking for an API, and a static Jekyll blog doesn't have one.

Both are guessing. The page knows exactly what's on it and has no way to say so.

## What WebMCP is

[WebMCP](https://github.com/webmachinelearning/webmcp) is a proposal in the W3C Web Machine Learning community group. In its own words, it lets developers "expose web application functionality, either JavaScript functions or HTML `<form>` elements, as tools with natural language descriptions and structured schemas, designed for AI agent ingestion."

If you've written an MCP server, the shape is familiar. The difference is where it runs: the tools live in the page, run in the page, and use the page's own code. The proposal is explicit that this doesn't replace the page: "The human web interface remains primary; agent tools augment rather than replace user interaction."

It's early. In Chrome it's available from version 149 behind a flag (`chrome://flags/#enable-webmcp-testing`) and an [origin trial](https://developer.chrome.com/docs/ai/webmcp). But it's not only a spec any more: Stripe already [documents WebMCP tools](https://docs.stripe.com/agentic-commerce/for-agents/webmcp) on Checkout, Elements and the hosted invoice page, "rather than relying on page scraping and simulating clicks".

## What pardel.dev exposes

Five tools:

| Tool | What it does |
|---|---|
| `search-posts` | Search the posts by keywords and/or tag |
| `list-tags` | Every tag, with how many posts carry it |
| `list-projects` | The open-source projects, newest first |
| `open-page` | Navigate to a page on this site |
| `list-talks` | Talks, podcasts and interviews (only on /public-speaking/) |

All of them except `open-page` only read, and they say so.

This is what Chrome sees on the home page. The dark panel isn't part of the site: it's a few lines of script I ran in the page, calling Chrome's own `document.modelContext.getTools()` and printing what comes back. The script is further down.

![The pardel.dev blog home page in Chrome, with a dark panel over the right-hand side. The panel's first line reads "await document.modelContext.getTools()". Below it are four tools, each with its name, description and inputs: list-projects, list-tags, open-page (input: url) and search-posts (inputs: query, tag, limit).](/assets/images/blog/2026-09-28-webmcp-this-blog-now-has-tools/tools-home.png "Four tools on the home page, as Chrome 154 lists them")

## The data: one JSON file, built with the site

A static site has no backend to call, so the tools need their data from somewhere. The answer is a JSON file Jekyll builds alongside everything else, from the same posts and project list the pages use:


```liquid
{
  "posts": [
    {%- for post in site.posts %}
    {
      "title": {{ post.title | jsonify }},
      "url": {{ post.url | jsonify }},
      "date": {{ post.date | date: "%Y-%m-%d" | jsonify }},
      "tags": {{ post.tags | jsonify }},
      "excerpt": {{ post.excerpt | strip_html | strip_newlines | strip | jsonify }}
    }{% unless forloop.last %},{% endunless %}
    {%- endfor %}
  ],
  ...
}
```


That's the important bit. There's no second copy of anything to keep up to date: publish a post and the tool knows about it on the next build.

## The tools: one script

Registering a tool is one call on `document.modelContext`. Here's `search-posts`, trimmed a little:

```javascript
await document.modelContext.registerTool({
  name: 'search-posts',
  description: 'Search the blog posts on pardel.dev by keywords and/or tag. ' +
    'Returns title, url, date, tags and excerpt, most relevant first.',
  inputSchema: {
    type: 'object',
    properties: {
      query: { type: 'string', description: 'Keywords, e.g. "swift testing"' },
      tag: { type: 'string', description: 'Only posts with this tag, e.g. "AI"' },
      limit: { type: 'number', description: 'Maximum results, default 10' }
    }
  },
  annotations: { readOnlyHint: true },
  execute: async function (input) {
    const data = await loadCatalog();  // fetches /webmcp.json, once
    return reply(search(data.posts, input));
  }
});
```

A name, a description, a JSON schema for the input, and a function. The description matters more than it looks: it's the only thing the agent reads to decide whether this is the tool it wants, so it's written for that reader.

The script starts by checking the API is there, and quietly does nothing if it isn't:

```javascript
var mc = document.modelContext;
if (!mc || typeof mc.registerTool !== 'function') { return; }
```

So every other browser gets exactly the site it had before.

Calling a tool is the agent's side of it. Here's `search-posts` asked for three posts tagged AI:

![The blog home page in Chrome with the dark panel showing a tool call: await document.modelContext.executeTool(search-posts, '{"tag":"AI","limit":3}'). The result below it is JSON with "count": 3 and a list of posts, starting with "GOVERN - a second brain that shows its work" and "The Governed Second Brain", each with url, date, tags and excerpt.](/assets/images/blog/2026-09-28-webmcp-this-blog-now-has-tools/call-search.png "search-posts, called through Chrome's own executeTool")

One thing that caught me out: in Chrome 154, `executeTool` wants its input as a JSON **string**. Pass it a plain object and you get `UnknownError: Failed to parse input arguments`. You'll only hit this if you're writing the agent side yourself, but it cost me a few minutes.

This is the script behind both panels, minus the styling. Paste it into the DevTools console on any page that registers tools (with the flag on) and you get the same data back:

```javascript
const mc = document.modelContext;

// What the page declares
const tools = await mc.getTools();
tools.map(t => ({
  name: t.name,
  description: t.description,
  inputs: Object.keys(JSON.parse(t.inputSchema).properties || {})
}));

// Calling one, the way an agent would
const search = tools.find(t => t.name === 'search-posts');
const result = await mc.executeTool(search, JSON.stringify({ tag: 'AI', limit: 3 }));
JSON.parse(JSON.parse(result).content[0].text);
```

Note the three `JSON.parse` calls. `getTools()` hands the input schema back as a string, `executeTool` returns a string, and inside that is the MCP-style `content` my tool returns, whose text is JSON again.

## A tool belongs on the page where its data is

`list-talks` is the one I like most. My talks aren't in a data file; they're hand-written HTML on the Public Speaking page. So that page, and only that page, registers the tool, and the tool reads the talks straight out of the DOM a human reader is looking at:

```javascript
var talks = document.querySelector('.public-speaking');
if (talks) {
  register({ name: 'list-talks', ... });
}
```

This is what the proposal recommends anyway: register the tools that make sense for the page you're on, rather than every tool everywhere, so the agent isn't wading through a long list that doesn't apply.

Go to the Public Speaking page and the list grows by one:

![The Public Speaking page in Chrome with the dark panel showing await document.modelContext.getTools(). There are now five tools: list-projects, list-tags, list-talks, open-page and search-posts. list-talks is described as listing talks, podcasts and radio interviews, with no input.](/assets/images/blog/2026-09-28-webmcp-this-blog-now-has-tools/tools-speaking.png "On /public-speaking/, list-talks joins the other four")

## Try it

1. Use Chrome 149 or later and turn on `chrome://flags/#enable-webmcp-testing`.
2. Install the [Model Context Tool Inspector](https://chromewebstore.google.com/detail/webmcp-model-context-tool/gbpdfapgefenggkahomfgkhfehlcenpd) extension, the one Chrome's [WebMCP docs](https://developer.chrome.com/docs/ai/webmcp) point to.
3. Open [pardel.dev](https://www.pardel.dev) and look at the tools it lists, then go to [Public Speaking](https://www.pardel.dev/public-speaking/) and watch `list-talks` appear.

## The Lesson

None of this was hard. One JSON file generated from data the site already had, one script of about 170 lines, one `<script>` tag in the footer. The blog looks and works exactly as it did.

What I didn't expect is how much of the work was writing descriptions. The code is the easy part; saying clearly what each tool does, for a reader who only has the description to go on, is the same job as writing a good docs page. Which is more or less what the talk is about.
