AWS Builder Center
Making my personal website callable for AI agents with WebMCP

Making my personal website callable for AI agents with WebMCP

My agent-friendly website told browser agents it had no tools. I added WebMCP so they can search my posts, talks and events directly.

I develop full-stack apps. With a beautiful UI and working backends.
The first time someone mentioned WebMCP to me, I assumed it was a diagnostics tool for web apps. The name sounds like something you would open next to the network tab. My second guess was worse: I thought maybe it was a Playwright alternative. Luckily, I was wrong both times, and figuring out why is how I ended up adding it to salih.dev  (where probably nobody would use).
A few months ago I wrote about building an agent-friendly personal website with specs . The site serves Markdown to agents, has an llms.txt, and answers a set of .well-known discovery files. One of those files was /.well-known/webmcp.json, and it said, politely, that there were no browser tools here. In case you don't know me, I am a very open person, if I don't have something, I would say "I don't have it". But I also don't take "No" for an answer, even to myself. So let's see how I added this very useful set of features on a not very useful scenario for the sake of learning.
The code is in the salih-dev repository : the tools are in PR #15 , and the speaker kit and discovery changes are in PR #16 .

What is WebMCP?

WebMCP  is a proposed web standard from the W3C Web Machine Learning Community Group, written by people from Microsoft and Google. It lets a web page declare tools that an AI agent running in the browser can call. The page registers each tool with a name, a description, a JSON schema for its input and a function that runs it:
1
2
3
4
5
6
document.modelContext.registerTool({
name: "list_events",
description: "List the conferences where Salih speaks...",
inputSchema: { type: "object", properties: { when: { type: "string" } } },
execute: async (input) => { /* return the answer */ },
});
The "MCP" is the same Model Context Protocol you may already run on a server. The difference is where it lives. A server MCP exposes tools from a backend. WebMCP exposes tools from the page the user has open, with the user's session and the page's state.

The tools I added

My site has three kinds of content an agent might care about: blog posts, past talks and upcoming events. I wrote eight tools around them:
ToolWhat it answers
get_site_overviewWhere I am right now, content counts, my next event, the latest post, and the filter values the other tools accept
search_siteFull-text search across posts, talks and events
list_blog_postsPosts filtered by tag, category or text
read_blog_postThe full Markdown of one post
list_talksPast talks by topic, event type, year, or whether a recording exists
list_eventsUpcoming, past or all events
get_speaker_kitMy bio, the topics I speak on, recent talks, where I am already booked, and how to reach me
show_talks_on_pageApplies a filter on the Talks page so the visitor sees it too
The first seven are read-only and registered on every page. The last one only exists on /talks/, and it is the one I like most. It presses the page's real filter button, so the person watching sees the same list the agent is describing. The page's own controller stays in charge of the state, including its aria-pressed attributes.
I kept everything read-only on purpose. There is a comment form on the site, and I thought about exposing it. An agent posting comments on my behalf is a decision I want to make separately, after I have watched how agents use the read tools.

Step 1: Bake an index at build time

The tools need data, and I did not want them scraping my own HTML. The site is built with Astro, so I added a static endpoint that writes everything the tools need into one JSON file.
Create it under src/pages/api/webmcp-index.json.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const GET: APIRoute = async () => {
const [posts, talks] = await Promise.all([
getPublishedPosts(),
getPublishedTalksSnapshot(),
]);

return jsonResponse(
buildSiteIndex({
siteName: site.name,
siteUrl: site.url,
posts,
talkCards: projectTalkArchive(talks).cards,
siteContent: getSiteContent(),
}),
{ headers: { "Cache-Control": "public, max-age=300" } },
);
};
  1. It reads the same published posts and talks the pages use, so the tools never disagree with what a human sees.
  2. buildSiteIndex flattens them into a small shape with titles, URLs, dates, tags and snippets.
  3. The file is static. My content API starts the publisher build after every write, and that build regenerates the file, so there is no extra Lambda to run.
