Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [1.3.0] - 2026-04-23

### Changed
- **Anonymous server identification in telemetry** — every analytics event now carries a short opaque `server` segment: a 16-hex-char SHA-256 prefix of the normalized Countly server URL. This lets `stats.count.ly` aggregate per-distinct-server counts (for any event type) without ever seeing the raw URL. The device ID stays at `"mcp"` — only events carry the hash. In HTTP transport the hash is recomputed per request from the request-scoped server URL (via `AsyncLocalStorage`), so multi-tenant deployments naturally emit per-tenant counts. The README analytics section was updated to reflect what is (and isn't) tracked. Opt-in still required (`ENABLE_ANALYTICS=true`).

### Security
- **Cross-tenant auth token mixing (HTTP transport)** (#110) — the HTTP transport previously mutated a shared axios client and shared config on every incoming request. Concurrent requests could interleave at `await` boundaries, causing tenant A's in-flight API calls to go out with tenant B's token. Fixed by constructing a per-request axios instance (with the `countly-token` header baked in) and passing state from the HTTP middleware to the MCP handler through `AsyncLocalStorage`. The shared client is now used only as a stdio-mode fallback and is never mutated per-request.
- **Cross-tenant data leak via shared AppCache** (#110) — the apps cache was a single instance per process, so the first tenant's apps were visible to every other tenant's `resolveAppId` lookups for up to five minutes. Replaced with `AppCacheRegistry`, which keeps one `AppCache` per tenant keyed by SHA-256(token) so the raw token is never held as a Map key.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,16 +239,18 @@ The MCP server includes optional anonymous usage analytics to help improve the p
- HTTP endpoint access patterns
- Error occurrences (type and message, NO sensitive data)
- Server start/stop events
- A **truncated opaque hash** of your Countly server URL (64-bit SHA-256 prefix), attached as the `server` segment on every event — used for distinct-server aggregation. The raw URL is never sent.

**What is NOT tracked:**
- Authentication tokens or credentials
- Server URLs or domains
- Raw Countly server URLs or domains (only the opaque `server` hash above)
- User data or analytics content
- Personal information
- IP addresses or client identifiers
- Tool arguments or request/response bodies

**Privacy & Device ID:**
All analytics are aggregated under a single device ID "mcp" to ensure complete anonymity. No server-specific or user-specific information is collected.
All analytics are aggregated under a single device ID `"mcp"` — Countly cannot distinguish individual operators from the device ID alone. The only per-deployment signal is the `server` hash on events, which is a truncated SHA-256 of the normalized server URL. The hash is intentionally coarse (64 bits) and the server URL is low-entropy, so do not assume the hash is unguessable for cloud patterns; it is meant for aggregation, not secrecy.

**To opt in:**
```bash
Expand Down
22 changes: 21 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,28 @@ class CountlyMCPServer {
// Initialize analytics. Opt-in: enabled only when ENABLE_ANALYTICS=true.
// README has always documented this as "disabled by default"; the previous
// `!== 'false'` check silently opted users in. Flip to explicit opt-in.
//
// The getServerUrl callback lets analytics attach a short opaque SHA-256
// hash of the current Countly server URL (as the `server` segment) to
// every event, so stats.count.ly can aggregate distinct-server counts
// without ever seeing raw URLs.
//
// Priority:
// 1. HTTP per-request URL from AsyncLocalStorage (multi-tenant)
// 2. static server config (stdio after constructor finishes)
// 3. process.env.COUNTLY_SERVER_URL (pre-config fallback — the
// `server_started` event fires from inside analytics.init() which
// runs before this.config is assigned, so without this fallback
// the very first event would ship without the `server` segment)
const analyticsEnabled = (process.env.ENABLE_ANALYTICS || '').toLowerCase() === 'true';
analytics.init(analyticsEnabled);
analytics.init(analyticsEnabled, () => {
const reqState = this.requestContext.getStore();
return (
reqState?.serverUrl
|| this.config?.serverUrl
|| process.env.COUNTLY_SERVER_URL
);
});
Comment thread
ar2rsawseen marked this conversation as resolved.

// Log configuration on startup (only in non-test mode)
if (!testMode) {
Expand Down
122 changes: 110 additions & 12 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,110 @@ import { redactSensitiveInMessage } from './error-handler.js';

const ANALYTICS_URL = 'https://stats.count.ly';
const ANALYTICS_APP_KEY = '5a106dec46bf2e2d4d23c2cd3cf7490b12c22fc7';
/**
* Length of the server-URL hash that accompanies every analytics event.
* 16 hex chars = 64 bits of entropy — enough to distinguish several billion
* distinct Countly servers with negligible collision risk, while keeping
* event payloads small. A collision between two real deployments is not a
* correctness issue for distinct-count aggregation.
*/
const SERVER_HASH_LENGTH = 16;

// Load the package version once. Uses createRequire because the rest of the
// file is ESM and require() isn't available natively.
const require = createRequire(import.meta.url);

/**
* Normalize a Countly server URL into a canonical form before hashing, so
* variations that are semantically equivalent collapse to the same hash:
*
* - scheme dropped (`http://` == `https://` for identity purposes)
* - hostname lowercased (URLs are case-insensitive on host)
* - default port stripped (`:80` for http, `:443` for https)
* - trailing slashes on the pathname stripped
* - path / query / fragment case preserved (RFC 3986: only the host is
* case-insensitive)
*
* Uses `new URL()` for structural correctness; falls back to a minimal
* regex-based strip when the input doesn't parse as a URL (so a bare
* hostname or misformatted value still produces a stable hash).
*/
Comment thread
ar2rsawseen marked this conversation as resolved.
export function normalizeServerUrlForHash(url: string): string {
const trimmed = (url ?? '').trim();
if (!trimmed) {
return '';
}

// If there's no scheme, prepend one so `new URL()` succeeds without
// changing the semantic identity — we drop the scheme again below.
const hasScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed);
try {
const parsed = new URL(hasScheme ? trimmed : `https://${trimmed}`);
const hostname = parsed.hostname.toLowerCase();
const isDefaultPort =
(parsed.protocol === 'http:' && parsed.port === '80') ||
(parsed.protocol === 'https:' && parsed.port === '443');
const port = parsed.port && !isDefaultPort ? `:${parsed.port}` : '';
const pathname = parsed.pathname.replace(/\/+$/, '');
// Preserve search + hash (rare on Countly URLs but keep case).
return `${hostname}${port}${pathname}${parsed.search}${parsed.hash}`;
} catch {
// Non-URL input (malformed, unexpected scheme, etc.): minimal best-
// effort normalization — strip scheme prefix and trailing slashes,
// preserve path case.
return trimmed
.replace(/^[a-z][a-z\d+.-]*:\/\//i, '')
.replace(/\/+$/, '');
}
}

/**
* Compute the short opaque server-URL hash that rides along as the `server`
* segment on every event.
*
* Privacy note: the raw URL is never sent. For cloud patterns the hash is
* brute-forceable by anyone with a dictionary of common URLs (including
* Countly themselves, who already know their own cloud customer URLs via
* billing). For custom on-prem URLs the hash is opaque in practice.
*/
export function computeServerHash(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
const normalized = normalizeServerUrlForHash(url);
if (!normalized) {
return undefined;
}
return createHash('sha256').update(normalized).digest('hex').substring(0, SERVER_HASH_LENGTH);
}

/**
* Optional callback supplied by the server to resolve the Countly server URL
* at event time. In stdio mode this just returns the env-supplied config
* value; in HTTP mode it reads from AsyncLocalStorage so the per-request
* server URL ends up in the per-request events.
*/
type ServerUrlResolver = () => string | undefined;

class Analytics {
private enabled: boolean = false;
private initialized: boolean = false;
private deviceId: string = 'mcp';
private getServerUrl?: ServerUrlResolver;

/**
* Initialize analytics tracking.
* Opt-in: enabled only when the caller passes true (which index.ts does
* only when ENABLE_ANALYTICS=true is set in the environment).
*
* `getServerUrl` is called at each event-track time to resolve the
* current request's server URL. The returned URL is normalized and
* hashed into a short opaque `server` segment on the outgoing event —
* no raw URLs ever leave the process.
*/
init(enabled: boolean = false): void {
init(enabled: boolean = false, getServerUrl?: ServerUrlResolver): void {
this.enabled = enabled;
this.getServerUrl = getServerUrl;

if (!this.enabled) {
console.error('📊 Analytics: Disabled (set ENABLE_ANALYTICS=true to opt in)');
Expand All @@ -52,7 +139,7 @@ class Analytics {

this.initialized = true;
console.error('📊 Analytics: Enabled and initialized');

// Track session start
this.trackServerStart();
} catch (error) {
Expand All @@ -62,13 +149,21 @@ class Analytics {
}

/**
* Hash server URL to create anonymous device ID
* Does NOT include auth tokens
* Build the segmentation object for an event, adding the `server` hash
* if we can resolve the current server URL. Callers hand in the
* event-specific fields; we merge the server hash on top.
*/
private hashServerUrl(url: string): string {
// Remove protocol and trailing slashes for consistency
const cleanUrl = url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
return createHash('sha256').update(cleanUrl).digest('hex').substring(0, 32);
private withServerSegment(
segmentation?: Record<string, string | number>
): Record<string, string | number> | undefined {
const hash = computeServerHash(this.getServerUrl?.());
if (!hash) {
return segmentation;
}
return {
...(segmentation ?? {}),
server: hash,
};
}

/**
Expand Down Expand Up @@ -230,7 +325,9 @@ class Analytics {
}

/**
* Track custom event
* Track custom event. Automatically injects the `server` hash segment
* (when a serverUrl resolver was supplied to init) so Countly can
* aggregate per-server without ever seeing the raw URL.
*/
trackEvent(eventName: string, segmentation?: Record<string, string | number>): void {
if (!this.isEnabled()) {
Expand All @@ -241,15 +338,16 @@ class Analytics {
Countly.add_event({
key: eventName,
count: 1,
segmentation,
segmentation: this.withServerSegment(segmentation),
});
} catch (error) {
console.error('📊 Analytics: Failed to track event:', error);
}
}

/**
* Track timed event
* Track timed event. Injects the `server` hash segment the same way
* trackEvent does.
*/
trackTimedEvent(eventName: string, segmentation: Record<string, string | number>, duration: number): void {
if (!this.isEnabled()) {
Expand All @@ -261,7 +359,7 @@ class Analytics {
key: eventName,
count: 1,
dur: duration,
segmentation,
segmentation: this.withServerSegment(segmentation),
});
} catch (error) {
console.error('📊 Analytics: Failed to track timed event:', error);
Expand Down
Loading
Loading