Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions core/audits/webmcp-schema-validity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Audit} from './audit.js';
import * as i18n from '../lib/i18n/i18n.js';

const UIStrings = {
/** Title of a Lighthouse audit that evaluates WebMCP schema validity. This descriptive title is shown to users when there are no schema validity issues. "WebMCP" stands for "Web Model Context Protocol" and should not be translated. */
title: 'WebMCP schemas are valid',
/** Title of a Lighthouse audit that provides detail on WebMCP schema validity. This descriptive title is shown to users when there are schema validity issues. "WebMCP" stands for "Web Model Context Protocol" and should not be translated. */
failureTitle: 'WebMCP schemas are invalid',
/** Description of a Lighthouse audit that tells the user why they should ensure WebMCP schemas are valid. This is displayed after a user expands the section to see more. No character length limits. "WebMCP" stands for "Web Model Context Protocol" and should not be translated. */
description: 'Valid WebMCP schemas are required for AI agents to ' +
' understand and interact with tools correctly. ' +
'Please fix any errors or warnings reported by the browser.',
/** Header of the table column which displays the element. */
columnElement: 'Element',
/** Header of the table column which displays the issue. */
columnIssue: 'Issue',
/** Descriptive reason for why a form fails WebMCP validation due to missing `toolname` attribute. */
missingToolName: 'Form level `toolname` attribute is missing. Add it to define the tool name.',
/** Descriptive reason for why a form fails WebMCP validation due to missing `tooldescription` attribute. */
missingToolDescription: 'Form level `tooldescription` attribute is missing. ' +
'Add it to describe the tool for AI agents.',
/** Descriptive reason for why a form field fails WebMCP validation due to missing `name` attribute for a required field. */
missingRequiredParamName: 'Missing `name` attribute for a required field. ' +
'Add it to define the parameter name.',
/** Descriptive reason for why a form field fails WebMCP validation due to missing `name` attribute for an optional field. */
missingOptionalParamName: 'Missing `name` attribute for an optional field. ' +
'Add it to define the parameter name.',
/** Descriptive reason for why a form field fails WebMCP validation due to missing description. */
missingParamDescription: 'Add a description to make this form more accessible for AI agents.',
};

const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);

class WebMcpSchemaValidity extends Audit {
/**
* @return {LH.Audit.Meta}
*/
static get meta() {
return {
id: 'webmcp-schema-validity',
title: str_(UIStrings.title),
failureTitle: str_(UIStrings.failureTitle),
description: str_(UIStrings.description),
requiredArtifacts: ['WebMcpSchemaIssues'],
supportedModes: ['navigation', 'snapshot'],
};
}

/**
* @param {LH.Artifacts} artifacts
* @return {Promise<LH.Audit.Product>}
*/
static async audit(artifacts) {
/** @enum {number} */
const Severity = {
ERROR: 1,
WARNING: 2,
};

/** @type {Record<string, {severity: Severity, description: LH.IcuMessage}>} */
const issueConfigs = {
'FormModelContextMissingToolName': {
severity: Severity.ERROR,
description: str_(UIStrings.missingToolName),
},
'FormModelContextMissingToolDescription': {
severity: Severity.ERROR,
description: str_(UIStrings.missingToolDescription),
},
'FormModelContextRequiredParameterMissingName': {
severity: Severity.ERROR,
description: str_(UIStrings.missingRequiredParamName),
},
'FormModelContextParameterMissingTitleAndDescription': {
severity: Severity.WARNING,
description: str_(UIStrings.missingParamDescription),
},
'FormModelContextParameterMissingName': {
severity: Severity.WARNING,
description: str_(UIStrings.missingOptionalParamName),
},
};

const rawIssues = artifacts.WebMcpSchemaIssues;

const uniqueIssues = [
...new Map(
rawIssues.map(issue => [`${issue.violatingNodeId}_${issue.errorType}`, issue])
).values(),
];
const sortedUniqueIssues = uniqueIssues.sort((a, b) => {
return (issueConfigs[a.errorType]?.severity || Severity.ERROR) -
(issueConfigs[b.errorType]?.severity || Severity.ERROR);
});
const items = sortedUniqueIssues.map(issue => {
return {
element: issue.nodeDetails ? Audit.makeNodeItem(issue.nodeDetails) : undefined,
issue: issueConfigs[issue.errorType]?.description || '',
};
});

/** @type {LH.Audit.Details.Table['headings']} */
const headings = [
{key: 'element', valueType: 'node', label: str_(UIStrings.columnElement)},
{key: 'issue', valueType: 'text', label: str_(UIStrings.columnIssue)},
];

const details = Audit.makeTableDetails(headings, items);

const hasErrors =
sortedUniqueIssues.some(issue => issueConfigs[issue.errorType]?.severity === Severity.ERROR);

return {
score: hasErrors ? 0 : (items.length > 0 ? 0.5 : 1),
details: items.length > 0 ? details : undefined,
};
}
}

