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
2 changes: 2 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerCheckVulnerabilitiesTool } from "./src/tools/check-vulnerabilit
import { registerAnalyzeDependenciesTool } from "./src/tools/analyze-dependencies.ts";
import { registerGetPackageDocsTool } from "./src/tools/get-package-docs.ts";
import { registerGetLicensesTool } from "./src/tools/get-licenses.ts";
import { registerFindParentVersionTool } from "./src/tools/find-parent-version.ts";

const server = new McpServer({
name: "mcp-dependency-version",
Expand All @@ -20,6 +21,7 @@ registerCheckVulnerabilitiesTool(server);
registerAnalyzeDependenciesTool(server);
registerGetPackageDocsTool(server);
registerGetLicensesTool(server);
registerFindParentVersionTool(server);

// Start the server
const transport = new StdioServerTransport();
Expand Down
51 changes: 50 additions & 1 deletion src/registries/jsr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
PackageMetadata,
Registry,
RegistryClient,
VersionDependency,
VersionDetail,
VersionInfo,
} from "./types.ts";
Expand All @@ -23,7 +24,7 @@ import {
sortVersionsDescending,
} from "../utils/version.ts";
import { versionCache } from "../utils/cache.ts";
import { fetchWithHeaders } from "../utils/http.ts";
import { fetchWithHeaders, fetchWithRetry } from "../utils/http.ts";
import { getRepositoryConfig } from "../config/loader.ts";

interface JsrPackageResponse {
Expand Down Expand Up @@ -61,6 +62,13 @@ interface JsrVersionDetailResponse {
readmePath?: string | null;
}

interface JsrDependencyResponse {
kind: "jsr" | "npm";
name: string;
constraint: string;
path?: string;
}

/**
* Parse a JSR package name into scope and name
* @param packageName Format: "@scope/name" (e.g., "@std/path")
Expand Down Expand Up @@ -306,6 +314,47 @@ export class JsrClient implements RegistryClient {
homepage: `https://jsr.io/${packageName}`,
};
}

async getVersionDependencies(
packageName: string,
version: string,
options?: { repository?: string },
): Promise<VersionDependency[] | undefined> {
const repoConfig = getRepositoryConfig("jsr", options?.repository);
const cacheKey =
`jsr:${repoConfig.url}:version-deps:${packageName}:${version}`;
const cached = versionCache.get(cacheKey);
if (cached) {
return cached as VersionDependency[];
}

const { scope, name } = parseJsrPackageName(packageName);
const url =
`${repoConfig.url}/scopes/${scope}/packages/${name}/versions/${version}/dependencies`;

let response: Response;
try {
// Scan loops can issue one request per parent version, so honor 429 +
// Retry-After here. fetchWithRetry returns the final 429 once retries
// are exhausted; we treat that the same as any other non-OK response.
response = await fetchWithRetry(url, { auth: repoConfig.auth });
} catch {
return undefined;
}
if (!response.ok) {
return undefined;
}

const raw = (await response.json()) as JsrDependencyResponse[];
const deps: VersionDependency[] = raw.map((d) => ({
name: d.name,
registry: (d.kind === "jsr" ? "jsr" : "npm") as Registry,
constraint: d.constraint,
scope: "runtime",
}));
versionCache.set(cacheKey, deps);
return deps;
}
}

export const jsrClient = new JsrClient();
31 changes: 31 additions & 0 deletions src/registries/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
PackageMetadata,
Registry,
RegistryClient,
VersionDependency,
VersionDetail,
VersionInfo,
} from "./types.ts";
Expand All @@ -35,6 +36,10 @@ interface NpmPackageResponse {
license?: string | { type: string };
homepage?: string;
repository?: { type?: string; url?: string } | string;
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
>;
time: Record<string, string>;
Expand Down Expand Up @@ -185,6 +190,32 @@ export class NpmClient implements RegistryClient {
repository: repoUrl,
};
}

async getVersionDependencies(
packageName: string,
version: string,
options?: { repository?: string },
): Promise<VersionDependency[] | undefined> {
const data = await this.fetchPackage(packageName, options?.repository);
const versionData = data.versions[version];
if (!versionData) {
return undefined;
}

const out: VersionDependency[] = [];
const groups: Array<[VersionDependency["scope"], Record<string, string>]> =
[
["runtime", versionData.dependencies ?? {}],
["peer", versionData.peerDependencies ?? {}],
["optional", versionData.optionalDependencies ?? {}],
];
for (const [scope, deps] of groups) {
for (const [name, constraint] of Object.entries(deps)) {
out.push({ name, registry: "npm", constraint, scope });
}
}
return out;
}
}

export const npmClient = new NpmClient();
24 changes: 24 additions & 0 deletions src/registries/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ export interface LookupOptions {
versionPrefix?: string;
}

/**
* A single dependency declared by a specific version of a package.
*
* `registry` is where the dependency itself lives, not the parent — e.g. a JSR
* package can declare an npm dependency, in which case `registry === "npm"`.
* `scope` distinguishes npm's dependency kinds; JSR packages flag everything
* as runtime since the registry does not draw the distinction.
*/
export interface VersionDependency {
name: string;
registry: Registry;
constraint: string;
scope?: "runtime" | "peer" | "optional" | "dev";
}

/**
* Interface for registry clients
*/
Expand All @@ -79,6 +94,15 @@ export interface RegistryClient {
): Promise<VersionInfo>;
listVersions(packageName: string): Promise<VersionDetail[]>;
getMetadata(packageName: string, version?: string): Promise<PackageMetadata>;
/**
* List the dependencies declared by a specific published version of the
* package. Returns `undefined` for registries that do not expose
* version-level dependency metadata.
*/
getVersionDependencies?(
packageName: string,
version: string,
): Promise<VersionDependency[] | undefined>;
}

/**
Expand Down
177 changes: 177 additions & 0 deletions src/tools/find-parent-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { assertEquals } from "@std/assert";
import {
findParentVersion,
scanForCompatibleParentVersion,
} from "./find-parent-version.ts";
import type { VersionDependency } from "../registries/types.ts";

function depsTable(
table: Record<string, VersionDependency[] | undefined>,
): (v: string) => Promise<VersionDependency[] | undefined> {
return (v) => Promise.resolve(table[v]);
}

Deno.test("scan - returns the first ascending version with a compatible range", async () => {
// Tilde ranges pin the minor: ~1.4.0 = [1.4.0, 1.5.0). So only 1.2.0's
// ~1.5.0 covers 1.5.4 — earlier versions declare ranges that exclude it.
const result = await scanForCompatibleParentVersion(
["1.0.0", "1.1.0", "1.2.0", "1.3.0"],
{ registry: "npm", name: "d" },
"1.5.4",
depsTable({
"1.0.0": [{
name: "d",
registry: "npm",
constraint: "~1.2.0",
scope: "runtime",
}],
"1.1.0": [{
name: "d",
registry: "npm",
constraint: "~1.4.0",
scope: "runtime",
}],
"1.2.0": [{
name: "d",
registry: "npm",
constraint: "~1.5.0",
scope: "runtime",
}],
"1.3.0": [{
name: "d",
registry: "npm",
constraint: "~1.6.0",
scope: "runtime",
}],
}),
);
assertEquals(result.status, "found");
assertEquals(result.parentVersion, "1.2.0");
assertEquals(result.declaredConstraint, "~1.5.0");
assertEquals(result.scope, "runtime");
assertEquals(result.versionsScanned, 3);
});

Deno.test("scan - returns 'none-compatible' when child is declared but no range covers target", async () => {
const result = await scanForCompatibleParentVersion(
["1.0.0", "1.1.0"],
{ registry: "npm", name: "d" },
"2.0.0",
depsTable({
"1.0.0": [{
name: "d",
registry: "npm",
constraint: "^1.0.0",
scope: "runtime",
}],
"1.1.0": [{
name: "d",
registry: "npm",
constraint: "~1.5.0",
scope: "runtime",
}],
}),
);
assertEquals(result.status, "none-compatible");
assertEquals(result.parentVersion, undefined);
assertEquals(result.versionsScanned, 2);
});

Deno.test("scan - returns 'child-never-declared' when no version lists the child", async () => {
const result = await scanForCompatibleParentVersion(
["1.0.0", "1.1.0"],
{ registry: "npm", name: "d" },
"1.0.0",
depsTable({
"1.0.0": [{
name: "other",
registry: "npm",
constraint: "^1.0.0",
scope: "runtime",
}],
"1.1.0": [],
}),
);
assertEquals(result.status, "child-never-declared");
});

Deno.test("scan - distinguishes children with same name on different registries", async () => {
// A parent might declare both an npm:zod and a jsr:@x/zod. The match must
// require BOTH registry and name to agree.
const result = await scanForCompatibleParentVersion(
["1.0.0", "1.1.0"],
{ registry: "jsr", name: "zod" },
"4.0.0",
depsTable({
"1.0.0": [
{
name: "zod",
registry: "npm",
constraint: "^4.0.0",
scope: "runtime",
},
],
"1.1.0": [
{
name: "zod",
registry: "npm",
constraint: "^4.0.0",
scope: "runtime",
},
{
name: "zod",
registry: "jsr",
constraint: "^4.0.0",
scope: "runtime",
},
],
}),
);
assertEquals(result.status, "found");
assertEquals(result.parentVersion, "1.1.0");
});

Deno.test("scan - skips versions whose deps are unavailable (undefined)", async () => {
const result = await scanForCompatibleParentVersion(
["1.0.0", "1.1.0", "1.2.0"],
{ registry: "npm", name: "d" },
"1.0.0",
depsTable({
"1.0.0": undefined,
"1.1.0": undefined,
"1.2.0": [{
name: "d",
registry: "npm",
constraint: "^1.0.0",
scope: "runtime",
}],
}),
);
assertEquals(result.status, "found");
assertEquals(result.parentVersion, "1.2.0");
assertEquals(result.versionsScanned, 3);
});

Deno.test("scan - reports 'child-never-declared' when every version's deps are undefined", async () => {
const result = await scanForCompatibleParentVersion(
["1.0.0"],
{ registry: "npm", name: "d" },
"1.0.0",
depsTable({ "1.0.0": undefined }),
);
assertEquals(result.status, "child-never-declared");
});

Deno.test("findParentVersion - reports 'error' for a registry without dependency support", async () => {
// `go` does not implement getVersionDependencies and never will (it's not in
// the SUPPORTED_PARENT_REGISTRIES list either, but the runtime guard exists
// to defend against future drift).
const result = await findParentVersion({
// deliberately bypass the typed enum to cover the runtime guard
parent: { registry: "go" as unknown as "npm", name: "example.com/foo" },
child: { registry: "npm", name: "d" },
childVersion: "1.0.0",
});
assertEquals(result.status, "error");
assertEquals(typeof result.error, "string");
});
Loading