WebMCP: How We Made pineapples.dev Usable by AI Agents

Anthony Wentzel
Founder, Pineapples

WebMCP: How We Made pineapples.dev Usable by AI Agents
WebMCP is a browser API that lets a page register tools an AI agent can call. On pineapples.dev we register three. estimate_fractional_cto_cost returns a BLS loaded cost range. search_guides returns matching guide titles and URLs from a build-time index. request_call opens the chat form with optional fields filled in. The visitor still has to press send.
What is WebMCP?
WebMCP is the draft that lets a web page hand tools to an AI agent in the browser. The agent calls a tool. The page runs it and returns text. The visitor stays on the site.
I read the W3C Web Machine Learning Community Group WebMCP Editor's Draft (https://webmachinelearning.github.io/webmcp/) on 2026-10-11. The comment in components/webmcp/WebMcpTools.tsx records the draft we followed: main commit d0e4e0e, dated 2026-10-09. That same comment records that the Chrome imperative API reference, last updated 2026-09-21, matches that shape.
registerTool takes a name, a description, an inputSchema written as JSON Schema, and an async execute. execute resolves to MCP content, { content: [{ type: "text", text }] }, which the draft JSON-serializes. The current draft unregisters with an AbortSignal passed in the options. unregisterTool(name) is the older method. We call it on unmount only when the function exists.
We shipped the three tools in pull request 453. The squash commit is 33aa4f0. I do not have a usage count. The tools are on the site. There is no call total to publish, because we have not collected one.
How does document.modelContext.registerTool work?
The host exposes modelContext on document. When document.modelContext.registerTool is a function, that is the function we call. The tool object is the first argument. The second argument is { signal } from an AbortController. Aborting that signal is how the current draft drops the tool.
The object we pass is the WebMcpTool type in lib/webmcp/tools.ts.
export type WebMcpTool = {
name: string
title: string
description: string
inputSchema: Record<string, unknown>
annotations: { readOnlyHint: boolean }
execute: (input: Record<string, unknown>) => Promise<WebMcpTextContent>
}
execute returns the helper in lib/webmcp/content.ts.
export function textContent(text: string): WebMcpTextContent {
return { content: [{ type: "text", text }] }
}
Registration is one client effect. WebMcpTools is mounted once from the root layout in app/layout.tsx and returns null. document and navigator are only read inside the effect, so the server render does not call the API. The call itself looks like this.
const registerTool = modelContext.registerTool.bind(modelContext)
const controller = new AbortController()
const tools = createWebMcpTools((href) => {
navigateRef.current(href)
})
for (const tool of tools) {
try {
const pending = registerTool(tool, { signal: controller.signal })
if (isPromise(pending)) pending.catch(() => {})
} catch {
// A throwing host must not break the page.
}
}
If registerTool returns a promise, we attach a catch so a rejected registration does not become an unhandled error. On cleanup we abort the controller. If the host still exposes unregisterTool, we call that by name for each tool.
What tools did we register (estimate_fractional_cto_cost, search_guides, request_call)?
createWebMcpTools returns the three tools. Each execute records the call, then does the work. A throw becomes a sentence of text. It does not escape the tool.
The checks that shipped with pull request 453 were the WebMCP tests, 13 passed, and the fractional CTO cost tests, 10 passed. The calculator module was not edited.
estimate_fractional_cto_cost
This tool wraps calculateFractionalCtoCost. The dollars are the BLS loaded range and the time-fraction cost. A quoted_monthly_rate, when the caller passes one, is the caller's number. The wrapper uses it for the annual difference: full-time loaded cost minus that quote times 12. If both days and hours are set, hours are used. Scope and engagement do not change the dollars. There is no Pineapples price.
The same math is on the fractional CTO cost calculator. The business case for the seat is Fractional CTO Rates: What the Seat Returns. This tool does not read a rate from that page. The deep dive on this one tool is estimate_fractional_cto_cost: What the Tool Returns.
const estimateFractionalCtoCostTool: WebMcpTool = {
name: "estimate_fractional_cto_cost",
title: "Estimate fractional CTO cost",
description:
"Estimate a fully loaded full-time Computer and Information Systems Manager cost and the time-fraction cost of part of that week. Uses published BLS OEWS and ECEC figures. Not a Pineapples rate and not a quote.",
inputSchema: {
type: "object",
properties: {
revenue_band: {
type: "string",
enum: [...REVENUE_BAND_ENUM],
description:
"Company revenue band. under-50m is under $50 million, 50m-to-250m is $50 million to $250 million, and 250m-or-more is $250 million or more. The band selects a published wage range. That mapping is an assumption.",
},
days_per_week: {
type: "number",
minimum: 0,
description: "Days per week. A full-time week is 5 days. Provide this or hours_per_week.",
},
hours_per_week: {
type: "number",
minimum: 0,
description:
"Hours per week. A full-time week is 40 hours. If both days_per_week and hours_per_week are set, hours_per_week is used.",
},
quoted_monthly_rate: {
type: "number",
minimum: 0,
description:
"Optional monthly quote from the visitor, used only to show the annual difference versus the full-time loaded cost. Not a Pineapples rate.",
},
},
required: ["revenue_band"],
},
annotations: { readOnlyHint: true },
execute: async (input) => {
pushWebmcpToolCall("estimate_fractional_cto_cost")
try {
return estimateFractionalCtoCost(input)
} catch {
return textContent("The estimate could not be calculated.")
}
},
}
search_guides
search_guides searches a build-time JSON index. Each entry has title, url, excerpt, and tags. The client does not fetch the site. The limit defaults to 5 and caps at 10. Each match is a title, a https://pineapples.dev/blog/... URL, and a one-line summary.
The index that shipped with the tools held 128 guides. tooling/webmcp/build-guide-index.mjs rewrites that file from blog frontmatter before next build. The one-line summary replaces an em dash with a spaced hyphen and an en dash with a hyphen, then collapses whitespace, so the tool does not return those characters.
const searchGuidesTool: WebMcpTool = {
name: "search_guides",
title: "Search guides",
description:
"Search Pineapples guide titles, excerpts, and tags. Returns matching titles, full https://pineapples.dev URLs, and a one-line summary. Does not fetch the site.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Words to match against guide titles, excerpts, and tags.",
},
limit: {
type: "integer",
minimum: 1,
maximum: 10,
description: "Maximum matches to return. Defaults to 5 and cannot exceed 10.",
},
},
required: ["query"],
},
annotations: { readOnlyHint: true },
execute: async (input) => {
pushWebmcpToolCall("search_guides")
try {
const query = typeof input.query === "string" ? input.query : ""
const matches = searchGuides(GUIDE_INDEX, query, input.limit)
return textContent(formatGuideMatches(query, matches))
} catch {
return textContent("The guide search could not be completed.")
}
},
}
request_call
request_call navigates to /chat?webmcp=call and prefills optional name, email, company, and message. It does not call fetch, sendMessage, XMLHttpRequest, or sendBeacon. It does not write pineapples-initial-prompt. That storage key is what auto-sends the home composer. The visitor uses chat and presses send.
readOnlyHint is true on the estimate and the search. It is false here, because this tool navigates.
const requestCallTool: WebMcpTool = {
name: "request_call",
title: "Open the chat form",
description:
"Open the Pineapples chat form and prefill optional name, email, company, and message. Does not submit, send, or post anything. The visitor must press send themselves.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Visitor name. Optional. Not submitted by this tool." },
email: { type: "string", description: "Visitor email. Optional. Not submitted by this tool." },
company: { type: "string", description: "Company name. Optional. Not submitted by this tool." },
message: {
type: "string",
description: "Message placed in the chat box. Optional. Not submitted by this tool.",
},
},
},
annotations: { readOnlyHint: false },
execute: async (input) => {
pushWebmcpToolCall("request_call")
try {
return openCallDraft(input as CallDraftInput, navigate)
} catch {
return textContent("The chat form could not be opened. Nothing was submitted or sent.")
}
},
}
How do you feature-detect WebMCP safely?
Detection has to fail closed. The page is unchanged in a browser that has never heard of WebMCP.
The effect returns immediately unless location.protocol is https:. It then reads document.modelContext and navigator.modelContext. It uses document.modelContext.registerTool when that value is a function. Otherwise it uses navigator.modelContext.registerTool when that value is a function. If neither is a function, it returns and registers nothing.
The whole effect is inside try/catch. Each registerTool call has its own try/catch. A throwing host must not break the page. From components/webmcp/WebMcpTools.tsx:
useEffect(() => {
try {
if (location.protocol !== "https:") return
const documentContext = (document as Document & ModelContextHost).modelContext
const navigatorContext = (navigator as Navigator & ModelContextHost).modelContext
const modelContext =
typeof documentContext?.registerTool === "function"
? documentContext
: typeof navigatorContext?.registerTool === "function"
? navigatorContext
: null
if (!modelContext?.registerTool) return
const registerTool = modelContext.registerTool.bind(modelContext)
const controller = new AbortController()
const tools = createWebMcpTools((href) => {
navigateRef.current(href)
})
for (const tool of tools) {
try {
const pending = registerTool(tool, { signal: controller.signal })
if (isPromise(pending)) pending.catch(() => {})
} catch {
// A throwing host must not break the page.
}
}
return () => {
controller.abort()
if (typeof modelContext.unregisterTool !== "function") return
const unregisterTool = modelContext.unregisterTool.bind(modelContext)
for (const tool of tools) {
try {
unregisterTool(tool.name)
} catch {
// Older hosts can omit unregister or reject it.
}
}
}
} catch {
// Feature detection must not break the page.
}
}, [])
Navigation for request_call is the only side effect we hand the tools. It uses the Next router, and falls back to a location assign if push throws.
navigateRef.current = (href: string) => {
try {
router.push(href)
} catch {
window.location.assign(href)
}
}
Why does request_call never submit?
The tool builds a path and navigates. The comment on openCallDraft in lib/webmcp/request-call.ts says the navigate callback is the only side effect, and the function does not fetch, post, or send the message.
/**
* Navigate to /chat and prefill the composer. The navigate callback is the
* only side effect. This function does not fetch, post, or send the message.
*/
export function openCallDraft(input: CallDraftInput, navigate: (href: string) => void): WebMcpTextContent {
const href = buildCallHref(input)
const draft = draftFromCallFields(input)
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(CALL_DRAFT_EVENT, { detail: { draft } }))
}
navigate(href)
return textContent(callOpenedMessage(input))
}
The path is /chat plus query params. webmcp is set to call. Name, email, company, and message are added only when they are non-empty. Opening that URL does not submit the draft.
The text the tool returns is fixed. With fields filled in, it says the form is open and nothing was submitted or sent. With no fields, it says the form is open and nothing was submitted or sent.
export function callOpenedMessage(input: CallDraftInput): string {
const filled = draftFromCallFields(input).length > 0
if (filled) {
return "The chat form is open and the fields are filled in. Nothing was submitted or sent. The visitor must press send themselves."
}
return "The chat form is open. Nothing was submitted or sent. The visitor must press send themselves."
}
BusinessChat treats webmcp=call as prefill only. The stored-prompt effect returns before sendMessage.
useEffect(() => {
if (!sessionReady || initialSent.current) return
// request_call opens this page with ?webmcp=call. Prefill only. Do not send.
if (shouldBlockStoredPromptSend(window.location.search)) {
initialSent.current = true
return
}
A separate effect writes the draft into the message box and does not send it. The home composer key pineapples-initial-prompt is never written by this path, so the auto-send that key triggers does not run.
The check that shipped with the tools opened http://127.0.0.1:3000/chat?webmcp=call with a name, an email, a company, and a message. The box held those four lines. The page showed no sent bubble and did not show the word webmcp. The only API request was the existing GET /api/chat/session. Pressing Send message did not post. The local session endpoint is not configured, so the box stayed disabled with the usual session error. That disabled state is the existing session bootstrap, not a send.
How do we measure tool calls (GA4 webmcp_tool_call via GTM dataLayer)?
We did not add a GTM tag. Each execute calls pushWebmcpToolCall first. If window is missing, or if dataLayer is missing, the function returns. Otherwise it pushes one event.
/**
* Existing GTM container. DeferredGtm creates dataLayer. This does not add a tag.
* If dataLayer is still undefined, skip the push.
*/
export function pushWebmcpToolCall(toolName: string): void {
if (typeof window === "undefined") return
const dataLayer = (window as DataLayerWindow).dataLayer
if (!dataLayer) return
dataLayer.push({
event: "webmcp_tool_call",
tool_name: toolName,
page_path: location.pathname,
})
}
DeferredGtm creates dataLayer for the existing container. GA4 already ships through that container. There is no second analytics snippet for this event. The event name is webmcp_tool_call. The payload is tool_name and page_path.
We have no usage data yet. I will not publish a call count, a conversion rate, or a before and after. The event is in the page. The number is not.
Should mid-market companies add WebMCP?
Add it when a public page already answers a question an agent would otherwise scrape, and when the tool cannot act for the visitor. Start with read-only tools. Feature-detect, so a browser without the API still loads the page. Do not register a tool that submits a form, posts a lead, or spends money.
We added these three because the calculator and the guides were already public, and the call tool only opens the form. I would not add WebMCP to chase a metric we do not have. Name the problem, the data, and the owner before you hand an agent a tool. That order is the enterprise AI strategy for mid-market companies. If nobody on the bench owns the AI calls, that is a fractional chief AI officer decision, not a script.
Frequently asked questions
Does request_call submit or send anything?
No. request_call navigates to /chat with the query flag webmcp=call and prefills optional name, email, company, and message. It does not call fetch, sendMessage, XMLHttpRequest, or sendBeacon, and it does not write pineapples-initial-prompt. BusinessChat sees webmcp=call and returns before the stored-prompt auto-send. The visitor has to press send.
Does estimate_fractional_cto_cost quote a Pineapples price?
No. The tool wraps calculateFractionalCtoCost. The dollars are the published BLS loaded range and the time-fraction of that range. An optional quoted_monthly_rate is a number the caller supplies, used only for the annual difference against the full-time loaded cost. The tool description says it is not a Pineapples rate and not a quote.
Will pineapples.dev break in a browser without WebMCP?
No. Registration runs in a client effect, only on https, and only when document.modelContext.registerTool or navigator.modelContext.registerTool is a function. If neither exists, the effect returns. The effect and each registerTool call are wrapped in try/catch. The component renders null.
How many results does search_guides return?
The limit defaults to 5 and cannot exceed 10. Each match is a title, a full https://pineapples.dev URL, and a one-line summary. The search reads a build-time JSON index of titles, excerpts, and tags. It does not fetch the site.
Do you have usage numbers for these WebMCP tools?
No. We have no usage data yet. Each execute pushes a webmcp_tool_call event onto the GTM dataLayer when dataLayer exists, with tool_name and page_path. If dataLayer is missing, the push is skipped. There is no count to report.
Working a live deal?
Book a 30-minute working session.
Same operator who runs the diligence engagements. No SDRs, no sales team. Bring the target, I'll bring the checklist.
Share this article

Anthony Wentzel
Founder, Pineapples
Anthony Wentzel has spent 26 years helping mid-market, PE, and family-office operators turn technology risk into decisions they can own. He is the founder of Pineapples.