From f093bcede19d63da7960b67b23c1ccc7485734ac Mon Sep 17 00:00:00 2001 From: gsdali <51393997+gsdali@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:04:47 +1000 Subject: [PATCH] fix(context): refresh get_docs when packages change on disk `context add` writes to ~/.context/packages from a separate process, but a running `serve` reads that directory once at startup. A long-lived stdio server therefore kept serving the package list it saw when it launched, and `get_docs` reported a package as missing when it was already installed. The only way out was to reconnect the client. The mechanism to avoid that already existed and was wired to a single trigger. refreshGetDocsTool rebuilds the tool's `library` enum and calls sendToolListChanged, which is the MCP notification telling a client to re-fetch the tool list; it was called only from the download_package handler, so packages that arrived any other way were invisible. This adds a second trigger rather than a second mechanism. - watch.ts: a small debounced directory watcher. Debounced because one install is several filesystem events (a temp file, then a rename), which would otherwise rebuild the schema three or four times. The watcher is unref'd so it never keeps the process alive on its own, and a callback that throws cannot tear it down. - serve: watches the data directory, reloads the store and refreshes the tool. Skipped when --libs is set, because that flag pins the session to a fixed library set on purpose and picking up new packages would defeat it. - refreshGetDocsTool is now public, since the trigger lives outside the class. - loadPackages now syncs rather than only adding. It is called repeatedly now, so a package removed from disk has to leave the store too, which the add-only version could not express. HTTP transport needed no change: it builds a fresh ContextServer per session over the same store, so a session started after an install already sees it. Only the long-lived stdio server needed the live notification. Tests cover the watcher directly: a single change fires once, a burst collapses to one call, stopping prevents further calls, a throwing callback does not kill the watcher, and a missing directory is a no-op. Verified discriminating by removing the debounce, which fails three of the five. Local `pnpm test` shows 40 pre-existing failures in this environment, identical before and after this change: better-sqlite3 11.10.0 does not build against Node v26, so every sqlite-backed test errors on the missing bindings. The five new tests pass (181 to 186 passing). pnpm lint and pnpm build are clean. --- .changeset/olive-otters-watch.md | 5 ++ packages/context/src/cli.ts | 44 +++++++++++--- packages/context/src/server.ts | 7 ++- packages/context/src/watch.test.ts | 94 ++++++++++++++++++++++++++++++ packages/context/src/watch.ts | 40 +++++++++++++ 5 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 .changeset/olive-otters-watch.md create mode 100644 packages/context/src/watch.test.ts create mode 100644 packages/context/src/watch.ts diff --git a/.changeset/olive-otters-watch.md b/.changeset/olive-otters-watch.md new file mode 100644 index 0000000..7d739f6 --- /dev/null +++ b/.changeset/olive-otters-watch.md @@ -0,0 +1,5 @@ +--- +"@neuledge/context": patch +--- + +`serve` now picks up packages installed while it is running. `context add` writes to the data directory from a separate process, so a long-lived stdio server used to serve the package list it read at startup until the client reconnected, and `get_docs` could not see a package that was already installed. diff --git a/packages/context/src/cli.ts b/packages/context/src/cli.ts index 34baa43..78b419b 100644 --- a/packages/context/src/cli.ts +++ b/packages/context/src/cli.ts @@ -65,6 +65,7 @@ import { packageKey, readPackageInfo, } from "./store.js"; +import { watchDirectory } from "./watch.js"; type SourceType = "file" | "url" | "git" | "local-dir" | "website"; @@ -506,19 +507,33 @@ function ensureDataDir(): void { mkdirSync(DATA_DIR, { recursive: true }); } -/** Load all packages from the data directory into the store. */ +/** + * Sync the store with the data directory: add or update what is on disk, drop + * what is not. + * + * Called once at startup and again whenever the directory changes, so removals + * have to be handled too, not only additions. + */ function loadPackages(store: PackageStore): void { - if (!existsSync(DATA_DIR)) return; + const onDisk = new Set(); - for (const file of readdirSync(DATA_DIR)) { - if (!file.endsWith(".db")) continue; - try { - const info = readPackageInfo(join(DATA_DIR, file)); - store.add(info); - } catch { - // Skip invalid packages + if (existsSync(DATA_DIR)) { + for (const file of readdirSync(DATA_DIR)) { + if (!file.endsWith(".db")) continue; + try { + const info = readPackageInfo(join(DATA_DIR, file)); + store.add(info); + onDisk.add(packageKey(info)); + } catch { + // Skip invalid packages + } } } + + for (const pkg of store.list()) { + const key = packageKey(pkg); + if (!onDisk.has(key)) store.remove(key); + } } /** @@ -1218,6 +1233,17 @@ program const server = new ContextServer(store, { allowedLibraries }); + // `context add` runs in a separate process, so a long-lived stdio server + // would otherwise serve the package list it read at startup until the + // client reconnected. Skipped under --libs, which pins the session to a + // fixed library set on purpose. + if (!allowedLibraries) { + watchDirectory(DATA_DIR, () => { + loadPackages(store); + server.refreshGetDocsTool(); + }); + } + if (options.http !== undefined) { const port = typeof options.http === "string" diff --git a/packages/context/src/server.ts b/packages/context/src/server.ts index d5a7559..c53c5c7 100644 --- a/packages/context/src/server.ts +++ b/packages/context/src/server.ts @@ -223,8 +223,13 @@ export class ContextServer { /** * Update the get_docs tool to include newly installed packages. * If get_docs doesn't exist yet, register it for the first time. + * + * Public because packages can also arrive from outside this process: `context + * add` writes to the data directory while a stdio server is already running. + * `serve` watches that directory and calls this, so the tool's `library` enum + * stays current without the client reconnecting. */ - private refreshGetDocsTool(): void { + refreshGetDocsTool(): void { const packages = this.visiblePackages(); if (this.getDocsRegistration) { diff --git a/packages/context/src/watch.test.ts b/packages/context/src/watch.test.ts new file mode 100644 index 0000000..5848196 --- /dev/null +++ b/packages/context/src/watch.test.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { watchDirectory } from "./watch.js"; + +/** Give the watcher's debounce window time to close before asserting. */ +const settle = () => new Promise((r) => setTimeout(r, 400)); + +describe("watchDirectory", () => { + let dir: string; + let stop: (() => void) | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "context-watch-")); + }); + + afterEach(() => { + stop?.(); + stop = undefined; + rmSync(dir, { recursive: true, force: true }); + }); + + it("fires when a file appears", async () => { + let calls = 0; + stop = watchDirectory(dir, () => { + calls++; + }); + + writeFileSync(join(dir, "demo.db"), "x"); + await settle(); + + expect(calls).toBe(1); + }); + + it("collapses a burst into one call", async () => { + let calls = 0; + stop = watchDirectory(dir, () => { + calls++; + }); + + // What one install looks like: a temp file, then a rename into place. + writeFileSync(join(dir, ".downloading-demo.db"), "x"); + writeFileSync(join(dir, "demo.db"), "x"); + writeFileSync(join(dir, "other.db"), "x"); + await settle(); + + expect(calls).toBe(1); + }); + + it("stops firing once stopped", async () => { + let calls = 0; + const cancel = watchDirectory(dir, () => { + calls++; + }); + cancel(); + + writeFileSync(join(dir, "demo.db"), "x"); + await settle(); + + expect(calls).toBe(0); + }); + + it("survives a callback that throws", async () => { + let calls = 0; + stop = watchDirectory(dir, () => { + calls++; + throw new Error("refresh failed"); + }); + + writeFileSync(join(dir, "one.db"), "x"); + await settle(); + writeFileSync(join(dir, "two.db"), "x"); + await settle(); + + // A throwing refresh must not tear the watcher down: the second write is + // still observed. + expect(calls).toBe(2); + }); + + it("is a no-op for a directory that does not exist", async () => { + let calls = 0; + const missing = join(dir, "absent"); + stop = watchDirectory(missing, () => { + calls++; + }); + + mkdirSync(missing); + writeFileSync(join(missing, "demo.db"), "x"); + await settle(); + + expect(calls).toBe(0); + }); +}); diff --git a/packages/context/src/watch.ts b/packages/context/src/watch.ts new file mode 100644 index 0000000..ebafffb --- /dev/null +++ b/packages/context/src/watch.ts @@ -0,0 +1,40 @@ +import { existsSync, watch } from "node:fs"; + +/** Wait this long after the last event before treating a burst as one change. */ +const DEBOUNCE_MS = 200; + +/** + * Call `onChange` when the contents of `dir` change. + * + * Debounced, because a single install is several filesystem events: `context + * add` writes a temporary file and renames it into place. Without this, one + * install would rebuild the tool schema three or four times. + * + * The watcher is unref'd, so watching never keeps the process alive by itself. + * Returns a stop function; a missing directory is a no-op rather than an error, + * since nothing has been installed yet in that case. + */ +export function watchDirectory(dir: string, onChange: () => void): () => void { + if (!existsSync(dir)) return () => {}; + + let pending: NodeJS.Timeout | undefined; + + const watcher = watch(dir, () => { + if (pending) clearTimeout(pending); + pending = setTimeout(() => { + pending = undefined; + try { + onChange(); + } catch { + // A refresh that throws must not take the server down with it. + } + }, DEBOUNCE_MS); + }); + + watcher.unref(); + + return () => { + if (pending) clearTimeout(pending); + watcher.close(); + }; +}