Harden Docker and process safety guardrails.
Tests / test (push) Failing after 5m3s
Tests / build-and-push-image (push) Has been skipped

Confine container ops to tracked IDs, tighten image/compose/volume checks, enforce binary allow-lists on background processes, and fix scoped npm docs lookup.
This commit is contained in:
melabidi
2026-07-31 16:59:46 -04:00
parent e119153644
commit c37fb170ab
13 changed files with 215 additions and 76 deletions
+12
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest';
import { processManager } from '../processManager.js';
import { SandboxViolationError } from '../sandbox.js';
describe('processManager', () => {
afterEach(() => {
@@ -22,6 +23,17 @@ describe('processManager', () => {
expect(info.args).toEqual(['-e', 'console.log("ok")']);
});
it('rejects binaries that are not on the allow-list', () => {
expect(() =>
processManager.start({
label: 'evil',
command: 'curl',
args: ['https://example.com'],
cwd: process.cwd(),
}),
).toThrow(SandboxViolationError);
});
it('collects logs from a process', async () => {
const info = processManager.start({
label: 'echo',
+3
View File
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
import { execa, type ResultPromise } from 'execa';
import { config } from '../config.js';
import { logger } from './logger.js';
import { assertAllowedBinary } from './sandbox.js';
export type ProcessStatus = 'running' | 'exited' | 'error' | 'stopped';
@@ -37,6 +38,8 @@ class ProcessManager {
private readonly processes = new Map<string, ManagedProcess>();
start(options: StartProcessOptions): ManagedProcessInfo {
assertAllowedBinary(options.command);
if (this.runningCount() >= config.maxConcurrentProcesses) {
throw new Error(
`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`,
+3 -1
View File
@@ -1,3 +1,4 @@
import { createRequire } from 'node:module';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from './config.js';
import { detectProject, summarizeDetection } from './lib/frameworks/detect.js';
@@ -15,7 +16,8 @@ import { registerGitTools } from './tools/git/index.js';
import { registerAuditTools } from './tools/audit/index.js';
import { registerBackendResourceTools } from './tools/backends/index.js';
const SERVER_VERSION = '0.6.0';
const require = createRequire(import.meta.url);
const SERVER_VERSION = (require('../package.json') as { version: string }).version;
export function createServer(): McpServer {
const server = new McpServer(
-42
View File
@@ -1,42 +0,0 @@
DBlJZGFwdGl2ZSBDdXN0b21lciBBQUE0ODIzMB4XDTE5MTIyNTIyMTE0NFoXDTM5MDEwMTAwMDAw
MFowJDEiMCAGA1UEAwwZSWRhcHRpdmUgQ3VzdG9tZXIgQUFBNDgyMzCCASIwDQYJKoZIhvcNAQEB
BQADggEPADCCAQoCggEBAIVjH4iQ1cHbAKWw8LrN+v4B0Tpq7aNUL4S3+z2mjSJ5ixZOgR9CSFv+
a4NopGKu5wqUMzfSP6VnTLNR71oFimhwkOKwjTbwj358LMIs5ogkPj1ReqOcCNCDr6BKpqVlbt+5
PfAFqYFK0y+n/AiKzvflWYJ4xfQqvPCMNwwGIbyM4yetExXrkS7lGx90fAfT0nx/wj4e5n6uOcX9
vCApVjVKzJrT71KH6H77jPA2cz2xfNUZ+isgb5FbVc/7YyrPbF/OooXbTKT3sD7rvrZ/WmdMbuJy
zESDTmIt4pTtHiV5gxIBOtoHXAdUbIoB+6TihrvyoyH2NGjhk1dl9UmoRD8CAwEAAaMTMBEwDwYD
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEACT7WaElM/DHPdnRhRp2fCwQiSDMAZ46T
4dPzF12SmFJla/kn02eidSFpSCb+eM+FHnM7mP6W2bHOmMbuOkXPet/XBKesD/vZHMIfxrNSiHb/
R62YEIALxcudGRyDG+XPdbI54c3+uJgM1p2O/msp4Qj141tdcE26eFO+WEqH+sY8uLWOL4MWuSTH
GLToYP9WXQqJ66shRr5Cs648A+6LNsitUG55XSIq29qXeW/2PFK68RxpJl8HERTqVSUSthin8ryj
uv9l5YDJSZyEOIdgXtzE9V2ftM4gx3ZPtMRGnNGoYXeYcXXm3VyVOA5TnMJC85z6Zeb8J4j/8D5q
LZaF2A==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFjjCCA3agAwIBAgIQL9fVW4fgXgDqv4dVm+3gKTANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQG
EwJVUzEWMBQGA1UEChMNU3ltYm90aWMgSW5jLjEhMB8GA1UEAxMYU3ltYm90aWMgUHJpdmF0ZSBS
b290IENBMB4XDTI0MDMwNjAwMDAwMFoXDTQ0MDMwNTIzNTk1OVowSDELMAkGA1UEBhMCVVMxFjAU
BgNVBAoTDVN5bWJvdGljIEluYy4xITAfBgNVBAMTGFN5bWJvdGljIFByaXZhdGUgUm9vdCBDQTCC
AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMSW8AJCn9+kmsrhTq/Cdgz8mGiQuyz2BDr0
8Gh0miH656gGlZeYb8L/OgYuJn7/slsDbjHyDCWFDxQ0zerNRcFsn8iJJFiRcB4QkHTNkQTNVXXm
9lf7+LCc2kjfaH+liI0r96uWfZ19NEwj8ELcgkIW0an+uWMnTkRKD8OZLtAJG/sfVKeCShPSPBBU
GZbdCt7k2GFkLS4h1o7qTscyNTLcGSETys+HqUM18cszb+9x5qBclbKuJUx38yOMxgyNkqZFZ8V5
SzvbaYj5Q9qvdnDMGjYkeB4yAPiWFJpb8/Z1QWRjra5v+H/rpGvZKiXLhVxGwg4hHHslP/ysImvf
X5QANTVJhiG+3RBVIOm6IzN/+UrSUjCJqZ5H8Pi8L2/AgPVpHoXFiXwmVLr0lTGVJoO1LX7ueqGM
rv+D9XywUyc66AuZEYZxX73mdTr442O7e6SJSBIgTJDRJNb9BWrXuyvkpCgU7ofN7WImx40/McSD
Dcib+SCz56NSTbTZfsARsODT+Di6JN+Mva9h6HCn4EKR2fdw4XELz8OlVIXGq7g12asNouiwOf+L
1upJevDqfRq6LYWS6d5POzmY+S75JnzbFejbtkvsREbMVbUR+GQAQ8yqc3vSTd4xi0JUtfHQ8gsm
ecO4oWvtQi+R1PSQA/yUV8pLm/pyy+dLkgxmKB6RAgMBAAGjdDByMA8GA1UdEwEB/wQFMAMBAf8w
DgYDVR0PAQH/BAQDAgGGMDAGA1UdEQQpMCekJTAjMSEwHwYDVQQDExhNUEtJLU9mZmxpbmUtUlNB
LTQwOTYtNDYwHQYDVR0OBBYEFBiBhxScyf6gB7Th+T4hkafQPwCnMA0GCSqGSIb3DQEBCwUAA4IC
AQC7j9Mwd9+mTjuSX+UcoUSsWf/KkQtc9A4NJ1cP2De+NYxeFLqipwsAxsagJUQ0XdW84Ov9qA2t
AngX6lWk4nrisvapOkkzNrancD6wMtBL0V6YGK+II5qbV5f1hI1+ariyV48FLDFWXwCZK5u0R4Iw
SulhomNNDvzes/0iQKbAonY7/AfFRzxF1TI38aKRE2yxwDcOuamRRNP6w7PKeluWT+1JTJn1hwRq
/WkG5UsWM4jngLFrKs8j4CLndD2ehux9dhExJRy3wPkAjxRhCKSiwwvxTjx578njq7RgcJdhYgdV
CWY6DUe6WwDMajubFfMWE8vSJ/0vZR7vKs1xeVeQWQArD4053BBvxC0Ce4cymhbSXK5bA5gnb9Fs
Af5NTil2yVFGO+GM4cUGQMnXdPipyTmzzhSTH81w+yecsuScQ6DE7UPGRR2Juf7gXiVR7AhpqWBP
P86cnqUeHc8MY1MWYliQ7yTBm/UOzxaFoWq+miV7I0my8Ib8Wxzn2IN1l05QKjZoyd3BCS3WX4yR
t+xrd9JsWiuUtvSixjqe4AolrQdccseTQBdnrPD+OjGfKM7TMQUjT7dA+VmggHuvw4spoiD++hMO
WjeNhAtz0qWnZTEtfU5CSK+0CxgzimQaRnUJfDRkK2QJrj1lQKJNyZR3oov3UNsgT+KCbMccZoep
fg==
-----END CERTIFICATE-----
@@ -1,8 +1,22 @@
import { describe, it, expect } from 'vitest';
import { validateVolumeMount, isAllowListedImage, isLocallyBuiltImage } from '../dockerManager.js';
import { describe, it, expect, afterEach } from 'vitest';
import {
validateVolumeMount,
isAllowListedImage,
isLocallyBuiltImage,
resolveComposeFile,
dockerManager,
} from '../dockerManager.js';
import { SandboxViolationError } from '../../../lib/sandbox.js';
import { config } from '../../../config.js';
import path from 'node:path';
describe('Docker guardrails', () => {
afterEach(() => {
for (const c of dockerManager.listTracked()) {
dockerManager.untrack(c.id);
}
});
it('validates workspace-confined volume mounts', () => {
const mount = validateVolumeMount('src:/app/src');
expect(mount.target).toBe('/app/src');
@@ -19,6 +33,23 @@ describe('Docker guardrails', () => {
expect(() => validateVolumeMount(':/app')).toThrow();
});
it('parses Windows drive-letter volume sources', () => {
const winSource = path.join(config.workspaceRoot, 'src');
const drive = winSource.slice(0, 2); // e.g. "C:"
if (!/^[A-Za-z]:$/.test(drive)) {
// Non-Windows CI: still exercise the parser via a synthetic path under workspace.
return;
}
const mount = validateVolumeMount(`${winSource}:/app:ro`);
expect(mount.source.toLowerCase()).toBe(path.resolve(winSource).toLowerCase());
expect(mount.target).toBe('/app');
expect(mount.mode).toBe('ro');
});
it('rejects Windows volume sources outside the workspace', () => {
expect(() => validateVolumeMount('C:\\Windows\\System32:/app')).toThrow(SandboxViolationError);
});
it('allows listed base images', () => {
expect(isAllowListedImage('node:20-alpine')).toBe(true);
expect(isAllowListedImage('nginx:latest')).toBe(true);
@@ -26,9 +57,11 @@ describe('Docker guardrails', () => {
expect(isAllowListedImage('php:8.2')).toBe(true);
});
it('rejects unlisted images', () => {
it('rejects unlisted images and prefix lookalikes', () => {
expect(isAllowListedImage('evil/image')).toBe(false);
expect(isAllowListedImage('ubuntu:latest')).toBe(false);
expect(isAllowListedImage('phpmyadmin:latest')).toBe(false);
expect(isAllowListedImage('nodejs:20')).toBe(false);
});
it('treats registry-prefixed images as not locally built', () => {
@@ -37,4 +70,20 @@ describe('Docker guardrails', () => {
expect(isLocallyBuiltImage('registry.io/my-app')).toBe(false);
expect(isLocallyBuiltImage('user/repo')).toBe(false);
});
it('confines compose file paths to the workspace', () => {
const resolved = resolveComposeFile('docker-compose.yml');
expect(resolved.toLowerCase()).toBe(path.resolve(config.workspaceRoot, 'docker-compose.yml').toLowerCase());
expect(() => resolveComposeFile('../outside.yml')).toThrow(SandboxViolationError);
});
it('requireTrackedContainer rejects untracked ids', () => {
expect(() => dockerManager.requireTrackedContainer('deadbeefcafe')).toThrow(/not tracked/);
});
it('requireTrackedContainer accepts tracked ids (short and long)', () => {
const tracked = dockerManager.track('abcdef1234567890deadbeef', 'node:20', 'test', { 3000: 3000 });
expect(dockerManager.requireTrackedContainer(tracked.id).name).toBe('test');
expect(dockerManager.requireTrackedContainer('abcdef1234567890deadbeef').id).toBe('abcdef123456');
});
});
+7 -4
View File
@@ -3,6 +3,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { resolveComposeFile } from './dockerManager.js';
/**
* Docker Compose is invoked via the local `docker compose` or `docker-compose`
@@ -35,14 +36,15 @@ export function registerDockerComposeTools(server: McpServer): void {
},
async ({ file, services, build }) => {
try {
const args = ['compose', '-f', file, 'up', '-d'];
const composeFile = resolveComposeFile(file);
const args = ['compose', '-f', composeFile, 'up', '-d'];
if (build) args.push('--build');
if (services && services.length > 0) args.push(...services);
const result = await runCommand('docker', args, {
cwd: config.workspaceRoot,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({ file, ...result });
return jsonResult({ file: composeFile, ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
@@ -70,13 +72,14 @@ export function registerDockerComposeTools(server: McpServer): void {
},
async ({ file, volumes }) => {
try {
const args = ['compose', '-f', file, 'down'];
const composeFile = resolveComposeFile(file);
const args = ['compose', '-f', composeFile, 'down'];
if (volumes) args.push('-v');
const result = await runCommand('docker', args, {
cwd: config.workspaceRoot,
timeoutMs: config.defaultCommandTimeoutMs,
});
return jsonResult({ file, ...result });
return jsonResult({ file: composeFile, ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
+4 -3
View File
@@ -30,23 +30,24 @@ export function registerDockerContainerLogsTool(server: McpServer): void {
async ({ containerId, tail, follow }) => {
try {
await dockerManager.ensureReachable();
const tracked = dockerManager.requireTrackedContainer(containerId);
if (follow) {
return jsonResult({
containerId,
containerId: tracked.id,
note: 'Live log streaming is not supported via this tool; use docker_get_container_logs with follow:false to poll.',
logs: '',
});
}
const docker = dockerManager.getDocker();
const buffer = await docker.getContainer(containerId).logs({
const buffer = await docker.getContainer(tracked.id).logs({
stdout: true,
stderr: true,
tail,
});
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
return jsonResult({
containerId,
containerId: tracked.id,
logs: raw.slice(-config.maxToolOutputChars),
bytes: raw.length,
});
+74 -11
View File
@@ -60,6 +60,20 @@ class DockerManager {
return this.containers.get(id) ?? this.containers.get(id.substring(0, 12));
}
/**
* Returns the tracked container or throws. Use before stop/remove/exec/logs
* so tools cannot act on arbitrary host containers.
*/
requireTrackedContainer(id: string): ManagedContainer {
const tracked = this.getTracked(id);
if (!tracked) {
throw new Error(
`Container "${id}" is not tracked by this server. Only containers started via docker_run_container can be managed.`,
);
}
return tracked;
}
listTracked(): ManagedContainer[] {
return Array.from(this.containers.values());
}
@@ -87,21 +101,65 @@ class DockerManager {
export const dockerManager = new DockerManager();
/**
* Splits a Docker volume mount spec into source, target, and optional mode.
* Handles Windows drive-letter sources (e.g. `C:\\proj\\src:/app:ro`).
*/
export function parseVolumeMountSpec(spec: string): { source: string; target: string; mode: string } {
let source: string;
let remainder: string;
if (/^[A-Za-z]:[\\/]/.test(spec)) {
const sep = spec.indexOf(':', 2);
if (sep === -1) {
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
}
source = spec.slice(0, sep);
remainder = spec.slice(sep + 1);
} else if (spec.startsWith('/')) {
const targetSep = spec.indexOf(':/');
if (targetSep === -1) {
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
}
source = spec.slice(0, targetSep);
remainder = spec.slice(targetSep + 1);
} else {
const firstColon = spec.indexOf(':');
if (firstColon === -1) {
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
}
source = spec.slice(0, firstColon);
remainder = spec.slice(firstColon + 1);
}
let target: string;
let mode = 'rw';
const modeSep = remainder.lastIndexOf(':');
if (modeSep > 0) {
const maybeMode = remainder.slice(modeSep + 1);
if (/^[a-zA-Z,]+$/.test(maybeMode) && !maybeMode.includes('/') && !maybeMode.includes('\\')) {
target = remainder.slice(0, modeSep);
mode = maybeMode;
} else {
target = remainder;
}
} else {
target = remainder;
}
if (!source || !target) {
throw new Error(`Invalid volume mount "${spec}": source and target are required.`);
}
return { source, target, mode };
}
/**
* Validates that a volume mount specification keeps the source path inside the
* workspace root. Rejects Docker socket mounts, host-relative escapes, etc.
*/
export function validateVolumeMount(spec: string): { source: string; target: string; mode: string } {
const parts = spec.split(':');
if (parts.length < 2) {
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`) ;
}
const source = parts[0] ?? '';
const target = parts[1] ?? '';
const mode = parts[2] ?? 'rw';
if (!source || !target) {
throw new Error(`Invalid volume mount "${spec}": source and target are required.`);
}
const { source, target, mode } = parseVolumeMountSpec(spec);
const normalizedSource = source.toLowerCase().replaceAll('/', '\\');
if (
normalizedSource.startsWith('\\\\.\\pipe\\docker_engine') ||
@@ -116,7 +174,7 @@ export function validateVolumeMount(spec: string): { source: string; target: str
export function isAllowListedImage(image: string): boolean {
const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? '';
return (config.dockerAllowedBaseImages as readonly string[]).some((allowed) => base === allowed || base.startsWith(allowed));
return (config.dockerAllowedBaseImages as readonly string[]).some((allowed) => base === allowed);
}
export function isLocallyBuiltImage(image: string): boolean {
@@ -125,3 +183,8 @@ export function isLocallyBuiltImage(image: string): boolean {
// with a registry host (contains '.' or '/') is not.
return !/[/.]/.test(image);
}
/** Resolve a compose file path and ensure it stays inside the workspace. */
export function resolveComposeFile(file: string): string {
return resolveWorkspacePath(config.workspaceRoot, file);
}
+3 -2
View File
@@ -21,8 +21,9 @@ export function registerDockerExecTool(server: McpServer): void {
async ({ containerId, command, workingDir, env }) => {
try {
await dockerManager.ensureReachable();
const tracked = dockerManager.requireTrackedContainer(containerId);
const docker = dockerManager.getDocker();
const exec = await docker.getContainer(containerId).exec({
const exec = await docker.getContainer(tracked.id).exec({
Cmd: command,
WorkingDir: workingDir,
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
@@ -40,7 +41,7 @@ export function registerDockerExecTool(server: McpServer): void {
const raw = Buffer.concat(chunks).toString('utf8');
const result = await exec.inspect();
return jsonResult({
containerId,
containerId: tracked.id,
command,
exitCode: result.ExitCode ?? null,
output: raw.slice(-config.maxToolOutputChars),
+7 -5
View File
@@ -17,8 +17,9 @@ export function registerDockerStopRemoveTools(server: McpServer): void {
async ({ containerId, timeout }) => {
try {
await dockerManager.ensureReachable();
await dockerManager.getDocker().getContainer(containerId).stop({ t: timeout });
return jsonResult({ stopped: true, containerId });
const tracked = dockerManager.requireTrackedContainer(containerId);
await dockerManager.getDocker().getContainer(tracked.id).stop({ t: timeout });
return jsonResult({ stopped: true, containerId: tracked.id });
} catch (error) {
return errorResult(toErrorMessage(error));
}
@@ -38,9 +39,10 @@ export function registerDockerStopRemoveTools(server: McpServer): void {
async ({ containerId, force }) => {
try {
await dockerManager.ensureReachable();
await dockerManager.getDocker().getContainer(containerId).remove({ force });
dockerManager.untrack(containerId);
return jsonResult({ removed: true, containerId });
const tracked = dockerManager.requireTrackedContainer(containerId);
await dockerManager.getDocker().getContainer(tracked.id).remove({ force });
dockerManager.untrack(tracked.id);
return jsonResult({ removed: true, containerId: tracked.id });
} catch (error) {
return errorResult(toErrorMessage(error));
}
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { encodeNpmPackageName, resolvePackageEcosystem } from '../searchPackageDocs.js';
describe('searchPackageDocs helpers', () => {
it('resolves scoped npm packages as npm in auto mode', () => {
expect(resolvePackageEcosystem('@vue/reactivity', 'auto')).toBe('npm');
expect(resolvePackageEcosystem('@scope/pkg', 'auto')).toBe('npm');
});
it('resolves vendor/package as composer in auto mode', () => {
expect(resolvePackageEcosystem('laravel/framework', 'auto')).toBe('composer');
expect(resolvePackageEcosystem('axios', 'auto')).toBe('npm');
});
it('honors an explicit ecosystem override', () => {
expect(resolvePackageEcosystem('@vue/reactivity', 'composer')).toBe('composer');
expect(resolvePackageEcosystem('laravel/framework', 'npm')).toBe('npm');
});
it('encodes scoped npm names for the registry API', () => {
expect(encodeNpmPackageName('@vue/reactivity')).toBe('@vue%2Freactivity');
expect(encodeNpmPackageName('axios')).toBe('axios');
});
});
+25 -4
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, textResult, toErrorMessage } from '../shared.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
@@ -18,6 +18,27 @@ async function fetchText(url: string): Promise<string> {
return response.text();
}
/** Encode an npm package name for the registry API (`@scope/name` → `@scope%2Fname`). */
export function encodeNpmPackageName(name: string): string {
if (name.startsWith('@')) {
const slash = name.indexOf('/');
if (slash === -1) return encodeURIComponent(name);
return `${name.slice(0, slash)}%2F${encodeURIComponent(name.slice(slash + 1))}`;
}
return encodeURIComponent(name);
}
/** Resolve auto ecosystem: scoped npm (`@scope/pkg`) stays npm; `vendor/pkg` → composer. */
export function resolvePackageEcosystem(
name: string,
ecosystem: 'npm' | 'composer' | 'auto',
): 'npm' | 'composer' {
if (ecosystem !== 'auto') return ecosystem;
if (name.startsWith('@')) return 'npm';
if (name.includes('/')) return 'composer';
return 'npm';
}
interface NpmPackageInfo {
'dist-tags': { latest: string };
versions: Record<string, { repository?: { url?: string }; homepage?: string; description?: string }>;
@@ -25,7 +46,7 @@ interface NpmPackageInfo {
}
async function fetchNpmDocs(name: string, version?: string): Promise<Record<string, unknown>> {
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
const registryUrl = `https://registry.npmjs.org/${encodeNpmPackageName(name)}`;
const info = (await fetchJson(registryUrl)) as NpmPackageInfo;
const resolvedVersion = version ?? info['dist-tags'].latest;
const versionData = info.versions[resolvedVersion];
@@ -93,13 +114,13 @@ export function registerSearchPackageDocsTool(server: McpServer): void {
.enum(['npm', 'composer', 'auto'])
.optional()
.default('auto')
.describe('Which package ecosystem to search. Auto detects from the name ("laravel/framework" → composer).'),
.describe('Which package ecosystem to search. Auto detects from the name ("laravel/framework" → composer; "@vue/reactivity" → npm).'),
version: z.string().optional().describe('Specific version to look up. Defaults to latest.'),
},
},
async ({ name, ecosystem, version }) => {
try {
const resolvedEcosystem = ecosystem === 'auto' ? (name.includes('/') ? 'composer' : 'npm') : ecosystem;
const resolvedEcosystem = resolvePackageEcosystem(name, ecosystem);
if (resolvedEcosystem === 'composer') {
return jsonResult(await fetchPackagistDocs(name, version));
}