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
2 changes: 2 additions & 0 deletions .github/workflows/binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ jobs:
shell: bash
env:
ARCHIVE: add-sg-mcp-${{ matrix.target }}.${{ matrix.archive }}
# Smoke tests must not land in the product analytics.
DO_NOT_TRACK: "1"
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 1.3.0

- The connect flow and `login` now send usage events to StackGuardian when an install starts, when you sign in and when it completes, so we can see where installs stop. They are tied to a random ID on your machine and carry your organization and region, never the key or the grant token. Sending adds at most two seconds to a run. Set `DO_NOT_TRACK=1` or `SG_NO_TELEMETRY=1` to turn them off. See "Usage analytics" in the README.

## 1.2.0

- The npm package is now `@stackguardian/mcp-wizard`: run `npx @stackguardian/mcp-wizard`. `add-sg-mcp` stops at 1.1.0. Your saved sign-in and installed skills carry over, so there is nothing to redo. The standalone executable and the install scripts keep the `add-sg-mcp` name.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ The executables are not yet notarized by Apple or code-signed for Windows. The i
- The CLI accepts a callback only when its one-time `state` matches, and sends the code and the credential only to API hosts under `stackguardian.io` (or `localhost`, for local development).
- `status` never prints the key or the grant token in full; the callback page strips the one-time code from the browser history, and the grant flow keeps its PKCE verifier off the front channel (only the one-time code travels through the browser).

### Usage analytics

The connect flow and `login` send four events to StackGuardian's PostHog (EU), so we can see where installs stop:

- `mcp_install_started`: the command and the sign-in mode.
- `mcp_install_logged_in`: the organization and region you signed in to, and whether a saved credential was reused.
- `mcp_install_login_failed`: why the browser sign-in did not finish: `cancelled`, `timeout` or a standard OAuth error code such as `access_denied`. Any other code is sent as `oauth_error`.
- `mcp_install_completed`: the agents that got the server, the scope, whether the skills installed and how many failed, the organization and region.

Each event also carries the CLI version, OS, CPU architecture and whether it ran in CI. Events are tied to a random ID stored in `~/.config/add-sg-mcp/telemetry-id`. They never include the API key, the grant token, file paths or error messages. Sending takes at most two seconds per run in total, and a failed or slow request never stops the install.

To turn it off, set `DO_NOT_TRACK=1` or `SG_NO_TELEMETRY=1`.

## Contributing

Pull requests are welcome: fixes to the installer, support for more agents, and improvements to the skills in [`skills/`](skills/). For a problem with your StackGuardian account or the hosted MCP server, contact support@stackguardian.io.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stackguardian/mcp-wizard",
"version": "1.2.2",
"version": "1.3.0",
"description": "Connect your coding agents to StackGuardian: installs the StackGuardian MCP server and skills into Claude Code, Codex, Cursor, VS Code, Gemini CLI, OpenCode and more with a single command.",
"author": "StackGuardian <support@stackguardian.io>",
"license": "Apache-2.0",
Expand Down
79 changes: 73 additions & 6 deletions src/sg/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ import {
type SgCredentials,
} from "./credentials.js";
import { LoginError } from "./auth.js";
import { loginViaGrant, revokeGrant } from "./oauth.js";
import { OAuthError, loginViaGrant, revokeGrant } from "./oauth.js";
import {
SG_SKILL_NAMES,
installSkills,
removeSkills,
skillsStatus,
} from "./skills.js";
import { oauthFailureReason, track } from "./telemetry.js";
import type { InstallOutcome, MainOptions } from "./types.js";

