Framework Guide · WebMCP · Wagtail
WebMCP in Wagtail: a Django pattern for agent-callable pages
Wagtail is a Python shop, and Python shops smell the smell early: instructions that begin in a server process and end in a browser object. The honest WebMCP answer for Wagtail is not a Python API — there isn’t one — but a Django-shaped division of labor: Python decides, centrally and testably, which pages carry which tools and what data they’re seeded with; a small generated script registers them in the tab; execution routes back through the views and ORM your application already trusts. This article, written for Django developers, builds that pipeline end to end on Wagtail’s real extension points: hooks, page contexts, StreamField structure, and FormPage.
Fact-checked and updated on September 2, 2026 (Wagtail hooks reference; Django documentation). Primary sources are cited at the end of the article.
The boundary, stated the way a Python developer states it
WebMCP’s registration surface is browser JavaScript: document.modelContext.registerTool(), secure-context only, top-level documents only — never iframes, never workers ([specification](https://webmachinelearning.github.io/webmcp)). The 2026 browser reality: an early preview in Chrome 146 behind #enable-webmcp-testing; a production-usable public origin trial from Chrome 149 through 156 (from June 2026), gated on a per-origin token delivered via <meta http-equiv="origin-trial"> or an Origin-Trial response header ([Chrome for Developers](https://developer.chrome.com/docs/ai/webmcp)).
So no register_tool() exists to call from models.py — and Wagtail integration is therefore three Python jobs and one JavaScript artifact:
- Emit the trial token — a template/base-layout concern, trivial.
- Decide, per page, which tools exist and what data seeds them — the job Wagtail’s hook system exists for.
- Generate the registration script from that decision — a view, versioned in Git, reviewed in code review.
- Enforce on execution — which means: tools call your Django URLs, and Django enforces what Django always enforced.
Hooks: the central decision point
Wagtail’s hook registry intercepts page serving — on_serve_page (previously before_serve_page) receives the page and request before the response is built ([hooks reference](https://docs.wagtail.org/en/stable/reference/hooks.html)). That is the natural home for the policy: one file, one dict, every agent surface in the project visible at a glance:
# mysite/wagtail_hooks.py
from wagtail import hooks
TOOL_REGISTRY = {
"search.SearchPage": ["site-search"],
"products.ProductPage": ["check-stock", "request-quote"],
"contact.ContactPage": ["submit-inquiry"],
}
@hooks.register("before_serve_page")
def attach_webmcp(page, request, args, kwargs):
key = f"{page._meta.app_label}.{page._meta.model_name.title()}Page"
keys = TOOL_REGISTRY.get(key, [])
if keys:
request._webmcp = {"tools": keys, "data": page.webmcp_seed()}
return None
Page types contribute a small method; the base template consumes it:
# models.py fragment
class ProductPage(Page):
# ... StreamField spec blocks, price fields ...
def webmcp_seed(self):
return {"sku": self.sku, "price": str(self.price),
"spec": streamfield_to_plain(self.body)}
<!-- base.html fragment -->
{% if request._webmcp %}
{{ request._webmcp.data|json_script:"webmcp-data" }}
<script src="{% url 'webmcp-tools' %}?t={{ request._webmcp.tools|join:',' }}" defer></script>
{% endif %}
Two Django-native details carry real weight. json_script is Django’s XSS-safe serialization helper — it escapes </script> inside payloads, which is exactly the seam where naive |safe template injection becomes a stored vulnerability ([Django docs](https://docs.djangoproject.com/en/stable/reference/templates/builtins/#json-script)). And the webmcp-tools URL is a view returning JavaScript assembled from a registry — meaning the tool definitions live in Python, are imported by tests, and change via pull request. Editors editing pages never touch agent semantics; developers reviewing diffs always see them.
StreamField is tool fuel
Wagtail’s structural differentiator quietly solves WebMCP’s hardest authoring problem — what does execute return? A product page whose specification is a StreamField of typed blocks hands the tool structured data on the same render pass the human got; check-stock answers from page-embedded state with zero extra HTTP. That is an architectural advantage over the SPA pattern, where every tool call refetches: server-rendered Wagtail gets latency-free tools for free by baking inputs into HTML it was already sending. The equivalent of this reasoning in JavaScript frameworks is called serialization cost; here it is simply what templates do.
FormPage: declarative tools with editor-authored descriptions
Wagtail’s FormPage renders ordinary POST forms — the exact substrate of the Declarative API, the HTML-native half of WebMCP whose schema-synthesis rules are dissected in our reference article ([explainer](https://github.com/webmachinelearning/webmcp/blob/main/declarative-api-explainer.md)). One copied-and-overridden template, one extra model field, and every future form page inherits agent-readiness:
<form action="{% pageurl page %}" method="post"
{% if page.webmcp_description %}
toolname="{{ page.slug }}-form"
tooldescription="{{ page.webmcp_description }}"
{% endif %}>
{% csrf_token %}
{{ form }}
<button type="submit">{{ page.submit_label }}</button>
</form>
The webmcp_description CharField is the quiet masterstroke: the people who understand the business process — the editors — author the text an LLM routes on, in the CMS they already work in, reviewed in the workflow they already trust. Omit toolautosubmit: agents fill, humans click send, {% csrf_token %} intact. CSRF, rate limiting, honeypots, and your spam middleware continue to apply unchanged — which is the entire argument for server-rendered forms in an agentic world.
The Pythonic MCP endpoint: the half your traffic actually uses
Desktop and server-side MCP clients — Claude Desktop, Cursor, internal copilots — do not render pages and cannot call in-browser tools; for most publications and B2B sites they are the larger agent audience today ([comparison with runnable code](https://mcptrail.com/blog/how-to-add-webmcp-to-a-website/)). Django serves that audience natively, and the architecture the whole series converges on fits it almost idiomatically — one registry module, two transports:
# services.py — single source of truth for BOTH surfaces
def tool_defs():
return [{
"name": "site-search",
"description": "Full-text search across published pages. Returns up to 20 results.",
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string"},
"type": {"type": "string", "enum": ["page", "article"]}},
"required": ["query"],
},
"annotations": {"readOnlyHint": True},
}]
def run_tool(name, args, request):
if name == "site-search":
from wagtail.search.backends import get_search_backend
qs = get_search_backend().search(args["query"], Page.objects.live())
return list(qs.select_related()[:20].values("title", "url"))
raise ValueError(name)
A View exposes tools/list and tools/call over tool_defs()/run_tool(); the browser script is generated from the same functions; permission_required and Django’s auth stack apply to agent calls exactly as to any request; pytest covers both transports against one code path. When the specification churns — the root object already migrated navigator.modelContext → document.modelContext across Chromium builds ([modern-web-guidance](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)) — the browser artifact regenerates and the server contract keeps its version number. That durability is what Django culture is for.
Verification
python manage.py runserver, Chrome with the testing flag, open a ProductPage: (await document.modelContext.getTools()).map(t => t.name) must equal that page type’s TOOL_REGISTRY slice — assertable headlessly by rendering the response body in your test suite, no browser required. Then the test Django people actually care about, the negative one: an unauthenticated run_tool("request-quote", …) must fail exactly as the corresponding view fails for an anonymous visitor. If it doesn’t, you shipped a demo. Full schema validation with the Model Context Tool Inspector.
FAQ
Which Wagtail hooks exactly, and what changed recently?
The page-serving family — historically before_serve_page, reworked into the serve-URL pipeline (on_serve_page/construct_page_serve_context era) — with the documented signatures in the hooks reference. Pin your hook signatures to your Wagtail version; the reference page is versioned per release.
Wagtail API vs hooks vs template — where else could this live?
Per-page get_context() achieves the same injection without global hooks (good for heterogeneous sites); Wagtail’s page-serving API is the same code path the hook wraps; template-only means duplicating the decision on every template, which is the failure mode hooks exist to prevent. The registry-in-Python principle matters more than the injection mechanism.
Does the headless/Wagtail-API frontend change the story?
Only geographically: registration moves into the decoupled front end (see the Next.js or Astro guides), while the registry still lives in Django — exactly the headless pattern from the Strapi guide.
Multi-site (Wagtail multi-tenant) setups?
Key TOOL_REGISTRY by site as well as page type, and serve the generated script with the same keying. Multi-tenancy is where hand-maintained per-site agent surfaces rot first; central generation is the control.
Conclusion
Wagtail won its niche by respecting the page as a structured object rather than a blob. WebMCP is the same respect extended to a new kind of visitor: structure the tool registry like you structure content, generate the browser artifact like you generate templates, and let Django’s permission machinery do what it has done for two decades to every client that shows up uninvited. The framework gives you the seams — hooks, contexts, StreamField, FormPage — precisely because it was built on the assumption that one platform serves many kinds of reader. The agent is only the latest to arrive with paperwork.