From c37fb170ab4ad70f760b1c463590d3654291652c Mon Sep 17 00:00:00 2001 From: melabidi Date: Fri, 31 Jul 2026 16:59:46 -0400 Subject: [PATCH] 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. --- README.md | 2 +- src/lib/__tests__/processManager.test.ts | 12 +++ src/lib/processManager.ts | 3 + src/server.ts | 4 +- src/tools/certs.txt | 42 --------- .../docker/__tests__/dockerManager.test.ts | 55 +++++++++++- src/tools/docker/compose.ts | 11 ++- src/tools/docker/containerLogs.ts | 7 +- src/tools/docker/dockerManager.ts | 85 ++++++++++++++++--- src/tools/docker/exec.ts | 5 +- src/tools/docker/stopRemove.ts | 12 +-- .../docs/__tests__/searchPackageDocs.test.ts | 24 ++++++ src/tools/docs/searchPackageDocs.ts | 29 ++++++- 13 files changed, 215 insertions(+), 76 deletions(-) delete mode 100644 src/tools/certs.txt create mode 100644 src/tools/docs/__tests__/searchPackageDocs.test.ts diff --git a/README.md b/README.md index c41ac60..c0aa688 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/src/lib/__tests__/processManager.test.ts b/src/lib/__tests__/processManager.test.ts index 58a83d6..5b44351 100644 --- a/src/lib/__tests__/processManager.test.ts +++ b/src/lib/__tests__/processManager.test.ts @@ -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', diff --git a/src/lib/processManager.ts b/src/lib/processManager.ts index f9d63c0..a434671 100644 --- a/src/lib/processManager.ts +++ b/src/lib/processManager.ts @@ -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(); 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.`, diff --git a/src/server.ts b/src/server.ts index 8eceec3..57b0e4c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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( diff --git a/src/tools/certs.txt b/src/tools/certs.txt deleted file mode 100644 index 2f634a1..0000000 --- a/src/tools/certs.txt +++ /dev/null @@ -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----- \ No newline at end of file diff --git a/src/tools/docker/__tests__/dockerManager.test.ts b/src/tools/docker/__tests__/dockerManager.test.ts index 464334e..22356e6 100644 --- a/src/tools/docker/__tests__/dockerManager.test.ts +++ b/src/tools/docker/__tests__/dockerManager.test.ts @@ -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'); + }); }); diff --git a/src/tools/docker/compose.ts b/src/tools/docker/compose.ts index c342563..3bb25fa 100644 --- a/src/tools/docker/compose.ts +++ b/src/tools/docker/compose.ts @@ -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)); } diff --git a/src/tools/docker/containerLogs.ts b/src/tools/docker/containerLogs.ts index 5a7de0c..64ab763 100644 --- a/src/tools/docker/containerLogs.ts +++ b/src/tools/docker/containerLogs.ts @@ -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, }); diff --git a/src/tools/docker/dockerManager.ts b/src/tools/docker/dockerManager.ts index 7d715cb..92972e1 100644 --- a/src/tools/docker/dockerManager.ts +++ b/src/tools/docker/dockerManager.ts @@ -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); +} diff --git a/src/tools/docker/exec.ts b/src/tools/docker/exec.ts index f329bbd..fced9e2 100644 --- a/src/tools/docker/exec.ts +++ b/src/tools/docker/exec.ts @@ -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), diff --git a/src/tools/docker/stopRemove.ts b/src/tools/docker/stopRemove.ts index 6838ce2..844234b 100644 --- a/src/tools/docker/stopRemove.ts +++ b/src/tools/docker/stopRemove.ts @@ -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)); } diff --git a/src/tools/docs/__tests__/searchPackageDocs.test.ts b/src/tools/docs/__tests__/searchPackageDocs.test.ts new file mode 100644 index 0000000..d646f8f --- /dev/null +++ b/src/tools/docs/__tests__/searchPackageDocs.test.ts @@ -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'); + }); +}); diff --git a/src/tools/docs/searchPackageDocs.ts b/src/tools/docs/searchPackageDocs.ts index 8f71aed..575558d 100644 --- a/src/tools/docs/searchPackageDocs.ts +++ b/src/tools/docs/searchPackageDocs.ts @@ -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 { const response = await fetch(url, { headers: { Accept: 'application/json' } }); @@ -18,6 +18,27 @@ async function fetchText(url: string): Promise { 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; @@ -25,7 +46,7 @@ interface NpmPackageInfo { } async function fetchNpmDocs(name: string, version?: string): Promise> { - 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)); }