One detail took me a moment. Events move from "upcoming" to "past" on their own, and a baked file does not know what day it is. So the index stores dates, and the tools decide upcoming or past in the browser against the visitor's clock. An event that ended yesterday shows as past even if I have not rebuilt the site since.

Step 2: Register the tools only when the browser supports them

Most visitors use a browser without WebMCP, and I wanted them to see zero difference. The entry point checks for document.modelContext and returns if it is missing.
Add it to src/lib/webmcp/register-tools.ts:
1
2
3
4
5
6
7
8
9
10
11
12
export async function registerWebMcpTools(): Promise<void> {
const context = document.modelContext;
if (context === undefined) return;

try {
await registerContentTools(context);
await registerTalksPageTool(context);
} catch (error: unknown) {
// A registration failure must never break the page for the human visitor.
console.warn("WebMCP tool registration failed", error);
}
}
Then load it from a component with no markup, src/components/WebMcpTools.astro, and add that component to the base layout:
1
2
3
4
5
<script>
import { registerWebMcpTools } from "../lib/webmcp/register-tools";

void registerWebMcpTools();
</script>
For types I used the webmcp-types package, pinned to 0.1.10, and referenced it from src/env.d.ts. That gives you WebMCP.ModelContext and the registerTool signature without writing your own copy of the API.

Step 3: Write descriptions for a model, and check every input

A tool description is a prompt. The agent reads it to decide whether to call the tool and what to pass. I wrote mine the way I would brief a colleague, including the defaults and the order of results:
1
2
description:
"List the conferences and events where Salih Güler speaks or runs workshops. 'upcoming' (the default) lists events that have not ended yet, soonest first; 'past' lists finished events, most recent first; 'all' lists both.",
get_site_overview returns the exact tag, category and topic values the other tools accept, and its description tells the agent to call it first when it is unsure. That one line saves a lot of failed filter calls.
The spec does not promise that the browser validates input against your schema. So every execute function checks its input again before using it:
1
2
const query = optionalString(input.query);
if (query === undefined) throw new Error("query must be a non-empty string");
The search and filter logic lives in src/lib/webmcp/queries.ts as pure functions, which made it easy to test without a browser. There are 12 tests for it, covering search ranking, filters, the upcoming and past split, post lookup and the speaker kit.

Step 4: Add a speaker kit

The first version answered questions about my content. Then I thought about who actually looks me up. Most of the time it is an event organizer deciding whether to invite me, and they want a bio, the topics I cover, proof that I can give a talk, and a way to reach me. Before, an agent had to stitch that together from four tool calls and the About page.
So the index got a speaker block, built from the same src/config/site.ts that renders my About and Contact pages:
1
2
3
4
5
6
7
8
9
10
11
12
speaker: {
role: input.speaker.role,
bio: input.speaker.bioShort,
longBio: [...input.speaker.bioLong],
focusAreas: [...input.speaker.focusAreas],
aboutUrl: absolute("/about/"),
contactUrl: absolute("/contact/"),
profiles: input.speaker.socials.map((social) => ({
label: social.label,
url: social.href,
})),
},
And src/lib/webmcp/queries.ts got a speakerKit function that combines it with the talks and events. It counts how many talks I have given per topic, so the agent sees what I talk about most:
1
2
3
4
5
6
7
8
9
10
const topicCounts = new Map<string, { topic: string; talks: number; order: number }>();
for (const talk of index.talks) {
for (const topic of talk.topics) {
const key = normalize(topic);
if (key.length === 0) continue;
const entry = topicCounts.get(key);
if (entry) entry.talks += 1;
else topicCounts.set(key, { topic, talks: 1, order: topicCounts.size });
}
}
The function returns the topics sorted by talk count, my five most recent talks with slides and recordings, the next five events I am booked for, and the contact routes. My site lists no email address, so the kit says that directly and points to the profiles on the contact page. An agent should not invent an address because the field looked empty.
Registering it looks like the other tools. The description names the situations where an agent should pick it:
1
2
3
4
5
6
7
8
9
await context.registerTool({
name: "get_speaker_kit",
title: "Speaker kit",
description:
"Get what an event organizer needs to invite Salih Güler as a speaker: his role and bio, focus areas, the topics he has spoken on with talk counts, his most recent talks with slide and recording URLs, the events he is already booked for, and how to contact him. Use this when someone wants to invite him, check his availability, or write a speaker introduction.",
inputSchema: { type: "object", properties: {} },
annotations: readOnly,
execute: async (_input, { signal }) => speakerKit(await loadIndex(signal), today()),
});
Adding the speaker block changed the index shape, so I bumped its version from 1 to 2.

