Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -30,19 +31,22 @@ 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<string,object>, resources: object, prompts: object }}
*/
export default function buildRegistry({
auditsController,
sitesController,
scrapeController,
cruxClient,
context,
} = {}) {
const tools = {
...utilTools,
...createAuditTools(auditsController),
...createSiteTools(sitesController, context),
...createScrapeContentTools(scrapeController, context),
...createCruxTools(cruxClient, context),
};

const resources = {
Expand Down
51 changes: 51 additions & 0 deletions src/mcp/registry/tools/crux.js
Original file line number Diff line number Diff line change
@@ -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_case>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.</use_case>\n'
+ '<important_notes>'
+ '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'
+ '</important_notes>\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,
};
}
68 changes: 68 additions & 0 deletions src/support/crux-client.js
Original file line number Diff line number Diff line change
@@ -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 */
39 changes: 39 additions & 0 deletions test/controllers/mcp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('MCP Controller', () => {
let auditsController;
let sitesController;
let scrapeController;
let cruxClient;

beforeEach(() => {
context = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 } },
},
});
});
});