The Worker auto-generates a small HTML page at /mcp (or whatever path you set in [webmcp_landing].path). It is the page humans and desktop MCP clients land on to pair, and the page browser-native agents see when they follow a <link rel="webmcp"> reference.
You can replace it with your own HTML template per deploy, with no code changes to the Worker.
If you do nothing, cf-webmcp uses templates/landing.default.html. It is a single-file standalone page with inline CSS, runtime state branching, and three sections (native / pair / disabled). It is intentionally minimal: works in any browser, no external assets.
Set a template path in your TOML:
[webmcp_landing]
path = "/mcp"
template = "path/to/my-landing.html"The path is resolved relative to your webmcp.toml file, not the project root. cf-webmcp reads the file at build time, substitutes a handful of placeholders, and emits the result as the landing page.
The build script does a literal {{name}} substitution. No conditionals, no loops, no expressions. Everything you might need is pre-computed and handed in as a string.
| Placeholder | Value | Notes |
|---|---|---|
{{lang}} |
site.locale |
HTML-escaped. Goes in <html lang="...">. |
{{site_name}} |
site.name |
HTML-escaped. |
{{site_description}} |
site.description |
HTML-escaped. |
{{config_hash}} |
Build-time hash of the TOML | 8 hex chars. Same value as in <ETag> and /_webmcp/health. |
{{tool_list}} |
Pre-rendered <li> items |
One per tool. Each is <li><code>name</code> - description</li>. Not HTML-escaped (it is already safe HTML). |
{{widget_block}} |
The widget mount + script tag | Empty string if [features].fallback_widget = false. Not HTML-escaped. |
{{widget_enabled_js}} |
Literal "true" or "false" |
For inlining into a JS expression. |
Unknown placeholders cause a build error so typos surface early.
The runtime state branching is a tiny inline <script> at the bottom of the template that toggles which of three <div id="state-*"> blocks becomes visible. As long as your template keeps:
- A
<div id="state-native">with content for native-API users. - A
<div id="state-pair">with content for desktop-client pairing. - A
<div id="state-disabled">for sites that havefallback_widget = false. - A closing
<script>block that flips.activeon the right state.
...the branching keeps working. The default template at the bottom of this doc is the canonical example.
Each state div is shown only when its condition matches. Put the content unique to that state (success notice, pairing steps, disabled notice) inside its div. Put content that should always appear (the tool list, an "About WebMCP" section, etc.) outside the state divs so it shows regardless of which state is active.
If you want none of the runtime branching (e.g. you are building a static "tools available" page for non-agent visitors), drop the script and the state-* classes from your template. Plain HTML works fine.
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<title>Connect - {{site_name}}</title>
<meta name="robots" content="noindex">
</head>
<body>
<h1>{{site_name}}</h1>
<p>{{site_description}}</p>
<div id="state-native"><p>Connected.</p></div>
<div id="state-pair"><p>Pair your MCP client.</p>{{widget_block}}</div>
<div id="state-disabled"><p>Widget disabled.</p></div>
<h2>Tools available</h2>
<ul>{{tool_list}}</ul>
<script>
(function () {
var hasNative = 'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function';
var widgetEnabled = {{widget_enabled_js}};
var id = hasNative ? 'state-native' : (widgetEnabled ? 'state-pair' : 'state-disabled');
document.querySelectorAll('.state').forEach(function(el){ el.style.display = 'none'; });
var active = document.getElementById(id);
if (active) active.style.display = 'block';
})();
</script>
</body>
</html>- Asset URLs. If your template references external CSS, JS, or images, use relative paths (
css/styles.css) so the same file works at bothlocalhost:8788/mcpandexample.com/mcp. Absolute root-relative paths (/css/styles.css) work for full-proxy mode but break if you mount the Worker under a sub-path. - No tracking on this page. Resist the temptation to add analytics or ads here. It is a system-style page for agents, not a marketing page.
noindex. Keep<meta name="robots" content="noindex">(or rely on the response header). This is a per-visitor pairing surface, not a search result.- Tool descriptions come from the tool definition. If a description reads wrong on the landing, edit the
description = "..."field on the tool in your TOML, not the landing template. {{tool_list}}is HTML. If you want a different layout (e.g. table rows instead of list items), you cannot reshape{{tool_list}}from the template alone. Either accept the<li>format or open an issue for additional tool-list variants.
If all you want to change is the page title or the site description, edit [site].name and [site].description in your TOML. The default template already surfaces both. You do not need a custom template for surface-level copy changes.