From 6c700b9836e611afc19db4d78ad6bcb672b7dcc9 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Fri, 6 Jun 2025 10:54:20 -0700 Subject: [PATCH 1/2] feat: expose crux data as an MCP tool --- src/controllers/crux.js | 38 ++++++++++++++ src/index.js | 4 ++ src/mcp/registry.js | 3 ++ src/mcp/registry/tools/crux.js | 43 ++++++++++++++++ src/support/crux-client.js | 60 ++++++++++++++++++++++ test/controllers/crux.test.js | 94 ++++++++++++++++++++++++++++++++++ test/controllers/mcp.test.js | 39 ++++++++++++++ 7 files changed, 281 insertions(+) create mode 100644 src/controllers/crux.js create mode 100644 src/mcp/registry/tools/crux.js create mode 100644 src/support/crux-client.js create mode 100644 test/controllers/crux.test.js diff --git a/src/controllers/crux.js b/src/controllers/crux.js new file mode 100644 index 000000000..adeee35e6 --- /dev/null +++ b/src/controllers/crux.js @@ -0,0 +1,38 @@ +/* + * 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. + */ + +import { ok } from '@adobe/spacecat-shared-http-utils'; +import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; +import { fetchCruxData } from '../support/crux-client.js'; + +function CRUXController(ctx) { + if (!isNonEmptyObject(ctx)) { + throw new Error('Context required'); + } + + const { env } = ctx; + const { CRUX_API_KEY } = env; + + const getCRUXDataByURL = async (context) => { + if (!CRUX_API_KEY) { + throw new Error('CRUX_API_KEY is not set'); + } + + const { url, formFactor } = context.params; + const cruxData = await fetchCruxData({ url, formFactor, apiKey: CRUX_API_KEY }); + return ok(cruxData, { 'Content-Type': 'application/json' }); + }; + + return { getCRUXDataByURL }; +} + +export default CRUXController; diff --git a/src/index.js b/src/index.js index 7fc01e576..f88292adb 100644 --- a/src/index.js +++ b/src/index.js @@ -45,6 +45,7 @@ import ExperimentsController from './controllers/experiments.js'; import HooksController from './controllers/hooks.js'; import SlackController from './controllers/slack.js'; import SitesAuditsToggleController from './controllers/sites-audits-toggle.js'; +import CRUXController from './controllers/crux.js'; import trigger from './controllers/trigger.js'; // prevents webpack build error @@ -115,12 +116,14 @@ async function run(request, context) { const demoController = DemoController(context); const scrapeController = ScrapeController(context); const fixesController = new FixesController(context); + const cruxController = CRUXController(context); /* ---------- build MCP registry & controller ---------- */ const mcpRegistry = buildRegistry({ auditsController, sitesController, scrapeController, + cruxController, context, }); const mcpController = McpController(context, mcpRegistry); @@ -146,6 +149,7 @@ async function run(request, context) { scrapeController, mcpController, fixesController, + cruxController, ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/mcp/registry.js b/src/mcp/registry.js index 44f2939e8..8af3d842e 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'; /** @@ -36,6 +37,7 @@ export default function buildRegistry({ auditsController, sitesController, scrapeController, + cruxController, context, } = {}) { const tools = { @@ -43,6 +45,7 @@ export default function buildRegistry({ ...createAuditTools(auditsController), ...createSiteTools(sitesController, context), ...createScrapeContentTools(scrapeController, context), + ...createCruxTools(cruxController), }; const resources = { diff --git a/src/mcp/registry/tools/crux.js b/src/mcp/registry/tools/crux.js new file mode 100644 index 000000000..1faf0cabc --- /dev/null +++ b/src/mcp/registry/tools/crux.js @@ -0,0 +1,43 @@ +/* + * 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 { createProxyTool } from '../../../utils/jsonrpc.js'; + +export function createCruxTools(cruxController) { + const getCRUXDataByURLTool = createProxyTool({ + 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'), + }), + fetchFn: (params) => cruxController.getCRUXDataByURL({ params }), + notFoundMessage: ({ url, formFactor }) => `CRUX data for URL ${url} and form factor ${formFactor} not found`, + }); + + return { + getCRUXDataByURL: getCRUXDataByURLTool, + }; +} diff --git a/src/support/crux-client.js b/src/support/crux-client.js new file mode 100644 index 000000000..feae91a37 --- /dev/null +++ b/src/support/crux-client.js @@ -0,0 +1,60 @@ +/* + * 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; +} +/* c8 ignore end */ diff --git a/test/controllers/crux.test.js b/test/controllers/crux.test.js new file mode 100644 index 000000000..16977f622 --- /dev/null +++ b/test/controllers/crux.test.js @@ -0,0 +1,94 @@ +/* + * Copyright 2023 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. + */ + +/* eslint-env mocha */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +use(chaiAsPromised); +use(sinonChai); + +describe('CRUX Controller', () => { + const sandbox = sinon.createSandbox(); + const mockContext = { + env: { + CRUX_API_KEY: 'test-api-key', + }, + }; + + beforeEach(() => { + sandbox.restore(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getCRUXDataByURL', () => { + it('should throw error if context is not provided', async () => { + const cruxControllerMock = await esmock('../../src/controllers/crux.js', { + '../../src/support/crux-client.js': { + fetchCruxData: sandbox.stub().resolves({}), + }, + }); + + expect(() => cruxControllerMock()).to.throw('Context required'); + }); + + it('should throw error if CRUX_API_KEY is not set', async () => { + const cruxControllerMock = await esmock('../../src/controllers/crux.js', { + '../../src/support/crux-client.js': { + fetchCruxData: sandbox.stub().resolves({}), + }, + }); + + const contextWithoutKey = { env: {} }; + const controller = cruxControllerMock(contextWithoutKey); + return expect(controller.getCRUXDataByURL({ params: { url: 'https://example.com', formFactor: 'desktop' } })) + .to.be.rejectedWith('CRUX_API_KEY is not set'); + }); + + it('should return CRUX data for valid URL and form factor', async () => { + const cruxControllerMock = await esmock('../../src/controllers/crux.js', { + '../../src/support/crux-client.js': { + fetchCruxData: sandbox.stub().resolves({ + key: { url: 'https://example.com', formFactor: 'DESKTOP' }, + metrics: { + first_contentful_paint: { percentiles: { p75: 1000 } }, + }, + }), + }, + }); + + const controller = cruxControllerMock(mockContext); + const result = await controller.getCRUXDataByURL({ + params: { + url: 'https://example.com', + formFactor: 'desktop', + }, + }); + + expect(result.ok).to.be.true; + const resultData = await result.json(); + expect(resultData).to.deep.equal({ + key: { url: 'https://example.com', formFactor: 'DESKTOP' }, + metrics: { + first_contentful_paint: { percentiles: { p75: 1000 } }, + }, + }); + }); + }); +}); diff --git a/test/controllers/mcp.test.js b/test/controllers/mcp.test.js index dd5c8f01a..da8640691 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 cruxController; beforeEach(() => { context = { @@ -129,10 +130,20 @@ describe('MCP Controller', () => { }), }; + cruxController = { + getCRUXDataByURL: sandbox.stub().resolves(ok({ + key: { url: 'https://example.com', formFactor: 'DESKTOP' }, + metrics: { + first_contentful_paint: { percentiles: { p75: 1000 } }, + }, + })), + }; + const registry = buildRegistry({ auditsController, sitesController, scrapeController, + cruxController, 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 } }, + }, + }); + }); }); From 9d409c737ad4d850c36a34586bf5e05afabccb95 Mon Sep 17 00:00:00 2001 From: Sean Steimer Date: Tue, 10 Jun 2025 08:40:24 -0700 Subject: [PATCH 2/2] refactored based on pr feedback. removed controller --- src/controllers/crux.js | 38 -------------- src/index.js | 7 ++- src/mcp/registry.js | 5 +- src/mcp/registry/tools/crux.js | 20 +++++--- src/support/crux-client.js | 8 +++ test/controllers/crux.test.js | 94 ---------------------------------- test/controllers/mcp.test.js | 10 ++-- 7 files changed, 33 insertions(+), 149 deletions(-) delete mode 100644 src/controllers/crux.js delete mode 100644 test/controllers/crux.test.js diff --git a/src/controllers/crux.js b/src/controllers/crux.js deleted file mode 100644 index adeee35e6..000000000 --- a/src/controllers/crux.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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. - */ - -import { ok } from '@adobe/spacecat-shared-http-utils'; -import { isNonEmptyObject } from '@adobe/spacecat-shared-utils'; -import { fetchCruxData } from '../support/crux-client.js'; - -function CRUXController(ctx) { - if (!isNonEmptyObject(ctx)) { - throw new Error('Context required'); - } - - const { env } = ctx; - const { CRUX_API_KEY } = env; - - const getCRUXDataByURL = async (context) => { - if (!CRUX_API_KEY) { - throw new Error('CRUX_API_KEY is not set'); - } - - const { url, formFactor } = context.params; - const cruxData = await fetchCruxData({ url, formFactor, apiKey: CRUX_API_KEY }); - return ok(cruxData, { 'Content-Type': 'application/json' }); - }; - - return { getCRUXDataByURL }; -} - -export default CRUXController; diff --git a/src/index.js b/src/index.js index f88292adb..721815a71 100644 --- a/src/index.js +++ b/src/index.js @@ -45,7 +45,6 @@ import ExperimentsController from './controllers/experiments.js'; import HooksController from './controllers/hooks.js'; import SlackController from './controllers/slack.js'; import SitesAuditsToggleController from './controllers/sites-audits-toggle.js'; -import CRUXController from './controllers/crux.js'; import trigger from './controllers/trigger.js'; // prevents webpack build error @@ -65,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; @@ -116,14 +116,14 @@ async function run(request, context) { const demoController = DemoController(context); const scrapeController = ScrapeController(context); const fixesController = new FixesController(context); - const cruxController = CRUXController(context); + const cruxClient = CruxClient(context); /* ---------- build MCP registry & controller ---------- */ const mcpRegistry = buildRegistry({ auditsController, sitesController, scrapeController, - cruxController, + cruxClient, context, }); const mcpController = McpController(context, mcpRegistry); @@ -149,7 +149,6 @@ async function run(request, context) { scrapeController, mcpController, fixesController, - cruxController, ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/mcp/registry.js b/src/mcp/registry.js index 8af3d842e..e17ee7ef7 100644 --- a/src/mcp/registry.js +++ b/src/mcp/registry.js @@ -31,13 +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, - cruxController, + cruxClient, context, } = {}) { const tools = { @@ -45,7 +46,7 @@ export default function buildRegistry({ ...createAuditTools(auditsController), ...createSiteTools(sitesController, context), ...createScrapeContentTools(scrapeController, context), - ...createCruxTools(cruxController), + ...createCruxTools(cruxClient, context), }; const resources = { diff --git a/src/mcp/registry/tools/crux.js b/src/mcp/registry/tools/crux.js index 1faf0cabc..aedc88068 100644 --- a/src/mcp/registry/tools/crux.js +++ b/src/mcp/registry/tools/crux.js @@ -11,10 +11,10 @@ */ import { z } from 'zod'; -import { createProxyTool } from '../../../utils/jsonrpc.js'; +import { withRpcErrorBoundary } from '../../../utils/jsonrpc.js'; -export function createCruxTools(cruxController) { - const getCRUXDataByURLTool = createProxyTool({ +export function createCruxTools(cruxClient) { + const getCRUXDataByURLTool = { annotations: { title: 'Get CRUX Data By URL', readOnlyHint: true, @@ -33,9 +33,17 @@ export function createCruxTools(cruxController) { url: z.string().url().describe('The URL of the page'), formFactor: z.enum(['desktop', 'mobile']).describe('The form factor'), }), - fetchFn: (params) => cruxController.getCRUXDataByURL({ params }), - notFoundMessage: ({ url, formFactor }) => `CRUX data for URL ${url} and form factor ${formFactor} not found`, - }); + 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 index feae91a37..e44815e2c 100644 --- a/src/support/crux-client.js +++ b/src/support/crux-client.js @@ -57,4 +57,12 @@ export async function fetchCruxData(params) { 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/crux.test.js b/test/controllers/crux.test.js deleted file mode 100644 index 16977f622..000000000 --- a/test/controllers/crux.test.js +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2023 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. - */ - -/* eslint-env mocha */ - -import { use, expect } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinonChai from 'sinon-chai'; -import sinon from 'sinon'; -import esmock from 'esmock'; - -use(chaiAsPromised); -use(sinonChai); - -describe('CRUX Controller', () => { - const sandbox = sinon.createSandbox(); - const mockContext = { - env: { - CRUX_API_KEY: 'test-api-key', - }, - }; - - beforeEach(() => { - sandbox.restore(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('getCRUXDataByURL', () => { - it('should throw error if context is not provided', async () => { - const cruxControllerMock = await esmock('../../src/controllers/crux.js', { - '../../src/support/crux-client.js': { - fetchCruxData: sandbox.stub().resolves({}), - }, - }); - - expect(() => cruxControllerMock()).to.throw('Context required'); - }); - - it('should throw error if CRUX_API_KEY is not set', async () => { - const cruxControllerMock = await esmock('../../src/controllers/crux.js', { - '../../src/support/crux-client.js': { - fetchCruxData: sandbox.stub().resolves({}), - }, - }); - - const contextWithoutKey = { env: {} }; - const controller = cruxControllerMock(contextWithoutKey); - return expect(controller.getCRUXDataByURL({ params: { url: 'https://example.com', formFactor: 'desktop' } })) - .to.be.rejectedWith('CRUX_API_KEY is not set'); - }); - - it('should return CRUX data for valid URL and form factor', async () => { - const cruxControllerMock = await esmock('../../src/controllers/crux.js', { - '../../src/support/crux-client.js': { - fetchCruxData: sandbox.stub().resolves({ - key: { url: 'https://example.com', formFactor: 'DESKTOP' }, - metrics: { - first_contentful_paint: { percentiles: { p75: 1000 } }, - }, - }), - }, - }); - - const controller = cruxControllerMock(mockContext); - const result = await controller.getCRUXDataByURL({ - params: { - url: 'https://example.com', - formFactor: 'desktop', - }, - }); - - expect(result.ok).to.be.true; - const resultData = await result.json(); - expect(resultData).to.deep.equal({ - key: { url: 'https://example.com', formFactor: 'DESKTOP' }, - metrics: { - first_contentful_paint: { percentiles: { p75: 1000 } }, - }, - }); - }); - }); -}); diff --git a/test/controllers/mcp.test.js b/test/controllers/mcp.test.js index da8640691..d3cd29546 100644 --- a/test/controllers/mcp.test.js +++ b/test/controllers/mcp.test.js @@ -29,7 +29,7 @@ describe('MCP Controller', () => { let auditsController; let sitesController; let scrapeController; - let cruxController; + let cruxClient; beforeEach(() => { context = { @@ -130,20 +130,20 @@ describe('MCP Controller', () => { }), }; - cruxController = { - getCRUXDataByURL: sandbox.stub().resolves(ok({ + 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, - cruxController, + cruxClient, context, }); mcpController = McpController(context, registry);