feat(mcp): notice when a newer CodeGraph release exists, without changing anything (#1243) (#1253)

The recommended MCP config launches the local binary, so a server left
running drifts behind releases silently — users discover the version gap
only when something breaks. Per the reporter's preferred option 1, the
server now checks the latest GitHub release in the background on startup
(never blocking; reuses the upgrade command's release-redirect resolution
so the two can't drift) and surfaces a one-line notice on three surfaces:
one stderr line (the MCP host's server log), the initialize instructions
(with do-not-run-it-yourself guidance for the agent), and codegraph_status.

Discipline: results cache in ~/.codegraph/update-check.json shared across
every proxy/daemon on the machine — 24h TTL on success, 1h backoff after
failure, an outage never hides an already-known update, and a stale cache
re-kicks a background refresh so long-lived daemons keep noticing. The
initialize path is a memoized synchronous cache read (the respond-fast
contract holds), and both handshake answerers (session + proxy) share one
helper so they can't diverge. Never stdout.

Hardening: the latest tag arrives from a network redirect via an on-disk
cache and ends up inside agent-visible instructions, so only a canonical
vX.Y.Z rebuilt from PARSED semver fields is ever interpolated — a tag
carrying trailing text (parseSemver is not end-anchored) renders without
it, and a non-version tag renders nothing and counts as a failed attempt.

Off is off: CODEGRAPH_NO_UPDATE_CHECK=1 (dedicated) or DO_NOT_TRACK=1
(broad convention — already set by data-plane deployments) suppresses the
network call and the notice entirely. Documented in TELEMETRY.md.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-10 17:36:47 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 8b82fe71f8
commit 47823944a3
8 changed files with 545 additions and 3 deletions
+9
View File
@@ -50,6 +50,7 @@ import {
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
import { getDaemonSocketCandidates } from './daemon-paths';
import { getTelemetry } from '../telemetry';
import { checkForUpdateInBackground } from '../upgrade/update-check';
import { EARLY_PPID } from './early-ppid';
import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from './ppid-watchdog';
import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
@@ -228,6 +229,14 @@ export class MCPServer {
// to the handshake path and never keeps the process alive.
getTelemetry().startInterval();
// #1243: the MCP config launches the local binary, so a server left
// running drifts behind releases with no signal. Refresh the shared
// update-check cache in the background and log ONE stderr notice when a
// newer version exists (stderr only — stdout is the protocol channel).
// The notice also reaches the agent via the initialize instructions and
// codegraph_status. Fire-and-forget: adds nothing to the handshake path.
checkForUpdateInBackground();
// The detached daemon process itself. Checked before the opt-out so the
// daemon honors the same env it was spawned with (it never sets NO_DAEMON).
if (daemonInternalSet()) {
+2 -2
View File
@@ -27,7 +27,7 @@ import { supervisionLostReason } from './ppid-watchdog';
import { armStartupHandshakeTimeout } from './startup-handshake';
import { treatStdinFailureAsShutdown } from './stdin-teardown';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session';
import { SERVER_INSTRUCTIONS } from './server-instructions';
import { getStaticTools } from './tools';
import { getTelemetry, ClientInfo } from '../telemetry';
@@ -309,7 +309,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
version: typeof initParams.clientInfo.version === 'string' ? initParams.clientInfo.version : undefined,
};
}
writeClient({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS } });
writeClient({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: initializeInstructions(SERVER_INSTRUCTIONS) } });
routeToDaemon(line); // prime the daemon so it resolves the project (its reply is suppressed below)
} else if (msg.method === 'tools/list') {
writeClient({ jsonrpc: '2.0', id: msg.id, result: { tools: getStaticTools() } });
+23 -1
View File
@@ -20,6 +20,7 @@ import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_NO_ROOT_INDEX } from './server
import { CodeGraphPackageVersion } from './version';
import { findNearestCodeGraphRoot } from '../directory';
import { getTelemetry, ClientInfo } from '../telemetry';
import { getUpdateNotice } from '../upgrade/update-check';
/**
* MCP Server Info — kept on the session because some clients log it. The
@@ -32,6 +33,27 @@ export const SERVER_INFO = {
version: CodeGraphPackageVersion,
};
/**
* Instructions for the `initialize` response, with the update-availability
* notice appended when one is known (#1243). Exported so the proxy's local
* handshake sends the IDENTICAL payload — same convention as SERVER_INFO.
* `getUpdateNotice` is a memoized synchronous cache read, so the #172
* respond-fast contract holds; when no notice exists the instructions are
* byte-identical to the bare constants.
*
* Test-authoring note: on a machine whose real `~/.codegraph` cache knows a
* newer release, spawned servers append the notice — a test asserting exact
* instructions equality must set `CODEGRAPH_NO_UPDATE_CHECK=1` in the spawn
* env or it will fail only in the weeks after a release ships.
*/
export function initializeInstructions(base: string, notice: string | null = getUpdateNotice()): string {
if (!notice) return base;
return (
`${base}\n\n---\n${notice} This server keeps running the old version until ` +
`the user upgrades — mention it when convenient; do not run the upgrade yourself.`
);
}
/** MCP Protocol Version (latest the server claims). */
export const PROTOCOL_VERSION = '2024-11-05';
@@ -207,7 +229,7 @@ export class MCPSession {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
instructions: indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_NO_ROOT_INDEX,
instructions: initializeInstructions(indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_NO_ROOT_INDEX),
});
if (explicitPath) {
+9
View File
@@ -30,6 +30,7 @@ import {
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
import { isGeneratedFile } from '../extraction/generated-detection';
import { scanDynamicDispatch } from './dynamic-boundaries';
import { getUpdateNotice } from '../upgrade/update-check';
/**
* An expected, recoverable "codegraph can't serve this" condition — most
@@ -4081,6 +4082,14 @@ export class ToolHandler {
);
}
// A newer release exists (#1243) — status is where users and agents look
// when something seems off, so surface the drift here too. Cheap memoized
// cache read; absent entirely when up to date or opted out.
const updateNotice = getUpdateNotice();
if (updateNotice) {
lines.push(`**Update available:** ${updateNotice}`);
}
// Non-zero at rest means a resolution pass was interrupted mid-run, so
// some files' call/impact edges are missing until the next sync sweeps
// the leftovers (#1187). Surface it — an agent trusting an incomplete