export interface SgAuthOptions {
Expand Down Expand Up @@ -125,6 +126,23 @@ function grantValidity(credentials: SgCredentials): string {
return `${isExpired(credentials) ? "expired" : "expires"} ${new Date(at).toLocaleString()}`;
}

function regionOf(apiBase: string): string {
return SG_ENVIRONMENTS.find((env) => env.apiBase === apiBase)?.id ?? "custom";
}

/** Funnel step 2: a credential is in hand, fresh or reused from an earlier run. */
async function trackLoggedIn(
credentials: SgCredentials,
reused: boolean,
): Promise<void> {
await track("mcp_install_logged_in", {
auth_mode: credentials.authType,
reused_saved_credential: reused,
sg_org: credentials.org,
sg_region: regionOf(credentials.apiBase),
});
}

async function chooseRegion(showQa?: boolean): Promise<string> {
const choice = await p.select({
message: "Where is your StackGuardian account?",
Expand Down Expand Up @@ -210,6 +228,7 @@ async function credentialsFromToken(
p.log.success(
`Saved credentials for ${chalk.cyan(org)} to ${shortenHome(path)}`,
);
await trackLoggedIn(credentials, false);
return credentials;
}

Expand Down Expand Up @@ -258,6 +277,7 @@ async function credentialsFromGrant(
p.log.info(
`Using the saved grant for ${chalk.cyan(saved.org)} (${saved.dashboardUrl || saved.apiBase}). Run ${chalk.cyan(`${CLI_COMMAND} logout`)} first to request a new one.`,
);
await trackLoggedIn(saved, true);
return saved;
}

Expand Down Expand Up @@ -289,6 +309,17 @@ async function credentialsFromGrant(
});
} catch (error) {
spinner.stop("The grant was not issued");
// Codes only, never the message: it can echo broker or user input.
await track("mcp_install_login_failed", {
auth_mode: "grant",
reason:
error instanceof LoginError
? error.code
: error instanceof OAuthError
? oauthFailureReason(error.error)
: "error",
sg_region: regionOf(apiBase),
});
if (error instanceof LoginError && error.code === "cancelled") {
fail("Cancelled in the browser.");
}
Expand All @@ -314,6 +345,7 @@ async function credentialsFromGrant(
};
const path = writeCredentials(credentials);
p.log.info(`Credentials saved to ${shortenHome(path)}`);
await trackLoggedIn(credentials, false);
return credentials;
}

Expand Down Expand Up @@ -350,6 +382,7 @@ export async function resolveCredentials(
p.log.info(
`Using saved credentials for ${chalk.cyan(saved.org)} (${saved.dashboardUrl || saved.apiBase}). Run ${chalk.cyan(`${CLI_COMMAND} login`)} to switch.`,
);
await trackLoggedIn(saved, true);
return saved;
}
}
Expand Down Expand Up @@ -493,6 +526,7 @@ function reportSkills(

/** What the install step needs to know about the server it writes. */
interface SgInstallTarget {
authMode: "apikey" | "grant" | "oauth";
apiBase: string;
org: string;
headers: string[];
Expand All @@ -501,6 +535,7 @@ interface SgInstallTarget {

function installTargetFor(credentials: SgCredentials): SgInstallTarget {
return {
authMode: credentials.authType,
apiBase: credentials.apiBase,
org: credentials.org,
headers: [buildAuthHeader(credentials)],
Expand All @@ -516,6 +551,20 @@ function warnProjectScope(options: SgInstallOptions): void {
}
}

/** Funnel step 1, awaited: an early `fail` or Ctrl-C exits the process before a pending request lands. */
async function trackStarted(
command: "connect" | "login",
auth: string,
options: SgConnectOptions,
): Promise<void> {
await track("mcp_install_started", {
command,
auth_mode: auth,
scope: scopeOf(options),
interactive: isInteractive(),
});
}

export async function runConnect(
options: SgConnectOptions,
deps: SgCommandDeps,
Expand All @@ -526,6 +575,7 @@ export async function runConnect(
if (auth !== "apikey" && auth !== "grant" && auth !== "oauth") {
fail(`--auth must be apikey, grant or oauth, got ${options.auth}`);
}
await trackStarted("connect", auth, options);
warnProjectScope(options);

let target: SgInstallTarget;
Expand Down Expand Up @@ -564,6 +614,7 @@ export async function runConnect(
);
}
target = {
authMode: "oauth",
apiBase: options.apiBase ?? env.apiBase,
org,
headers: [],
Expand Down Expand Up @@ -591,7 +642,7 @@ const SUGGESTED_PROMPTS = [

/** Agent pick, server entry, skills and the closing note: everything after the credential. */
async function installServerAndSkills(
{ apiBase, org, headers, allowed }: SgInstallTarget,
{ authMode, apiBase, org, headers, allowed }: SgInstallTarget,
options: SgConnectOptions,
deps: SgCommandDeps,
): Promise<void> {
Expand All @@ -616,10 +667,25 @@ async function installServerAndSkills(
if (installed.length === 0) return;
const scope = outcome.routing.get(installed[0]!) ?? scopeOf(options);

if (!options.skipSkills) {
const results = installSkills(installed, scope, process.cwd());
reportSkills(results, scope);
}
const skillResults = options.skipSkills
? []
: installSkills(installed, scope, process.cwd());
if (!options.skipSkills) reportSkills(skillResults, scope);
const skillsInstalled = skillResults.filter(
(r) => !r.error && r.mode !== "skipped",
).length;
// Funnel step 3: at least one agent has the server.
await track("mcp_install_completed", {
auth_mode: authMode,
agents: installed,
agent_count: installed.length,
failed_agent_count: outcome.results.size - installed.length,
scope,
skills_installed: skillsInstalled > 0,
failed_skill_count: skillResults.filter((r) => r.error).length,
sg_org: org,
sg_region: regionOf(apiBase),
});

p.note(
[
Expand Down Expand Up @@ -650,6 +716,7 @@ export async function runLogin(
if (auth !== "apikey" && auth !== "grant") {
fail(`--auth must be apikey, grant or oauth, got ${options.auth}`);
}
await trackStarted("login", auth, options);
// login always signs in afresh, so a grant login always asks for consent again.
const grant = auth === "grant";
const credentials = grant
Expand Down
158 changes: 158 additions & 0 deletions src/sg/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { request } from "node:https";
import { join } from "node:path";
import packageJson from "../../package.json" with { type: "json" };
import { getConfigDir } from "./credentials.js";

/** Public, write-only project key: it can send events but read nothing. */
const POSTHOG_KEY = "phc_CLrSgLXTieeMGMXKFff6nYZutjbsjYr0NXlTvmPuT6s";
const POSTHOG_CAPTURE_URL = "https://eu.i.posthog.com/i/v0/e/";
/** Time all events together may spend sending in one run; later events are dropped. */
const BUDGET_MS = 2_000;
const ID_FILE = "telemetry-id";

/** The install funnel, in order, plus the reason a sign-in did not finish. */
export type TelemetryEvent =
| "mcp_install_started"
| "mcp_install_logged_in"
| "mcp_install_login_failed"
| "mcp_install_completed";

type Properties = Record<string, string | number | boolean | string[]>;

/** Standard OAuth error codes (RFC 6749) and the CLI's own; anything else may echo broker or user text. */
const OAUTH_ERROR_CODES = new Set([
"invalid_request",
"invalid_client",
"invalid_grant",
"unauthorized_client",
"unsupported_grant_type",
"unsupported_response_type",
"invalid_scope",
"access_denied",
"server_error",
"temporarily_unavailable",
"invalid_response",
]);

export function oauthFailureReason(code: string): string {
return OAUTH_ERROR_CODES.has(code) ? code : "oauth_error";
}

function optedOut(value: string | undefined): boolean {
return Boolean(value) && value !== "0" && value?.toLowerCase() !== "false";
}

/** `DO_NOT_TRACK` (https://consoledonottrack.com) or `SG_NO_TELEMETRY` turns it off. */
export function isTelemetryDisabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return optedOut(env.DO_NOT_TRACK) || optedOut(env.SG_NO_TELEMETRY);
}

let cachedId: string | undefined;

/** A random ID per machine, so one person's runs line up in the funnel; never tied to the credential. */
export function anonymousId(): string {
if (cachedId) return cachedId;
const path = join(getConfigDir(), ID_FILE);
try {
const saved = readFileSync(path, "utf-8").trim();
if (saved) return (cachedId = saved);
} catch {
// First run.
}
cachedId = randomUUID();
try {
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
writeFileSync(path, `${cachedId}\n`, { encoding: "utf-8", mode: 0o600 });
} catch {
// Best effort: an unwritable config dir only splits this run from later ones.
}
return cachedId;
}

export function buildPayload(
event: TelemetryEvent,
properties: Properties,
distinctId: string,
): Record<string, unknown> {
return {
api_key: POSTHOG_KEY,
event,
distinct_id: distinctId,
timestamp: new Date().toISOString(),
properties: {
...properties,
cli_version: packageJson.version,
os: process.platform,
arch: process.arch,
// The standalone executable is compiled with Bun; npx runs on Node.
runtime: process.versions.bun ? "bun" : "node",
ci: Boolean(process.env.CI),
$lib: "add-sg-mcp",
$lib_version: packageJson.version,
$process_person_profile: false,
},
};
}

/**
* POSTs the body and resolves when the request ends or `timeoutMs` passes.
* On timeout the socket is destroyed, so a stalled connection or TLS handshake
* cannot keep the process alive after the CLI is done.
*/
export function send(
url: string,
body: string,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve) => {
const req = request(
url,
{
method: "POST",
agent: false,
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
},
},
(res) => res.resume(),
);
const timer = setTimeout(() => req.destroy(), timeoutMs);
req.on("error", () => {});
req.on("close", () => {
clearTimeout(timer);
resolve();
});
req.end(body);
});
}

let spentMs = 0;

/**
* Sends one event and never throws. Callers await it, so no event is lost to
* a later `process.exit`; the shared budget caps the delay for the whole run.
*/
export async function track(
event: TelemetryEvent,
properties: Properties = {},
): Promise<void> {
if (isTelemetryDisabled()) return;
const remaining = BUDGET_MS - spentMs;
if (remaining <= 0) return;
const started = Date.now();
try {
await send(
POSTHOG_CAPTURE_URL,
JSON.stringify(buildPayload(event, properties, anonymousId())),
remaining,
);
} catch {
// Analytics must never get in the way.
}
spentMs += Date.now() - started;
}
Loading
Loading