From 7f5b1a6fbf429b98bcaff7c26a7281ca03b8abe2 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:25:39 +0000 Subject: [PATCH 1/7] scaffold support for glean --- README.md | 2 ++ eslint.config.js | 1 + glean/generated/README.md | 9 +++++++ glean/glean.js | 56 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 35 +++++++++++++++++++++--- package.json | 4 ++- scripts/dev.js | 3 +++ tsconfig.json | 12 +++++++++ 8 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 glean/generated/README.md create mode 100644 glean/glean.js diff --git a/README.md b/README.md index 37ad19c..f3fe42c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ This MCP is being run as an experiment. While it is an experiment, we will be st As this MCP is experimental it may be withdrawn at any time, in particular, the URL will change before it becomes production ready. +To opt out of first-party analytics, send the `X-Moz-1st-Party-Data-Opt-Out: 1` header along with requests to the MCP. + ## Using the remote server Add the remote server to your tool of choice, e.g. in Claude Code: diff --git a/eslint.config.js b/eslint.config.js index 47a0084..83b0c26 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,7 @@ const gitignorePath = path.resolve(__dirname, ".gitignore"); export default defineConfig([ includeIgnoreFile(gitignorePath), + { ignores: ["glean/generated/"] }, jsdoc.configs["flat/recommended"], n.configs["flat/recommended"], unicorn.configs["recommended"], diff --git a/glean/generated/README.md b/glean/generated/README.md new file mode 100644 index 0000000..d9eb15d --- /dev/null +++ b/glean/generated/README.md @@ -0,0 +1,9 @@ +# Glean generated files + +The files in this directory are autogenerated by [`glean_parser`](https://mozilla.github.io/glean_parser/) from [`../metrics.yml`](../metrics.yml) and are committed to avoid requiring a Python environment to run the server or tests. + +If you change `metrics.yml`, or update glean, regenerate these files with: + +``` +npm run glean:generate +``` diff --git a/glean/glean.js b/glean/glean.js new file mode 100644 index 0000000..e467eff --- /dev/null +++ b/glean/glean.js @@ -0,0 +1,56 @@ +import Glean from "@mozilla/glean/node"; +import { captureException } from "@sentry/node"; + +/** + * @import EventMetricType from "@mozilla/glean/private/metrics/event"; + * @import { ExtraMap } from "@mozilla/glean/private/metrics/events_database/recorded_event"; + * @import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; + * @import { Request, Notification } from "@modelcontextprotocol/sdk/types.js"; + */ + +const uploadEnabled = process.env.GLEAN_ENABLED === "true"; +const GLEAN_CHANNEL = process.env.GLEAN_CHANNEL || "dev"; +const GLEAN_DEBUG = process.env.GLEAN_DEBUG === "true"; + +/* node:coverage disable */ +if (GLEAN_DEBUG) { + Glean.setDebugViewTag("mdn-dev"); + Glean.setLogPings(true); +} +/* node:coverage enable */ + +Glean.initialize("mdn-mcp", uploadEnabled, { + channel: GLEAN_CHANNEL, +}); + +/** + * @template {ExtraMap} E + * @template {Request} R + * @template {Notification} N + * @param {EventMetricType} event + * @param {RequestHandlerExtra} [request] + * @param {E} [extra] + */ +export function submitEvent(event, request, extra) { + try { + const optOut = + request?.requestInfo?.headers?.["x-moz-1st-party-data-opt-out"] === "1"; + if (optOut) return; + + const user_agent = + request?.requestInfo?.headers?.["user-agent"]?.toString(); + event.record( + /** @type {E} */ ({ + ...extra, + ...(user_agent ? { user_agent } : undefined), + }), + ); + /* node:coverage disable */ + } catch (error) { + // we don't want to throw, the request should work + // even if glean fails in some way + console.error(error); + captureException(error); + } + /* node:coverage enable */ +} diff --git a/package-lock.json b/package-lock.json index 41b3f5b..846e5bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@lit-labs/ssr": "^4.0.0", "@mdn/fred": "^2.2.1", "@modelcontextprotocol/sdk": "^1.27.1", + "@mozilla/glean": "^2.0.5", "@sentry/node": "^10.40.0", "express": "^5.1.0", "turndown": "^7.2.2", @@ -935,6 +936,24 @@ "node": ">=24" } }, + "node_modules/@mdn/fred/node_modules/@mozilla/glean": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@mozilla/glean/-/glean-5.0.7.tgz", + "integrity": "sha512-IcjtCObOnZbcJUH8BoUJpXuIKZ/QBeE7CI7gso0XyAJIDELlcgy61sZCBjBlw7FymjmIyJVGY+OOAQ1CJ3SFbA==", + "license": "MPL-2.0", + "dependencies": { + "fflate": "^0.8.0", + "tslib": "^2.3.1", + "uuid": "^9.0.0" + }, + "bin": { + "glean": "dist/cli/cli.js" + }, + "engines": { + "node": ">=12.20.0", + "npm": ">=7.0.0" + } + }, "node_modules/@mdn/rari": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@mdn/rari/-/rari-0.2.12.tgz", @@ -1222,12 +1241,13 @@ "license": "MIT" }, "node_modules/@mozilla/glean": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@mozilla/glean/-/glean-5.0.7.tgz", - "integrity": "sha512-IcjtCObOnZbcJUH8BoUJpXuIKZ/QBeE7CI7gso0XyAJIDELlcgy61sZCBjBlw7FymjmIyJVGY+OOAQ1CJ3SFbA==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mozilla/glean/-/glean-2.0.5.tgz", + "integrity": "sha512-9OKK+bUuhfIrDOt5CK/mXQdZ76uSjX68H25JlX0yXBw0b8k+Ft1vdA7ToTjlL4vkgrOymhPLfwMCmEsc1/kX5Q==", "license": "MPL-2.0", "dependencies": { "fflate": "^0.8.0", + "jose": "^4.0.4", "tslib": "^2.3.1", "uuid": "^9.0.0" }, @@ -1239,6 +1259,15 @@ "npm": ">=7.0.0" } }, + "node_modules/@mozilla/glean/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", diff --git a/package.json b/package.json index 5fece5e..27491db 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "dev:inspector": "mcp-inspector --config mcp.json", "tsc": "tsc", "test": "node --test --experimental-test-coverage --test-coverage-lines 100 --test-coverage-exclude 'test/**/*' 'test/**/*.test.js'", - "test:watch": "node --test --watch test/**/*.test.js" + "test:watch": "node --test --watch test/**/*.test.js", + "glean:generate": "glean translate glean/metrics.yml -f javascript -o glean/generated/ && glean translate glean/metrics.yml -f typescript -o glean/generated/ && sed -i 's/ DO NOT COMMIT\\.//g' glean/generated/*" }, "dependencies": { "@lit-labs/ssr": "^4.0.0", "@mdn/fred": "^2.2.1", "@modelcontextprotocol/sdk": "^1.27.1", + "@mozilla/glean": "^2.0.5", "@sentry/node": "^10.40.0", "express": "^5.1.0", "turndown": "^7.2.2", diff --git a/scripts/dev.js b/scripts/dev.js index 22a7369..db1632b 100644 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -16,6 +16,9 @@ concurrently( command: `npm run test:watch`, name: "tests", prefixColor: "red", + env: { + GLEAN_ENABLED: "false", + }, }, ], { diff --git a/tsconfig.json b/tsconfig.json index c7a0ccc..7d8c2d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,18 @@ "moduleResolution": "nodenext", // fix for badly-formed types in dependencies: "skipLibCheck": true, + // fix badly defined imports: + "paths": { + "@mozilla/glean/node": [ + "./node_modules/@mozilla/glean/dist/types/entry/node.d.ts" + ], + "@mozilla/glean/private/metrics/event": [ + "./node_modules/@mozilla/glean/dist/types/core/metrics/types/event.d.ts" + ], + "@mozilla/glean/private/metrics/events_database/recorded_event": [ + "./node_modules/@mozilla/glean/dist/types/core/metrics/events_database/recorded_event.d.ts" + ] + }, // rules: "strict": true, "noUncheckedIndexedAccess": true, From f2202337486c7a1c967a5fc0f098f5ea838d7e91 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:26:41 +0000 Subject: [PATCH 2/7] add metrics.yml file --- glean/metrics.yml | 99 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 glean/metrics.yml diff --git a/glean/metrics.yml b/glean/metrics.yml new file mode 100644 index 0000000..37aa5df --- /dev/null +++ b/glean/metrics.yml @@ -0,0 +1,99 @@ +# Schema +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + +get_doc: + fetched: + type: event + description: Recorded when an MDN documentation page is successfully fetched. + data_sensitivity: + - interaction + notification_emails: + - leo@mozilla.com + - mdn-team@mozilla.com + bugs: + - https://github.com/mdn/mcp/issues/98 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=2020347 + expires: never + extra_keys: + path: + description: Normalised path of the page fetched from MDN. + type: string + user_agent: + description: The HTTP User-Agent of the AI harness making the request. + type: string + +search: + completed: + type: event + description: Recorded when a search of MDN documentation is completed. + data_sensitivity: + - interaction + notification_emails: + - leo@mozilla.com + - mdn-team@mozilla.com + bugs: + - https://github.com/mdn/mcp/issues/98 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=2020347 + expires: never + extra_keys: + result_count: + description: The number of results returned. + type: quantity + top_score: + description: The search relevancy score of the top result. + type: quantity + top_path: + description: The path to the top result. + type: string + user_agent: + description: The HTTP User-Agent of the AI harness making the request. + type: string + +get_compat: + fetched: + type: event + description: Recorded when a BCD key is successfully fetched. + data_sensitivity: + - interaction + notification_emails: + - leo@mozilla.com + - mdn-team@mozilla.com + bugs: + - https://github.com/mdn/mcp/issues/98 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=2020347 + expires: never + extra_keys: + key: + description: The BCD key that was fetched. + type: string + user_agent: + description: The HTTP User-Agent of the AI harness making the request. + type: string + +error: + silent_error: + type: event + description: Recorded when a non-critical error occurs. + data_sensitivity: + - technical + notification_emails: + - leo@mozilla.com + - mdn-team@mozilla.com + bugs: + - https://github.com/mdn/mcp/issues/98 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=2020347 + expires: never + extra_keys: + tool: + description: The MCP tool where the error occurred. + type: string + reason: + description: A hard-coded reason the error occurred. + type: string + user_agent: + description: The HTTP User-Agent of the AI harness making the request. + type: string From be9aa9f249622e8e1b25867b66c0c7ddc2744fd9 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:30:13 +0000 Subject: [PATCH 3/7] add silentError glean event and send when we capture a non-sentry error --- glean/generated/error.js | 23 +++++++++++++++++++++++ glean/generated/error.ts | 27 +++++++++++++++++++++++++++ sentry/error.js | 11 ++++++++++- sentry/wrapped-server.js | 11 ++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 glean/generated/error.js create mode 100644 glean/generated/error.ts diff --git a/glean/generated/error.js b/glean/generated/error.js new file mode 100644 index 0000000..6f31352 --- /dev/null +++ b/glean/generated/error.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when a non-critical error occurs. + * + * Generated from `error.silent_error`. + */ +export const silentError = new EventMetricType( + { + category: "error", + name: "silent_error", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["reason", "tool", "user_agent"], +); diff --git a/glean/generated/error.ts b/glean/generated/error.ts new file mode 100644 index 0000000..e7faec0 --- /dev/null +++ b/glean/generated/error.ts @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when a non-critical error occurs. + * + * Generated from `error.silent_error`. + */ +export const silentError = new EventMetricType<{ + reason?: string; + tool?: string; + user_agent?: string; +}>( + { + category: "error", + name: "silent_error", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["reason", "tool", "user_agent"], +); diff --git a/sentry/error.js b/sentry/error.js index cc44bfd..758b89c 100644 --- a/sentry/error.js +++ b/sentry/error.js @@ -1 +1,10 @@ -export class NonSentryError extends Error {} +export class NonSentryError extends Error { + /** + * @param {string} message + * @param {string} gleanReason + */ + constructor(message, gleanReason) { + super(message); + this.gleanReason = gleanReason; + } +} diff --git a/sentry/wrapped-server.js b/sentry/wrapped-server.js index 0d93f4f..85a1697 100644 --- a/sentry/wrapped-server.js +++ b/sentry/wrapped-server.js @@ -1,5 +1,8 @@ import * as Sentry from "@sentry/node"; +import { silentError } from "../glean/generated/error.js"; +import { submitEvent } from "../glean/glean.js"; + import { NonSentryError } from "./error.js"; /** @@ -23,7 +26,13 @@ export const SentryMixin = (McpServer) => // @ts-expect-error: ts can't seem to handle passing through args like this return await callback(...callbackArgs); } catch (error) { - if (!(error instanceof NonSentryError)) { + if (error instanceof NonSentryError) { + const request = callbackArgs[1]; + submitEvent(silentError, request, { + tool: name, + reason: error.gleanReason, + }); + } else { Sentry.captureException(error); } throw error; From 6456b25cb59773d63061184ee066ce56368da58f Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:31:36 +0000 Subject: [PATCH 4/7] add get-compat fetched glean event, send and test --- glean/generated/getCompat.js | 23 +++++++++++++++ glean/generated/getCompat.ts | 26 +++++++++++++++++ test/tools/get-compat.test.js | 53 ++++++++++++++++++++++++++++++++++- tools/get-compat.js | 8 +++++- 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 glean/generated/getCompat.js create mode 100644 glean/generated/getCompat.ts diff --git a/glean/generated/getCompat.js b/glean/generated/getCompat.js new file mode 100644 index 0000000..47aa510 --- /dev/null +++ b/glean/generated/getCompat.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when a BCD key is successfully fetched. + * + * Generated from `get_compat.fetched`. + */ +export const fetched = new EventMetricType( + { + category: "get_compat", + name: "fetched", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["key", "user_agent"], +); diff --git a/glean/generated/getCompat.ts b/glean/generated/getCompat.ts new file mode 100644 index 0000000..0fc886a --- /dev/null +++ b/glean/generated/getCompat.ts @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when a BCD key is successfully fetched. + * + * Generated from `get_compat.fetched`. + */ +export const fetched = new EventMetricType<{ + key?: string; + user_agent?: string; +}>( + { + category: "get_compat", + name: "fetched", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["key", "user_agent"], +); diff --git a/test/tools/get-compat.test.js b/test/tools/get-compat.test.js index f5bb296..2807c46 100644 --- a/test/tools/get-compat.test.js +++ b/test/tools/get-compat.test.js @@ -1,9 +1,11 @@ /* eslint-disable jsdoc/reject-any-type */ import assert from "node:assert/strict"; -import { after, before, describe, it } from "node:test"; +import { after, before, describe, it, mock } from "node:test"; import { MockAgent, setGlobalDispatcher } from "undici"; +import { silentError } from "../../glean/generated/error.js"; +import { fetched } from "../../glean/generated/getCompat.js"; import arrayCompat from "../fixtures/bcd-array.json" with { type: "json" }; import { createClient, createServer } from "../helpers/client.js"; @@ -133,6 +135,55 @@ describe("get-compat tool", () => { assert.ok(text.startsWith("Error:"), "response starts with 'Error:'"); }); + describe("glean", () => { + it("should send fetched event", async () => { + const key = "javascript.builtins.Array.Array"; + const record = mock.method(fetched, "record"); + + mockPool + .intercept({ + path: `/bcd/api/v0/current/${key}.json`, + method: "GET", + }) + .reply(200, arrayCompat); + + await client.callTool({ + name: "get-compat", + arguments: { key }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + key, + user_agent: "node", + }); + }); + + it("should send error for missing key", async () => { + const key = "javascript.builtins.Array.foobar"; + const record = mock.method(silentError, "record"); + + mockPool + .intercept({ + path: `/bcd/api/v0/current/${key}.json`, + method: "GET", + }) + .reply(404); + + await client.callTool({ + name: "get-compat", + arguments: { key }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + tool: "get-compat", + reason: "404", + user_agent: "node", + }); + }); + }); + after(() => { server.listener.close(); }); diff --git a/tools/get-compat.js b/tools/get-compat.js index 25480ea..214b31e 100644 --- a/tools/get-compat.js +++ b/tools/get-compat.js @@ -1,5 +1,7 @@ import z from "zod"; +import { fetched } from "../glean/generated/getCompat.js"; +import { submitEvent } from "../glean/glean.js"; import { NonSentryError } from "../sentry/error.js"; /** @param {InstanceType} server */ @@ -19,7 +21,7 @@ export function registerGetCompatTool(server) { .describe("BCD feature path from MDN (e.g., 'api.fetch')"), }, }, - async ({ key }) => { + async ({ key }, request) => { const url = new URL( `${key}.json`, "https://bcd.developer.mozilla.org/bcd/api/v0/current/", @@ -30,12 +32,16 @@ export function registerGetCompatTool(server) { if (res.status === 404) { throw new NonSentryError( `Error: We couldn't find "${key}" in the Browser Compatibility Data.`, + "404", ); } throw new Error(`Error: ${res.status}: ${res.statusText}`); } const json = await res.json(); + + submitEvent(fetched, request, { key }); + return { content: [ { From 9b0219c7326bcbd3e954ad2d2ef9ee6f05fd3bf1 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:32:15 +0000 Subject: [PATCH 5/7] add get-doc fetched glean event, send and test --- glean/generated/getDoc.js | 23 ++++++++++ glean/generated/getDoc.ts | 26 +++++++++++ test/tools/get-doc.test.js | 92 +++++++++++++++++++++++++++++++++++++- tools/get-doc.js | 14 ++++-- 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 glean/generated/getDoc.js create mode 100644 glean/generated/getDoc.ts diff --git a/glean/generated/getDoc.js b/glean/generated/getDoc.js new file mode 100644 index 0000000..b816985 --- /dev/null +++ b/glean/generated/getDoc.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when an MDN documentation page is successfully fetched. + * + * Generated from `get_doc.fetched`. + */ +export const fetched = new EventMetricType( + { + category: "get_doc", + name: "fetched", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["path", "user_agent"], +); diff --git a/glean/generated/getDoc.ts b/glean/generated/getDoc.ts new file mode 100644 index 0000000..1d91553 --- /dev/null +++ b/glean/generated/getDoc.ts @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when an MDN documentation page is successfully fetched. + * + * Generated from `get_doc.fetched`. + */ +export const fetched = new EventMetricType<{ + path?: string; + user_agent?: string; +}>( + { + category: "get_doc", + name: "fetched", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["path", "user_agent"], +); diff --git a/test/tools/get-doc.test.js b/test/tools/get-doc.test.js index e8a3b38..90441cf 100644 --- a/test/tools/get-doc.test.js +++ b/test/tools/get-doc.test.js @@ -1,9 +1,11 @@ /* eslint-disable jsdoc/reject-any-type */ import assert from "node:assert/strict"; -import { after, before, beforeEach, describe, it } from "node:test"; +import { after, before, beforeEach, describe, it, mock } from "node:test"; import { MockAgent, setGlobalDispatcher } from "undici"; +import { silentError } from "../../glean/generated/error.js"; +import { fetched } from "../../glean/generated/getDoc.js"; import clipboardDoc from "../fixtures/clipboard-api.json" with { type: "json" }; import glossaryDoc from "../fixtures/glossary.json" with { type: "json" }; import headersDoc from "../fixtures/headers.json" with { type: "json" }; @@ -279,6 +281,94 @@ describe("get-doc tool", () => { }); }); + describe("glean", () => { + it("should send fetched event", async () => { + mockPool + .intercept({ + path: "/en-US/docs/Web/API/Headers/index.json", + method: "GET", + }) + .reply(200, headersDoc); + const record = mock.method(fetched, "record"); + + await client.callTool({ + name: "get-doc", + arguments: { + path: "/en-US/docs/Web/API/Headers", + }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + path: "/en-US/docs/Web/API/Headers", + user_agent: "node", + }); + }); + + it("should send error for wrong base url", async () => { + const record = mock.method(silentError, "record"); + + const path = "https://example.com/en-US/docs/MDN/Kitchensink"; + await client.callTool({ + name: "get-doc", + arguments: { + path, + }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + tool: "get-doc", + reason: "non_mdn_host", + user_agent: "node", + }); + }); + + it("should send error for non-doc path", async () => { + const record = mock.method(silentError, "record"); + + await client.callTool({ + name: "get-doc", + arguments: { + path: "/en-US/observatory", + }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + tool: "get-doc", + reason: "non_doc_path", + user_agent: "node", + }); + }); + + it("should send error for missing doc", async () => { + const record = mock.method(silentError, "record"); + const path = "/en-US/docs/MDN/Knisnehctik"; + + mockPool + .intercept({ + path: path + "/index.json", + method: "GET", + }) + .reply(404); + + await client.callTool({ + name: "get-doc", + arguments: { + path, + }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + tool: "get-doc", + reason: "404", + user_agent: "node", + }); + }); + }); + after(() => { server.listener.close(); }); diff --git a/tools/get-doc.js b/tools/get-doc.js index 5c154b4..e0e3105 100644 --- a/tools/get-doc.js +++ b/tools/get-doc.js @@ -11,6 +11,8 @@ import TurndownService from "turndown"; import turndownPluginGfm from "turndown-plugin-gfm"; import z from "zod"; +import { fetched } from "../glean/generated/getDoc.js"; +import { submitEvent } from "../glean/glean.js"; import { NonSentryError } from "../sentry/error.js"; const turndownService = new TurndownService({ @@ -32,14 +34,18 @@ export function registerGetDocTool(server) { .describe("path or full URL: e.g. '/en-US/docs/Web/API/Headers'"), }, }, - async ({ path }) => { + async ({ path }, request) => { const url = new URL(path, "https://developer.mozilla.org"); if (url.host !== "developer.mozilla.org") { - throw new NonSentryError(`Error: ${url} doesn't look like an MDN url`); + throw new NonSentryError( + `Error: ${url} doesn't look like an MDN url`, + "non_mdn_host", + ); } if (!/^\/?([a-z-]+?\/)?docs\//i.test(url.pathname)) { throw new NonSentryError( `Error: ${path} doesn't look like the path to a piece of MDN documentation`, + "non_doc_path", ); } if (!url.pathname.endsWith("/index.json")) { @@ -49,7 +55,7 @@ export function registerGetDocTool(server) { const res = await fetch(url); if (!res.ok) { if (res.status === 404) { - throw new NonSentryError(`Error: We couldn't find ${path}`); + throw new NonSentryError(`Error: We couldn't find ${path}`, "404"); } throw new Error(`${res.status}: ${res.statusText} for ${path}`); } @@ -97,6 +103,8 @@ export function registerGetDocTool(server) { frontmatter += "---\n"; } + submitEvent(fetched, request, { path: json.url }); + return { content: [ { From c3ab07c6f618fc0135ac1cf55b34eba705a6a4e4 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:33:06 +0000 Subject: [PATCH 6/7] add search completed glean event, send and test --- glean/generated/search.js | 23 ++++++++++++++++ glean/generated/search.ts | 28 +++++++++++++++++++ test/tools/search.test.js | 57 ++++++++++++++++++++++++++++++++++++++- tools/search.js | 15 ++++++++++- 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 glean/generated/search.js create mode 100644 glean/generated/search.ts diff --git a/glean/generated/search.js b/glean/generated/search.js new file mode 100644 index 0000000..02a30ac --- /dev/null +++ b/glean/generated/search.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when a search of MDN documentation is completed. + * + * Generated from `search.completed`. + */ +export const completed = new EventMetricType( + { + category: "search", + name: "completed", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["result_count", "top_path", "top_score", "user_agent"], +); diff --git a/glean/generated/search.ts b/glean/generated/search.ts new file mode 100644 index 0000000..c8cb7ee --- /dev/null +++ b/glean/generated/search.ts @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// AUTOGENERATED BY glean_parser v8.1.1. DO NOT EDIT. + +import EventMetricType from "@mozilla/glean/private/metrics/event"; + +/** + * Recorded when a search of MDN documentation is completed. + * + * Generated from `search.completed`. + */ +export const completed = new EventMetricType<{ + result_count?: number; + top_path?: string; + top_score?: number; + user_agent?: string; +}>( + { + category: "search", + name: "completed", + sendInPings: ["events"], + lifetime: "ping", + disabled: false, + }, + ["result_count", "top_path", "top_score", "user_agent"], +); diff --git a/test/tools/search.test.js b/test/tools/search.test.js index 0152ace..6a121f8 100644 --- a/test/tools/search.test.js +++ b/test/tools/search.test.js @@ -1,9 +1,10 @@ /* eslint-disable jsdoc/reject-any-type */ import assert from "node:assert/strict"; -import { after, before, describe, it } from "node:test"; +import { after, before, describe, it, mock } from "node:test"; import { MockAgent, setGlobalDispatcher } from "undici"; +import { completed } from "../../glean/generated/search.js"; import clipboardApiMetadata from "../fixtures/clipboard-api-metadata.json" with { type: "json" }; import clipboardMetadata from "../fixtures/clipboard-metadata.json" with { type: "json" }; import searchResultEmpty from "../fixtures/search-result-empty.json" with { type: "json" }; @@ -173,6 +174,60 @@ describe("search tool", () => { ); }); + describe("glean", () => { + it("should send completed event with result count", async () => { + const record = mock.method(completed, "record"); + + mockPool + .intercept({ + path: "/api/v1/search?q=clipboard+api", + method: "GET", + }) + .reply(200, searchResult); + mockPool + .intercept({ + path: "/en-US/docs/Web/API/Clipboard/metadata.json", + method: "GET", + }) + .reply(500); + + await client.callTool({ + name: "search", + arguments: { query: "clipboard api" }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + result_count: 4197, + top_score: 252, + top_path: "/en-US/docs/Web/API/Clipboard_API", + user_agent: "node", + }); + }); + + it("should send completed event with zero result count", async () => { + const record = mock.method(completed, "record"); + + mockPool + .intercept({ + path: "/api/v1/search?q=testempty", + method: "GET", + }) + .reply(200, searchResultEmpty); + + await client.callTool({ + name: "search", + arguments: { query: "testempty" }, + }); + + assert.equal(record.mock.calls.length, 1); + assert.deepEqual(record.mock.calls[0]?.arguments[0], { + result_count: 0, + user_agent: "node", + }); + }); + }); + after(() => { server.listener.close(); }); diff --git a/tools/search.js b/tools/search.js index 15bcc66..afe4f44 100644 --- a/tools/search.js +++ b/tools/search.js @@ -1,5 +1,8 @@ import z from "zod"; +import { completed } from "../glean/generated/search.js"; +import { submitEvent } from "../glean/glean.js"; + /** * @import { SearchResponse, SearchDocument } from "@mdn/fred/components/site-search/types.js"; * @import { Doc } from "@mdn/rari"; @@ -16,7 +19,7 @@ export function registerSearchTool(server) { query: z.string().describe("search terms: e.g. 'array methods'"), }, }, - async ({ query }) => { + async ({ query }, request) => { const url = new URL(`https://developer.mozilla.org/api/v1/search`); url.searchParams.set("q", query); @@ -63,6 +66,16 @@ ${getBrowserCompat(document)}${document.summary}`, ) .join("\n\n"); + submitEvent(completed, request, { + result_count: searchResponse.metadata.total.value, + ...(searchResponse.documents[0] + ? { + top_score: Math.round(searchResponse.documents[0].score), + top_path: searchResponse.documents[0].mdn_url, + } + : undefined), + }); + return { content: [ { From b0f1ff13e4c9bfbe3814c64778f7deb332459c6e Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 2 Mar 2026 16:33:19 +0000 Subject: [PATCH 7/7] test opt-out mechanism --- test/glean.test.js | 37 +++++++++++++++++++++++++++++++++++++ test/helpers/client.js | 12 ++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 test/glean.test.js diff --git a/test/glean.test.js b/test/glean.test.js new file mode 100644 index 0000000..0281170 --- /dev/null +++ b/test/glean.test.js @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { after, before, describe, it, mock } from "node:test"; + +import { silentError } from "../glean/generated/error.js"; + +import { createClient, createServer } from "./helpers/client.js"; + +describe("glean", () => { + /** @type {Awaited>} */ + let server; + /** @type {Awaited>} */ + let optOutClient; + + before(async () => { + server = await createServer(); + optOutClient = await createClient(server.port, { + requestInit: { + headers: { "X-Moz-1st-Party-Data-Opt-Out": "1" }, + }, + }); + }); + + it("should not record events when opt-out header is set", async () => { + const record = mock.method(silentError, "record"); + + await optOutClient.callTool({ + name: "get-doc", + arguments: { path: "https://example.com" }, + }); + + assert.equal(record.mock.calls.length, 0); + }); + + after(() => { + server.listener.close(); + }); +}); diff --git a/test/helpers/client.js b/test/helpers/client.js index 86d04ad..1ba78ea 100644 --- a/test/helpers/client.js +++ b/test/helpers/client.js @@ -1,6 +1,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +/** + * @import { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + */ + import listen from "../../index.js"; export function createServer() { @@ -8,14 +12,18 @@ export function createServer() { return listen(0); } -/** @param {number} port */ -export async function createClient(port) { +/** + * @param {number} port + * @param {StreamableHTTPClientTransportOptions} [opts] + */ +export async function createClient(port, opts) { const client = new Client({ name: "test-client", version: "0.0.1", }); const transport = new StreamableHTTPClientTransport( new URL(`http://localhost:${port}/mcp`), + opts, ); await client.connect(transport); return client;