export default WebMcpSchemaValidity;
export {UIStrings};
2 changes: 2 additions & 0 deletions core/config/agentic-browsing-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const config = {
audits: [
'webmcp-registered-tools',
'webmcp-form-coverage',
'webmcp-schema-validity',
'agentic/llms-txt',
],
artifacts: [
Expand All @@ -56,6 +57,7 @@ const config = {
auditRefs: [
{id: 'webmcp-form-coverage', weight: 1, group: 'webmcp'},
{id: 'webmcp-registered-tools', weight: 1, group: 'webmcp'},
{id: 'webmcp-schema-validity', weight: 1, group: 'webmcp'},
{id: 'cumulative-layout-shift', weight: 1, acronym: 'CLS'},
{id: 'button-name', weight: 1, group: 'agent-accessibility'},
{id: 'input-button-name', weight: 1, group: 'agent-accessibility'},
Expand Down
2 changes: 1 addition & 1 deletion core/gather/gatherers/meta-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function collectMetaElements() {
property: getAttribute('property'),
httpEquiv: meta.httpEquiv ? meta.httpEquiv.toLowerCase() : undefined,
charset: getAttribute('charset'),
node: functions.getNodeDetails(meta),
node: /** @type {LH.Artifacts.NodeDetails} */ (functions.getNodeDetails(meta)),
};
});
}
Expand Down
105 changes: 105 additions & 0 deletions core/gather/gatherers/webmcp-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import BaseGatherer from '../base-gatherer.js';
import {resolveNodeIdToObjectId} from '../driver/dom.js';
import {pageFunctions} from '../../lib/page-functions.js';
import {ExecutionContext} from '../driver/execution-context.js';

class WebMcpSchemaIssues extends BaseGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
supportedModes: ['navigation', 'snapshot'],
};

constructor() {
super();
/** @type {LH.Artifacts.WebMcpSchemaIssue[]} */
this._issues = [];
this._onIssueAdded = this.onIssueAdded.bind(this);
}

/**
* @param {Record<string, any>} event
*/
onIssueAdded(event) {
const issue = event.issue;
if (!issue || issue.code !== 'GenericIssue') return;

const details = issue.details?.genericIssueDetails;
if (!details) return;

const errorType = details.errorType;
if (errorType && (
errorType === 'FormModelContextMissingToolName' ||
errorType === 'FormModelContextMissingToolDescription' ||
errorType === 'FormModelContextRequiredParameterMissingName' ||
errorType === 'FormModelContextParameterMissingTitleAndDescription' ||
errorType === 'FormModelContextParameterMissingName'
)) {
this._issues.push(details);
}
}

/**
* @param {LH.Gatherer.Context} passContext
*/
async startInstrumentation(passContext) {
const session = passContext.driver.defaultSession;
session.on('Audits.issueAdded', this._onIssueAdded);
await session.sendCommand('Audits.enable');
}

/**
* @param {LH.Gatherer.Context} passContext
*/
async stopInstrumentation(passContext) {
const session = passContext.driver.defaultSession;
session.off('Audits.issueAdded', this._onIssueAdded);
}

