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
24 changes: 24 additions & 0 deletions .netlify/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
* Implements MCP JSON-RPC protocol for discovering Glean telemetry metadata.
*/

const { submitTelemetryEvent } = require("./telemetry");

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;

// MCP client info captured during initialize
let mcpClientName = null;
let mcpClientVersion = null;

/**
* Fetch JSON from a URL
*/
Expand Down Expand Up @@ -457,8 +463,16 @@ async function handleJsonRpc(request) {

const { id, method, params } = request;

console.log("MCP request params:", JSON.stringify(params, null, 2));

switch (method) {
case "initialize":
mcpClientName = params?.clientInfo?.name || null;
mcpClientVersion = params?.clientInfo?.version || null;
await submitTelemetryEvent("mcp", "initialize", {
client_name: mcpClientName,
client_version: mcpClientVersion,
});
return {
jsonrpc: "2.0",
id,
Expand All @@ -478,6 +492,11 @@ async function handleJsonRpc(request) {
params.name,
params.arguments || {}
);
await submitTelemetryEvent("mcp", "tool_call", {
tool_name: params.name,
app_name: (params.arguments || {}).app_name,
success: "true",
});
return {
jsonrpc: "2.0",
id,
Expand All @@ -486,6 +505,11 @@ async function handleJsonRpc(request) {
},
};
} catch (error) {
await submitTelemetryEvent("mcp", "tool_call", {
tool_name: params.name,
app_name: (params.arguments || {}).app_name,
success: "false",
});
return {
jsonrpc: "2.0",
id,
Expand Down
108 changes: 108 additions & 0 deletions .netlify/telemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Lightweight Glean telemetry for the MCP server.
*
* We can't use the Glean JS SDK here because @mozilla/glean only ships
* browser bundles — there's no Node.js entry point. Since this runs in
* a Netlify Function (Node.js), we handcraft a minimal events ping and
* POST it directly to the ingestion endpoint.
*/

const { randomUUID } = require("crypto");

const DEFAULT_APP_ID = "glean-dictionary-dev";
const DEFAULT_INGESTION_URL = "https://incoming.telemetry.mozilla.org";

/**
* Map Netlify CONTEXT env var to app_channel.
*/
function getAppChannel() {
const context = process.env.CONTEXT;
if (context === "production") return "production";
if (context === "deploy-preview" || context === "branch-deploy")
return "deploy-preview";
return "development";
}

/**
* Build a Glean events ping payload.
*
* @param {Array<Object>} events - Array of event objects with category, name, extra
* @returns {Object} A valid glean.1 ping payload
*/
function buildPing(events) {
const now = new Date().toISOString();

return {
ping_info: {
seq: 0,
start_time: now,
end_time: now,
},
client_info: {
app_build: "Unknown",
app_display_version: "1.0.0",
app_channel: getAppChannel(),
architecture: "n/a",
os: "n/a",
os_version: "n/a",
telemetry_sdk_build: "glean-mcp/1.0.0",
first_run_date: now,
locale: "und",
},
events: events.map((event, index) => ({
category: event.category,
name: event.name,
timestamp: index,
extra: event.extra,
})),
};
}

/**
* Submit a telemetry event to the Glean ingestion endpoint.
*
* @param {string} category - Event category (e.g. "mcp")
* @param {string} name - Event name (e.g. "tool_call")
* @param {Object} extra - Extra key-value pairs (all string values)
* @returns {Promise<void>} Resolves silently; never throws.
*/
async function submitTelemetryEvent(category, name, extra = {}) {
try {
const appId = process.env.GLEAN_APPLICATION_ID || DEFAULT_APP_ID;
const ingestionUrl =
process.env.GLEAN_INGESTION_URL || DEFAULT_INGESTION_URL;
const debugTag = process.env.GLEAN_DEBUG_VIEW_TAG;

// Filter out undefined/null extra values
const cleanExtra = {};
for (const [k, v] of Object.entries(extra)) {
if (v != null) {
cleanExtra[k] = String(v);
}
}

const ping = buildPing([{ category, name, extra: cleanExtra }]);
const uuid = randomUUID();
const url = `${ingestionUrl}/submit/${appId}/events/1/${uuid}`;

const headers = {
"Content-Type": "application/json; charset=utf-8",
Date: new Date().toUTCString(),
"X-Telemetry-Agent": "Glean/handcrafted",
};

if (debugTag) {
headers["X-Debug-ID"] = debugTag;
}

await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(ping),
});
} catch {
// Telemetry must never break MCP responses — silently ignore all errors.
}
}

module.exports = { submitTelemetryEvent, buildPing };
36 changes: 36 additions & 0 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,42 @@ Once connected, you can ask Claude:
claude mcp add --transport http glean-dictionary https://dictionary.telemetry.mozilla.org/mcp
```

## Telemetry

The MCP server sends lightweight Glean telemetry to track usage. We can't use
the Glean JS SDK because `@mozilla/glean` only ships browser bundles — there's
no Node.js entry point. Instead, we handcraft a minimal events ping and POST it
directly to the ingestion endpoint (see `.netlify/telemetry.js`).

**Events:**

| Event | Extras | Description |
| ---------------- | ---------------------------------- | ------------------------------ |
| `mcp.initialize` | `client_name`, `client_version` | Fired on each MCP `initialize` |
| `mcp.tool_call` | `tool_name`, `app_name`, `success` | Fired on each `tools/call` |

Pings are submitted under the same `glean_dictionary` app ID as the frontend,
with `app_channel` set from the Netlify `CONTEXT` env var (`production`,
`deploy-preview`, or `development`).

**Environment variables** (all optional):

| Variable | Default | Description |
| ---------------------- | ---------------------------------------- | --------------------------------------- |
| `GLEAN_APPLICATION_ID` | `glean-dictionary-dev` | App ID in the submission URL |
| `GLEAN_INGESTION_URL` | `https://incoming.telemetry.mozilla.org` | Ingestion endpoint |
| `GLEAN_DEBUG_VIEW_TAG` | _(unset)_ | Adds `X-Debug-ID` header for debug view |
| `CONTEXT` | _(unset)_ | Netlify build context → `app_channel` |

**Debug pings locally:**

```bash
GLEAN_DEBUG_VIEW_TAG=mcp-test npx netlify dev
```

Then check https://debug-ping-preview.firebaseapp.com/pings/mcp-test after
making MCP calls.

## Data Sources

- **Probeinfo API**: `probeinfo.telemetry.mozilla.org` - metrics, pings, apps
Expand Down
7 changes: 3 additions & 4 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ command = """
pip install -e .
./scripts/gd build-metadata
# glean.js currently requires a virtual environment
python -mvenv venv --without-pip
wget https://bootstrap.pypa.io/get-pip.py
venv/bin/python get-pip.py
venv/bin/pip install wheel
Comment on lines -7 to -10
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pip 26 no longer bundles setuptools so I needed to clean this up a bit to make the build work.

python3 -m venv venv
venv/bin/pip3 install setuptools wheel
npm ci
npm run build
if [ "$STORYBOOK" ]; then
Expand All @@ -21,6 +19,7 @@ publish = "public"
[context.production]
[context.production.environment]
CONTEXT = "production"
GLEAN_APPLICATION_ID = "glean-dictionary"


[[headers]]
Expand Down
Loading