Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
128 changes: 128 additions & 0 deletions core/audits/webmcp-schema-validity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @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 have validity issues. ' +
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:)

how about something like WebMCP schema is invalid

'Fix them to improve accessibility for AI agents.',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drop the second sentence. we dont do 2 sentence titles :)

/** 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};
6 changes: 6 additions & 0 deletions core/config/agentic-browsing-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ const config = {
audits: [
'webmcp-registered-tools',
'webmcp-form-coverage',
'webmcp-schema-validity',
'accessibility/autocomplete-valid',
'accessibility/presentation-role-conflict',
'accessibility/svg-img-alt',
],
artifacts: [
{id: 'WebMCPTools', gatherer: 'webmcp-tools'},
{id: 'WebMcpSchemaIssues', gatherer: 'webmcp-schema'},
],
groups: {
'webmcp': {
Expand All @@ -54,6 +59,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
106 changes: 106 additions & 0 deletions core/gather/gatherers/webmcp-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @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,
pageFunctions.getNodeDetailsData,
]);

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 getNodeDetailsData(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;
26 changes: 2 additions & 24 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,7 +111,7 @@ class WebMCPTools extends BaseGatherer {
if (objectId) {
const deps = ExecutionContext.serializeDeps([
pageFunctions.getNodeDetails,
getNodeDetailsData,
pageFunctions.getNodeDetailsData,
]);
const response = await session.sendCommand('Runtime.callFunctionOn', {
objectId,
Expand All @@ -145,7 +123,7 @@ class WebMCPTools extends BaseGatherer {
awaitPromise: true,
});
if (response && response.result && response.result.value) {
tool.nodeDetails = response.result.value.node;
tool.nodeDetails = response.result.value;
}
}
} catch (err) {
Expand Down
16 changes: 15 additions & 1 deletion core/lib/page-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {Util} from '../../shared/util.js';
* @typedef {import('typed-query-selector/parser').ParseSelector<T>} ParseSelector
*/

/* global window document Node ShadowRoot HTMLElement */
/* global window document Node ShadowRoot HTMLElement Element */

/**
* The `exceptionDetails` provided by the debugger protocol does not contain the useful
Expand Down Expand Up @@ -505,6 +505,19 @@ function getNodeDetails(element) {
return details;
}

/**
* Resolves non-element nodes and shadow roots to elements for getNodeDetails.
* @param {Node} node
* @return {LH.Artifacts.NodeDetails | null}
*/
function getNodeDetailsData(node) {
let elem = node instanceof Element ? node : node.parentElement;
if (!elem && node instanceof ShadowRoot) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

element = element instanceof ShadowRoot ? element.host : element;

get nodeDetails already does this.

so... hmmm why do you think you needed this?

i'd prefer putting the let elem = node instanceof Element ? node : node.parentElement; case into getNodeDetails itself... i guess for handling Node's that arent Elements and arent shadowRoots?

elem = node.host;
}
return elem ? getNodeDetails(elem) : null;
}

/**
*
* @param {string} string
Expand Down Expand Up @@ -628,6 +641,7 @@ export const pageFunctions = {
getOuterHTMLSnippet,
computeBenchmarkIndex,
getNodeDetails,
getNodeDetailsData,
getNodePath,
getNodeSelector,
getNodeLabel,
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