/**
* @param {LH.Gatherer.Context} context
* @return {Promise<LH.Artifacts.WebMcpSchemaIssue[]>}
*/
async getArtifact(context) {
const session = context.driver.defaultSession;

const deps = ExecutionContext.serializeDeps([
pageFunctions.getNodeDetails,
]);

const promises = this._issues.map(async (issue) => {
const processedIssue = {...issue};
if (issue.violatingNodeId) {
try {
const objectId = await resolveNodeIdToObjectId(session, issue.violatingNodeId);
if (objectId) {
const response = await session.sendCommand('Runtime.callFunctionOn', {
objectId,
functionDeclaration: `function () {
${deps}
return getNodeDetails(this);
}`,
returnByValue: true,
awaitPromise: true,
});
if (response && response.result && response.result.value) {
processedIssue.nodeDetails = response.result.value;
}
}
} catch (err) {
// Ignore error
}
}
return processedIssue;
});

return Promise.all(promises);
}
}

export default WebMcpSchemaIssues;
27 changes: 2 additions & 25 deletions core/gather/gatherers/webmcp-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,6 @@ import {ExecutionContext} from '../driver/execution-context.js';
* @property {any} [stackTrace]
* @property {LH.Artifacts.NodeDetails} [nodeDetails]
*/

/* global getNodeDetails */
/* c8 ignore start */
/**
* @param {Node} node
*/
function getNodeDetailsData(node) {
/** @type {Element|null} */
let elem = node instanceof Element ? node : node.parentElement;
if (!elem && node instanceof ShadowRoot) {
elem = node.host;
}

let traceElement;
if (elem) {
// @ts-expect-error - getNodeDetails put into scope via stringification
traceElement = {node: getNodeDetails(elem)};
}
return traceElement;
}
/* c8 ignore stop */

class WebMCPTools extends BaseGatherer {
/** @type {LH.Gatherer.GathererMeta} */
meta = {
Expand Down Expand Up @@ -133,19 +111,18 @@ class WebMCPTools extends BaseGatherer {
if (objectId) {
const deps = ExecutionContext.serializeDeps([
pageFunctions.getNodeDetails,
getNodeDetailsData,
]);
const response = await session.sendCommand('Runtime.callFunctionOn', {
objectId,
functionDeclaration: `function () {
${deps}
return getNodeDetailsData(this);
return getNodeDetails(this);
}`,
returnByValue: true,
awaitPromise: true,
});
if (response && response.result && response.result.value) {
tool.nodeDetails = response.result.value.node;
tool.nodeDetails = response.result.value;
}
}
} catch (err) {
Expand Down
15 changes: 11 additions & 4 deletions core/lib/page-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,16 +450,23 @@ function wrapRequestIdleCallback(cpuSlowdownMultiplier) {
}

/**
* @param {Element|ShadowRoot} element
* @return {LH.Artifacts.NodeDetails}
* @param {Node|ShadowRoot} node
* @return {LH.Artifacts.NodeDetails | null}
*/
function getNodeDetails(element) {
function getNodeDetails(node) {
// This bookkeeping is for the FullPageScreenshot gatherer.
if (!window.__lighthouseNodesDontTouchOrAllVarianceGoesAway) {
window.__lighthouseNodesDontTouchOrAllVarianceGoesAway = new Map();
}

element = element instanceof ShadowRoot ? element.host : element;
let elem = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (!elem && node instanceof ShadowRoot) {
elem = node.host;
}

if (!elem) return null;

const element = /** @type {Element} */ (elem);
const selector = getNodeSelector(element);

// Create an id that will be unique across all execution contexts.
Expand Down
1 change: 1 addition & 0 deletions core/scripts/i18n/collect-strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ function checkKnownFixedCollisions(strings) {
'Document has a valid $MARKDOWN_SNIPPET_0$',
'Element',
'Element',
'Element',
'Est Savings',
'Est Savings',
'Failing Elements',
Expand Down
Loading
Loading