From 101e8a8a32cbecb4f4bcd2659d4d06a6ad49501f Mon Sep 17 00:00:00 2001 From: Arkadiusz Komarzewski Date: Thu, 8 Jan 2026 10:48:16 +0100 Subject: [PATCH 1/4] Add MCP server exposing Glean metadata to AI assistants --- .gitignore | 4 + .netlify/.gitignore | 1 + .netlify/mcp.js | 550 ++++++++++++++++++++++++++++++++++++++++++++ netlify.toml | 6 + 4 files changed, 561 insertions(+) create mode 100644 .netlify/mcp.js diff --git a/.gitignore b/.gitignore index 6e266922b..2734b4297 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ __pycache__/ .probe_cache .DS_Store src/telemetry/generated/*.js + +# Netlify CLI generated files +deno.lock +local-run.log diff --git a/.netlify/.gitignore b/.netlify/.gitignore index 3ffb80e0b..9001b332b 100644 --- a/.netlify/.gitignore +++ b/.netlify/.gitignore @@ -1,2 +1,3 @@ metrics_search_*.js supported_glam_metric_types.json +state.json diff --git a/.netlify/mcp.js b/.netlify/mcp.js new file mode 100644 index 000000000..bc31192b5 --- /dev/null +++ b/.netlify/mcp.js @@ -0,0 +1,550 @@ +/** + * Netlify Function handler for Glean Dictionary MCP Server + * Implements MCP JSON-RPC protocol for discovering Glean telemetry metadata. + */ + +const PROBEINFO_BASE_URL = "https://probeinfo.telemetry.mozilla.org"; +const ANNOTATIONS_URL = "https://mozilla.github.io/glean-annotations/api.json"; + +// Cache for annotations (shared across invocations in same instance) +let cachedAnnotations = null; + +/** + * Fetch JSON from a URL + */ +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `API error: ${response.status} ${response.statusText} for ${url}` + ); + } + return response.json(); +} + +/** + * Get annotations index (cached) + */ +async function getAnnotations() { + if (cachedAnnotations) return cachedAnnotations; + try { + cachedAnnotations = await fetchJson(ANNOTATIONS_URL); + return cachedAnnotations; + } catch { + return {}; + } +} + +/** + * Get all app listings from probeinfo + */ +async function getAppListings() { + return fetchJson(`${PROBEINFO_BASE_URL}/v2/glean/app-listings`); +} + +/** + * Find app by name + */ +async function findApp(appName) { + const apps = await getAppListings(); + // Try exact match + let app = apps.find((a) => a.app_name === appName); + if (app) return app; + // Try case-insensitive + app = apps.find((a) => a.app_name.toLowerCase() === appName.toLowerCase()); + return app || null; +} + +/** + * Get v1 API identifier for an app + */ +function getV1AppId(app) { + return app.v1_name; +} + +/** + * Get metrics for an app + */ +async function getMetrics(appName) { + const app = await findApp(appName); + if (!app) throw new Error(`App not found: ${appName}`); + return fetchJson(`${PROBEINFO_BASE_URL}/glean/${getV1AppId(app)}/metrics`); +} + +/** + * Get pings for an app + */ +async function getPings(appName) { + const app = await findApp(appName); + if (!app) throw new Error(`App not found: ${appName}`); + return fetchJson(`${PROBEINFO_BASE_URL}/glean/${getV1AppId(app)}/pings`); +} + +/** + * Get tags for an app + */ +async function getTags(appName) { + const app = await findApp(appName); + if (!app) throw new Error(`App not found: ${appName}`); + try { + return await fetchJson( + `${PROBEINFO_BASE_URL}/glean/${getV1AppId(app)}/tags` + ); + } catch { + return {}; + } +} + +// ============================================================================ +// Tool implementations +// ============================================================================ + +async function listApps({ include_deprecated = false } = {}) { + const [appListings, annotations] = await Promise.all([ + getAppListings(), + getAnnotations(), + ]); + + const appsByName = new Map(); + for (const app of appListings) { + if (app.deprecated && !include_deprecated) continue; + if (appsByName.has(app.app_name)) continue; + + const annotation = annotations[app.app_name]?.app; + appsByName.set(app.app_name, { + app_name: app.app_name, + app_description: app.app_description, + canonical_app_name: app.canonical_app_name, + deprecated: app.deprecated || false, + featured: annotation?.featured, + url: app.url, + }); + } + + const apps = Array.from(appsByName.values()); + apps.sort((a, b) => { + if (a.featured && !b.featured) return -1; + if (!a.featured && b.featured) return 1; + return a.app_name.localeCompare(b.app_name); + }); + + return apps; +} + +async function getApp({ app_name }) { + const app = await findApp(app_name); + if (!app) + throw new Error( + `App not found: ${app_name}. Use list_apps to see available apps.` + ); + + const [appListings, metrics, pings, tags, annotations] = await Promise.all([ + getAppListings(), + getMetrics(app_name), + getPings(app_name), + getTags(app_name), + getAnnotations(), + ]); + + const appIds = appListings + .filter((a) => a.app_name === app.app_name) + .map((a) => ({ + app_id: a.app_id, + channel: a.app_channel || "release", + deprecated: a.deprecated || false, + })); + + const pingSummaries = Object.entries(pings).map(([name, def]) => { + const latest = def.history?.[def.history.length - 1] || def; + return { + name, + description: latest.description || def.description, + include_client_id: latest.include_client_id ?? def.include_client_id, + }; + }); + + const tagSummaries = Object.entries(tags).map(([name, def]) => ({ + name, + description: def.description, + })); + + const annotation = annotations[app_name]?.app; + + return { + app_name: app.app_name, + app_description: app.app_description, + canonical_app_name: app.canonical_app_name, + deprecated: app.deprecated || false, + featured: annotation?.featured, + url: app.url, + notification_emails: app.notification_emails, + app_ids: appIds, + metrics_count: Object.keys(metrics).length, + pings: pingSummaries, + tags: tagSummaries, + annotation, + }; +} + +async function searchMetrics({ + app_name, + query, + type, + include_expired = false, + limit = 50, + offset = 0, +}) { + const metrics = await getMetrics(app_name); + + let results = []; + for (const [name, def] of Object.entries(metrics)) { + const latest = def.history?.[def.history.length - 1] || def; + const metricType = latest.type || def.type; + const description = latest.description || def.description; + const expires = latest.expires || def.expires; + const sendInPings = latest.send_in_pings || def.send_in_pings; + + // Filter expired + if (!include_expired && expires !== "never" && expires) { + try { + if (new Date() > new Date(expires)) continue; + } catch {} + } + + // Filter by type + if (type && metricType !== type) continue; + + // Filter by query + if (query) { + const searchText = `${name} ${description}`.toLowerCase(); + if (!searchText.includes(query.toLowerCase())) continue; + } + + results.push({ + name, + type: metricType, + description, + expires, + send_in_pings: sendInPings, + }); + } + + results.sort((a, b) => a.name.localeCompare(b.name)); + const total = results.length; + results = results.slice(offset, offset + limit); + + return { metrics: results, total, limit, offset }; +} + +async function getMetricDetails({ app_name, metric_name }) { + const [metrics, annotations] = await Promise.all([ + getMetrics(app_name), + getAnnotations(), + ]); + + const metric = metrics[metric_name]; + if (!metric) + throw new Error(`Metric not found: ${metric_name} in app ${app_name}`); + + const latest = metric.history?.[metric.history.length - 1] || metric; + const annotation = annotations[app_name]?.metrics?.[metric_name]; + + return { + app_name, + name: metric_name, + type: latest.type || metric.type, + description: latest.description || metric.description, + expires: latest.expires || metric.expires, + bugs: latest.bugs || metric.bugs, + data_reviews: latest.data_reviews || metric.data_reviews, + notification_emails: + latest.notification_emails || metric.notification_emails, + send_in_pings: latest.send_in_pings || metric.send_in_pings, + lifetime: latest.lifetime || metric.lifetime, + extra_keys: latest.extra_keys || metric.extra_keys, + labels: latest.labels || metric.labels, + data_sensitivity: latest.data_sensitivity || metric.data_sensitivity, + annotation, + }; +} + +async function getPingDetails({ app_name, ping_name }) { + const [pings, metrics, annotations] = await Promise.all([ + getPings(app_name), + getMetrics(app_name), + getAnnotations(), + ]); + + const ping = pings[ping_name]; + if (!ping) throw new Error(`Ping not found: ${ping_name} in app ${app_name}`); + + const latest = ping.history?.[ping.history.length - 1] || ping; + + // Find metrics in this ping + const metricsInPing = []; + for (const [name, def] of Object.entries(metrics)) { + const metricLatest = def.history?.[def.history.length - 1] || def; + const sendInPings = metricLatest.send_in_pings || def.send_in_pings; + // Check for ping name or "all pings" variants (all-pings, all_pings, glean_client_info, glean_internal_info) + const allPingsKeywords = [ + "all-pings", + "all_pings", + "glean_client_info", + "glean_internal_info", + ]; + if ( + sendInPings.includes(ping_name) || + allPingsKeywords.some((kw) => sendInPings.includes(kw)) + ) { + metricsInPing.push({ + name, + type: metricLatest.type || def.type, + description: metricLatest.description || def.description, + }); + } + } + metricsInPing.sort((a, b) => a.name.localeCompare(b.name)); + + const annotation = annotations[app_name]?.pings?.[ping_name]; + + return { + app_name, + name: ping_name, + description: latest.description || ping.description, + include_client_id: latest.include_client_id ?? ping.include_client_id, + send_if_empty: latest.send_if_empty ?? ping.send_if_empty, + bugs: latest.bugs || ping.bugs, + data_reviews: latest.data_reviews || ping.data_reviews, + reasons: latest.reasons || ping.reasons, + metrics: metricsInPing, + annotation, + }; +} + +// ============================================================================ +// MCP Protocol Handler +// ============================================================================ + +const TOOLS = [ + { + name: "list_apps", + description: "List all Mozilla applications that use Glean telemetry.", + inputSchema: { + type: "object", + properties: { + include_deprecated: { + type: "boolean", + description: "Include deprecated apps (default: false)", + }, + }, + }, + }, + { + name: "get_app", + description: + "Get detailed information about a Glean application including metrics count, pings, and tags.", + inputSchema: { + type: "object", + properties: { + app_name: { + type: "string", + description: "Application name (e.g., 'firefox_desktop', 'fenix')", + }, + }, + required: ["app_name"], + }, + }, + { + name: "search_metrics", + description: + "Search for metrics in a Glean application by name or description.", + inputSchema: { + type: "object", + properties: { + app_name: { + type: "string", + description: "Application name to search within", + }, + query: { type: "string", description: "Text to search for" }, + type: { + type: "string", + description: "Filter by metric type (counter, event, boolean, etc.)", + }, + include_expired: { + type: "boolean", + description: "Include expired metrics (default: false)", + }, + limit: { type: "number", description: "Max results (default: 50)" }, + offset: { + type: "number", + description: "Pagination offset (default: 0)", + }, + }, + required: ["app_name"], + }, + }, + { + name: "get_metric", + description: "Get the complete definition of a specific metric.", + inputSchema: { + type: "object", + properties: { + app_name: { type: "string", description: "Application name" }, + metric_name: { + type: "string", + description: + "Metric identifier (e.g., 'browser.engagement.active_ticks')", + }, + }, + required: ["app_name", "metric_name"], + }, + }, + { + name: "get_ping", + description: + "Get detailed information about a ping including all metrics it contains.", + inputSchema: { + type: "object", + properties: { + app_name: { type: "string", description: "Application name" }, + ping_name: { + type: "string", + description: "Ping name (e.g., 'baseline', 'metrics', 'events')", + }, + }, + required: ["app_name", "ping_name"], + }, + }, +]; + +async function handleToolCall(name, args) { + switch (name) { + case "list_apps": + return listApps(args); + case "get_app": + return getApp(args); + case "search_metrics": + return searchMetrics(args); + case "get_metric": + return getMetricDetails(args); + case "get_ping": + return getPingDetails(args); + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +async function handleJsonRpc(request) { + const { id, method, params } = request; + + switch (method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + serverInfo: { name: "glean-dictionary", version: "1.0.0" }, + capabilities: { tools: {} }, + }, + }; + + case "tools/list": + return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; + + case "tools/call": + try { + const result = await handleToolCall( + params.name, + params.arguments || {} + ); + return { + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }, + }; + } catch (error) { + return { + jsonrpc: "2.0", + id, + result: { + content: [ + { type: "text", text: JSON.stringify({ error: error.message }) }, + ], + isError: true, + }, + }; + } + + case "notifications/initialized": + return { jsonrpc: "2.0", id }; + + default: + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + } +} + +// ============================================================================ +// Netlify Function Export +// ============================================================================ + +exports.handler = async function (event) { + // CORS preflight + if (event.httpMethod === "OPTIONS") { + return { + statusCode: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }; + } + + if (event.httpMethod !== "POST") { + return { + statusCode: 405, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32600, message: "Only POST accepted" }, + id: null, + }), + }; + } + + try { + const request = JSON.parse(event.body); + const response = await handleJsonRpc(request); + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + body: JSON.stringify(response), + }; + } catch (error) { + return { + statusCode: 500, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32603, message: error.message }, + id: null, + }), + }; + } +}; diff --git a/netlify.toml b/netlify.toml index c7e5aafc0..c254a6751 100644 --- a/netlify.toml +++ b/netlify.toml @@ -34,5 +34,11 @@ publish = "public" to = "/.netlify/functions/:splat" status = 200 +# MCP server endpoint +[[redirects]] + from = "/mcp" + to = "/.netlify/functions/mcp" + status = 200 + [functions] directory = ".netlify" From 59be139a6e7d044f2dcd37815e516ce6bbb2a873 Mon Sep 17 00:00:00 2001 From: Arkadiusz Komarzewski Date: Wed, 28 Jan 2026 16:11:26 +0100 Subject: [PATCH 2/4] Retrigger build From 502f31169dcd626eae2c5e325ee1785eb60de640 Mon Sep 17 00:00:00 2001 From: Arkadiusz Komarzewski Date: Sun, 1 Feb 2026 16:42:10 +0100 Subject: [PATCH 3/4] Add request validation and tests --- .netlify/mcp.js | 20 ++++ tests/mcp.test.js | 234 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 tests/mcp.test.js diff --git a/.netlify/mcp.js b/.netlify/mcp.js index bc31192b5..c48340aa3 100644 --- a/.netlify/mcp.js +++ b/.netlify/mcp.js @@ -435,6 +435,26 @@ async function handleToolCall(name, args) { } async function handleJsonRpc(request) { + // Validate request structure + if (!request || typeof request !== "object") { + return { + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Parse error: invalid JSON" }, + }; + } + + if (typeof request.method !== "string") { + return { + jsonrpc: "2.0", + id: request.id ?? null, + error: { + code: -32600, + message: "Invalid request: method must be a string", + }, + }; + } + const { id, method, params } = request; switch (method) { diff --git a/tests/mcp.test.js b/tests/mcp.test.js new file mode 100644 index 000000000..e13151731 --- /dev/null +++ b/tests/mcp.test.js @@ -0,0 +1,234 @@ +/** + * Tests for MCP server JSON-RPC handler + */ + +// Mock global fetch before requiring the module +global.fetch = jest.fn(); + +// Require the handler (CommonJS module) +const { handler } = require("../.netlify/mcp"); + +// Helper to create HTTP events +function createEvent(method, body) { + return { + httpMethod: method, + body: typeof body === "string" ? body : JSON.stringify(body), + }; +} + +// Helper to call handler and parse response +async function callHandler(method, body) { + const event = createEvent(method, body); + const response = await handler(event); + return { + ...response, + parsedBody: response.body ? JSON.parse(response.body) : null, + }; +} + +describe("MCP Server HTTP handling", () => { + it("handles OPTIONS requests for CORS preflight", async () => { + const response = await callHandler("OPTIONS", ""); + expect(response.statusCode).toBe(204); + expect(response.headers["Access-Control-Allow-Origin"]).toBe("*"); + expect(response.headers["Access-Control-Allow-Methods"]).toContain("POST"); + }); + + it("rejects non-POST methods", async () => { + const response = await callHandler("GET", ""); + expect(response.statusCode).toBe(405); + expect(response.parsedBody.error.code).toBe(-32600); + expect(response.parsedBody.error.message).toContain("POST"); + }); + + it("handles malformed JSON in request body", async () => { + const response = await callHandler("POST", "not valid json"); + expect(response.statusCode).toBe(500); + expect(response.parsedBody.error.code).toBe(-32603); + expect(response.parsedBody.id).toBeNull(); + }); +}); + +describe("MCP Server request validation", () => { + it("handles null request", async () => { + const response = await callHandler("POST", null); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.error.code).toBe(-32700); + expect(response.parsedBody.error.message).toContain("invalid JSON"); + expect(response.parsedBody.id).toBeNull(); + }); + + it("handles request with missing method", async () => { + const response = await callHandler("POST", { jsonrpc: "2.0", id: 123 }); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.error.code).toBe(-32600); + expect(response.parsedBody.error.message).toContain( + "method must be a string" + ); + expect(response.parsedBody.id).toBe(123); + }); + + it("handles request with non-string method", async () => { + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 456, + method: 123, + }); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.error.code).toBe(-32600); + expect(response.parsedBody.error.message).toContain( + "method must be a string" + ); + expect(response.parsedBody.id).toBe(456); + }); + + it("handles unknown methods", async () => { + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 789, + method: "unknown/method", + }); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.error.code).toBe(-32601); + expect(response.parsedBody.error.message).toContain("Method not found"); + }); +}); + +describe("MCP Server protocol methods", () => { + it("handles initialize request", async () => { + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 1, + method: "initialize", + }); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.id).toBe(1); + expect(response.parsedBody.result.protocolVersion).toBe("2024-11-05"); + expect(response.parsedBody.result.serverInfo.name).toBe("glean-dictionary"); + expect(response.parsedBody.result.capabilities.tools).toBeDefined(); + }); + + it("handles tools/list request", async () => { + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + }); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.id).toBe(2); + expect(response.parsedBody.result.tools).toBeInstanceOf(Array); + expect(response.parsedBody.result.tools.length).toBeGreaterThan(0); + + // Verify tool schema structure + const toolNames = response.parsedBody.result.tools.map((t) => t.name); + expect(toolNames).toContain("list_apps"); + expect(toolNames).toContain("get_app"); + expect(toolNames).toContain("search_metrics"); + expect(toolNames).toContain("get_metric"); + expect(toolNames).toContain("get_ping"); + }); + + it("handles notifications/initialized", async () => { + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 3, + method: "notifications/initialized", + }); + expect(response.statusCode).toBe(200); + expect(response.parsedBody.id).toBe(3); + }); +}); + +describe("MCP Server tool calls", () => { + beforeEach(() => { + // Reset fetch mock + global.fetch.mockReset(); + }); + + it("handles tools/call for list_apps", async () => { + // Mock the API responses + global.fetch.mockImplementation((url) => { + if (url.includes("app-listings")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve([ + { + app_name: "test_app", + app_description: "Test application", + canonical_app_name: "Test App", + deprecated: false, + url: "https://example.com", + v1_name: "test-app", + }, + ]), + }); + } + if (url.includes("annotations")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { name: "list_apps", arguments: {} }, + }); + + expect(response.statusCode).toBe(200); + expect(response.parsedBody.id).toBe(10); + expect(response.parsedBody.result.content).toBeInstanceOf(Array); + expect(response.parsedBody.result.content[0].type).toBe("text"); + + const resultData = JSON.parse(response.parsedBody.result.content[0].text); + expect(resultData).toBeInstanceOf(Array); + expect(resultData[0].app_name).toBe("test_app"); + }); + + it("handles tool call errors gracefully", async () => { + // Mock a failing API + global.fetch.mockImplementation(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }) + ); + + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { name: "list_apps", arguments: {} }, + }); + + expect(response.statusCode).toBe(200); + expect(response.parsedBody.id).toBe(11); + expect(response.parsedBody.result.isError).toBe(true); + expect(response.parsedBody.result.content[0].type).toBe("text"); + + const errorData = JSON.parse(response.parsedBody.result.content[0].text); + expect(errorData.error).toContain("API error"); + }); + + it("handles unknown tool name", async () => { + const response = await callHandler("POST", { + jsonrpc: "2.0", + id: 12, + method: "tools/call", + params: { name: "nonexistent_tool", arguments: {} }, + }); + + expect(response.statusCode).toBe(200); + expect(response.parsedBody.id).toBe(12); + expect(response.parsedBody.result.isError).toBe(true); + + const errorData = JSON.parse(response.parsedBody.result.content[0].text); + expect(errorData.error).toContain("Unknown tool"); + }); +}); From f589cb84efc0f4d67df43f0eb04fa19c6aefb38d Mon Sep 17 00:00:00 2001 From: Arkadiusz Komarzewski Date: Sun, 1 Feb 2026 18:12:45 +0100 Subject: [PATCH 4/4] Add MCP server documentation --- README.md | 7 ++++ docs/mcp-server.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 docs/mcp-server.md diff --git a/README.md b/README.md index 725e669da..d39be51e4 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,13 @@ http://localhost:8888/api/v1/metrics_search_firefox_legacy?search=ms [netlify command line interface]: https://docs.netlify.com/cli/get-started/ [netlify functions]: https://docs.netlify.com/functions/overview/ +## MCP Server + +The Glean Dictionary provides an +[MCP (Model Context Protocol)](https://modelcontextprotocol.io) server for AI +assistants to query telemetry metadata programmatically. See +[docs/mcp-server.md](./docs/mcp-server.md) for setup and usage. + ## Storybook We use [Storybook](https://storybook.js.org/) for developing and validating diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 000000000..7246cf84e --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,80 @@ +# Glean Dictionary MCP Server + +An MCP (Model Context Protocol) server that exposes Mozilla's Glean telemetry +metadata to AI assistants like Claude. + +## Why MCP? + +The Glean Dictionary is a Single Page Application (SPA) - metrics are loaded +dynamically via JavaScript. Web scraping tools and AI web fetchers only see an +empty HTML shell, not the actual content. The MCP server provides direct, +structured API access to the underlying telemetry metadata. + +## Endpoint + +``` +https://dictionary.telemetry.mozilla.org/mcp +``` + +## Tools + +| Tool | Description | +| ---------------- | ----------------------------------------------------- | +| `list_apps` | List all Glean applications (~50 apps) | +| `get_app` | Get app details: metrics count, pings, tags, channels | +| `search_metrics` | Search metrics by name/description with pagination | +| `get_metric` | Get full metric definition with annotations | +| `get_ping` | Get ping details and all metrics it contains | + +## Example Queries + +Once connected, you can ask Claude: + +- "What metrics does Firefox Desktop collect about search?" +- "Show me all event metrics in Fenix" +- "What's in the baseline ping for Firefox iOS?" +- "List all deprecated Glean applications" + +## Local Testing + +1. Start the dev server: + + ```bash + npx netlify dev + ``` + +2. Test the endpoint: + + ```bash + # List available tools + curl -X POST http://localhost:8888/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + + # List apps + curl -X POST http://localhost:8888/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_apps","arguments":{}}}' + + # Search metrics + curl -X POST http://localhost:8888/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_metrics","arguments":{"app_name":"fenix","query":"startup"}}}' + ``` + +3. Connect Claude Code locally: + + ```bash + claude mcp add --transport http glean-dictionary-local http://localhost:8888/mcp + ``` + +4. After deployment, connect to production: + ```bash + claude mcp add --transport http glean-dictionary https://dictionary.telemetry.mozilla.org/mcp + ``` + +## Data Sources + +- **Probeinfo API**: `probeinfo.telemetry.mozilla.org` - metrics, pings, apps +- **Glean Annotations**: `mozilla.github.io/glean-annotations` - commentary and + warnings