From aed7f66432ce9e64046dc179d654ccda8051d800 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 09:25:04 +0000 Subject: [PATCH] feat(context): restrict MCP sessions to a subset of installed libs via --libs `context serve --libs react next@15` filters the get_docs library enum to the listed packages and hides search_packages / download_package so the session is locked to that fixed set. Lets users keep many packages installed globally while exposing only the relevant subset per project (closes #84). --- .changeset/breezy-falcon-841.md | 5 ++ packages/context/README.md | 4 + packages/context/src/cli.test.ts | 87 ++++++++++++++++++++- packages/context/src/cli.ts | 117 +++++++++++++++++++++------- packages/context/src/server.test.ts | 57 ++++++++++++++ packages/context/src/server.ts | 41 ++++++++-- 6 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 .changeset/breezy-falcon-841.md diff --git a/.changeset/breezy-falcon-841.md b/.changeset/breezy-falcon-841.md new file mode 100644 index 0000000..d92a64f --- /dev/null +++ b/.changeset/breezy-falcon-841.md @@ -0,0 +1,5 @@ +--- +"@neuledge/context": minor +--- + +Add `--libs` option to `context serve` for restricting an MCP session to a fixed subset of installed libraries. Each entry can be a name (`react`) or `name@version` (`react@18.3.1`). When set, `search_packages` and `download_package` are hidden so the session is locked to that list — useful for per-project scoping when many packages are installed globally. diff --git a/packages/context/README.md b/packages/context/README.md index 1a6217f..c2d3e55 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -522,12 +522,16 @@ context serve context serve --http context serve --http 3000 context serve --http 3000 --host 0.0.0.0 + +# Restrict the session to a subset of installed packages +context serve --libs react next@15.0.4 ``` | Option | Description | |--------|-------------| | `--http [port]` | Start as HTTP server instead of stdio (default port: 8080) | | `--host ` | Host to bind to (default: 127.0.0.1) | +| `--libs ` | Restrict the session to a fixed set of installed libraries. Each entry is a name (`react`) or `name@version` (`react@18.3.1`). When set, `search_packages` and `download_package` are hidden so the session is locked to that list. Useful for per-project scoping when you have many packages installed globally. | The HTTP transport uses the [MCP Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) protocol, enabling multiple clients on the local network to connect to a single server instance. The endpoint is available at `http://:/mcp`. diff --git a/packages/context/src/cli.test.ts b/packages/context/src/cli.test.ts index 4223ecb..8255c0b 100644 --- a/packages/context/src/cli.test.ts +++ b/packages/context/src/cli.test.ts @@ -1,12 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { detectSourceType, fetchWebPage, packageNameFromUrl, + parseLibSpec, parseRegistryPackage, + resolveAllowedLibraries, resolveLlmsTxtUrls, suggestPackageNameFromUrl, } from "./cli.js"; +import type { PackageInfo } from "./store.js"; describe("detectSourceType", () => { describe("file sources", () => { @@ -420,3 +423,85 @@ describe("fetchWebPage", () => { expect(page.reason).toMatch(/timed out after 100ms/); }); }); + +describe("parseLibSpec", () => { + it("treats a bare name as no version", () => { + expect(parseLibSpec("react")).toEqual({ name: "react" }); + }); + + it("splits on the last @", () => { + expect(parseLibSpec("next@15.0.0")).toEqual({ + name: "next", + version: "15.0.0", + }); + }); + + it("preserves scoped names without a version", () => { + expect(parseLibSpec("@trpc/server")).toEqual({ name: "@trpc/server" }); + }); + + it("splits a scoped name with a version on the last @", () => { + expect(parseLibSpec("@trpc/server@11.0.0")).toEqual({ + name: "@trpc/server", + version: "11.0.0", + }); + }); +}); + +describe("resolveAllowedLibraries", () => { + const installed: PackageInfo[] = [ + { + name: "react", + version: "18.3.1", + path: "/react.db", + sizeBytes: 0, + sectionCount: 0, + }, + { + name: "next", + version: "15.0.4", + path: "/next.db", + sizeBytes: 0, + sectionCount: 0, + }, + ]; + + it("returns the set of matching names for valid specs", () => { + const result = resolveAllowedLibraries(["react", "next@15.0.4"], installed); + expect(result).toEqual(new Set(["react", "next"])); + }); + + it("exits with an error when a name isn't installed", () => { + const exit = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + resolveAllowedLibraries(["missing"], installed); + expect(exit).toHaveBeenCalledWith(1); + expect(err.mock.calls.flat().join("\n")).toContain( + "missing: not installed", + ); + } finally { + exit.mockRestore(); + err.mockRestore(); + } + }); + + it("exits with an error when the pinned version doesn't match", () => { + const exit = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + resolveAllowedLibraries(["react@17.0.0"], installed); + expect(exit).toHaveBeenCalledWith(1); + expect(err.mock.calls.flat().join("\n")).toContain( + "installed version is 18.3.1", + ); + } finally { + exit.mockRestore(); + err.mockRestore(); + } + }); +}); diff --git a/packages/context/src/cli.ts b/packages/context/src/cli.ts index f074246..e107568 100644 --- a/packages/context/src/cli.ts +++ b/packages/context/src/cli.ts @@ -511,6 +511,53 @@ function loadPackages(store: PackageStore): void { } } +/** Parse a `--libs` spec into name (optionally with @version). */ +// Uses lastIndexOf so scoped names like `@trpc/server@1.0.0` split correctly, +// while a bare scoped name `@trpc/server` (leading `@` only) keeps its name. +export function parseLibSpec(spec: string): { name: string; version?: string } { + const at = spec.lastIndexOf("@"); + if (at <= 0) return { name: spec }; + return { name: spec.slice(0, at), version: spec.slice(at + 1) }; +} + +/** + * Resolve `--libs` specs against installed packages. Exits the process with a + * descriptive error if any spec doesn't match an installed package. + */ +export function resolveAllowedLibraries( + specs: string[], + installed: PackageInfo[], +): Set { + const allowed = new Set(); + const errors: string[] = []; + + for (const raw of specs) { + const spec = parseLibSpec(raw); + const pkg = installed.find((p) => p.name === spec.name); + + if (!pkg) { + errors.push(` - ${raw}: not installed`); + continue; + } + if (spec.version && pkg.version !== spec.version) { + errors.push( + ` - ${raw}: installed version is ${pkg.version}, not ${spec.version}`, + ); + continue; + } + allowed.add(pkg.name); + } + + if (errors.length > 0) { + console.error("Cannot start --libs session:"); + for (const e of errors) console.error(e); + console.error("Run `context list` to see installed packages."); + process.exit(1); + } + + return allowed; +} + const program = new Command() .name("context") .description("Local-first documentation for AI agents") @@ -1032,35 +1079,53 @@ program "Start as HTTP server instead of stdio (default port: 8080)", ) .option("--host ", "Host to bind to (default: 127.0.0.1)") - .action(async (options: { http?: string | true; host?: string }) => { - const store = new PackageStore(); - loadPackages(store); - - const packages = store.list(); - if (packages.length > 0) { - const names = packages.map((p) => `${p.name}@${p.version}`).join(", "); - console.error(`Context MCP Server starting...`); - console.error(`Loaded ${packages.length} packages: ${names}`); - } else { - console.error("Context MCP Server starting..."); - console.error("No packages installed. Run: context add "); - } + .option( + "--libs ", + "Restrict the session to a fixed set of installed libraries (e.g., react next@15). Hides search_packages and download_package.", + ) + .action( + async (options: { + http?: string | true; + host?: string; + libs?: string[]; + }) => { + const store = new PackageStore(); + loadPackages(store); + + const allowedLibraries = options.libs + ? resolveAllowedLibraries(options.libs, store.list()) + : undefined; + + const visible = allowedLibraries + ? store.list().filter((p) => allowedLibraries.has(p.name)) + : store.list(); + + if (visible.length > 0) { + const names = visible.map((p) => `${p.name}@${p.version}`).join(", "); + console.error(`Context MCP Server starting...`); + const prefix = allowedLibraries ? "Restricted to" : "Loaded"; + console.error(`${prefix} ${visible.length} packages: ${names}`); + } else { + console.error("Context MCP Server starting..."); + console.error("No packages installed. Run: context add "); + } - const server = new ContextServer(store); + const server = new ContextServer(store, { allowedLibraries }); - if (options.http !== undefined) { - const port = - typeof options.http === "string" - ? Number.parseInt(options.http, 10) - : 8080; - const host = options.host ?? "127.0.0.1"; + if (options.http !== undefined) { + const port = + typeof options.http === "string" + ? Number.parseInt(options.http, 10) + : 8080; + const host = options.host ?? "127.0.0.1"; - const { port: actualPort } = await server.startHTTP({ port, host }); - console.error(`Listening on http://${host}:${actualPort}/mcp`); - } else { - await server.start(); - } - }); + const { port: actualPort } = await server.startHTTP({ port, host }); + console.error(`Listening on http://${host}:${actualPort}/mcp`); + } else { + await server.start(); + } + }, + ); function formatLibraryName(pkg: PackageInfo): string { return `${pkg.name}@${pkg.version}`; diff --git a/packages/context/src/server.test.ts b/packages/context/src/server.test.ts index 6e88f23..56a0c18 100644 --- a/packages/context/src/server.test.ts +++ b/packages/context/src/server.test.ts @@ -65,6 +65,63 @@ describe("ContextServer", () => { await new Promise((resolve) => server.close(() => resolve())); } }); + + it("hides registry tools and filters get_docs when allowedLibraries is set", async () => { + const testDir = join( + tmpdir(), + `context-allowed-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(testDir, { recursive: true }); + + try { + const store = new PackageStore(); + for (const name of ["alpha", "beta", "gamma"]) { + const path = join(testDir, `${name}@1.0.0.db`); + const db = createTestDb(path, { name, version: "1.0.0" }); + insertChunk(db, { + docPath: "x.md", + docTitle: name, + sectionTitle: "intro", + content: name, + tokens: 1, + }); + rebuildFtsIndex(db); + db.close(); + store.add(readPackageInfo(path)); + } + + const ctx = new ContextServer(store, { + allowedLibraries: new Set(["alpha", "gamma"]), + }); + const { server, port } = await ctx.startHTTP({ port: 0 }); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${port}/mcp`), + ); + + try { + await client.connect(transport); + + const tools = await client.listTools(); + const toolNames = tools.tools.map((t) => t.name); + expect(toolNames).toContain("get_docs"); + expect(toolNames).not.toContain("search_packages"); + expect(toolNames).not.toContain("download_package"); + + const getDocs = tools.tools.find((t) => t.name === "get_docs"); + const schema = JSON.stringify(getDocs); + expect(schema).toContain("alpha@1.0.0"); + expect(schema).toContain("gamma@1.0.0"); + expect(schema).not.toContain("beta@1.0.0"); + } finally { + await client.close().catch(() => {}); + await new Promise((resolve) => server.close(() => resolve())); + } + } finally { + if (existsSync(testDir)) rmSync(testDir, { recursive: true }); + } + }); }); describe("ContextServer integration", () => { diff --git a/packages/context/src/server.ts b/packages/context/src/server.ts index 81b2259..dfde5ec 100644 --- a/packages/context/src/server.ts +++ b/packages/context/src/server.ts @@ -23,6 +23,16 @@ import type { PackageInfo, PackageStore } from "./store.js"; const require = createRequire(import.meta.url); const { version } = require("../package.json") as { version: string }; +export interface ContextServerOptions { + /** + * Restrict which installed libraries the agent can see. When set, + * `get_docs` only lists these packages and the registry tools + * (`search_packages`, `download_package`) are not registered — the session + * is locked to a fixed set. + */ + allowedLibraries?: ReadonlySet; +} + /** * MCP server for documentation retrieval. * Accepts a PackageStore to provide the get_docs tool. @@ -30,25 +40,38 @@ const { version } = require("../package.json") as { version: string }; export class ContextServer { private mcp: McpServer; private store: PackageStore; + private allowedLibraries?: ReadonlySet; private getDocsRegistration: ReturnType | null = null; - constructor(store: PackageStore) { + constructor(store: PackageStore, options: ContextServerOptions = {}) { this.store = store; + this.allowedLibraries = options.allowedLibraries; this.mcp = new McpServer({ name: "context", version, }); } + /** Packages visible to the agent, after applying any --libs filter. */ + private visiblePackages(): PackageInfo[] { + const all = this.store.list(); + if (!this.allowedLibraries) return all; + return all.filter((p) => this.allowedLibraries?.has(p.name)); + } + /** * Register all MCP tools. Called before connecting a transport. */ private registerTools(): void { - const packages = this.store.list(); - this.registerGetDocsTool(packages); - this.registerSearchPackagesTool(); - this.registerDownloadPackageTool(); + this.registerGetDocsTool(this.visiblePackages()); + + // When the session is locked to a fixed library set, registry tools are + // hidden so the agent can't expand its scope mid-session. + if (!this.allowedLibraries) { + this.registerSearchPackagesTool(); + this.registerDownloadPackageTool(); + } } /** @@ -134,7 +157,9 @@ export class ContextServer { transports.set(newSessionId, transport); // Each new transport gets its own ContextServer sharing the same store - const sessionCtx = new ContextServer(this.store); + const sessionCtx = new ContextServer(this.store, { + allowedLibraries: this.allowedLibraries, + }); sessionCtx.registerTools(); await sessionCtx.mcp.connect(transport); @@ -194,7 +219,7 @@ export class ContextServer { * If get_docs doesn't exist yet, register it for the first time. */ private refreshGetDocsTool(): void { - const packages = this.store.list(); + const packages = this.visiblePackages(); if (this.getDocsRegistration) { // Update existing tool with new enum @@ -224,7 +249,7 @@ export class ContextServer { library: string, topic: string, ): { content: { type: "text"; text: string }[] } { - const packages = this.store.list(); + const packages = this.visiblePackages(); const pkg = packages.find((p) => formatLibraryName(p) === library); if (!pkg) {