Author SHA1 Message Date
westfarn e2e93e962e Fix WebMCP schema validity on contact form for Lighthouse audits.
Add toolparamdescription to contact fields, move reCAPTCHA outside the annotated form, and always render declarative WebMCP form attributes.
2026-07-02 13:01:50 -05:00
westfarn 4199527372 Add WebMCP tool registration for agentic browsing (closes #9).
Expose public marketing actions as navigator.modelContext tools behind WEBMCP_ENABLED, including contact submission and service navigation helpers with docs and regression tests.
2026-07-02 11:48:46 -05:00
12 changed files with 685 additions and 30 deletions
+2
View File
@@ -26,6 +26,7 @@ SECRET_KEY = 'django-insecure-0$+hho_6%-(ud^t%0zos(q&i@2&)9m+u&dgj77&51g$m#hr^0s
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
TIANJI_ENABLED = not DEBUG TIANJI_ENABLED = not DEBUG
WEBMCP_ENABLED = False
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
@@ -71,6 +72,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'public.context_processors.tianji_tracking', 'public.context_processors.tianji_tracking',
'public.context_processors.webmcp_context',
], ],
}, },
}, },
+14 -3
View File
@@ -25,8 +25,17 @@ Test at minimum:
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs | | **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs | | **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks | | **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
| **WebMCP tools** | `navigator.modelContext` tools behind `WEBMCP_ENABLED` (see [webmcp.md](webmcp.md)) |
WebMCP is intentionally deferred until browser and agent support stabilizes. ## WebMCP (issue #9)
When `WEBMCP_ENABLED=True`:
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
Full tool catalog, Chrome flag setup, and reCAPTCHA notes: **[docs/webmcp.md](webmcp.md)**
## Regression checklist ## Regression checklist
@@ -36,10 +45,12 @@ Before merging public-facing template or CSS changes:
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`. 2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
3. Cookie consent banner stays `position: fixed` (no document flow shift). 3. Cookie consent banner stays `position: fixed` (no document flow shift).
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200. 4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
5. Re-run Lighthouse agentic-browsing on homepage and contact page. 5. With `WEBMCP_ENABLED=True`, public pages include `webmcp-config` and `webmcp-tools.js`; contact form always has `toolname`, `tooldescription`, and `toolparamdescription` on fields.
6. Re-run Lighthouse agentic-browsing on homepage and contact page (Chrome experimental flag on).
## References ## References
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/) - [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
- [llms.txt proposal](https://llmstxt.org/) - [llms.txt proposal](https://llmstxt.org/)
- Gitea issue #5 - [WebMCP tool catalog](webmcp.md)
- Gitea issue #5 (foundational), issue #9 (WebMCP)
+108
View File
@@ -0,0 +1,108 @@
# WebMCP Tool Catalog
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
## Enablement
### 1. Django setting
```python
# company_site/settings.py (or environment-specific settings)
WEBMCP_ENABLED = True
```
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
### 2. Chrome experimental flag
WebMCP requires the experimental web platform features flag:
1. Open `chrome://flags/#enable-experimental-web-platform-features`
2. Set **Enable experimental web platform features** to **Enabled**
3. Restart Chrome
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
### 3. Lighthouse audit
```bash
npx lighthouse@latest https://aimloperations.com/contact \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-contact.html
npx lighthouse@latest https://aimloperations.com/ \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-home.html
```
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
## Registered tools
| Tool | Pages | readOnly | Description |
|------|-------|----------|-------------|
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
### `submit_contact_inquiry`
**Input schema:**
| Field | Type | Required |
|-------|------|----------|
| `name` | string | Yes |
| `email` | string | Yes |
| `subject` | string | Yes |
| `message` | string | No |
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
### Declarative form annotation
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
```html
<form id="contact-form" toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
...
</form>
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
```
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
## Data source
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
## Security scope
- Tools run in the visitor's browser session (no API keys)
- Only unauthenticated marketing flows are exposed
- Planning, financial, and admin actions are **not** registered
## Manual verification
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
```javascript
// Feature-detect (Chrome with experimental flag)
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
```
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
## References
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
- Gitea issue #9
+34
View File
@@ -1,4 +1,9 @@
import json
from django.conf import settings from django.conf import settings
from django.urls import reverse
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
def tianji_tracking(request): def tianji_tracking(request):
@@ -16,3 +21,32 @@ def tianji_tracking(request):
), ),
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''), 'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
} }
def webmcp_context(request):
page_name = getattr(getattr(request, 'resolver_match', None), 'url_name', '')
services = [
{
**entry,
"url": request.build_absolute_uri(reverse(entry["slug"])),
}
for entry in get_service_entries()
]
page_lookup = {
url_name: {
"slug": url_name,
"name": title,
"summary": summary,
"url": request.build_absolute_uri(reverse(url_name)),
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
}
return {
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
'webmcp_page_name': page_name,
'webmcp_contact_url': request.build_absolute_uri(reverse('contact')),
'webmcp_recaptcha_required': not settings.DEBUG,
'webmcp_services_json': json.dumps(services),
'webmcp_pages_json': json.dumps(page_lookup),
}
+2 -1
View File
@@ -7,7 +7,8 @@ class FormWithCaptcha(forms.Form):
captcha = ReCaptchaField( captcha = ReCaptchaField(
widget=ReCaptchaV3( widget=ReCaptchaV3(
attrs={ attrs={
'required_score':0.85, 'required_score': 0.85,
'form': 'contact-form',
} }
), ),
public_key=settings.RECAPTCHA_PUBLIC_KEY, public_key=settings.RECAPTCHA_PUBLIC_KEY,
+112 -14
View File
@@ -5,21 +5,119 @@ from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
# Public marketing pages included in sitemap and llms.txt. # Public marketing pages included in sitemap and llms.txt.
# Tuple: (url_name, title, changefreq, priority, summary)
PUBLIC_PAGE_ENTRIES = ( PUBLIC_PAGE_ENTRIES = (
("public_index", "Home", "weekly", "1.0"), (
("forward_deployed", "Forward-Deployed AI", "monthly", "0.9"), "public_index",
("bot", "AI Agents", "monthly", "0.9"), "Home",
("ml_model", "ML Models", "monthly", "0.8"), "weekly",
("chat", "Secure AI Chat", "monthly", "0.8"), "1.0",
("ai_sensor", "AI Sensor Algorithms", "monthly", "0.7"), "Company homepage with an overview of AI ML Operations services.",
("ai_education", "AI Education", "monthly", "0.7"), ),
("computers", "Computer Builds", "monthly", "0.7"), (
("file_hosting", "File Hosting", "monthly", "0.7"), "forward_deployed",
("web_design", "Web Design and Hosting", "monthly", "0.8"), "Forward-Deployed AI",
("contact", "Contact", "monthly", "0.9"), "monthly",
("terms_of_service", "Terms of Service and Privacy", "yearly", "0.3"), "0.9",
"Embedded AI engineering — we work inside your environment to build production systems.",
),
(
"bot",
"AI Agents",
"monthly",
"0.9",
"Custom AI agents and agentic workflows tailored to your operational bottlenecks.",
),
(
"ml_model",
"ML Models",
"monthly",
"0.8",
"Machine learning model development, training, and deployment for production use.",
),
(
"chat",
"Secure AI Chat",
"monthly",
"0.8",
"Private, hosted AI chat deployments with enterprise-grade security.",
),
(
"ai_sensor",
"AI Sensor Algorithms",
"monthly",
"0.7",
"Computer vision and sensor-fusion algorithms for real-world sensing applications.",
),
(
"ai_education",
"AI Education",
"monthly",
"0.7",
"Hands-on AI training and workshops for teams adopting agentic workflows.",
),
(
"computers",
"Computer Builds",
"monthly",
"0.7",
"Custom workstation and server builds optimized for AI and ML workloads.",
),
(
"file_hosting",
"File Hosting",
"monthly",
"0.7",
"Managed file hosting and storage for teams that need reliable data access.",
),
(
"web_design",
"Web Design and Hosting",
"monthly",
"0.8",
"Web design, development, and managed hosting for business sites and apps.",
),
(
"contact",
"Contact",
"monthly",
"0.9",
"Contact form to inquire about forward-deployed AI engineering services.",
),
(
"terms_of_service",
"Terms of Service and Privacy",
"yearly",
"0.3",
"Terms of service and privacy policy for AI ML Operations, LLC.",
),
) )
SERVICE_URL_NAMES = frozenset({
"forward_deployed",
"bot",
"ml_model",
"chat",
"ai_sensor",
"ai_education",
"computers",
"file_hosting",
"web_design",
})
def get_service_entries():
"""Return service page metadata for WebMCP navigation tools."""
return [
{
"slug": url_name,
"name": title,
"summary": summary,
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
if url_name in SERVICE_URL_NAMES
]
def _absolute_url(request, url_name): def _absolute_url(request, url_name):
return request.build_absolute_uri(reverse(url_name)) return request.build_absolute_uri(reverse(url_name))
@@ -41,7 +139,7 @@ def sitemap_xml(request):
"changefreq": changefreq, "changefreq": changefreq,
"priority": priority, "priority": priority,
} }
for url_name, _title, changefreq, priority in PUBLIC_PAGE_ENTRIES for url_name, _title, changefreq, priority, _summary in PUBLIC_PAGE_ENTRIES
] ]
content = render_to_string("public/sitemap.xml", {"pages": pages}) content = render_to_string("public/sitemap.xml", {"pages": pages})
return HttpResponse(content, content_type="application/xml; charset=utf-8") return HttpResponse(content, content_type="application/xml; charset=utf-8")
@@ -53,7 +151,7 @@ def llms_txt(request):
"title": title, "title": title,
"url": _absolute_url(request, url_name), "url": _absolute_url(request, url_name),
} }
for url_name, title, _changefreq, _priority in PUBLIC_PAGE_ENTRIES for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
] ]
content = render_to_string( content = render_to_string(
"public/llms.txt", "public/llms.txt",
@@ -780,6 +780,14 @@ input:focus, select:focus, textarea:focus {
margin: 0 0 1rem 1.5rem; margin: 0 0 1rem 1.5rem;
} }
.contact-captcha-group {
margin-top: 1rem;
}
.contact-captcha-group + .btn {
margin-top: 1rem;
}
/* Cookie consent banner */ /* Cookie consent banner */
.cookie-consent-banner { .cookie-consent-banner {
position: fixed; position: fixed;
@@ -0,0 +1,286 @@
(function () {
'use strict';
var configEl = document.getElementById('webmcp-config');
if (!configEl) {
return;
}
var modelContext = navigator.modelContext || document.modelContext;
if (!modelContext || typeof modelContext.registerTool !== 'function') {
return;
}
var contactUrl = configEl.dataset.contactUrl || '/contact';
var pageName = configEl.dataset.pageName || '';
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
var services = [];
var pages = {};
try {
services = JSON.parse(configEl.dataset.services || '[]');
} catch (e) {
services = [];
}
try {
pages = JSON.parse(configEl.dataset.pages || '{}');
} catch (e) {
pages = {};
}
function textResult(payload) {
return {
content: [{
type: 'text',
text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2),
}],
};
}
function getCsrfToken() {
var match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function resolvePageUrl(slug) {
var page = pages[slug];
if (page && page.url) {
return page.url;
}
var service = services.find(function (entry) {
return entry.slug === slug;
});
return service ? service.url : null;
}
function findPage(query) {
var normalized = String(query || '').trim().toLowerCase();
if (!normalized) {
return null;
}
var keys = Object.keys(pages);
for (var i = 0; i < keys.length; i += 1) {
var slug = keys[i];
var page = pages[slug];
if (
slug.toLowerCase() === normalized ||
page.name.toLowerCase() === normalized ||
page.name.toLowerCase().indexOf(normalized) !== -1
) {
return {
slug: slug,
name: page.name,
summary: page.summary,
url: page.url || resolvePageUrl(slug),
};
}
}
return null;
}
async function getRecaptchaToken() {
if (!recaptchaRequired) {
return null;
}
if (!window.grecaptcha || typeof window.grecaptcha.execute !== 'function') {
throw new Error(
'reCAPTCHA is required in production. Use the HTML contact form in a browser session, ' +
'or enable DEBUG for audit demos without captcha.'
);
}
var widget = document.querySelector('.g-recaptcha');
var siteKey = widget ? widget.getAttribute('data-sitekey') : null;
if (!siteKey) {
var script = document.querySelector('script[src*="recaptcha"]');
if (script && script.src) {
var keyMatch = script.src.match(/[?&]render=([^&]+)/);
siteKey = keyMatch ? decodeURIComponent(keyMatch[1]) : null;
}
}
if (!siteKey) {
throw new Error('reCAPTCHA site key not found on the contact page.');
}
return window.grecaptcha.execute(siteKey, { action: 'contact' });
}
async function submitContactInquiry(input) {
var formData = new FormData();
formData.append('name', input.name);
formData.append('email', input.email);
formData.append('subject', input.subject);
formData.append('message', input.message || '');
formData.append('csrfmiddlewaretoken', getCsrfToken());
if (recaptchaRequired) {
var token = await getRecaptchaToken();
formData.append('g-recaptcha-response', token);
}
var response = await fetch(contactUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
var html = await response.text();
if (html.indexOf("We'll be in contact shortly!") !== -1) {
return textResult({
success: true,
message: 'Contact inquiry submitted successfully.',
});
}
if (html.indexOf('There was an error submitting') !== -1) {
return textResult({
success: false,
error: 'Submission failed validation (often reCAPTCHA in production).',
});
}
if (html.indexOf('is required') !== -1) {
return textResult({
success: false,
error: 'Missing required fields: name, email, and subject are required.',
});
}
return textResult({
success: false,
error: 'Unexpected response from contact endpoint.',
status: response.status,
});
}
modelContext.registerTool({
name: 'list_services',
description: 'List AI ML Operations service pages with name, URL, and summary.',
inputSchema: {
type: 'object',
properties: {},
},
annotations: { readOnlyHint: true },
execute: function () {
var results = services.map(function (service) {
return {
name: service.name,
slug: service.slug,
url: service.url || resolvePageUrl(service.slug),
summary: service.summary,
};
});
return textResult({ services: results });
},
});
modelContext.registerTool({
name: 'get_page_content',
description: 'Look up a public page by slug or service name and return its URL and summary.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Page slug (e.g. forward_deployed) or service name (e.g. AI Agents).',
},
},
required: ['query'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var page = findPage(input.query);
if (!page) {
return textResult({
success: false,
error: 'No page found for query: ' + input.query,
});
}
return textResult({ success: true, page: page });
},
});
modelContext.registerTool({
name: 'navigate_to_service',
description: 'Resolve a service slug or name to its canonical marketing page URL.',
inputSchema: {
type: 'object',
properties: {
service: {
type: 'string',
description: 'Service slug or display name.',
},
},
required: ['service'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var page = findPage(input.service);
if (!page) {
return textResult({
success: false,
error: 'Unknown service: ' + input.service,
});
}
return textResult({
success: true,
url: page.url,
name: page.name,
summary: page.summary,
});
},
});
modelContext.registerTool({
name: 'open_contact_with_subject',
description: 'Build a contact page URL with a pre-filled subject for hosting, pricing, or service inquiries.',
inputSchema: {
type: 'object',
properties: {
subject: {
type: 'string',
description: 'Subject line to pre-fill on the contact form.',
},
},
required: ['subject'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var url = new URL(contactUrl, window.location.origin);
url.searchParams.set('subject', input.subject);
return textResult({
success: true,
url: url.toString(),
subject: input.subject,
});
},
});
if (pageName === 'contact') {
modelContext.registerTool({
name: 'submit_contact_inquiry',
description: 'Submit a contact inquiry to AI ML Operations. Requires name, email, and subject.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Sender full name.' },
email: { type: 'string', description: 'Sender email address.' },
subject: { type: 'string', description: 'Inquiry subject line.' },
message: { type: 'string', description: 'Optional message body.' },
},
required: ['name', 'email', 'subject'],
},
annotations: { readOnlyHint: false },
execute: submitContactInquiry,
});
}
})();
+14
View File
@@ -44,6 +44,16 @@
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %} {% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
hidden></div> hidden></div>
{% endif %} {% endif %}
{% if webmcp_enabled %}
<div id="webmcp-config"
data-page-name="{{ webmcp_page_name }}"
data-contact-url="{{ webmcp_contact_url }}"
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
data-services='{{ webmcp_services_json|escapejs }}'
data-pages='{{ webmcp_pages_json|escapejs }}'
hidden></div>
{% endif %}
</head> </head>
<body> <body>
@@ -151,6 +161,10 @@
<script src="{% static 'public/js/tianji-consent.js' %}"></script> <script src="{% static 'public/js/tianji-consent.js' %}"></script>
{% endif %} {% endif %}
{% if webmcp_enabled %}
<script src="{% static 'public/js/webmcp-tools.js' %}"></script>
{% endif %}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const mobileBtn = document.querySelector('.mobile-menu-btn'); const mobileBtn = document.querySelector('.mobile-menu-btn');
@@ -0,0 +1,8 @@
{% include "django_recaptcha/includes/js_v3.html" %}
<input
type="hidden"
name="{{ widget.name }}"
class="g-recaptcha"
form="contact-form"
{% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
>
@@ -33,40 +33,53 @@
<div class="card"> <div class="card">
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2> <h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
<form action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"> <form id="contact-form" action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"
toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
{% csrf_token %} {% csrf_token %}
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="contact-name">Your Name</label> <label class="form-label" for="contact-name">Your Name</label>
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required> <input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required
toolparamdescription="Full name of the person submitting the inquiry.">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="contact-email">Your Email</label> <label class="form-label" for="contact-email">Your Email</label>
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required> <input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required
toolparamdescription="Email address where AI ML Operations can reply.">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="contact-subject">Subject</label> <label class="form-label" for="contact-subject">Subject</label>
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required> <input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required
toolparamdescription="Short summary of the inquiry topic or service of interest.">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="contact-message">Message</label> <label class="form-label" for="contact-message">Message</label>
<textarea name="message" class="form-control" id="contact-message" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea> <textarea name="message" class="form-control" id="contact-message" rows="5"
</div> placeholder="What workflow is costing you the most time? What systems does it touch?"
toolparamdescription="Optional details about the workflow, bottleneck, or systems involved."></textarea>
<div class="form-group">
{% if capchaForm %}
{{ capchaForm }}
{% endif %}
</div> </div>
{% if not capchaForm %}
<button class="btn" type="submit" <button class="btn" type="submit"
data-tianji-event="contact_form_submit"> data-tianji-event="contact_form_submit">
Send Message Send Message
</button> </button>
{% endif %}
</form> </form>
{% if capchaForm %}
<div class="form-group contact-captcha-group">
{{ capchaForm }}
</div>
<button class="btn" type="submit" form="contact-form"
data-tianji-event="contact_form_submit">
Send Message
</button>
{% endif %}
</div> </div>
</div> </div>
+72
View File
@@ -4,6 +4,7 @@ from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from .models import Contact from .models import Contact
from .seo import SERVICE_URL_NAMES, get_service_entries
@override_settings( @override_settings(
@@ -170,3 +171,74 @@ class AgenticBrowsingSeoTests(TestCase):
self.assertContains(response, 'id="services-menu-button"') self.assertContains(response, 'id="services-menu-button"')
self.assertContains(response, 'aria-haspopup="true"') self.assertContains(response, 'aria-haspopup="true"')
self.assertContains(response, 'aria-label="Main navigation"') self.assertContains(response, 'aria-label="Main navigation"')
@override_settings(DEBUG=True, WEBMCP_ENABLED=True)
class WebMcpTests(TestCase):
def test_homepage_includes_webmcp_when_enabled(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "webmcp-config")
self.assertContains(response, "webmcp-tools.js")
self.assertContains(response, 'data-page-name="public_index"')
self.assertContains(response, "forward_deployed")
@override_settings(WEBMCP_ENABLED=False)
def test_homepage_omits_webmcp_when_disabled(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "webmcp-config")
self.assertNotContains(response, "webmcp-tools.js")
def test_contact_page_includes_webmcp_form_annotations(self):
response = self.client.get(reverse("contact"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="contact-form"')
self.assertContains(response, 'toolname="submit_contact_inquiry"')
self.assertContains(response, "tooldescription=")
self.assertContains(response, "toolparamdescription=")
self.assertContains(response, "submit_contact_inquiry")
@override_settings(DEBUG=False)
def test_contact_page_renders_captcha_outside_annotated_form(self):
response = self.client.get(reverse("contact"))
self.assertEqual(response.status_code, 200)
form_end = response.content.index(b"</form>")
captcha_index = response.content.index(b"g-recaptcha")
self.assertGreater(captcha_index, form_end)
self.assertContains(response, b'form="contact-form"')
def test_webmcp_services_json_includes_all_service_pages(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
for slug in SERVICE_URL_NAMES:
self.assertContains(response, slug)
def test_service_entries_match_public_seo_config(self):
entries = get_service_entries()
self.assertEqual(len(entries), len(SERVICE_URL_NAMES))
self.assertEqual({entry["slug"] for entry in entries}, SERVICE_URL_NAMES)
def test_webmcp_static_script_defines_expected_tools(self):
from pathlib import Path
script_path = Path(__file__).resolve().parent / "static/public/js/webmcp-tools.js"
script = script_path.read_text(encoding="utf-8")
for tool_name in (
"list_services",
"get_page_content",
"navigate_to_service",
"open_contact_with_subject",
"submit_contact_inquiry",
):
self.assertIn("name: '" + tool_name + "'", script)
self.assertIn("readOnlyHint: true", script)
self.assertIn("readOnlyHint: false", script)
self.assertIn("navigator.modelContext || document.modelContext", script)