Step 5: Tell the humans too

The tools are invisible to a person reading the site. If someone finds this post and opens salih.dev, nothing on the page says what to ask. So the About page has a new section, "Use this site with an AI agent", with example prompts and the console check from the "Try it yourself" section below.
The About page also has a Markdown version for agents, and I did not want the two to drift apart. Both now render from one small module, src/lib/webmcp/visitor-guide.ts:
1
2
3
4
5
6
7
8
9
10
11
export const AGENT_GUIDE = {
heading: "Use this site with an AI agent",
promptsLead: "Some questions to try:",
prompts: [
"Where is Salih speaking next, and what has he talked about before?",
"Find Salih's posts about AWS Amplify and summarize the newest one.",
"Which of Salih's talks have a recording?",
"Get Salih's speaker kit so I can invite him to our meetup.",
],
// intro text, the Chrome flag, and the console call are here too
} as const;
about.astro maps over it for the HTML, and the Markdown serializer builds the same section from it.

Try it yourself

You can call the tools on salih.dev today. You need Chrome 150 or newer.

From the Chrome console

  1. Open chrome://flags/#enable-webmcp-testing, set WebMCP for testing to Enabled, and relaunch Chrome.
  2. Open salih.dev , then open DevTools and go to the Console.
  3. List the tools and call one:
1
2
3
4
5
6
7
8
const tools = await document.modelContext.getTools();
tools.map((tool) => tool.name);

const kit = tools.find((tool) => tool.name === "get_speaker_kit");
await document.modelContext.executeTool(kit, {});

const search = tools.find((tool) => tool.name === "search_site");
await document.modelContext.executeTool(search, { query: "agents" });
You should see seven tools on most pages and eight on /talks/ . If document.modelContext is undefined, the flag is not on yet. Some Chrome builds expect the input as a JSON string, so if executeTool complains, pass JSON.stringify({ query: "agents" }) instead.
On the Talks page, run show_talks_on_page with { filter: "All talks" } or one of the topic labels and watch the filter buttons on the page change.

With the MCP Inspector

If you want to see the calls before you hand them to an agent, use the MCP Inspector . It is the reference tool for testing MCP servers, and it can start Chrome DevTools MCP for you with the same flags as above:
1
2
3
4
npx @modelcontextprotocol/inspector \
npx chrome-devtools-mcp@latest \
--categoryExperimentalWebmcp \
--chromeArg=--enable-features=WebMCP
It prints a URL with a one-time token, so open that URL rather than typing localhost:6274 yourself. Turn on the switch next to the server to connect, then switch to Tools. You are now doing by hand what Claude Code and Codex did on their own:
  1. Run new_page with https://salih.dev/talks/ as the url. Chrome opens the page, and the result lists its page ID.
  2. Run list_webmcp_tools with that pageId. You get all eight tools with their descriptions and input schemas, exactly as an agent receives them.
  3. Run execute_webmcp_tool with the pageId, a toolName and the input as a JSON string, for example get_speaker_kit and {}.
The MCP Inspector showing list_webmcp_tools for salih.dev, with each tool's name, description and input schema
The MCP Inspector showing the result of get_speaker_kit, with my role, bio, focus areas and talk topics
Step 2 is the useful one when you write your own tools. You read each description the way the model reads it, flattened into one long block with its schema, so a description that only makes sense next to your page is easy to spot. The Protocol tab on the right shows the raw JSON-RPC for every call if you need to see exactly what was sent.
Every tool in the list has a small warning triangle. That is the Inspector's schema portability check on Chrome DevTools MCP's own schemas, not on my tools, and the calls work anyway.

