diff --git a/main.ts b/main.ts index 70a4cbd..30c4cdc 100644 --- a/main.ts +++ b/main.ts @@ -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", @@ -20,6 +21,7 @@ registerCheckVulnerabilitiesTool(server); registerAnalyzeDependenciesTool(server); registerGetPackageDocsTool(server); registerGetLicensesTool(server); +registerFindParentVersionTool(server); // Start the server const transport = new StdioServerTransport(); diff --git a/src/registries/jsr.ts b/src/registries/jsr.ts index 661736e..1687b0f 100644 --- a/src/registries/jsr.ts +++ b/src/registries/jsr.ts @@ -13,6 +13,7 @@ import type { PackageMetadata, Registry, RegistryClient, + VersionDependency, VersionDetail, VersionInfo, } from "./types.ts"; @@ -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 { @@ -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") @@ -306,6 +314,47 @@ export class JsrClient implements RegistryClient { homepage: `https://jsr.io/${packageName}`, }; } + + async getVersionDependencies( + packageName: string, + version: string, + options?: { repository?: string }, + ): Promise { + 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(); diff --git a/src/registries/npm.ts b/src/registries/npm.ts index c474453..1c31bbb 100644 --- a/src/registries/npm.ts +++ b/src/registries/npm.ts @@ -9,6 +9,7 @@ import type { PackageMetadata, Registry, RegistryClient, + VersionDependency, VersionDetail, VersionInfo, } from "./types.ts"; @@ -35,6 +36,10 @@ interface NpmPackageResponse { license?: string | { type: string }; homepage?: string; repository?: { type?: string; url?: string } | string; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + devDependencies?: Record; } >; time: Record; @@ -185,6 +190,32 @@ export class NpmClient implements RegistryClient { repository: repoUrl, }; } + + async getVersionDependencies( + packageName: string, + version: string, + options?: { repository?: string }, + ): Promise { + 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]> = + [ + ["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(); diff --git a/src/registries/types.ts b/src/registries/types.ts index 1916947..de32d71 100644 --- a/src/registries/types.ts +++ b/src/registries/types.ts @@ -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 */ @@ -79,6 +94,15 @@ export interface RegistryClient { ): Promise; listVersions(packageName: string): Promise; getMetadata(packageName: string, version?: string): Promise; + /** + * 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; } /** diff --git a/src/tools/find-parent-version.test.ts b/src/tools/find-parent-version.test.ts new file mode 100644 index 0000000..7bfc198 --- /dev/null +++ b/src/tools/find-parent-version.test.ts @@ -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, +): (v: string) => Promise { + 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"); +}); diff --git a/src/tools/find-parent-version.ts b/src/tools/find-parent-version.ts new file mode 100644 index 0000000..1a939b4 --- /dev/null +++ b/src/tools/find-parent-version.ts @@ -0,0 +1,300 @@ +/** + * find_parent_version MCP tool + * + * Answers a single hop of "to update transitive D@target, which version of its + * direct parent C do I need?". Iterates C's published versions ascending and + * returns the first one whose declared range for D includes target. + * + * Intended for ecosystems (Deno, Go modules) where you cannot override a + * transitive dependency from the top level — a target version of D requires + * lifting every parent in the chain. The caller chains this tool to walk the + * chain hop by hop. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getClient } from "../registries/index.ts"; +import type { Registry, VersionDependency } from "../registries/types.ts"; +import { + compareVersions, + isPrerelease, + satisfiesConstraint, +} from "../utils/version.ts"; + +const SUPPORTED_PARENT_REGISTRIES = ["npm", "jsr"] as const; +const SUPPORTED_CHILD_REGISTRIES = ["npm", "jsr"] as const; + +export type FindParentVersionParentRegistry = + (typeof SUPPORTED_PARENT_REGISTRIES)[number]; +export type FindParentVersionChildRegistry = + (typeof SUPPORTED_CHILD_REGISTRIES)[number]; + +export type FindParentVersionStatus = + | "found" + | "none-compatible" + | "child-never-declared" + | "error"; + +export interface PackageRef { + registry: R; + name: string; +} + +export interface FindParentVersionInput { + parent: PackageRef; + child: PackageRef; + childVersion: string; + fromParentVersion?: string; + includePrerelease?: boolean; +} + +export interface FindParentVersionResult { + parent: PackageRef; + child: PackageRef; + childVersion: string; + status: FindParentVersionStatus; + parentVersion?: string; + declaredConstraint?: string; + scope?: VersionDependency["scope"]; + versionsScanned: number; + versionsConsidered: number; + note?: string; + error?: string; +} + +/** + * Pure algorithm: scan `versions` ascending and return the first one whose + * dependency list contains `child` declared at a range that includes + * `childVersion`. + * + * `versions` is expected to already be sorted ascending and filtered + * (prerelease handling, fromParentVersion, deprecated/yanked) — this helper + * does no filtering itself so it stays trivially testable. + * + * `getDeps(version)` should return `undefined` when the version has no + * extractable dependency manifest (treated as "no info"); an empty array + * means "no declared deps". + */ +export async function scanForCompatibleParentVersion( + versions: string[], + child: PackageRef, + childVersion: string, + getDeps: (version: string) => Promise, +): Promise< + Omit< + FindParentVersionResult, + "parent" | "child" | "childVersion" | "versionsConsidered" + > +> { + let everSawChild = false; + let scanned = 0; + + for (const version of versions) { + scanned++; + const deps = await getDeps(version); + if (!deps) continue; + + const match = deps.find( + (d) => d.registry === child.registry && d.name === child.name, + ); + if (!match) continue; + everSawChild = true; + + if (satisfiesConstraint(childVersion, match.constraint)) { + return { + status: "found", + parentVersion: version, + declaredConstraint: match.constraint, + scope: match.scope, + versionsScanned: scanned, + }; + } + } + + if (everSawChild) { + return { + status: "none-compatible", + versionsScanned: scanned, + note: + `No scanned parent version declares the child at a range that includes ${childVersion}.`, + }; + } + return { + status: "child-never-declared", + versionsScanned: scanned, + note: + `The child package was not declared as a dependency in any scanned parent version.`, + }; +} + +export async function findParentVersion( + input: FindParentVersionInput, +): Promise { + const { parent, child, childVersion, fromParentVersion, includePrerelease } = + input; + + try { + const client = getClient(parent.registry); + if (!client.getVersionDependencies) { + return { + parent, + child, + childVersion, + status: "error", + error: + `Registry '${parent.registry}' does not expose version-level dependency metadata.`, + versionsScanned: 0, + versionsConsidered: 0, + }; + } + + const all = await client.listVersions(parent.name); + let versions = all + .filter((v) => !v.yanked) + .map((v) => v.version); + + if (!includePrerelease) { + versions = versions.filter((v) => !isPrerelease(v)); + } + + if (fromParentVersion) { + versions = versions.filter( + (v) => compareVersions(v, fromParentVersion) >= 0, + ); + } + + versions.sort(compareVersions); + const considered = versions.length; + + const partial = await scanForCompatibleParentVersion( + versions, + child, + childVersion, + (v) => client.getVersionDependencies!(parent.name, v), + ); + + return { + parent, + child, + childVersion, + versionsConsidered: considered, + ...partial, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + parent, + child, + childVersion, + status: "error", + error: message, + versionsScanned: 0, + versionsConsidered: 0, + }; + } +} + +const packageRefSchema = ( + registries: readonly string[], + description: string, +) => + z.object({ + registry: z.enum(registries as [string, ...string[]]).describe(description), + name: z.string().describe( + "Package name. npm: 'foo' or '@scope/foo'. jsr: '@scope/name'.", + ), + }); + +const inputSchema = z.object({ + parent: packageRefSchema( + SUPPORTED_PARENT_REGISTRIES, + "Registry hosting the parent package (npm or jsr)", + ), + child: packageRefSchema( + SUPPORTED_CHILD_REGISTRIES, + "Registry hosting the child dependency (npm or jsr)", + ), + childVersion: z.string().describe( + "Concrete target version of the child (e.g. '1.5.4'). The tool returns the minimum parent version whose declared range for the child includes this version.", + ), + fromParentVersion: z.string().optional().describe( + "Optional: only consider parent versions >= this one. Use when you already know the current parent version and only care about upgrade candidates.", + ), + includePrerelease: z.boolean().optional().describe( + "Include prerelease parent versions in the scan (default: false).", + ), +}); + +export function registerFindParentVersionTool(server: McpServer): void { + server.tool( + "find_parent_version", + `Find the minimum parent version whose declared dependency range includes a +given target version of a child package. + +Use case: dependency systems like Deno pin transitive dependencies, so +updating a transitive D to a specific version requires also updating every +parent in the chain that brought it in. This tool answers one hop at a time: +"what is the lowest version of C whose declared range for D includes +D@?". Chain the tool to walk a full A -> B -> C -> D upgrade. + +Supported parent registries: npm, jsr. The child can live on either npm or +jsr (JSR packages can declare npm packages as transitives). + +How the match works: +- Lists all parent versions, ascending, filtered to non-yanked and non- + prerelease (unless includePrerelease is set). +- For each version, fetches its declared dependencies and looks for one whose + (registry, name) match the child. +- Uses semver range matching ('^1.4.0', '~1.4.0', '>=1.4.0', exact, etc.) to + test whether the declared range includes childVersion. +- Returns the first (= lowest) parent version that satisfies. + +Status values: + - "found" a matching parent version was located; see + 'parentVersion' and 'declaredConstraint' + - "none-compatible" the child was declared in some scanned parent + versions, but none at a range covering childVersion + - "child-never-declared" the child was not declared in any scanned version + - "error" see 'error' field + +The tool reports only the manifest-level match — your downstream tooling owns +the actual lockfile rewrite or 'install' step.`, + inputSchema.shape, + async ({ + parent, + child, + childVersion, + fromParentVersion, + includePrerelease, + }) => { + try { + const result = await findParentVersion({ + parent: { + registry: parent.registry as FindParentVersionParentRegistry, + name: parent.name, + }, + child: { + registry: child.registry as FindParentVersionChildRegistry, + name: child.name, + }, + childVersion, + fromParentVersion, + includePrerelease, + }); + return { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { type: "text", text: JSON.stringify({ error: message }, null, 2) }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/src/utils/http.test.ts b/src/utils/http.test.ts new file mode 100644 index 0000000..6d6bfaf --- /dev/null +++ b/src/utils/http.test.ts @@ -0,0 +1,33 @@ +import { assertEquals } from "@std/assert"; +import { parseRetryAfter } from "./http.ts"; + +Deno.test("parseRetryAfter - returns null for missing/empty values", () => { + assertEquals(parseRetryAfter(null), null); + assertEquals(parseRetryAfter(""), null); + assertEquals(parseRetryAfter(" "), null); +}); + +Deno.test("parseRetryAfter - parses delta-seconds form to milliseconds", () => { + assertEquals(parseRetryAfter("0"), 0); + assertEquals(parseRetryAfter("1"), 1000); + assertEquals(parseRetryAfter("120"), 120_000); + assertEquals(parseRetryAfter(" 30 "), 30_000); +}); + +Deno.test("parseRetryAfter - parses HTTP-date relative to provided now()", () => { + const now = new Date("2026-10-21T07:28:00Z"); + // 60 seconds in the future + const future = "Wed, 21 Oct 2026 07:29:00 GMT"; + assertEquals(parseRetryAfter(future, now), 60_000); +}); + +Deno.test("parseRetryAfter - clamps past HTTP-date to zero", () => { + const now = new Date("2026-10-21T08:00:00Z"); + const past = "Wed, 21 Oct 2026 07:00:00 GMT"; + assertEquals(parseRetryAfter(past, now), 0); +}); + +Deno.test("parseRetryAfter - returns null for unparseable garbage", () => { + assertEquals(parseRetryAfter("not-a-date"), null); + assertEquals(parseRetryAfter("soon-please"), null); +}); diff --git a/src/utils/http.ts b/src/utils/http.ts index bd55791..f408771 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -78,3 +78,64 @@ export function fetchWithHeaders( headers, }); } + +/** + * Parse an HTTP Retry-After header value into a delay in milliseconds. + * Accepts the delta-seconds form ("120") and the HTTP-date form + * ("Wed, 21 Oct 2026 07:28:00 GMT"). Returns null for missing or + * unparseable values so the caller can fall back to exponential backoff. + */ +export function parseRetryAfter( + value: string | null, + now: Date = new Date(), +): number | null { + if (!value) return null; + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10) * 1000; + } + const dateMs = Date.parse(trimmed); + if (!Number.isNaN(dateMs)) { + return Math.max(0, dateMs - now.getTime()); + } + return null; +} + +export interface FetchRetryOptions extends FetchOptions { + /** Maximum number of retries on HTTP 429. Defaults to 3. */ + maxRetries?: number; + /** Cap on the per-attempt delay in milliseconds. Defaults to 60s. */ + maxBackoffMs?: number; +} + +/** + * Fetch with automatic retry on HTTP 429 Too Many Requests, honoring the + * Retry-After header. When Retry-After is missing or unparseable, falls back + * to exponential backoff starting at 500ms (capped by maxBackoffMs). Returns + * the final response once retries are exhausted so the caller can decide + * what to do with the 429. + * + * Non-429 responses (including 4xx and 5xx) are returned without retry. + */ +export async function fetchWithRetry( + url: string, + options?: FetchRetryOptions, +): Promise { + const maxRetries = options?.maxRetries ?? 3; + const maxBackoffMs = options?.maxBackoffMs ?? 60_000; + let attempt = 0; + while (true) { + const response = await fetchWithHeaders(url, options); + if (response.status !== 429 || attempt >= maxRetries) { + return response; + } + // Drain the body so the underlying connection can be reused. + await response.body?.cancel(); + + const retryAfter = parseRetryAfter(response.headers.get("Retry-After")); + const fallback = 500 * Math.pow(2, attempt); + const delay = Math.min(maxBackoffMs, retryAfter ?? fallback); + await new Promise((resolve) => setTimeout(resolve, delay)); + attempt++; + } +}