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
21 changes: 21 additions & 0 deletions packages/core/src/composite/account-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ export const accountStatus: Tool<Record<string, never>> = {
type: "object",
properties: { region: { type: "string" } },
},
// Auto-update block. Populated by the MCP server wrapper (NOT this
// composite) when a newer release is published on GitHub AND the
// user hasn't suppressed it. When present, the agent should prompt
// the user via ask_user_input_v0 with three options and route the
// chosen action through leadbay_acknowledge_update.
update_available: {
type: ["object", "null"],
properties: {
current_version: { type: "string" },
latest_version: { type: "string" },
mcpb_url: {
type: "string",
description: "Direct download URL for the .mcpb installer asset.",
},
release_url: {
type: "string",
description: "GitHub release page (changelog).",
},
},
required: ["current_version", "latest_version", "mcpb_url", "release_url"],
},
},
required: ["user", "organization"],
},
Expand Down
29 changes: 29 additions & 0 deletions packages/mcp/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from "@leadbay/core";
import { buildServer } from "./server.js";
import { initTelemetry } from "./telemetry.js";
import { createDefaultUpdateStateStore } from "./update-state.js";
import { checkForUpdate, recordRunningVersion } from "./update-check.js";

// __LEADBAY_MCP_VERSION__ is replaced at build time by tsup with the string
// literal from packages/mcp/package.json#version. Single source of truth —
Expand Down Expand Up @@ -1393,13 +1395,40 @@ async function main(): Promise<void> {
// Fails loudly unless LEADBAY_BULK_STORE_ALLOW_MEMORY=1 is set.
const bulkTracker = await createDefaultBulkStore({ logger });

// Auto-update state — best-effort; falls back to in-memory when
// ~/.leadbay is unwritable. Created BEFORE the version-check kicks
// off below so checkForUpdate can persist its result.
const updateStateStore = await createDefaultUpdateStateStore({ logger });
// Fire-and-forget: detect "this boot is on a newer version than the
// previous boot" → emit `mcp version updated` PostHog event. Works
// even when offline. Runs before the GitHub check so the event fires
// promptly on a fresh upgrade.
void recordRunningVersion(VERSION, updateStateStore, telemetry).catch((err) => {
logger.warn?.(
`update_state.record_version_failed ${err?.message ?? err}`
);
});
// Fire-and-forget GitHub releases check. Throttled to ~1h via state.
// Hard opt-out: LEADBAY_UPDATE_CHECK_DISABLED=1.
if (process.env.LEADBAY_UPDATE_CHECK_DISABLED !== "1") {
void checkForUpdate({
currentVersion: VERSION,
stateStore: updateStateStore,
telemetry,
logger,
}).catch((err) => {
logger.warn?.(`update_check.unexpected ${err?.message ?? err}`);
});
}

const server = buildServer(client, {
includeAdvanced,
includeWrite,
logger,
bulkTracker,
version: VERSION,
telemetry,
updateStateStore,
});
const transport = new StdioServerTransport();
logger.info?.(
Expand Down
112 changes: 112 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ import {
type ToolLogger,
} from "@leadbay/core";
import { NOOP_TELEMETRY, type TelemetryHandle } from "./telemetry.js";
import type { UpdateStateStore } from "./update-state.js";
import {
checkForUpdate,
getCachedUpdateInfo,
type UpdateInfo,
} from "./update-check.js";
import { buildAcknowledgeUpdateTool } from "./update-tool.js";

// SERVER_INSTRUCTIONS is now BUILT from the actual exposed tool set (see
// buildServerInstructions below). 0.2.x shipped a single static string that
Expand Down Expand Up @@ -107,6 +114,25 @@ function buildStartHereParagraph(has: (name: string) => boolean): string {
);
}

function buildUpdateAvailableParagraph(has: (name: string) => boolean): string | null {
// Only emit the routing instruction when the acknowledge tool is actually
// exposed — keeps the agent prompt free of dead references when bin.ts
// omits updateStateStore (offline embeds, tests).
if (!has("leadbay_acknowledge_update")) return null;
return (
"MCP auto-update: when `leadbay_account_status` returns an `update_available` field " +
"(`{ current_version, latest_version, mcpb_url, release_url }`), a newer MCP server release " +
"is published and the user has NOT suppressed it. Surface a prompt via `ask_user_input_v0` " +
"with EXACTLY these three options: \"Install now\", \"Remind me tomorrow\", \"Skip this version\". " +
"Map the user's choice to `leadbay_acknowledge_update({ action: 'install' | 'remind_tomorrow' | 'skip', version: latest_version })`. " +
"On 'install', the tool returns `mcpb_url` — render it as a clickable markdown link the user " +
"can open in Claude Desktop (the .mcpb extension triggers the native installer). The user does " +
"NOT need to restart anything before clicking — the new server takes effect on the next MCP " +
"session. Prompt the user ONCE per session per version — don't re-prompt within the same chat " +
"after they've acknowledged."
);
}

function buildRhythmParagraph(has: (name: string) => boolean): string {
if (has("leadbay_report_outreach")) {
return (
Expand Down Expand Up @@ -243,6 +269,8 @@ export function buildServerInstructions(exposed: Set<string>): string {
parts.push(buildScoringParagraph(has));
parts.push(buildStartHereParagraph(has));
parts.push(buildRhythmParagraph(has));
const updateParagraph = buildUpdateAvailableParagraph(has);
if (updateParagraph) parts.push(updateParagraph);
const promptsCatalog = buildPromptsCatalogParagraph(has);
if (promptsCatalog) parts.push(promptsCatalog);
parts.push(RESOURCES_PARAGRAPH);
Expand Down Expand Up @@ -275,6 +303,12 @@ interface BuildServerOptions {
// omitted (tests + offline embeds). The CLI builds the real handle via
// initTelemetry() in bin.ts and passes it here.
telemetry?: TelemetryHandle;
// Auto-update state store. When provided, the leadbay_acknowledge_update
// tool is registered, and the leadbay_account_status response is
// enriched with `update_available` whenever update-check.ts has cached
// a newer release. Omitted in tests + embeds that don't want auto-update
// surface area; the server stays functional either way.
updateStateStore?: UpdateStateStore;
}

function formatErrorForLLM(err: any): string {
Expand Down Expand Up @@ -327,6 +361,20 @@ export function buildServer(
exposedTools.push(...granularWriteTools);
}
}
// Auto-update tool — only registered when bin.ts provides a state
// store. Tests + embeds that omit it never see the tool (keeping the
// exposed catalogue lean) and also never see update_available
// injection, since both share the same gate.
if (opts.updateStateStore) {
exposedTools.push(
buildAcknowledgeUpdateTool({
stateStore: opts.updateStateStore,
telemetry: opts.telemetry ?? NOOP_TELEMETRY,
currentVersion: opts.version ?? "0.0.0-dev",
logger: opts.logger,
})
);
}
// Test-only injection point.
if (opts.extraTools) {
exposedTools.push(...opts.extraTools);
Expand Down Expand Up @@ -467,6 +515,60 @@ export function buildServer(
// initTelemetry() and emits to PostHog + Sentry.
const telemetry: TelemetryHandle = opts.telemetry ?? NOOP_TELEMETRY;

// Track versions we've already emitted `mcp update prompted` for in
// this server lifetime. Without this, every account_status call after
// a new release lands would fire the event — dashboards would lose
// the funnel signal. Set is per-server (per-process) so a restart
// re-prompts the analytics layer; that's intentional — restart = new
// session = new opportunity to convert.
const promptedVersionsThisSession = new Set<string>();
const serverVersion = opts.version ?? "0.0.0-dev";

// Fire-and-forget background re-check on every tool call. The
// checkForUpdate() itself throttles to 24h via state.last_check_time,
// so the cost when state is fresh is one disk read; when stale, one
// GitHub roundtrip. The in-flight guard inside update-check.ts
// prevents concurrent tool calls from racing. Never blocks the
// current call: the freshest result is what the NEXT account_status
// call sees, not this one. Opt-out: LEADBAY_UPDATE_CHECK_DISABLED=1.
const UPDATE_CHECK_DISABLED = process.env.LEADBAY_UPDATE_CHECK_DISABLED === "1";
const maybeRefreshUpdate = (): void => {
if (UPDATE_CHECK_DISABLED) return;
if (!opts.updateStateStore) return;
void checkForUpdate({
currentVersion: serverVersion,
stateStore: opts.updateStateStore,
telemetry,
logger: opts.logger,
}).catch((err: any) => {
opts.logger?.warn?.(
`update_check.unexpected ${err?.message ?? err}`
);
});
};

const maybeAttachUpdate = (toolName: string, result: unknown): void => {
if (toolName !== "leadbay_account_status") return;
if (!opts.updateStateStore) return; // gate symmetric with tool registration
if (
result === null ||
typeof result !== "object" ||
Array.isArray(result)
) {
return;
}
const info: UpdateInfo | null = getCachedUpdateInfo();
if (!info) return;
(result as Record<string, unknown>).update_available = info;
if (!promptedVersionsThisSession.has(info.latest_version)) {
promptedVersionsThisSession.add(info.latest_version);
telemetry.captureUpdatePrompted?.({
current_version: serverVersion,
latest_version: info.latest_version,
});
}
};

// A LeadbayError surfaced either via throw OR via the `{ error: true,
// code, ... }` envelope shape (see formatErrorForLLM). Either way it
// represents a business outcome (QUOTA_EXCEEDED, NOT_FOUND, AUTH_EXPIRED,
Expand All @@ -484,6 +586,11 @@ export function buildServer(
// gating moves to the stderr-write step.
const callStart = Date.now();
const name = req.params.name;
// Fire-and-forget update re-check on every tool call. checkForUpdate
// itself returns immediately if last_check_time is within 24h, so
// the steady-state cost is one disk read. When stale, one GitHub
// roundtrip — never awaited, never blocks the tool.
maybeRefreshUpdate();
const tool = toolByName.get(name);
if (!tool) {
return {
Expand Down Expand Up @@ -559,6 +666,11 @@ export function buildServer(
progress,
elicit,
});
// Inject `update_available` into account_status returns when an
// upgrade is cached. Other tools pass through untouched. Done
// BEFORE the error/markdown/json branching so the field appears
// in either the JSON serialization OR structuredContent.
maybeAttachUpdate(name, result);
// Leadbay tools may return error envelopes ({ error: true, code, ... })
// rather than throwing. Surface those as MCP isError so the LLM doesn't
// treat them as success.
Expand Down
38 changes: 38 additions & 0 deletions packages/mcp/src/telemetry-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ export const EV_TOOL_CALL = "mcp tool called";
export const EV_QUOTA_HIT = "mcp quota hit";
export const EV_TOPUP_LINK = "mcp topup link created";
export const EV_STARTUP = "mcp startup";
// Auto-update lifecycle. Five events let dashboards build the funnel
// (check → prompted → install_clicked OR dismissed) and the conversion
// (version_updated fires on the next boot under the new VERSION).
export const EV_MCP_UPDATE_CHECK = "mcp update check";
export const EV_MCP_UPDATE_PROMPTED = "mcp update prompted";
export const EV_MCP_UPDATE_INSTALL_CLICKED = "mcp update install_clicked";
export const EV_MCP_UPDATE_DISMISSED = "mcp update dismissed";
export const EV_MCP_VERSION_UPDATED = "mcp version updated";

export type ToolCallFormat = "json" | "markdown" | "error-envelope";

Expand Down Expand Up @@ -41,3 +49,33 @@ export interface StartupProps {
auth_state: StartupAuthState;
region: string;
}

export interface UpdateCheckProps {
current_version: string;
latest_version?: string;
/** Populated only on the failure path (network error / non-2xx). */
check_error?: string;
}

export interface UpdatePromptedProps {
current_version: string;
latest_version: string;
}

export interface UpdateInstallClickedProps {
current_version: string;
latest_version: string;
}

export type UpdateDismissAction = "remind_tomorrow" | "skip";

export interface UpdateDismissedProps {
current_version: string;
latest_version: string;
action: UpdateDismissAction;
}

export interface VersionUpdatedProps {
from_version: string;
to_version: string;
}
39 changes: 39 additions & 0 deletions packages/mcp/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import {
EMBEDDED_SENTRY_DSN,
} from "./telemetry-constants.js";
import {
EV_MCP_UPDATE_CHECK,
EV_MCP_UPDATE_DISMISSED,
EV_MCP_UPDATE_INSTALL_CLICKED,
EV_MCP_UPDATE_PROMPTED,
EV_MCP_VERSION_UPDATED,
EV_QUOTA_HIT,
EV_STARTUP,
EV_TOOL_CALL,
Expand All @@ -36,6 +41,11 @@ import {
type StartupProps,
type ToolCallProps,
type TopupLinkProps,
type UpdateCheckProps,
type UpdateDismissedProps,
type UpdateInstallClickedProps,
type UpdatePromptedProps,
type VersionUpdatedProps,
} from "./telemetry-events.js";

export interface TelemetryHandle {
Expand All @@ -49,6 +59,15 @@ export interface TelemetryHandle {
captureTopupLink(props: TopupLinkProps): void;
captureStartup(props: StartupProps): void;
captureException(err: unknown, ctx: ExceptionCtx): void;
// Auto-update lifecycle. Optional on the interface so out-of-tree
// TelemetryHandle implementations don't have to implement them; the
// update-check site null-checks before calling. NOOP_TELEMETRY +
// the real PostHog impl both provide them.
captureUpdateCheck?(props: UpdateCheckProps): void;
captureUpdatePrompted?(props: UpdatePromptedProps): void;
captureUpdateInstallClicked?(props: UpdateInstallClickedProps): void;
captureUpdateDismissed?(props: UpdateDismissedProps): void;
captureVersionUpdated?(props: VersionUpdatedProps): void;
shutdown(): Promise<void>;
}

Expand All @@ -59,6 +78,11 @@ export const NOOP_TELEMETRY: TelemetryHandle = {
captureTopupLink: () => {},
captureStartup: () => {},
captureException: () => {},
captureUpdateCheck: () => {},
captureUpdatePrompted: () => {},
captureUpdateInstallClicked: () => {},
captureUpdateDismissed: () => {},
captureVersionUpdated: () => {},
shutdown: async () => {},
};

Expand Down Expand Up @@ -284,6 +308,21 @@ export function initTelemetry(opts: InitOpts): TelemetryHandle {
captureStartup(props) {
emit(EV_STARTUP, { ...props });
},
captureUpdateCheck(props) {
emit(EV_MCP_UPDATE_CHECK, { ...props });
},
captureUpdatePrompted(props) {
emit(EV_MCP_UPDATE_PROMPTED, { ...props });
},
captureUpdateInstallClicked(props) {
emit(EV_MCP_UPDATE_INSTALL_CLICKED, { ...props });
},
captureUpdateDismissed(props) {
emit(EV_MCP_UPDATE_DISMISSED, { ...props });
},
captureVersionUpdated(props) {
emit(EV_MCP_VERSION_UPDATED, { ...props });
},
captureException(err, ctx) {
if (!sentryReady) return;
try {
Expand Down
Loading
Loading