Harden Docker and process safety guardrails.
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:
@@ -73,7 +73,7 @@ Post-v1 roadmap (see `PLAN.md` §9): **v1.4** Vue/Nuxt adapters, **v1.5** Sail/p
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18.17 (developed/tested on Node 24)
|
||||
- Node.js >= 22 (developed/tested on Node 24)
|
||||
- For Laravel/CodeIgniter tools: PHP + Composer on `PATH`
|
||||
- For scaffolding Laravel/CodeIgniter projects: Composer on `PATH`
|
||||
- For browser tools: `npx playwright install chromium` (run once after `npm install`)
|
||||
|
||||
@@ -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,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
@@ -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(
|
||||
|
||||
@@ -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-----
|
||||
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
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user