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
+1 -1
View File
@@ -73,7 +73,7 @@ Post-v1 roadmap (see `PLAN.md` §9): **v1.4** Vue/Nuxt adapters, **v1.5** Sail/p
## Requirements ## 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 Laravel/CodeIgniter tools: PHP + Composer on `PATH`
- For scaffolding Laravel/CodeIgniter projects: Composer on `PATH` - For scaffolding Laravel/CodeIgniter projects: Composer on `PATH`
- For browser tools: `npx playwright install chromium` (run once after `npm install`) - For browser tools: `npx playwright install chromium` (run once after `npm install`)
+12
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { processManager } from '../processManager.js'; import { processManager } from '../processManager.js';
import { SandboxViolationError } from '../sandbox.js';
describe('processManager', () => { describe('processManager', () => {
afterEach(() => { afterEach(() => {
@@ -22,6 +23,17 @@ describe('processManager', () => {
expect(info.args).toEqual(['-e', 'console.log("ok")']); 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 () => { it('collects logs from a process', async () => {
const info = processManager.start({ const info = processManager.start({
label: 'echo', label: 'echo',
+3
View File
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
import { execa, type ResultPromise } from 'execa'; import { execa, type ResultPromise } from 'execa';
import { config } from '../config.js'; import { config } from '../config.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { assertAllowedBinary } from './sandbox.js';
export type ProcessStatus = 'running' | 'exited' | 'error' | 'stopped'; export type ProcessStatus = 'running' | 'exited' | 'error' | 'stopped';
@@ -37,6 +38,8 @@ class ProcessManager {
private readonly processes = new Map<string, ManagedProcess>(); private readonly processes = new Map<string, ManagedProcess>();
start(options: StartProcessOptions): ManagedProcessInfo { start(options: StartProcessOptions): ManagedProcessInfo {
assertAllowedBinary(options.command);
if (this.runningCount() >= config.maxConcurrentProcesses) { if (this.runningCount() >= config.maxConcurrentProcesses) {
throw new Error( throw new Error(
`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`, `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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from './config.js'; import { config } from './config.js';
import { detectProject, summarizeDetection } from './lib/frameworks/detect.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 { registerAuditTools } from './tools/audit/index.js';
import { registerBackendResourceTools } from './tools/backends/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 { export function createServer(): McpServer {
const server = new 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 { describe, it, expect, afterEach } from 'vitest';
import { validateVolumeMount, isAllowListedImage, isLocallyBuiltImage } from '../dockerManager.js'; import {
validateVolumeMount,
isAllowListedImage,
isLocallyBuiltImage,
resolveComposeFile,
dockerManager,
} from '../dockerManager.js';
import { SandboxViolationError } from '../../../lib/sandbox.js'; import { SandboxViolationError } from '../../../lib/sandbox.js';
import { config } from '../../../config.js';
import path from 'node:path';
describe('Docker guardrails', () => { describe('Docker guardrails', () => {
afterEach(() => {
for (const c of dockerManager.listTracked()) {
dockerManager.untrack(c.id);
}
});
it('validates workspace-confined volume mounts', () => { it('validates workspace-confined volume mounts', () => {
const mount = validateVolumeMount('src:/app/src'); const mount = validateVolumeMount('src:/app/src');
expect(mount.target).toBe('/app/src'); expect(mount.target).toBe('/app/src');
@@ -19,6 +33,23 @@ describe('Docker guardrails', () => {
expect(() => validateVolumeMount(':/app')).toThrow(); 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', () => { it('allows listed base images', () => {
expect(isAllowListedImage('node:20-alpine')).toBe(true); expect(isAllowListedImage('node:20-alpine')).toBe(true);
expect(isAllowListedImage('nginx:latest')).toBe(true); expect(isAllowListedImage('nginx:latest')).toBe(true);
@@ -26,9 +57,11 @@ describe('Docker guardrails', () => {
expect(isAllowListedImage('php:8.2')).toBe(true); 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('evil/image')).toBe(false);
expect(isAllowListedImage('ubuntu:latest')).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', () => { 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('registry.io/my-app')).toBe(false);
expect(isLocallyBuiltImage('user/repo')).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 { config } from '../../config.js';
import { runCommand } from '../../lib/runCommand.js'; import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.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` * 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 }) => { async ({ file, services, build }) => {
try { 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 (build) args.push('--build');
if (services && services.length > 0) args.push(...services); if (services && services.length > 0) args.push(...services);
const result = await runCommand('docker', args, { const result = await runCommand('docker', args, {
cwd: config.workspaceRoot, cwd: config.workspaceRoot,
timeoutMs: config.scaffoldCommandTimeoutMs, timeoutMs: config.scaffoldCommandTimeoutMs,
}); });
return jsonResult({ file, ...result }); return jsonResult({ file: composeFile, ...result });
} catch (error) { } catch (error) {
return errorResult(toErrorMessage(error)); return errorResult(toErrorMessage(error));
} }
@@ -70,13 +72,14 @@ export function registerDockerComposeTools(server: McpServer): void {
}, },
async ({ file, volumes }) => { async ({ file, volumes }) => {
try { try {
const args = ['compose', '-f', file, 'down']; const composeFile = resolveComposeFile(file);
const args = ['compose', '-f', composeFile, 'down'];
if (volumes) args.push('-v'); if (volumes) args.push('-v');
const result = await runCommand('docker', args, { const result = await runCommand('docker', args, {
cwd: config.workspaceRoot, cwd: config.workspaceRoot,
timeoutMs: config.defaultCommandTimeoutMs, timeoutMs: config.defaultCommandTimeoutMs,
}); });
return jsonResult({ file, ...result }); return jsonResult({ file: composeFile, ...result });
} catch (error) { } catch (error) {
return errorResult(toErrorMessage(error)); return errorResult(toErrorMessage(error));
} }
+4 -3
View File
@@ -30,23 +30,24 @@ export function registerDockerContainerLogsTool(server: McpServer): void {
async ({ containerId, tail, follow }) => { async ({ containerId, tail, follow }) => {
try { try {
await dockerManager.ensureReachable(); await dockerManager.ensureReachable();
const tracked = dockerManager.requireTrackedContainer(containerId);
if (follow) { if (follow) {
return jsonResult({ 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.', note: 'Live log streaming is not supported via this tool; use docker_get_container_logs with follow:false to poll.',
logs: '', logs: '',
}); });
} }
const docker = dockerManager.getDocker(); const docker = dockerManager.getDocker();
const buffer = await docker.getContainer(containerId).logs({ const buffer = await docker.getContainer(tracked.id).logs({
stdout: true, stdout: true,
stderr: true, stderr: true,
tail, tail,
}); });
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer); const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
return jsonResult({ return jsonResult({
containerId, containerId: tracked.id,
logs: raw.slice(-config.maxToolOutputChars), logs: raw.slice(-config.maxToolOutputChars),
bytes: raw.length, 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)); 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[] { listTracked(): ManagedContainer[] {
return Array.from(this.containers.values()); return Array.from(this.containers.values());
} }
@@ -87,21 +101,65 @@ class DockerManager {
export const dockerManager = new 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 * Validates that a volume mount specification keeps the source path inside the
* workspace root. Rejects Docker socket mounts, host-relative escapes, etc. * workspace root. Rejects Docker socket mounts, host-relative escapes, etc.
*/ */
export function validateVolumeMount(spec: string): { source: string; target: string; mode: string } { export function validateVolumeMount(spec: string): { source: string; target: string; mode: string } {
const parts = spec.split(':'); const { source, target, mode } = parseVolumeMountSpec(spec);
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 normalizedSource = source.toLowerCase().replaceAll('/', '\\'); const normalizedSource = source.toLowerCase().replaceAll('/', '\\');
if ( if (
normalizedSource.startsWith('\\\\.\\pipe\\docker_engine') || normalizedSource.startsWith('\\\\.\\pipe\\docker_engine') ||
@@ -116,7 +174,7 @@ export function validateVolumeMount(spec: string): { source: string; target: str
export function isAllowListedImage(image: string): boolean { export function isAllowListedImage(image: string): boolean {
const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? ''; 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 { export function isLocallyBuiltImage(image: string): boolean {
@@ -125,3 +183,8 @@ export function isLocallyBuiltImage(image: string): boolean {
// with a registry host (contains '.' or '/') is not. // with a registry host (contains '.' or '/') is not.
return !/[/.]/.test(image); 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 }) => { async ({ containerId, command, workingDir, env }) => {
try { try {
await dockerManager.ensureReachable(); await dockerManager.ensureReachable();
const tracked = dockerManager.requireTrackedContainer(containerId);
const docker = dockerManager.getDocker(); const docker = dockerManager.getDocker();
const exec = await docker.getContainer(containerId).exec({ const exec = await docker.getContainer(tracked.id).exec({
Cmd: command, Cmd: command,
WorkingDir: workingDir, WorkingDir: workingDir,
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined, 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 raw = Buffer.concat(chunks).toString('utf8');
const result = await exec.inspect(); const result = await exec.inspect();
return jsonResult({ return jsonResult({
containerId, containerId: tracked.id,
command, command,
exitCode: result.ExitCode ?? null, exitCode: result.ExitCode ?? null,
output: raw.slice(-config.maxToolOutputChars), output: raw.slice(-config.maxToolOutputChars),
+7 -5
View File
@@ -17,8 +17,9 @@ export function registerDockerStopRemoveTools(server: McpServer): void {
async ({ containerId, timeout }) => { async ({ containerId, timeout }) => {
try { try {
await dockerManager.ensureReachable(); await dockerManager.ensureReachable();
await dockerManager.getDocker().getContainer(containerId).stop({ t: timeout }); const tracked = dockerManager.requireTrackedContainer(containerId);
return jsonResult({ stopped: true, containerId }); await dockerManager.getDocker().getContainer(tracked.id).stop({ t: timeout });
return jsonResult({ stopped: true, containerId: tracked.id });
} catch (error) { } catch (error) {
return errorResult(toErrorMessage(error)); return errorResult(toErrorMessage(error));
} }
@@ -38,9 +39,10 @@ export function registerDockerStopRemoveTools(server: McpServer): void {
async ({ containerId, force }) => { async ({ containerId, force }) => {
try { try {
await dockerManager.ensureReachable(); await dockerManager.ensureReachable();
await dockerManager.getDocker().getContainer(containerId).remove({ force }); const tracked = dockerManager.requireTrackedContainer(containerId);
dockerManager.untrack(containerId); await dockerManager.getDocker().getContainer(tracked.id).remove({ force });
return jsonResult({ removed: true, containerId }); dockerManager.untrack(tracked.id);
return jsonResult({ removed: true, containerId: tracked.id });
} catch (error) { } catch (error) {
return errorResult(toErrorMessage(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 { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 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> { async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url, { headers: { Accept: 'application/json' } }); const response = await fetch(url, { headers: { Accept: 'application/json' } });
@@ -18,6 +18,27 @@ async function fetchText(url: string): Promise<string> {
return response.text(); 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 { interface NpmPackageInfo {
'dist-tags': { latest: string }; 'dist-tags': { latest: string };
versions: Record<string, { repository?: { url?: string }; homepage?: string; description?: 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>> { 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 info = (await fetchJson(registryUrl)) as NpmPackageInfo;
const resolvedVersion = version ?? info['dist-tags'].latest; const resolvedVersion = version ?? info['dist-tags'].latest;
const versionData = info.versions[resolvedVersion]; const versionData = info.versions[resolvedVersion];
@@ -93,13 +114,13 @@ export function registerSearchPackageDocsTool(server: McpServer): void {
.enum(['npm', 'composer', 'auto']) .enum(['npm', 'composer', 'auto'])
.optional() .optional()
.default('auto') .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.'), version: z.string().optional().describe('Specific version to look up. Defaults to latest.'),
}, },
}, },
async ({ name, ecosystem, version }) => { async ({ name, ecosystem, version }) => {
try { try {
const resolvedEcosystem = ecosystem === 'auto' ? (name.includes('/') ? 'composer' : 'npm') : ecosystem; const resolvedEcosystem = resolvePackageEcosystem(name, ecosystem);
if (resolvedEcosystem === 'composer') { if (resolvedEcosystem === 'composer') {
return jsonResult(await fetchPackagistDocs(name, version)); return jsonResult(await fetchPackagistDocs(name, version));
} }