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
5 changes: 5 additions & 0 deletions .changeset/breezy-falcon-841.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>` | Host to bind to (default: 127.0.0.1) |
| `--libs <names...>` | 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://<host>:<port>/mcp`.

Expand Down
87 changes: 86 additions & 1 deletion packages/context/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
}
});
});
117 changes: 91 additions & 26 deletions packages/context/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const allowed = new Set<string>();
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")
Expand Down Expand Up @@ -1032,35 +1079,53 @@ program
"Start as HTTP server instead of stdio (default port: 8080)",
)
.option("--host <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 <package.db>");
}
.option(
"--libs <names...>",
"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 <package.db>");
}

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}`;
Expand Down
57 changes: 57 additions & 0 deletions packages/context/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,63 @@ describe("ContextServer", () => {
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()));
}
} finally {
if (existsSync(testDir)) rmSync(testDir, { recursive: true });
}
});
});

describe("ContextServer integration", () => {
Expand Down
Loading
Loading