From Claude Code, Codex or Kiro CLI

Claude Code, Codex and Kiro CLI run in a terminal, not in a browser tab, so they cannot see document.modelContext on their own. The bridge is Chrome DevTools MCP , Google's MCP server that gives a coding agent a real Chrome. It has an experimental WebMCP category with two tools: list_webmcp_tools lists what a page registers, and execute_webmcp_tool calls one of them with JSON input.
The category is off by default. You need two flags: --categoryExperimentalWebmcp turns on the two tools, and --chromeArg=--enable-features=WebMCP turns on WebMCP in the Chrome that the server starts. That Chrome uses its own profile, so the flag you set in chrome://flags does not carry over.
For Claude Code:
1
2
3
4
claude mcp add chrome-devtools --scope user -- \
npx chrome-devtools-mcp@latest \
--categoryExperimentalWebmcp \
--chromeArg=--enable-features=WebMCP
For Codex:
1
2
3
4
codex mcp add chrome-devtools -- \
npx chrome-devtools-mcp@latest \
--categoryExperimentalWebmcp \
--chromeArg=--enable-features=WebMCP
If you prefer editing ~/.codex/config.toml directly, this is the same server:
1
2
3
4
5
6
7
[mcp_servers.chrome-devtools]
command = "npx"
args = [
"chrome-devtools-mcp@latest",
"--categoryExperimentalWebmcp",
"--chromeArg=--enable-features=WebMCP",
]
For Kiro CLI:
1
2
3
kiro-cli mcp add --name chrome-devtools --scope global \
--command npx \
--args '["chrome-devtools-mcp@latest","--categoryExperimentalWebmcp","--chromeArg=--enable-features=WebMCP"]'
That writes the server to ~/.kiro/settings/mcp.json. Use --scope workspace instead if you only want it in one project, in that project's .kiro/settings/mcp.json.
Restart the agent and ask it something about the site. I gave Codex this prompt:
1
2
3
Open https://salih.dev/ in the browser. Use the page's WebMCP tools to answer:
where is Salih speaking next, and which topics has he given talks on?
Then say which WebMCP tools you called. Do not read the page HTML.
Codex Screenshot of the Tool Call
It called list_webmcp_tools, then execute_webmcp_tool three times with get_site_overview, list_events and list_talks. The answer had CityJS Athens on 21 to 23 October as my next event, the two events after it, and the topics from both talks. It did not read the page HTML once.
I gave Codex a speaker question instead, and it went straight to get_speaker_kit after listing the tools. That one call was enough for my bio, focus areas and contact routes. It also repeated the kit's note that the site has no email address, which is the line I added so an agent would not make one up. One small lesson from that run: I asked for "three focus areas", the kit has four, and Codex merged two of them to match my number. The agent trusted my prompt over the data.
With Kiro CLI I asked which talks I have given and whether any of them has a recording. It opened the page, called list_webmcp_tools, and then made a single execute_webmcp_tool call with list_talks. The answer had both talks with their events and dates, and it said neither has a recording because videoUrl is empty for both. Kiro CLI asks before it runs each tool, so every one of those calls went through an approval prompt first.
Two things to know before you try this:
  • Chrome DevTools MCP can read and change anything in the browser it controls. The server prints that warning on start, and it is right. Use it on sites you trust, and keep it away from tabs with accounts you care about.
  • The site cannot see who is calling its tools or ask the visitor for permission. Your agent's own approval prompts are the only check, so read what it wants to call before you allow it. My tools only read, so it does not matter much here. It would matter on a site with a checkout tool.
Chrome's Debug WebMCP tools with AI agents  page shows the same setup from the author's side: asking your coding agent to check that your own tools register and that their schemas are clear enough for a model.

With other agents

If your agent runs inside a browser that supports WebMCP, ask it one of the questions from my About page, for example "Where is Salih speaking next, and what has he talked about before?", and check which tools it calls.
An agent without a browser still gets the same answers. Point it at llms.txt . It lists the tools and links /api/webmcp-index.json , the data the tools read.

Resources

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article