diff --git a/src/index.js b/src/index.js index 7fc01e576..721815a71 100644 --- a/src/index.js +++ b/src/index.js @@ -64,6 +64,7 @@ import DemoController from './controllers/demo.js'; import ScrapeController from './controllers/scrape.js'; import McpController from './controllers/mcp.js'; import buildRegistry from './mcp/registry.js'; +import CruxClient from './support/crux-client.js'; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -115,12 +116,14 @@ async function run(request, context) { const demoController = DemoController(context); const scrapeController = ScrapeController(context); const fixesController = new FixesController(context); + const cruxClient = CruxClient(context); /* ---------- build MCP registry & controller ---------- */ const mcpRegistry = buildRegistry({ auditsController, sitesController, scrapeController, + cruxClient, context, }); const mcpController = McpController(context, mcpRegistry); diff --git a/src/mcp/registry.js b/src/mcp/registry.js index 44f2939e8..e17ee7ef7 100644 --- a/src/mcp/registry.js +++ b/src/mcp/registry.js @@ -18,6 +18,7 @@ import { createScrapeContentResources } from './registry/resources/scrape-conten import { createSiteTools } from './registry/tools/sites.js'; import { createAuditTools } from './registry/tools/audits.js'; import { createScrapeContentTools } from './registry/tools/scrape-content.js'; +import { createCruxTools } from './registry/tools/crux.js'; import utilTools from './registry/tools/utils.js'; /** @@ -30,12 +31,14 @@ import utilTools from './registry/tools/utils.js'; * @param {object} deps.sitesController – instance of the Sites controller. * @param {object} deps.scrapeController – instance of the Scrape controller. * @param {object} deps.context – the context object. + * @param {object} [deps.cruxClient] – optional mock crux client for testing. * @returns {{ tools: Record, resources: object, prompts: object }} */ export default function buildRegistry({ auditsController, sitesController, scrapeController, + cruxClient, context, } = {}) { const tools = { @@ -43,6 +46,7 @@ export default function buildRegistry({ ...createAuditTools(auditsController), ...createSiteTools(sitesController, context), ...createScrapeContentTools(scrapeController, context), + ...createCruxTools(cruxClient, context), }; const resources = { diff --git a/src/mcp/registry/tools/crux.js b/src/mcp/registry/tools/crux.js new file mode 100644 index 000000000..aedc88068 --- /dev/null +++ b/src/mcp/registry/tools/crux.js @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { z } from 'zod'; +import { withRpcErrorBoundary } from '../../../utils/jsonrpc.js'; + +export function createCruxTools(cruxClient) { + const getCRUXDataByURLTool = { + annotations: { + title: 'Get CRUX Data By URL', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: '\n' + + 'Use this tool to obtain the Chrome User Experience Report data for a page based on it\'s URL. This data is useful to understand the performance of the page, and whether it meets Core Web Vitals thresholds.\n' + + '' + + '1. The URL must be a valid URL. Ask the user to provide it.\n' + + '2. The form factor must be one of the supported types. Ask the user to provide it.\n' + + '\n' + + '', + inputSchema: z.object({ + url: z.string().url().describe('The URL of the page'), + formFactor: z.enum(['desktop', 'mobile']).describe('The form factor'), + }), + handler: async (args) => withRpcErrorBoundary(async () => { + const { url, formFactor } = args; + const cruxData = await cruxClient.fetchCruxData({ url, formFactor }); + return { + content: [{ + type: 'text', + text: JSON.stringify(cruxData), + }], + }; + }, args), + }; + + return { + getCRUXDataByURL: getCRUXDataByURLTool, + }; +} diff --git a/src/support/crux-client.js b/src/support/crux-client.js new file mode 100644 index 000000000..e44815e2c --- /dev/null +++ b/src/support/crux-client.js @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +/* c8 ignore start */ + +const FORM_FACTORS = { + desktop: 'DESKTOP', + mobile: 'PHONE', + tablet: 'TABLET', +}; + +const CRUX_API_URL = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord'; + +export async function fetchCruxData(params) { + if (!params.apiKey) { + throw new Error('API key is required'); + } + + if (!FORM_FACTORS[params.formFactor]) { + throw new Error(`Invalid form factor: ${params.formFactor}`); + } + const requestBody = { + formFactor: FORM_FACTORS[params.formFactor], + }; + + if (!params.origin && !params.url) { + throw new Error('Either origin or url must be provided'); + } else if (params.origin && params.url) { + throw new Error('Only one of origin or url can be provided'); + } else if (params.origin) { + requestBody.origin = params.origin; + } else { + requestBody.url = params.url; + } + + const response = await fetch(`${CRUX_API_URL}?key=${params.apiKey}`, { + method: 'POST', + body: JSON.stringify(requestBody), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch CRUX data: ${response.statusText}`); + } + + const json = await response.json(); + return json.record; +} + +export default function CruxClient(context) { + const apiKey = context?.env?.CRUX_API_KEY; + + return { + fetchCruxData: async (params) => fetchCruxData({ ...params, apiKey }), + }; +} +/* c8 ignore end */ diff --git a/test/controllers/mcp.test.js b/test/controllers/mcp.test.js index dd5c8f01a..d3cd29546 100644 --- a/test/controllers/mcp.test.js +++ b/test/controllers/mcp.test.js @@ -29,6 +29,7 @@ describe('MCP Controller', () => { let auditsController; let sitesController; let scrapeController; + let cruxClient; beforeEach(() => { context = { @@ -129,10 +130,20 @@ describe('MCP Controller', () => { }), }; + cruxClient = { + fetchCruxData: sandbox.stub().resolves({ + key: { url: 'https://example.com', formFactor: 'DESKTOP' }, + metrics: { + first_contentful_paint: { percentiles: { p75: 1000 } }, + }, + }), + }; + const registry = buildRegistry({ auditsController, sitesController, scrapeController, + cruxClient, context, }); mcpController = McpController(context, registry); @@ -495,4 +506,32 @@ describe('MCP Controller', () => { const [first] = body.result.contents; expect(first).to.have.property('uri', `scrape-content://sites/${siteId}/files/${encodedKey}`); }); + + it('retrieves crux data by url', async () => { + const url = 'https://example.com'; + const formFactor = 'desktop'; + const payload = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'getCRUXDataByURL', + arguments: { url, formFactor }, + }, + }; + + context.data = payload; + const resp = await mcpController.handleRpc(context); + + expect(resp.status).to.equal(200); + const body = await resp.json(); + expect(body).to.have.property('result'); + const [first] = body.result.content; + expect(JSON.parse(first.text)).to.deep.equal({ + key: { url, formFactor: 'DESKTOP' }, + metrics: { + first_contentful_paint: { percentiles: { p75: 1000 } }, + }, + }); + }); });