init project

This commit is contained in:
root
2026-07-31 13:12:54 -04:00
parent 0da92d5e02
commit f3863f760c
7215 changed files with 1860260 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
import path from 'node:path';
function resolveWorkspaceRoot(): string {
const fromEnv = process.env.WORKSPACE_ROOT;
const raw = fromEnv && fromEnv.trim().length > 0 ? fromEnv : process.cwd();
return path.resolve(raw);
}
export const config = {
/** Absolute path every tool call is confined to. */
workspaceRoot: resolveWorkspaceRoot(),
/** HTTP transport settings (only used by transports/http.ts). */
httpHost: process.env.MCP_HTTP_HOST ?? '127.0.0.1',
httpPort: Number.parseInt(process.env.MCP_HTTP_PORT ?? '3939', 10),
httpToken: process.env.MCP_HTTP_TOKEN,
/** Binaries the server is ever allowed to spawn. Basenames only (extension-insensitive). */
allowedBinaries: [
'npm',
'pnpm',
'yarn',
'bun',
'node',
'npx',
'git',
'php',
'composer',
'powershell',
'sh',
'bash',
// Composer vendor/bin tools invoked by the Laravel/CodeIgniter adapters.
// These are only ever resolved to a path inside the detected project's
// own vendor/bin directory (see PhpFrameworkAdapter.vendorBin), never
// taken verbatim from tool-call input.
'phpunit',
'pest',
'pint',
'phpcs',
'phpcbf',
// Docker CLI used by docker_compose_up/down.
'docker',
// Lighthouse CLI used by lighthouse_audit (also invokable via npx).
'lighthouse',
// v1.4 adapters: Django / Rails tooling.
'python',
'pip',
'poetry',
'uv',
'ruby',
'bundle',
'rails',
'gem',
] as const,
/** Hard caps to keep tool output/process usage bounded. */
maxBufferedLogLines: 2000,
maxToolOutputChars: 20_000,
defaultCommandTimeoutMs: 120_000,
scaffoldCommandTimeoutMs: 5 * 60_000,
defaultReadyTimeoutMs: 30_000,
maxConcurrentProcesses: 10,
/** Docker-specific guardrails. */
dockerAllowedBaseImages: [
'node',
'nginx',
'postgres',
'redis',
'mysql',
'mariadb',
'mongo',
'php',
'composer',
'python',
'alpine',
'busybox',
] as const,
maxConcurrentContainers: 10,
defaultContainerMemory: '512m',
defaultContainerCpus: '1.0',
} as const;
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { SandboxViolationError } from '../sandbox.js';
describe('baselines', () => {
let tmpDir: string;
let originalRoot: string;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-baselines-'));
const config = await import('../../config.js');
originalRoot = config.config.workspaceRoot;
(config.config as { workspaceRoot: string }).workspaceRoot = tmpDir;
});
afterEach(async () => {
const config = await import('../../config.js');
(config.config as { workspaceRoot: string }).workspaceRoot = originalRoot;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('writes and reads a baseline round-trip', async () => {
const { writeBaseline, readBaseline, listBaselines } = await import('../baselines.js');
const png = PNG.sync.write(new PNG({ width: 2, height: 2 }));
const saved = writeBaseline('home', png);
expect(saved.relativePath.replace(/\\/g, '/')).toBe('.web-dev-mcp/baselines/home.png');
expect(Buffer.compare(readBaseline('home'), png)).toBe(0);
expect(listBaselines()).toContain('home.png');
});
it('rejects path-like baseline names', async () => {
const { writeBaseline } = await import('../baselines.js');
const png = PNG.sync.write(new PNG({ width: 1, height: 1 }));
expect(() => writeBaseline('../escape', png)).toThrow(SandboxViolationError);
expect(() => writeBaseline('a/b', png)).toThrow(SandboxViolationError);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, afterEach } from 'vitest';
import { processManager } from '../processManager.js';
describe('processManager', () => {
afterEach(() => {
for (const p of processManager.list()) {
processManager.stop(p.id);
}
});
it('starts a tracked process and returns metadata', () => {
const info = processManager.start({
label: 'echo',
command: 'node',
args: ['-e', 'console.log("ok")'],
cwd: process.cwd(),
});
expect(info.label).toBe('echo');
expect(info.command).toBe('node');
expect(info.status).toBe('running');
expect(info.args).toEqual(['-e', 'console.log("ok")']);
});
it('collects logs from a process', async () => {
const info = processManager.start({
label: 'echo',
command: 'node',
args: ['-e', 'console.log("line1"); console.log("line2")'],
cwd: process.cwd(),
});
await new Promise((resolve) => setTimeout(resolve, 500));
const { logs } = processManager.getLogs(info.id, 100);
expect(logs).toContain('line1');
expect(logs).toContain('line2');
});
it('reports a stopped process', async () => {
const info = processManager.start({
label: 'sleep',
command: 'node',
args: ['-e', 'setTimeout(() => {}, 60000)'],
cwd: process.cwd(),
});
processManager.stop(info.id);
await new Promise((resolve) => setTimeout(resolve, 200));
const stopped = processManager.list().find((p) => p.id === info.id);
expect(stopped?.status).toBe('stopped');
});
it('throws for an unknown process id', () => {
expect(() => processManager.getLogs('not-a-real-id')).toThrow(/Unknown processId/);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { runCommand } from '../runCommand.js';
import { SandboxViolationError } from '../sandbox.js';
describe('runCommand', () => {
it('runs an allowed binary and captures stdout', async () => {
const result = await runCommand('node', ['-e', 'console.log("hello")'], { cwd: process.cwd() });
expect(result.exitCode).toBe(0);
expect(result.output).toContain('hello');
expect(result.failed).toBe(false);
expect(result.timedOut).toBe(false);
});
it('captures stderr and a non-zero exit code', async () => {
const result = await runCommand('node', ['-e', 'console.error("boom"); process.exit(1)'], { cwd: process.cwd() });
expect(result.exitCode).toBe(1);
expect(result.output).toContain('boom');
expect(result.failed).toBe(true);
});
it('rejects a disallowed binary', async () => {
await expect(runCommand('rm', ['-rf', '/'], { cwd: process.cwd() })).rejects.toThrow(SandboxViolationError);
});
it('truncates oversized output', async () => {
const longString = 'x'.repeat(30_000);
const result = await runCommand('node', ['-e', `console.log('${longString}')`], { cwd: process.cwd() });
expect(result.output.length).toBeLessThan(30_000);
expect(result.output).toContain('... [truncated');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import { resolveWorkspacePath, assertAllowedBinary, SandboxViolationError } from '../sandbox.js';
const ROOT = path.resolve('/workspace');
describe('resolveWorkspacePath', () => {
it('resolves a simple relative path inside the root', () => {
const result = resolveWorkspacePath(ROOT, 'src/index.ts');
expect(result).toBe(path.join(ROOT, 'src', 'index.ts'));
});
it('resolves "." to the root itself', () => {
const result = resolveWorkspacePath(ROOT, '.');
expect(result).toBe(ROOT);
});
it('throws for a single .. escape attempt', () => {
expect(() => resolveWorkspacePath(ROOT, '../etc/passwd')).toThrow(SandboxViolationError);
});
it('throws for nested .. escape attempts', () => {
expect(() => resolveWorkspacePath(ROOT, 'a/b/../../../../etc/passwd')).toThrow(SandboxViolationError);
});
it('throws for an absolute path outside the root', () => {
expect(() => resolveWorkspacePath(ROOT, '/etc/passwd')).toThrow(SandboxViolationError);
});
it('allows a path that walks up and back down inside the root', () => {
const result = resolveWorkspacePath(ROOT, 'a/b/../c/d.ts');
expect(result).toBe(path.join(ROOT, 'a', 'c', 'd.ts'));
});
});
describe('assertAllowedBinary', () => {
it('allows an explicitly listed binary', () => {
expect(() => assertAllowedBinary('npm')).not.toThrow();
expect(() => assertAllowedBinary('php')).not.toThrow();
expect(() => assertAllowedBinary('docker')).not.toThrow();
});
it('allows listed binaries with common Windows extensions', () => {
expect(() => assertAllowedBinary('npm.exe')).not.toThrow();
expect(() => assertAllowedBinary('node.cmd')).not.toThrow();
expect(() => assertAllowedBinary('composer.ps1')).not.toThrow();
});
it('allows a binary with a directory prefix', () => {
expect(() => assertAllowedBinary('vendor/bin/phpunit')).not.toThrow();
});
it('rejects a binary not on the allow-list', () => {
expect(() => assertAllowedBinary('rm')).toThrow(SandboxViolationError);
expect(() => assertAllowedBinary('curl')).toThrow(SandboxViolationError);
});
it('rejects a path that would execute an arbitrary binary', () => {
expect(() => assertAllowedBinary('/usr/bin/rm')).toThrow(SandboxViolationError);
});
});
+61
View File
@@ -0,0 +1,61 @@
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { resolveWorkspacePath, SandboxViolationError } from './sandbox.js';
const BASELINES_DIR = path.join('.web-dev-mcp', 'baselines');
function sanitizeBaselineName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
throw new SandboxViolationError('Baseline name must be a non-empty string.');
}
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes(':')) {
throw new SandboxViolationError(
`Invalid baseline name "${name}": use a simple name without path separators or "..".`,
);
}
return trimmed.endsWith('.png') ? trimmed : `${trimmed}.png`;
}
export function resolveBaselinePath(name: string): string {
const fileName = sanitizeBaselineName(name);
const relative = path.join(BASELINES_DIR, fileName);
return resolveWorkspacePath(config.workspaceRoot, relative);
}
export function ensureBaselinesDir(): string {
const dir = resolveWorkspacePath(config.workspaceRoot, BASELINES_DIR);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
export function writeBaseline(name: string, png: Buffer): { absolutePath: string; relativePath: string } {
ensureBaselinesDir();
const absolutePath = resolveBaselinePath(name);
fs.writeFileSync(absolutePath, png);
return {
absolutePath,
relativePath: path.relative(config.workspaceRoot, absolutePath),
};
}
export function readBaseline(name: string): Buffer {
const absolutePath = resolveBaselinePath(name);
if (!fs.existsSync(absolutePath)) {
throw new Error(
`Baseline not found at "${path.relative(config.workspaceRoot, absolutePath)}". ` +
'Capture one first with browser_screenshot_baseline.',
);
}
return fs.readFileSync(absolutePath);
}
export function listBaselines(): string[] {
const dir = resolveWorkspacePath(config.workspaceRoot, BASELINES_DIR);
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((entry) => entry.toLowerCase().endsWith('.png'))
.sort();
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { detectProject, summarizeDetection } from '../detect.js';
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-test-'));
}
describe('detectProject', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = createTempDir();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('detects a Laravel project', () => {
fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'laravel/framework': '^10.0' } }));
fs.writeFileSync(path.join(tmpDir, 'artisan'), '#!/usr/bin/env php');
const detected = detectProject(tmpDir);
expect(detected.backend?.kind).toBe('laravel');
expect(detected.primary.kind).toBe('laravel');
});
it('detects a CodeIgniter project', () => {
fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'codeigniter4/framework': '^4.0' } }));
fs.writeFileSync(path.join(tmpDir, 'spark'), '#!/usr/bin/env php');
const detected = detectProject(tmpDir);
expect(detected.backend?.kind).toBe('codeigniter');
expect(detected.primary.kind).toBe('codeigniter');
});
it('detects a React project', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { react: '^18.0' } }));
const detected = detectProject(tmpDir);
expect(detected.frontend?.kind).toBe('react');
expect(detected.primary.kind).toBe('react');
});
it('detects a Vue project', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { vue: '^3.0' } }));
const detected = detectProject(tmpDir);
expect(detected.frontend?.kind).toBe('vue');
expect(detected.primary.kind).toBe('vue');
});
it('detects Nuxt over Vue when both are present', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ dependencies: { vue: '^3.0', nuxt: '^3.0' } }),
);
fs.writeFileSync(path.join(tmpDir, 'nuxt.config.ts'), 'export default {}\n');
const detected = detectProject(tmpDir);
expect(detected.frontend?.kind).toBe('nuxt');
});
it('detects Vue via .vue SFC + Vite when vue is not in package.json', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
fs.writeFileSync(path.join(tmpDir, 'vite.config.ts'), 'export default {}\n');
const src = path.join(tmpDir, 'src');
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, 'App.vue'), '<template><div /></template>\n');
const detected = detectProject(tmpDir);
expect(detected.frontend?.kind).toBe('vue');
});
it('detects a Symfony project', () => {
fs.writeFileSync(
path.join(tmpDir, 'composer.json'),
JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }),
);
fs.mkdirSync(path.join(tmpDir, 'bin'));
fs.writeFileSync(path.join(tmpDir, 'bin', 'console'), '#!/usr/bin/env php\n');
const detected = detectProject(tmpDir);
expect(detected.backend?.kind).toBe('symfony');
});
it('detects a Django project', () => {
fs.writeFileSync(path.join(tmpDir, 'manage.py'), '#!/usr/bin/env python\n');
fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'Django>=5.0\n');
const detected = detectProject(tmpDir);
expect(detected.backend?.kind).toBe('django');
expect(detected.primary.kind).toBe('django');
});
it('detects a Rails project', () => {
fs.writeFileSync(path.join(tmpDir, 'Gemfile'), "gem 'rails', '~> 7.0'\n");
fs.mkdirSync(path.join(tmpDir, 'bin'));
fs.writeFileSync(path.join(tmpDir, 'bin', 'rails'), '#!/usr/bin/env ruby\n');
const detected = detectProject(tmpDir);
expect(detected.backend?.kind).toBe('rails');
});
it('detects a Laravel backend with a separate React frontend', () => {
fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'laravel/framework': '^10.0' } }));
fs.writeFileSync(path.join(tmpDir, 'artisan'), '#!/usr/bin/env php');
const frontendDir = path.join(tmpDir, 'frontend');
fs.mkdirSync(frontendDir);
fs.writeFileSync(path.join(frontendDir, 'package.json'), JSON.stringify({ dependencies: { react: '^18.0' } }));
const detected = detectProject(tmpDir);
expect(detected.backend?.kind).toBe('laravel');
expect(detected.frontend?.kind).toBe('react');
expect(detected.primary.kind).toBe('laravel');
});
it('falls back to generic for an empty directory', () => {
const detected = detectProject(tmpDir);
expect(detected.backend).toBeNull();
expect(detected.frontend).toBeNull();
expect(detected.primary.kind).toBe('generic');
});
it('summarizeDetection returns serializable metadata', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { react: '^18.0' } }));
const summary = summarizeDetection(detectProject(tmpDir));
expect(summary.primary).toBe('react');
expect(summary.combined).toBe(false);
expect(summary.frontend).toHaveProperty('kind', 'react');
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { LaravelAdapter } from '../laravel.js';
describe('LaravelAdapter Sail-aware devCommand', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-sail-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('uses php artisan serve when Sail is not present', () => {
fs.writeFileSync(
path.join(tmpDir, 'composer.json'),
JSON.stringify({ require: { 'laravel/framework': '^11.0' } }),
);
const adapter = new LaravelAdapter(tmpDir);
const cmd = adapter.devCommand();
expect(cmd.command).toBe('php');
expect(cmd.args).toContain('artisan');
expect(adapter.describe().usesSail).toBe(false);
});
it('uses sail up when laravel/sail + compose file exist', () => {
fs.writeFileSync(
path.join(tmpDir, 'composer.json'),
JSON.stringify({
require: { 'laravel/framework': '^11.0' },
'require-dev': { 'laravel/sail': '^1.0' },
}),
);
fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), 'services: {}\n');
fs.mkdirSync(path.join(tmpDir, 'vendor', 'bin'), { recursive: true });
const sail = path.join(tmpDir, 'vendor', 'bin', 'sail');
fs.writeFileSync(sail, '#!/usr/bin/env bash\n');
const adapter = new LaravelAdapter(tmpDir);
const cmd = adapter.devCommand();
expect(cmd.command).toBe('bash');
expect(cmd.args[0]?.replace(/\\/g, '/')).toContain('vendor/bin/sail');
expect(cmd.args).toContain('up');
expect(adapter.describe().usesSail).toBe(true);
});
it('falls back to docker compose up when Sail package is present but sail script is missing', () => {
fs.writeFileSync(
path.join(tmpDir, 'composer.json'),
JSON.stringify({
require: { 'laravel/framework': '^11.0' },
'require-dev': { 'laravel/sail': '^1.0' },
}),
);
fs.writeFileSync(path.join(tmpDir, 'compose.yaml'), 'services: {}\n');
const adapter = new LaravelAdapter(tmpDir);
const cmd = adapter.devCommand();
expect(cmd.command).toBe('docker');
expect(cmd.args).toEqual(['compose', 'up']);
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { ReactAdapter } from '../react.js';
describe('ReactAdapter Next.js metadata', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-next-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('reports app router and rsc for Next App Router projects', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ dependencies: { react: '^18.0', next: '^14.0' } }),
);
fs.writeFileSync(path.join(tmpDir, 'next.config.mjs'), 'export default {}');
fs.mkdirSync(path.join(tmpDir, 'app'));
fs.writeFileSync(path.join(tmpDir, 'app', 'layout.tsx'), 'export default function Root({ children }) { return children; }');
const described = new ReactAdapter(tmpDir).describe();
expect(described.bundler).toBe('next');
expect(described.router).toBe('app');
expect(described.rsc).toBe(true);
});
it('reports pages router without rsc', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ dependencies: { react: '^18.0', next: '^13.0' } }),
);
fs.mkdirSync(path.join(tmpDir, 'pages'));
fs.writeFileSync(path.join(tmpDir, 'pages', 'index.tsx'), 'export default function Home() { return null; }');
const described = new ReactAdapter(tmpDir).describe();
expect(described.bundler).toBe('next');
expect(described.router).toBe('pages');
expect(described.rsc).toBe(false);
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { detectWorkspace, resolvePackage, summarizeWorkspace } from '../detect.js';
describe('detectWorkspace', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-ws-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('includes the root and apps/* packages', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'root', private: true }));
fs.mkdirSync(path.join(tmpDir, 'apps', 'web'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'apps', 'web', 'package.json'),
JSON.stringify({ name: 'web', dependencies: { react: '^18.0' } }),
);
const workspace = detectWorkspace(tmpDir);
const summary = summarizeWorkspace(workspace);
expect(summary.packageCount).toBeGreaterThanOrEqual(2);
const names = workspace.packages.map((p) => p.name);
expect(names).toContain('web');
});
it('resolvePackage finds by name and relative path', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'root' }));
fs.mkdirSync(path.join(tmpDir, 'packages', 'api'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'packages', 'api', 'package.json'), JSON.stringify({ name: 'api' }));
const byName = resolvePackage(tmpDir, 'api');
expect(byName.pkg.relativePath.replace(/\\/g, '/')).toBe('packages/api');
const byPath = resolvePackage(tmpDir, 'packages/api');
expect(byPath.pkg.name).toBe('api');
});
it('resolvePackage throws for unknown packages', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'root' }));
expect(() => resolvePackage(tmpDir, 'missing')).toThrow(/Unknown package/);
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { AdapterCommand } from './types.js';
import { PhpFrameworkAdapter } from './phpBase.js';
export class CodeIgniterAdapter extends PhpFrameworkAdapter {
readonly kind = 'codeigniter' as const;
readonly label = 'CodeIgniter 4';
devCommand(): AdapterCommand {
return { command: 'php', args: ['spark', 'serve'], cwd: this.root };
}
buildCommand(): AdapterCommand | null {
// CodeIgniter has no dedicated production-build step beyond a lean install;
// callers that need config/route caching should extend this via `spark`.
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
}
readyPattern(): RegExp {
// `php spark serve` prints: "CodeIgniter development server started on http://localhost:8080"
return /CodeIgniter development server started on (https?:\/\/\S+)/i;
}
describe(): Record<string, unknown> {
return {
kind: this.kind,
label: this.label,
root: this.root,
packageManager: this.packageManager,
codeigniterVersion: this.composerJson?.require?.['codeigniter4/framework'] ?? null,
devUrl: 'http://localhost:8080',
};
}
}
+29
View File
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
export interface ComposerJson {
name?: string;
require?: Record<string, string>;
'require-dev'?: Record<string, string>;
scripts?: Record<string, string>;
}
export function readComposerJson(root: string): ComposerJson | null {
const file = path.join(root, 'composer.json');
if (!fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, 'utf8')) as ComposerJson;
} catch {
return null;
}
}
export function hasComposerDependency(pkg: ComposerJson | null, name: string): boolean {
if (!pkg) return false;
return Boolean(pkg.require?.[name] || pkg['require-dev']?.[name]);
}
export function vendorBinExists(root: string, bin: string): boolean {
const suffix = process.platform === 'win32' ? '.bat' : '';
return fs.existsSync(path.join(root, 'vendor', 'bin', `${bin}${suffix}`)) || fs.existsSync(path.join(root, 'vendor', 'bin', bin));
}
+287
View File
@@ -0,0 +1,287 @@
import fs from 'node:fs';
import path from 'node:path';
import { readComposerJson, hasComposerDependency } from './composer.js';
import { hasDependency, readPackageJson } from './packageManager.js';
import { LaravelAdapter } from './laravel.js';
import { CodeIgniterAdapter } from './codeigniter.js';
import { ReactAdapter } from './react.js';
import { VueAdapter, NuxtAdapter } from './vue.js';
import { DjangoAdapter, RailsAdapter, SymfonyAdapter } from './djangoRailsSymfony.js';
import { GenericAdapter } from './generic.js';
import type { DetectedPackage, DetectedProject, DetectedWorkspace, FrameworkAdapter } from './types.js';
const FRONTEND_SUBDIRS = ['frontend', 'client', 'web', 'resources/js-app'];
const MONOREPO_GLOBS = ['apps', 'packages', 'services', 'libs'];
function fileExists(root: string, names: string[]): boolean {
return names.some((name) => fs.existsSync(path.join(root, name)));
}
function hasVueSfc(root: string): boolean {
const src = path.join(root, 'src');
if (!fs.existsSync(src)) return false;
try {
const stack = [src];
let checked = 0;
while (stack.length > 0 && checked < 200) {
const dir = stack.pop()!;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
checked += 1;
if (entry.isDirectory() && entry.name !== 'node_modules') {
stack.push(path.join(dir, entry.name));
} else if (entry.isFile() && entry.name.endsWith('.vue')) {
return true;
}
}
}
} catch {
return false;
}
return false;
}
function detectPhpOrSymfonyBackend(root: string): FrameworkAdapter | null {
const composerJson = readComposerJson(root);
if (!composerJson) return null;
const hasArtisan = fs.existsSync(path.join(root, 'artisan'));
const hasSpark = fs.existsSync(path.join(root, 'spark'));
if (hasComposerDependency(composerJson, 'laravel/framework') || hasArtisan) {
return new LaravelAdapter(root);
}
if (hasComposerDependency(composerJson, 'codeigniter4/framework') || hasSpark) {
return new CodeIgniterAdapter(root);
}
if (
hasComposerDependency(composerJson, 'symfony/framework-bundle') ||
hasComposerDependency(composerJson, 'symfony/symfony') ||
fileExists(root, ['bin/console', 'symfony.lock'])
) {
return new SymfonyAdapter(root);
}
return null;
}
function detectDjangoAt(root: string): FrameworkAdapter | null {
if (fileExists(root, ['manage.py'])) return new DjangoAdapter(root);
for (const name of ['requirements.txt', 'requirements-dev.txt', 'pyproject.toml']) {
const file = path.join(root, name);
if (!fs.existsSync(file)) continue;
const text = fs.readFileSync(file, 'utf8').toLowerCase();
if (/\bdjango\b/.test(text)) return new DjangoAdapter(root);
}
return null;
}
function detectRailsAt(root: string): FrameworkAdapter | null {
const gemfile = path.join(root, 'Gemfile');
if (fs.existsSync(gemfile)) {
const text = fs.readFileSync(gemfile, 'utf8').toLowerCase();
if (/\brails\b/.test(text) || fileExists(root, ['bin/rails', 'config/application.rb'])) {
return new RailsAdapter(root);
}
}
if (fileExists(root, ['bin/rails', 'config/application.rb'])) {
return new RailsAdapter(root);
}
return null;
}
function detectBackend(root: string): FrameworkAdapter | null {
return detectPhpOrSymfonyBackend(root) ?? detectDjangoAt(root) ?? detectRailsAt(root);
}
function detectFrontendAt(root: string): FrameworkAdapter | null {
const pkg = readPackageJson(root);
if (
hasDependency(pkg, 'nuxt') ||
fileExists(root, ['nuxt.config.js', 'nuxt.config.ts', 'nuxt.config.mjs'])
) {
return new NuxtAdapter(root);
}
if (
hasDependency(pkg, 'vue') ||
(hasVueSfc(root) && fileExists(root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']))
) {
return new VueAdapter(root);
}
if (hasDependency(pkg, 'react')) return new ReactAdapter(root);
return null;
}
/**
* Inspects `workspaceRoot` and returns the detected backend/frontend
* adapter(s). Supports PHP (Laravel/CI/Symfony), Django, Rails backends and
* React/Vue/Nuxt frontends, including sibling frontend folders.
*/
export function detectProject(workspaceRoot: string): DetectedProject {
const backend = detectBackend(workspaceRoot);
let frontend = detectFrontendAt(workspaceRoot);
if (backend && !frontend) {
for (const subdir of FRONTEND_SUBDIRS) {
const candidateRoot = path.join(workspaceRoot, subdir);
if (!fs.existsSync(candidateRoot)) continue;
const candidate = detectFrontendAt(candidateRoot);
if (candidate) {
frontend = candidate;
break;
}
}
}
// Pure frontend-only Vue/Nuxt/React at root without backend.
if (!backend && !frontend) {
frontend = detectFrontendAt(workspaceRoot);
}
if (backend) {
return { backend, frontend, primary: backend };
}
if (frontend) {
return { backend: null, frontend, primary: frontend };
}
const generic = new GenericAdapter(workspaceRoot);
return { backend: null, frontend: null, primary: generic };
}
export function summarizeDetection(detected: DetectedProject): Record<string, unknown> {
return {
backend: detected.backend?.describe() ?? null,
frontend: detected.frontend?.describe() ?? null,
primary: detected.primary.kind,
combined: Boolean(detected.backend && detected.frontend),
};
}
function readWorkspaceGlobs(root: string): string[] {
const globs = new Set<string>([...MONOREPO_GLOBS, ...FRONTEND_SUBDIRS.map((d) => d.split('/')[0]!).filter(Boolean)]);
const pkg = readPackageJson(root);
const workspaces = pkg?.workspaces;
if (Array.isArray(workspaces)) {
for (const entry of workspaces) {
if (typeof entry === 'string' && entry.includes('/*')) {
globs.add(entry.replace(/\/\*$/, ''));
}
}
} else if (workspaces && typeof workspaces === 'object' && Array.isArray((workspaces as { packages?: string[] }).packages)) {
for (const entry of (workspaces as { packages: string[] }).packages) {
if (entry.includes('/*')) globs.add(entry.replace(/\/\*$/, ''));
}
}
const pnpm = path.join(root, 'pnpm-workspace.yaml');
if (fs.existsSync(pnpm)) {
const text = fs.readFileSync(pnpm, 'utf8');
for (const match of text.matchAll(/['"]?([A-Za-z0-9_-]+)\/\*['"]?/g)) {
if (match[1]) globs.add(match[1]);
}
}
return [...globs];
}
function listImmediateSubdirs(dir: string): string[] {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'vendor')
.map((entry) => entry.name);
}
function packageLooksLikeProject(dir: string): boolean {
return (
fs.existsSync(path.join(dir, 'package.json')) ||
fs.existsSync(path.join(dir, 'composer.json')) ||
fs.existsSync(path.join(dir, 'artisan')) ||
fs.existsSync(path.join(dir, 'spark')) ||
fs.existsSync(path.join(dir, 'manage.py')) ||
fs.existsSync(path.join(dir, 'Gemfile')) ||
fs.existsSync(path.join(dir, 'bin', 'console'))
);
}
/**
* Scan the workspace for monorepo packages under apps/*, packages/*, services/*,
* frontend/client/web, plus the workspace root itself.
*/
export function detectWorkspace(workspaceRoot: string): DetectedWorkspace {
const absoluteRoot = path.resolve(workspaceRoot);
const packages: DetectedPackage[] = [];
const seen = new Set<string>();
const addPackage = (name: string, absolutePath: string) => {
const normalized = path.resolve(absolutePath);
if (seen.has(normalized.toLowerCase())) return;
if (!packageLooksLikeProject(normalized) && normalized !== absoluteRoot) return;
seen.add(normalized.toLowerCase());
const relativePath = path.relative(absoluteRoot, normalized) || '.';
packages.push({
name,
relativePath: relativePath === '' ? '.' : relativePath.replace(/\\/g, '/'),
absolutePath: normalized,
detected: detectProject(normalized),
});
};
addPackage(path.basename(absoluteRoot) || 'root', absoluteRoot);
for (const globRoot of readWorkspaceGlobs(absoluteRoot)) {
const base = path.join(absoluteRoot, globRoot);
for (const child of listImmediateSubdirs(base)) {
addPackage(child, path.join(base, child));
}
if (FRONTEND_SUBDIRS.some((d) => d === globRoot || d.startsWith(`${globRoot}/`)) && packageLooksLikeProject(base)) {
addPackage(globRoot, base);
}
}
return { root: absoluteRoot, packages };
}
export function summarizeWorkspace(workspace: DetectedWorkspace): Record<string, unknown> {
return {
root: workspace.root,
packageCount: workspace.packages.length,
packages: workspace.packages.map((pkg) => ({
name: pkg.name,
path: pkg.relativePath,
primary: pkg.detected.primary.kind,
combined: Boolean(pkg.detected.backend && pkg.detected.frontend),
backend: pkg.detected.backend?.kind ?? null,
frontend: pkg.detected.frontend?.kind ?? null,
})),
};
}
/**
* Resolve a package name or relative path to a DetectedPackage inside the workspace.
*/
export function resolvePackage(
workspaceRoot: string,
packageNameOrPath: string | undefined,
): { workspace: DetectedWorkspace; pkg: DetectedPackage } {
const workspace = detectWorkspace(workspaceRoot);
if (!packageNameOrPath) {
const rootPkg = workspace.packages.find((p) => p.relativePath === '.') ?? workspace.packages[0];
if (!rootPkg) throw new Error('No packages detected in the workspace.');
return { workspace, pkg: rootPkg };
}
const needle = packageNameOrPath.replace(/\\/g, '/').replace(/^\.\//, '');
const match = workspace.packages.find(
(p) =>
p.name === packageNameOrPath ||
p.relativePath === needle ||
p.relativePath === packageNameOrPath ||
p.absolutePath.replace(/\\/g, '/').endsWith(`/${needle}`),
);
if (!match) {
const available = workspace.packages.map((p) => `${p.name} (${p.relativePath})`).join(', ');
throw new Error(`Unknown package "${packageNameOrPath}". Available: ${available || '(none)'}.`);
}
return { workspace, pkg: match };
}
+202
View File
@@ -0,0 +1,202 @@
import fs from 'node:fs';
import path from 'node:path';
import type { AdapterCommand, FrameworkAdapter } from './types.js';
function fileExists(root: string, names: string[]): boolean {
return names.some((name) => fs.existsSync(path.join(root, name)));
}
function readRequirements(root: string): string {
for (const name of ['requirements.txt', 'requirements-dev.txt', 'pyproject.toml']) {
const file = path.join(root, name);
if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').toLowerCase();
}
return '';
}
/**
* Django detection stub: install/dev/test via manage.py / common Python tooling.
* Deeper generators land in a later phase.
*/
export class DjangoAdapter implements FrameworkAdapter {
readonly kind = 'django' as const;
readonly label = 'Django';
constructor(readonly root: string) {}
installCommand(): AdapterCommand {
if (fileExists(this.root, ['pyproject.toml']) && fileExists(this.root, ['uv.lock'])) {
return { command: 'uv', args: ['sync'], cwd: this.root };
}
if (fileExists(this.root, ['poetry.lock', 'pyproject.toml'])) {
return { command: 'poetry', args: ['install'], cwd: this.root };
}
if (fileExists(this.root, ['requirements.txt'])) {
return { command: 'pip', args: ['install', '-r', 'requirements.txt'], cwd: this.root };
}
throw new Error(
`No Django dependency file found at "${this.root}" (expected requirements.txt, poetry.lock, or uv.lock).`,
);
}
devCommand(): AdapterCommand {
if (!fileExists(this.root, ['manage.py'])) {
throw new Error(`No manage.py found at "${this.root}".`);
}
return { command: 'python', args: ['manage.py', 'runserver', '127.0.0.1:8000'], cwd: this.root };
}
buildCommand(): AdapterCommand | null {
if (!fileExists(this.root, ['manage.py'])) return null;
return { command: 'python', args: ['manage.py', 'collectstatic', '--noinput'], cwd: this.root };
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
if (!fileExists(this.root, ['manage.py'])) return null;
return { command: 'python', args: ['manage.py', 'test', ...extraArgs], cwd: this.root };
}
lintCommand(): AdapterCommand | null {
return null;
}
readyPattern(): RegExp {
return /Starting development server at (https?:\/\/\S+)/i;
}
describe(): Record<string, unknown> {
const req = readRequirements(this.root);
return {
kind: this.kind,
label: this.label,
root: this.root,
hasManagePy: fileExists(this.root, ['manage.py']),
notes: 'install/dev/test + generate_*_resource stubs available.',
hintsDjango: /django/.test(req),
};
}
}
/**
* Rails detection stub: bundle install + bin/rails server / test.
*/
export class RailsAdapter implements FrameworkAdapter {
readonly kind = 'rails' as const;
readonly label = 'Rails';
constructor(readonly root: string) {}
installCommand(): AdapterCommand {
return { command: 'bundle', args: ['install'], cwd: this.root };
}
devCommand(): AdapterCommand {
const binRails = path.join(this.root, 'bin', 'rails');
if (fs.existsSync(binRails)) {
return { command: 'ruby', args: [binRails, 'server', '-b', '127.0.0.1', '-p', '3000'], cwd: this.root };
}
return { command: 'rails', args: ['server', '-b', '127.0.0.1', '-p', '3000'], cwd: this.root };
}
buildCommand(): AdapterCommand | null {
const binRails = path.join(this.root, 'bin', 'rails');
if (fs.existsSync(binRails)) {
return { command: 'ruby', args: [binRails, 'assets:precompile'], cwd: this.root };
}
return { command: 'rails', args: ['assets:precompile'], cwd: this.root };
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
const binRails = path.join(this.root, 'bin', 'rails');
if (fs.existsSync(binRails)) {
return { command: 'ruby', args: [binRails, 'test', ...extraArgs], cwd: this.root };
}
return { command: 'rails', args: ['test', ...extraArgs], cwd: this.root };
}
lintCommand(): AdapterCommand | null {
return null;
}
readyPattern(): RegExp {
return /Listening on (https?:\/\/\S+)|Puma starting/i;
}
describe(): Record<string, unknown> {
return {
kind: this.kind,
label: this.label,
root: this.root,
hasBinRails: fs.existsSync(path.join(this.root, 'bin', 'rails')),
notes: 'install/dev/test + generate_*_resource stubs available.',
};
}
}
/**
* Symfony detection stub: composer + symfony CLI / php -S patterns via composer scripts when present.
*/
export class SymfonyAdapter implements FrameworkAdapter {
readonly kind = 'symfony' as const;
readonly label = 'Symfony';
constructor(readonly root: string) {}
installCommand(): AdapterCommand {
return { command: 'composer', args: ['install'], cwd: this.root };
}
devCommand(): AdapterCommand {
// Prefer composer script when present; otherwise PHP built-in server on public/
const composerPath = path.join(this.root, 'composer.json');
if (fs.existsSync(composerPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(composerPath, 'utf8')) as { scripts?: Record<string, string> };
if (pkg.scripts?.['serve'] || pkg.scripts?.dev) {
const script = pkg.scripts.dev ? 'dev' : 'serve';
return { command: 'composer', args: ['run', script], cwd: this.root };
}
} catch {
// fall through
}
}
return {
command: 'php',
args: ['-S', '127.0.0.1:8000', '-t', 'public'],
cwd: this.root,
};
}
buildCommand(): AdapterCommand | null {
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
const phpunit = path.join(this.root, 'bin', 'phpunit');
if (fs.existsSync(phpunit) || fs.existsSync(`${phpunit}.bat`)) {
return { command: 'php', args: ['bin/phpunit', ...extraArgs], cwd: this.root };
}
const vendor = path.join(this.root, 'vendor', 'bin', 'phpunit');
if (fs.existsSync(vendor) || fs.existsSync(`${vendor}.bat`)) {
return { command: 'php', args: [path.join('vendor', 'bin', 'phpunit'), ...extraArgs], cwd: this.root };
}
return null;
}
lintCommand(): AdapterCommand | null {
return null;
}
readyPattern(): RegExp {
return /(?:Development Server|started|listening).*(https?:\/\/\S+)/i;
}
describe(): Record<string, unknown> {
return {
kind: this.kind,
label: this.label,
root: this.root,
notes: 'install/dev/test + generate_*_resource stubs available.',
};
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { AdapterCommand, FrameworkAdapter } from './types.js';
import {
detectJsPackageManager,
installCommandFor,
readPackageJson,
runScriptCommandFor,
type JsPackageManager,
type PackageJson,
} from './packageManager.js';
/**
* Fallback adapter for any project that isn't recognized as Laravel,
* CodeIgniter, or React. Drives everything off `package.json` scripts when
* one exists; otherwise tool calls fail with a clear, actionable message
* instead of guessing.
*/
export class GenericAdapter implements FrameworkAdapter {
readonly kind = 'generic' as const;
readonly label: string;
readonly packageManager?: JsPackageManager;
private readonly pkg: PackageJson | null;
constructor(readonly root: string) {
this.pkg = readPackageJson(root);
this.packageManager = this.pkg ? detectJsPackageManager(root) : undefined;
this.label = this.pkg ? `Generic Node project (${this.packageManager})` : 'Unrecognized project';
}
installCommand(): AdapterCommand {
if (!this.packageManager) {
throw new Error(
`No package.json found at "${this.root}" and no recognized framework detected. ` +
`Nothing for run_install to do here.`,
);
}
const { command, args } = installCommandFor(this.packageManager);
return { command, args, cwd: this.root };
}
devCommand(): AdapterCommand {
return this.scriptCommand(['dev', 'start', 'serve']);
}
buildCommand(): AdapterCommand | null {
return this.scriptCommandOrNull(['build']);
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
return this.scriptCommandOrNull(['test'], extraArgs);
}
lintCommand(fix = false): AdapterCommand | null {
return this.scriptCommandOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
}
readyPattern(): RegExp {
return /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[:\d]*\S*)|ready|listening/i;
}
describe(): Record<string, unknown> {
return {
kind: this.kind,
label: this.label,
root: this.root,
packageManager: this.packageManager ?? null,
scripts: this.pkg?.scripts ?? {},
};
}
private scriptCommand(candidates: string[]): AdapterCommand {
const result = this.scriptCommandOrNull(candidates);
if (!result) {
throw new Error(
`No package.json script found for any of [${candidates.join(', ')}] in "${this.root}". ` +
`Add one of these scripts, or use run_script with an explicit script name.`,
);
}
return result;
}
private scriptCommandOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
if (!this.pkg?.scripts || !this.packageManager) return null;
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
if (!script) return null;
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
return { command, args, cwd: this.root };
}
}
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import path from 'node:path';
import type { AdapterCommand } from './types.js';
import { hasComposerDependency, readComposerJson } from './composer.js';
import { PhpFrameworkAdapter } from './phpBase.js';
const DEV_HOST = '127.0.0.1';
const DEV_PORT = '8000';
function composeFileExists(root: string): boolean {
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].some((name) =>
fs.existsSync(path.join(root, name)),
);
}
function sailScriptPath(root: string): string | null {
const candidates = [
path.join(root, 'vendor', 'bin', 'sail'),
path.join(root, 'vendor', 'bin', 'sail.bat'),
];
return candidates.find((file) => fs.existsSync(file)) ?? null;
}
/** True when laravel/sail is required and a compose file is present. */
export function laravelUsesSail(root: string): boolean {
return Boolean(hasComposerDependency(readComposerJson(root), 'laravel/sail') && composeFileExists(root));
}
export class LaravelAdapter extends PhpFrameworkAdapter {
readonly kind = 'laravel' as const;
readonly label = 'Laravel';
private get usesSail(): boolean {
return hasComposerDependency(this.composerJson, 'laravel/sail') && composeFileExists(this.root);
}
devCommand(): AdapterCommand {
if (this.usesSail) {
const sail = sailScriptPath(this.root);
if (sail) {
// Sail is a bash script; invoke via bash so Windows+Git Bash/WSL and Unix share one path.
return {
command: 'bash',
args: [sail.replace(/\\/g, '/'), 'up'],
cwd: this.root,
};
}
return {
command: 'docker',
args: ['compose', 'up'],
cwd: this.root,
};
}
return {
command: 'php',
args: ['artisan', 'serve', `--host=${DEV_HOST}`, `--port=${DEV_PORT}`],
cwd: this.root,
};
}
readyPattern(): RegExp {
if (this.usesSail) {
return /(?:Container|Service).+(?:Started|Healthy|Running)|Listening on (https?:\/\/\S+)|APP_URL[=:].*(https?:\/\/\S+)|Local:\s+(https?:\/\/\S+)|Server running on \[?(https?:\/\/[^\s\]]+)/i;
}
// `php artisan serve` prints: "INFO Server running on [http://127.0.0.1:8000]."
return /Server running on \[?(https?:\/\/[^\s\]]+)/i;
}
describe(): Record<string, unknown> {
const sail = this.usesSail;
return {
kind: this.kind,
label: this.label,
root: this.root,
packageManager: this.packageManager,
laravelVersion: this.composerJson?.require?.['laravel/framework'] ?? null,
usesPest: Boolean(this.composerJson?.['require-dev']?.['pestphp/pest']),
usesSail: sail,
sailScript: sail ? sailScriptPath(this.root) : null,
devUrl: sail ? 'http://localhost' : `http://${DEV_HOST}:${DEV_PORT}`,
notes: sail
? 'Sail detected: start_dev_server uses `vendor/bin/sail up` (or docker compose up).'
: undefined,
};
}
}
+94
View File
@@ -0,0 +1,94 @@
import fs from 'node:fs';
import path from 'node:path';
export type JsPackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
export function detectJsPackageManager(root: string): JsPackageManager {
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
if (fs.existsSync(path.join(root, 'bun.lockb')) || fs.existsSync(path.join(root, 'bun.lock'))) return 'bun';
return 'npm';
}
export function installCommandFor(pm: JsPackageManager): { command: string; args: string[] } {
switch (pm) {
case 'pnpm':
return { command: 'pnpm', args: ['install'] };
case 'yarn':
return { command: 'yarn', args: ['install'] };
case 'bun':
return { command: 'bun', args: ['install'] };
case 'npm':
default:
return { command: 'npm', args: ['install'] };
}
}
export function runScriptCommandFor(pm: JsPackageManager, script: string, extraArgs: string[] = []): { command: string; args: string[] } {
switch (pm) {
case 'pnpm':
return { command: 'pnpm', args: ['run', script, ...extraArgs] };
case 'yarn':
return { command: 'yarn', args: [script, ...extraArgs] };
case 'bun':
return { command: 'bun', args: ['run', script, ...extraArgs] };
case 'npm':
default:
return { command: 'npm', args: ['run', script, ...(extraArgs.length ? ['--', ...extraArgs] : [])] };
}
}
export function addDependencyCommandFor(
pm: JsPackageManager,
packages: string[],
options: { dev?: boolean; remove?: boolean },
): { command: string; args: string[] } {
if (options.remove) {
switch (pm) {
case 'pnpm':
return { command: 'pnpm', args: ['remove', ...packages] };
case 'yarn':
return { command: 'yarn', args: ['remove', ...packages] };
case 'bun':
return { command: 'bun', args: ['remove', ...packages] };
case 'npm':
default:
return { command: 'npm', args: ['uninstall', ...packages] };
}
}
switch (pm) {
case 'pnpm':
return { command: 'pnpm', args: ['add', ...(options.dev ? ['-D'] : []), ...packages] };
case 'yarn':
return { command: 'yarn', args: ['add', ...(options.dev ? ['-D'] : []), ...packages] };
case 'bun':
return { command: 'bun', args: ['add', ...(options.dev ? ['-d'] : []), ...packages] };
case 'npm':
default:
return { command: 'npm', args: ['install', ...(options.dev ? ['-D'] : []), ...packages] };
}
}
export interface PackageJson {
name?: string;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
workspaces?: string[] | { packages?: string[] };
}
export function readPackageJson(root: string): PackageJson | null {
const file = path.join(root, 'package.json');
if (!fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, 'utf8')) as PackageJson;
} catch {
return null;
}
}
export function hasDependency(pkg: PackageJson | null, name: string): boolean {
if (!pkg) return false;
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
}
+57
View File
@@ -0,0 +1,57 @@
import path from 'node:path';
import type { AdapterCommand, FrameworkAdapter, FrameworkKind } from './types.js';
import { readComposerJson, vendorBinExists, type ComposerJson } from './composer.js';
/**
* Shared behaviour for Composer-based PHP frameworks (Laravel, CodeIgniter).
* Subclasses only need to override the bits that actually differ: the CLI
* used for scaffolding/migrations (artisan vs spark), the dev-server "ready"
* line, and which test runner is used.
*/
export abstract class PhpFrameworkAdapter implements FrameworkAdapter {
abstract readonly kind: FrameworkKind;
abstract readonly label: string;
readonly packageManager = 'composer';
protected readonly composerJson: ComposerJson | null;
constructor(readonly root: string) {
this.composerJson = readComposerJson(root);
}
installCommand(): AdapterCommand {
return { command: 'composer', args: ['install'], cwd: this.root };
}
buildCommand(): AdapterCommand | null {
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
if (vendorBinExists(this.root, 'pest')) {
return { command: this.vendorBin('pest'), args: extraArgs, cwd: this.root };
}
if (vendorBinExists(this.root, 'phpunit')) {
return { command: this.vendorBin('phpunit'), args: extraArgs, cwd: this.root };
}
return null;
}
lintCommand(fix = false): AdapterCommand | null {
if (vendorBinExists(this.root, 'pint')) {
return { command: this.vendorBin('pint'), args: fix ? [] : ['--test'], cwd: this.root };
}
if (vendorBinExists(this.root, 'phpcs')) {
return { command: this.vendorBin(fix ? 'phpcbf' : 'phpcs'), args: [], cwd: this.root };
}
return null;
}
protected vendorBin(bin: string): string {
const suffix = process.platform === 'win32' ? '.bat' : '';
return path.join(this.root, 'vendor', 'bin', `${bin}${suffix}`);
}
abstract devCommand(): AdapterCommand;
abstract readyPattern(): RegExp;
abstract describe(): Record<string, unknown>;
}
+131
View File
@@ -0,0 +1,131 @@
import fs from 'node:fs';
import path from 'node:path';
import type { AdapterCommand, FrameworkAdapter } from './types.js';
import {
detectJsPackageManager,
hasDependency,
installCommandFor,
readPackageJson,
runScriptCommandFor,
type JsPackageManager,
type PackageJson,
} from './packageManager.js';
type Bundler = 'vite' | 'next' | 'create-react-app' | 'unknown';
export type NextRouter = 'app' | 'pages' | 'unknown';
function detectBundler(root: string, pkg: PackageJson | null): Bundler {
if (hasDependency(pkg, 'next') || fileExists(root, ['next.config.js', 'next.config.mjs', 'next.config.ts'])) {
return 'next';
}
if (fileExists(root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'])) return 'vite';
if (hasDependency(pkg, 'react-scripts')) return 'create-react-app';
return 'unknown';
}
function detectNextRouter(root: string): NextRouter {
const appLayouts = [
path.join(root, 'app', 'layout.tsx'),
path.join(root, 'app', 'layout.jsx'),
path.join(root, 'src', 'app', 'layout.tsx'),
path.join(root, 'src', 'app', 'layout.jsx'),
];
if (appLayouts.some((p) => fs.existsSync(p))) return 'app';
const pagesIndexes = [
path.join(root, 'pages', 'index.tsx'),
path.join(root, 'pages', 'index.jsx'),
path.join(root, 'pages', 'index.js'),
path.join(root, 'src', 'pages', 'index.tsx'),
path.join(root, 'src', 'pages', 'index.jsx'),
];
if (pagesIndexes.some((p) => fs.existsSync(p))) return 'pages';
return 'unknown';
}
function fileExists(root: string, names: string[]): boolean {
return names.some((name) => fs.existsSync(path.join(root, name)));
}
export class ReactAdapter implements FrameworkAdapter {
readonly kind = 'react' as const;
readonly label: string;
readonly packageManager: JsPackageManager;
private readonly pkg: PackageJson | null;
private readonly bundler: Bundler;
private readonly nextRouter: NextRouter;
constructor(readonly root: string) {
this.pkg = readPackageJson(root);
this.packageManager = detectJsPackageManager(root);
this.bundler = detectBundler(root, this.pkg);
this.nextRouter = this.bundler === 'next' ? detectNextRouter(root) : 'unknown';
this.label = `React (${this.bundler})`;
}
installCommand(): AdapterCommand {
const { command, args } = installCommandFor(this.packageManager);
return { command, args, cwd: this.root };
}
devCommand(): AdapterCommand {
const script = this.findScript(['dev', 'start']);
if (!script) {
throw new Error(`No "dev" or "start" script found in package.json at "${this.root}".`);
}
const { command, args } = runScriptCommandFor(this.packageManager, script);
return { command, args, cwd: this.root };
}
buildCommand(): AdapterCommand | null {
return this.scriptCommandOrNull(['build']);
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
return this.scriptCommandOrNull(['test'], extraArgs);
}
lintCommand(fix = false): AdapterCommand | null {
return this.scriptCommandOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
}
readyPattern(): RegExp {
switch (this.bundler) {
case 'vite':
return /Local:\s+(https?:\/\/\S+)/i;
case 'next':
return /(?:Local|ready)[^:]*:\s*(https?:\/\/\S+)|started server on[^,]*,\s*url:\s*(https?:\/\/\S+)/i;
case 'create-react-app':
return /Local:\s+(https?:\/\/\S+)|Compiled successfully/i;
default:
return /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[:\d]*\S*)/i;
}
}
describe(): Record<string, unknown> {
const isNext = this.bundler === 'next';
return {
kind: this.kind,
label: this.label,
root: this.root,
packageManager: this.packageManager,
bundler: this.bundler,
router: isNext ? this.nextRouter : null,
rsc: isNext && this.nextRouter === 'app',
reactVersion: this.pkg?.dependencies?.react ?? this.pkg?.devDependencies?.react ?? null,
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
scripts: this.pkg?.scripts ?? {},
};
}
private findScript(candidates: string[]): string | undefined {
return candidates.find((name) => this.pkg?.scripts?.[name]);
}
private scriptCommandOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
const script = this.findScript(candidates);
if (!script) return null;
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
return { command, args, cwd: this.root };
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { AdapterTarget, DetectedProject, FrameworkAdapter } from './types.js';
/**
* Picks which adapter a workflow tool call should act on.
* - If the caller passed an explicit `target`, honor it (error if that side wasn't detected).
* - If only one side was detected, use it regardless of `target`.
* - If both sides were detected and no `target` was given, default to backend
* (matches the common "start the API, then the frontend separately" flow)
* but callers that care about the frontend by default (e.g. dev server) can
* pass `defaultTarget: 'frontend'`.
*/
export function resolveTarget(
detected: DetectedProject,
target: AdapterTarget | undefined,
defaultTarget: AdapterTarget = 'backend',
): { adapter: FrameworkAdapter; target: AdapterTarget | 'primary' } {
if (target === 'backend') {
if (!detected.backend) {
throw new Error(
'target "backend" was requested, but no backend (Laravel/CodeIgniter/Symfony/Django/Rails) project was detected.',
);
}
return { adapter: detected.backend, target: 'backend' };
}
if (target === 'frontend') {
if (!detected.frontend) {
throw new Error(
'target "frontend" was requested, but no frontend (React/Vue/Nuxt) project was detected.',
);
}
return { adapter: detected.frontend, target: 'frontend' };
}
if (detected.backend && detected.frontend) {
const adapter = defaultTarget === 'frontend' ? detected.frontend : detected.backend;
return { adapter, target: defaultTarget };
}
return { adapter: detected.primary, target: 'primary' };
}
+64
View File
@@ -0,0 +1,64 @@
export type FrameworkKind =
| 'laravel'
| 'codeigniter'
| 'react'
| 'vue'
| 'nuxt'
| 'django'
| 'rails'
| 'symfony'
| 'generic';
export interface AdapterCommand {
command: string;
args: string[];
cwd: string;
}
/**
* A FrameworkAdapter knows how to install, run, build, test, and lint a
* specific kind of project. Generic workflow tools resolve one (or two, for
* combined backend+frontend projects) via detect.ts and dispatch through
* this interface instead of hardcoding any framework's CLI.
*/
export interface FrameworkAdapter {
readonly kind: FrameworkKind;
readonly label: string;
readonly root: string;
readonly packageManager?: string;
installCommand(): AdapterCommand;
devCommand(): AdapterCommand;
buildCommand(): AdapterCommand | null;
testCommand(extraArgs?: string[]): AdapterCommand | null;
lintCommand(fix?: boolean): AdapterCommand | null;
/** Matched against dev-server output to detect "ready" + extract the URL. */
readyPattern(): RegExp;
/** Metadata surfaced via the detect_framework tool and project.json resource. */
describe(): Record<string, unknown>;
}
export type AdapterTarget = 'backend' | 'frontend';
export interface DetectedProject {
backend: FrameworkAdapter | null;
frontend: FrameworkAdapter | null;
/** Convenience accessor: backend if present, else frontend, else the generic fallback. */
primary: FrameworkAdapter;
}
/** One package/app inside a monorepo (or the single root project). */
export interface DetectedPackage {
name: string;
relativePath: string;
absolutePath: string;
detected: DetectedProject;
}
/** Workspace-level view: root plus zero or more packages. */
export interface DetectedWorkspace {
root: string;
packages: DetectedPackage[];
}
+152
View File
@@ -0,0 +1,152 @@
import fs from 'node:fs';
import path from 'node:path';
import type { AdapterCommand, FrameworkAdapter } from './types.js';
import {
detectJsPackageManager,
hasDependency,
installCommandFor,
readPackageJson,
runScriptCommandFor,
type JsPackageManager,
type PackageJson,
} from './packageManager.js';
function fileExists(root: string, names: string[]): boolean {
return names.some((name) => fs.existsSync(path.join(root, name)));
}
/**
* Vite + Vue SFC projects (non-Nuxt).
*/
export class VueAdapter implements FrameworkAdapter {
readonly kind = 'vue' as const;
readonly label: string;
readonly packageManager: JsPackageManager;
private readonly pkg: PackageJson | null;
constructor(readonly root: string) {
this.pkg = readPackageJson(root);
this.packageManager = detectJsPackageManager(root);
this.label = 'Vue';
}
installCommand(): AdapterCommand {
const { command, args } = installCommandFor(this.packageManager);
return { command, args, cwd: this.root };
}
devCommand(): AdapterCommand {
return this.requireScript(['dev', 'start']);
}
buildCommand(): AdapterCommand | null {
return this.scriptOrNull(['build']);
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
return this.scriptOrNull(['test'], extraArgs);
}
lintCommand(fix = false): AdapterCommand | null {
return this.scriptOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
}
readyPattern(): RegExp {
return /Local:\s+(https?:\/\/\S+)/i;
}
describe(): Record<string, unknown> {
return {
kind: this.kind,
label: this.label,
root: this.root,
packageManager: this.packageManager,
vueVersion: this.pkg?.dependencies?.vue ?? this.pkg?.devDependencies?.vue ?? null,
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
vite: fileExists(this.root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']),
scripts: this.pkg?.scripts ?? {},
};
}
private requireScript(candidates: string[]): AdapterCommand {
const cmd = this.scriptOrNull(candidates);
if (!cmd) {
throw new Error(`No "${candidates.join('"/"')}" script found in package.json at "${this.root}".`);
}
return cmd;
}
private scriptOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
if (!script) return null;
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
return { command, args, cwd: this.root };
}
}
/**
* Nuxt (Vue meta-framework) projects.
*/
export class NuxtAdapter implements FrameworkAdapter {
readonly kind = 'nuxt' as const;
readonly label = 'Nuxt';
readonly packageManager: JsPackageManager;
private readonly pkg: PackageJson | null;
constructor(readonly root: string) {
this.pkg = readPackageJson(root);
this.packageManager = detectJsPackageManager(root);
}
installCommand(): AdapterCommand {
const { command, args } = installCommandFor(this.packageManager);
return { command, args, cwd: this.root };
}
devCommand(): AdapterCommand {
return this.requireScript(['dev', 'start']);
}
buildCommand(): AdapterCommand | null {
return this.scriptOrNull(['build', 'generate']);
}
testCommand(extraArgs: string[] = []): AdapterCommand | null {
return this.scriptOrNull(['test'], extraArgs);
}
lintCommand(fix = false): AdapterCommand | null {
return this.scriptOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
}
readyPattern(): RegExp {
return /Local:\s+(https?:\/\/\S+)|Nuxt\s+.+ready/i;
}
describe(): Record<string, unknown> {
return {
kind: this.kind,
label: this.label,
root: this.root,
packageManager: this.packageManager,
nuxtVersion: this.pkg?.dependencies?.nuxt ?? this.pkg?.devDependencies?.nuxt ?? null,
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
scripts: this.pkg?.scripts ?? {},
};
}
private requireScript(candidates: string[]): AdapterCommand {
const cmd = this.scriptOrNull(candidates);
if (!cmd) {
throw new Error(`No "${candidates.join('"/"')}" script found in package.json at "${this.root}".`);
}
return cmd;
}
private scriptOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
if (!script) return null;
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
return { command, args, cwd: this.root };
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* All logging MUST go to stderr, never stdout: the stdio MCP transport uses
* stdout exclusively for JSON-RPC protocol messages, and writing anything
* else there would corrupt the stream.
*/
type Level = 'debug' | 'info' | 'warn' | 'error';
function write(level: Level, message: string, meta?: unknown): void {
const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}`;
if (meta !== undefined) {
process.stderr.write(`${line} ${JSON.stringify(meta)}\n`);
} else {
process.stderr.write(`${line}\n`);
}
}
export const logger = {
debug: (message: string, meta?: unknown) => write('debug', message, meta),
info: (message: string, meta?: unknown) => write('info', message, meta),
warn: (message: string, meta?: unknown) => write('warn', message, meta),
error: (message: string, meta?: unknown) => write('error', message, meta),
};
+202
View File
@@ -0,0 +1,202 @@
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { execa, type ResultPromise } from 'execa';
import { config } from '../config.js';
import { logger } from './logger.js';
export type ProcessStatus = 'running' | 'exited' | 'error' | 'stopped';
export interface StartProcessOptions {
label: string;
command: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
}
export interface ManagedProcessInfo {
id: string;
label: string;
command: string;
args: string[];
cwd: string;
pid?: number;
status: ProcessStatus;
exitCode: number | null;
startedAt: string;
exitedAt?: string;
}
interface ManagedProcess extends ManagedProcessInfo {
emitter: EventEmitter;
logs: string[];
subprocess: ResultPromise;
}
class ProcessManager {
private readonly processes = new Map<string, ManagedProcess>();
start(options: StartProcessOptions): ManagedProcessInfo {
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.`,
);
}
const id = randomUUID();
const emitter = new EventEmitter();
const subprocess = execa(options.command, options.args, {
cwd: options.cwd,
env: options.env,
reject: false,
stdin: 'ignore',
forceKillAfterDelay: 5000,
});
const managed: ManagedProcess = {
id,
label: options.label,
command: options.command,
args: options.args,
cwd: options.cwd,
pid: subprocess.pid,
status: 'running',
exitCode: null,
startedAt: new Date().toISOString(),
emitter,
logs: [],
subprocess,
};
const appendLog = (chunk: Buffer | string) => {
const text = chunk.toString();
for (const line of text.split(/\r?\n/)) {
if (line.length === 0) continue;
managed.logs.push(line);
if (managed.logs.length > config.maxBufferedLogLines) {
managed.logs.splice(0, managed.logs.length - config.maxBufferedLogLines);
}
emitter.emit('log', line);
}
};
subprocess.stdout?.on('data', appendLog);
subprocess.stderr?.on('data', appendLog);
subprocess
.then((result) => {
managed.status = managed.status === 'stopped' ? 'stopped' : 'exited';
managed.exitCode = result.exitCode ?? null;
managed.exitedAt = new Date().toISOString();
emitter.emit('exit');
})
.catch((error: unknown) => {
managed.status = 'error';
managed.exitedAt = new Date().toISOString();
appendLog(`[process error] ${error instanceof Error ? error.message : String(error)}`);
emitter.emit('exit');
logger.error(`managed process ${id} failed`, { error: error instanceof Error ? error.message : error });
});
this.processes.set(id, managed);
return this.toInfo(managed);
}
/**
* Resolves once a log line matches `pattern`, the process exits, or `timeoutMs` elapses.
* Used by start_dev_server so it can report the actual "ready" URL instead of guessing.
*/
waitForLog(
id: string,
pattern: RegExp,
timeoutMs: number,
): Promise<{ matchedLine: string | null; timedOut: boolean }> {
const managed = this.require(id);
const existing = managed.logs.find((line) => pattern.test(line));
if (existing) return Promise.resolve({ matchedLine: existing, timedOut: false });
return new Promise((resolve) => {
const cleanup = () => {
clearTimeout(timer);
managed.emitter.off('log', onLog);
managed.emitter.off('exit', onExit);
};
const onLog = (line: string) => {
if (pattern.test(line)) {
cleanup();
resolve({ matchedLine: line, timedOut: false });
}
};
const onExit = () => {
cleanup();
resolve({ matchedLine: null, timedOut: false });
};
const timer = setTimeout(() => {
cleanup();
resolve({ matchedLine: null, timedOut: true });
}, timeoutMs);
managed.emitter.on('log', onLog);
managed.emitter.on('exit', onExit);
});
}
stop(id: string): ManagedProcessInfo {
const managed = this.require(id);
if (managed.status === 'running') {
managed.status = 'stopped';
managed.subprocess.kill('SIGTERM');
}
return this.toInfo(managed);
}
getLogs(id: string, tail?: number): { info: ManagedProcessInfo; logs: string[] } {
const managed = this.require(id);
const logs = tail && tail > 0 ? managed.logs.slice(-tail) : managed.logs.slice();
return { info: this.toInfo(managed), logs };
}
list(): ManagedProcessInfo[] {
return Array.from(this.processes.values()).map((p) => this.toInfo(p));
}
stopAll(): void {
for (const managed of this.processes.values()) {
if (managed.status === 'running') {
managed.subprocess.kill('SIGTERM');
}
}
}
private runningCount(): number {
return Array.from(this.processes.values()).filter((p) => p.status === 'running').length;
}
private require(id: string): ManagedProcess {
const managed = this.processes.get(id);
if (!managed) {
throw new Error(
`Unknown processId "${id}". It may have already exited and been forgotten, or never existed.`,
);
}
return managed;
}
private toInfo(p: ManagedProcess): ManagedProcessInfo {
return {
id: p.id,
label: p.label,
command: p.command,
args: p.args,
cwd: p.cwd,
pid: p.pid,
status: p.status,
exitCode: p.exitCode,
startedAt: p.startedAt,
exitedAt: p.exitedAt,
};
}
}
export const processManager = new ProcessManager();
+59
View File
@@ -0,0 +1,59 @@
import { execa } from 'execa';
import { config } from '../config.js';
import { assertAllowedBinary } from './sandbox.js';
export interface RunCommandResult {
command: string;
args: string[];
cwd: string;
exitCode: number | null;
timedOut: boolean;
failed: boolean;
output: string;
}
export interface RunCommandOptions {
cwd: string;
timeoutMs?: number;
env?: NodeJS.ProcessEnv;
}
/**
* Runs a command to completion (foreground) and returns its combined,
* length-capped output. Intended for install/build/lint/test-style commands
* that the agent needs the final result of, as opposed to long-running dev
* servers (see processManager.ts for those).
*/
export async function runCommand(
command: string,
args: string[],
options: RunCommandOptions,
): Promise<RunCommandResult> {
assertAllowedBinary(command);
const timeoutMs = options.timeoutMs ?? config.defaultCommandTimeoutMs;
const result = await execa(command, args, {
cwd: options.cwd,
env: options.env,
timeout: timeoutMs,
reject: false,
all: true,
});
const rawOutput = result.all ?? [result.stdout, result.stderr].filter(Boolean).join('\n');
return {
command,
args,
cwd: options.cwd,
exitCode: result.exitCode ?? null,
timedOut: result.timedOut,
failed: result.failed,
output: truncate(String(rawOutput ?? ''), config.maxToolOutputChars),
};
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
const omitted = text.length - max;
return `${text.slice(0, max)}\n... [truncated ${omitted} more characters] ...`;
}
+54
View File
@@ -0,0 +1,54 @@
import path from 'node:path';
import { config } from '../config.js';
export class SandboxViolationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SandboxViolationError';
}
}
/**
* Resolves `relativePath` against `root` and guarantees the result stays
* inside `root`. Throws SandboxViolationError on any attempt to escape
* (e.g. via `..`, absolute paths outside the root, or symlink-style tricks
* handled at the OS level by the caller when it actually touches the fs).
*/
export function resolveWorkspacePath(root: string, relativePath: string): string {
const absoluteRoot = path.resolve(root);
const target = path.resolve(absoluteRoot, relativePath || '.');
const normalizedRoot = absoluteRoot.toLowerCase();
const normalizedTarget = target.toLowerCase();
if (normalizedTarget !== normalizedRoot && !normalizedTarget.startsWith(normalizedRoot + path.sep)) {
throw new SandboxViolationError(
`Refusing to access "${relativePath}": it resolves outside the workspace root (${absoluteRoot}).`,
);
}
return target;
}
function basenameNoExt(binary: string): string {
const base = path.basename(binary);
return base.replace(/\.(exe|cmd|bat|ps1)$/i, '');
}
/**
* Throws unless `binary` is on the configured allow-list. Command
* construction elsewhere in the codebase always passes `args` as a separate
* array (never a single shell string), so this allow-list is the only line
* of defense needed against arbitrary command execution.
*/
export function assertAllowedBinary(binary: string): void {
const name = basenameNoExt(binary).toLowerCase();
const allowed = (config.allowedBinaries as readonly string[]).some(
(candidate) => candidate.toLowerCase() === name,
);
if (!allowed) {
throw new SandboxViolationError(
`Refusing to run "${binary}": it is not on the allow-list (${config.allowedBinaries.join(', ')}).`,
);
}
}
+23
View File
@@ -0,0 +1,23 @@
function words(input: string): string[] {
return input
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[\s_-]+/)
.filter(Boolean);
}
export function pascalCase(input: string): string {
return words(input)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join('');
}
export function camelCase(input: string): string {
const pascal = pascalCase(input);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
export function kebabCase(input: string): string {
return words(input)
.map((w) => w.toLowerCase())
.join('-');
}
+70
View File
@@ -0,0 +1,70 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from './config.js';
import { detectProject, summarizeDetection } from './lib/frameworks/detect.js';
import { registerWorkflowTools } from './tools/workflow/index.js';
import { registerScaffoldTools } from './tools/scaffold/index.js';
import { registerLaravelTools } from './tools/laravel/index.js';
import { registerCodeIgniterTools } from './tools/codeigniter/index.js';
import { registerReactTools } from './tools/react/index.js';
import { registerBrowserTools } from './tools/browser/index.js';
import { registerDockerTools } from './tools/docker/index.js';
import { registerDocsTools } from './tools/docs/index.js';
import { registerResources } from './tools/resources/index.js';
import { registerPrompts } from './tools/prompts/index.js';
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';
export function createServer(): McpServer {
const server = new McpServer(
{ name: 'web-dev-mcp', version: SERVER_VERSION },
{
instructions:
'Tools for web development: framework-aware workflow for Laravel (Sail-aware), CodeIgniter, Symfony, Django, Rails, ' +
'React, Vue, and Nuxt (with monorepo detect_workspace + package scoping), scaffolding, ' +
'Laravel/CodeIgniter/React/Next + generate_django/rails/symfony_resource tools, browser_*, docker_*, docs, ' +
'git_* (incl. git_diff_summarize), lighthouse_audit, resources, and prompts (incl. visual-diff-ci). ' +
'Call detect_framework or detect_workspace first.',
},
);
registerWorkflowTools(server);
registerScaffoldTools(server);
registerLaravelTools(server);
registerCodeIgniterTools(server);
registerReactTools(server);
registerBackendResourceTools(server);
registerBrowserTools(server);
registerDockerTools(server);
registerDocsTools(server);
registerGitTools(server);
registerAuditTools(server);
registerResources(server);
registerPrompts(server);
server.registerResource(
'project-info',
'workspace://project.json',
{
title: 'Project Info',
description: 'Detected framework adapter(s) and metadata for the current workspace.',
mimeType: 'application/json',
},
async (uri) => {
const detected = detectProject(config.workspaceRoot);
return {
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(summarizeDetection(detected), null, 2),
},
],
};
},
);
return server;
}
+14
View File
@@ -0,0 +1,14 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from '../server.js';
export async function createInMemoryClient(): Promise<Client> {
const mcpServer = createServer();
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '0.0.1' });
await Promise.all([mcpServer.connect(serverTransport), client.connect(clientTransport)]);
return client;
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { parseLighthouseOutput } from '../lighthouse.js';
import { assertAllowedBinary } from '../../../lib/sandbox.js';
describe('lighthouse_audit helpers', () => {
it('allows lighthouse and npx binaries', () => {
expect(() => assertAllowedBinary('lighthouse')).not.toThrow();
expect(() => assertAllowedBinary('npx')).not.toThrow();
});
it('parses category scores and opportunities from Lighthouse JSON', () => {
const raw = JSON.stringify({
categories: {
performance: {
title: 'Performance',
description: 'How fast',
score: 0.82,
auditRefs: [{ id: 'unused-javascript', weight: 10 }],
},
accessibility: { title: 'Accessibility', score: 0.91 },
},
audits: {
'unused-javascript': {
title: 'Reduce unused JavaScript',
score: 0.3,
displayValue: 'Est savings of 200 KiB',
},
'good-audit': {
title: 'Already good',
score: 1,
},
},
});
const parsed = parseLighthouseOutput(`noise\n${raw}\nmore noise`);
expect(parsed.scores.performance).toBe(82);
expect(parsed.scores.accessibility).toBe(91);
expect(parsed.opportunities[0]?.title).toBe('Reduce unused JavaScript');
expect(parsed.categories.find((c) => c.id === 'performance')?.title).toBe('Performance');
expect(parsed.failingByCategory.performance?.[0]?.id).toBe('unused-javascript');
});
});
+6
View File
@@ -0,0 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerLighthouseAuditTool } from './lighthouse.js';
export function registerAuditTools(server: McpServer): void {
registerLighthouseAuditTool(server);
}
+218
View File
@@ -0,0 +1,218 @@
import { z } from 'zod';
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';
interface LighthouseAuditRef {
id: string;
weight?: number;
group?: string;
}
interface LighthouseCategory {
id?: string;
title?: string;
description?: string;
score: number | null;
auditRefs?: LighthouseAuditRef[];
}
interface LighthouseAudit {
id?: string;
title?: string;
description?: string;
score?: number | null;
displayValue?: string;
scoreDisplayMode?: string;
details?: { type?: string; overallSavingsMs?: number; overallSavingsBytes?: number };
}
interface LighthouseJson {
categories?: Record<string, LighthouseCategory>;
audits?: Record<string, LighthouseAudit>;
}
export interface LighthouseCategoryDetail {
id: string;
title: string;
description: string | null;
score: number | null;
auditCount: number;
}
export interface LighthouseOpportunity {
id: string;
title: string;
category: string | null;
score: number | null;
displayValue: string | null;
weight: number;
savingsMs: number | null;
savingsBytes: number | null;
}
function scorePercent(score: number | null | undefined): number | null {
if (score == null) return null;
return Number((score * 100).toFixed(1));
}
function extractScores(report: LighthouseJson): Record<string, number | null> {
const scores: Record<string, number | null> = {};
for (const [id, category] of Object.entries(report.categories ?? {})) {
scores[id] = scorePercent(category.score);
}
return scores;
}
function extractCategoryDetails(report: LighthouseJson): LighthouseCategoryDetail[] {
return Object.entries(report.categories ?? {}).map(([id, category]) => ({
id,
title: category.title ?? id,
description: category.description ?? null,
score: scorePercent(category.score),
auditCount: category.auditRefs?.length ?? 0,
}));
}
function categoryForAudit(report: LighthouseJson, auditId: string): string | null {
for (const [categoryId, category] of Object.entries(report.categories ?? {})) {
if (category.auditRefs?.some((ref) => ref.id === auditId)) return categoryId;
}
return null;
}
function weightForAudit(report: LighthouseJson, auditId: string): number {
for (const category of Object.values(report.categories ?? {})) {
const ref = category.auditRefs?.find((r) => r.id === auditId);
if (ref?.weight != null) return ref.weight;
}
return 0;
}
function topOpportunities(report: LighthouseJson, limit = 12): LighthouseOpportunity[] {
const audits = report.audits ?? {};
const candidates = Object.entries(audits)
.filter(([, audit]) => typeof audit.score === 'number' && (audit.score ?? 1) < 0.9)
.filter(([, audit]) => audit.scoreDisplayMode !== 'informative' && audit.scoreDisplayMode !== 'manual')
.map(([id, audit]) => {
const details = audit.details;
return {
id,
title: audit.title ?? id,
category: categoryForAudit(report, id),
score: audit.score ?? null,
displayValue: audit.displayValue ?? null,
weight: weightForAudit(report, id),
savingsMs: details?.overallSavingsMs ?? null,
savingsBytes: details?.overallSavingsBytes ?? null,
};
})
.sort(
(a, b) =>
(b.weight ?? 0) - (a.weight ?? 0) ||
(b.savingsMs ?? 0) - (a.savingsMs ?? 0) ||
(a.score ?? 1) - (b.score ?? 1),
);
return candidates.slice(0, limit);
}
function failingByCategory(report: LighthouseJson, perCategory = 5): Record<string, LighthouseOpportunity[]> {
const byCategory: Record<string, LighthouseOpportunity[]> = {};
for (const opportunity of topOpportunities(report, 50)) {
const cat = opportunity.category ?? 'other';
if (!byCategory[cat]) byCategory[cat] = [];
if (byCategory[cat]!.length < perCategory) byCategory[cat]!.push(opportunity);
}
return byCategory;
}
export function parseLighthouseOutput(raw: string): {
scores: Record<string, number | null>;
categories: LighthouseCategoryDetail[];
opportunities: LighthouseOpportunity[];
failingByCategory: Record<string, LighthouseOpportunity[]>;
} {
// Lighthouse may print non-JSON noise before/after the JSON blob when run via npx.
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
if (start === -1 || end === -1 || end <= start) {
throw new Error('Could not parse Lighthouse JSON output.');
}
const report = JSON.parse(raw.slice(start, end + 1)) as LighthouseJson;
return {
scores: extractScores(report),
categories: extractCategoryDetails(report),
opportunities: topOpportunities(report),
failingByCategory: failingByCategory(report),
};
}
export function registerLighthouseAuditTool(server: McpServer): void {
server.registerTool(
'lighthouse_audit',
{
title: 'Lighthouse Audit',
description:
'Run a headless Lighthouse audit against a URL (prefer localhost / the running dev server). Returns per-category ' +
'scores and titles, top opportunities across categories (with savings when available), and failing audits grouped by category.',
inputSchema: {
url: z.string().url().describe('URL to audit, e.g. http://127.0.0.1:5173/'),
categories: z
.array(z.enum(['performance', 'accessibility', 'best-practices', 'seo', 'pwa']))
.optional()
.describe(
'Optional subset of Lighthouse categories. Defaults to performance, accessibility, best-practices, seo (add pwa explicitly if needed).',
),
},
},
async ({ url, categories }) => {
try {
const selected =
categories && categories.length > 0
? categories
: (['performance', 'accessibility', 'best-practices', 'seo'] as const);
// Prefer npx so lighthouse need not be globally installed; npx is already allow-listed.
const result = await runCommand(
'npx',
[
'--yes',
'lighthouse',
url,
'--output=json',
'--quiet',
'--chrome-flags=--headless --no-sandbox',
...selected.flatMap((category) => ['--only-categories', category]),
],
{
cwd: config.workspaceRoot,
timeoutMs: config.scaffoldCommandTimeoutMs,
},
);
if (result.failed && !result.output.includes('{')) {
return errorResult(
`Lighthouse failed (exit ${result.exitCode}). Ensure Chrome/Chromium is available.\n${result.output}`,
);
}
const parsed = parseLighthouseOutput(result.output);
return jsonResult({
url,
requestedCategories: selected,
scores: parsed.scores,
categories: parsed.categories,
opportunities: parsed.opportunities,
failingByCategory: parsed.failingByCategory,
timedOut: result.timedOut,
exitCode: result.exitCode,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { config } from '../../../config.js';
import { createInMemoryClient } from '../../../test/mcpServer.js';
function firstText(result: unknown): string {
const r = result as { content?: Array<{ text?: string }> } | undefined;
return r?.content?.[0]?.text ?? '';
}
describe('backend resource generators', () => {
let tmpDir: string;
let previousRoot: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-backends-'));
previousRoot = config.workspaceRoot;
(config as { workspaceRoot: string }).workspaceRoot = tmpDir;
});
afterEach(() => {
(config as { workspaceRoot: string }).workspaceRoot = previousRoot;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('generate_django_resource writes app stubs', async () => {
fs.writeFileSync(path.join(tmpDir, 'manage.py'), '#!/usr/bin/env python\n');
fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'Django>=5.0\n');
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'generate_django_resource',
arguments: { name: 'Post' },
});
const payload = JSON.parse(firstText(result)) as { kind: string; files: string[] };
expect(payload.kind).toBe('django');
expect(payload.files.some((f) => f.endsWith('models.py'))).toBe(true);
expect(fs.readFileSync(payload.files.find((f) => f.endsWith('models.py'))!, 'utf8')).toContain('class Post');
});
it('generate_rails_resource writes model and controller', async () => {
fs.writeFileSync(path.join(tmpDir, 'Gemfile'), "gem 'rails'\n");
fs.mkdirSync(path.join(tmpDir, 'config'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'config', 'routes.rb'),
"Rails.application.routes.draw do\nend\n",
);
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'generate_rails_resource',
arguments: { name: 'Post', api: true },
});
const payload = JSON.parse(firstText(result)) as { kind: string; routeWired: boolean; files: string[] };
expect(payload.kind).toBe('rails');
expect(payload.routeWired).toBe(true);
expect(payload.files.some((f) => f.includes(`${path.sep}models${path.sep}`) || f.includes('/models/'))).toBe(true);
});
it('generate_symfony_resource writes entity/repo/controller', async () => {
fs.writeFileSync(
path.join(tmpDir, 'composer.json'),
JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }),
);
fs.mkdirSync(path.join(tmpDir, 'bin'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'bin', 'console'), '#!/usr/bin/env php\n');
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'generate_symfony_resource',
arguments: { name: 'Post' },
});
const payload = JSON.parse(firstText(result)) as { kind: string; files: string[] };
expect(payload.kind).toBe('symfony');
expect(payload.files).toHaveLength(3);
expect(fs.readFileSync(payload.files[0]!, 'utf8')).toContain('class Post');
});
});
+410
View File
@@ -0,0 +1,410 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { detectProject } from '../../lib/frameworks/detect.js';
import { DjangoAdapter } from '../../lib/frameworks/djangoRailsSymfony.js';
import { kebabCase, pascalCase, camelCase } from '../../lib/strings.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
function pluralize(word: string): string {
const lower = word.toLowerCase();
if (lower.endsWith('ies')) return word;
if (lower.endsWith('y') && !/[aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
if (/(s|x|z|ch|sh)$/i.test(word)) return `${word}es`;
if (lower.endsWith('s')) return word;
return `${word}s`;
}
function requireDjango(): DjangoAdapter {
const detected = detectProject(config.workspaceRoot);
if (detected.backend?.kind !== 'django') {
throw new Error('No Django project detected. Expected manage.py or a Django dependency file.');
}
return detected.backend as DjangoAdapter;
}
function assertWritable(file: string, overwrite: boolean): void {
if (!overwrite && fs.existsSync(file)) {
throw new Error(`"${file}" already exists. Pass overwrite: true to replace it.`);
}
}
export function registerGenerateDjangoResourceTool(server: McpServer): void {
server.registerTool(
'generate_django_resource',
{
title: 'Generate Django Resource',
description:
'Scaffold a Django app resource: models.py model class, views (APIView-style stubs), urls.py, and admin registration. ' +
'Writes files under the given app directory (created if missing). Does not run migrate.',
inputSchema: {
name: z.string().min(1).describe('Resource/model name, e.g. "Post" or "blog_post".'),
app: z
.string()
.optional()
.describe('Django app directory relative to the project root. Defaults to a kebab-case plural of the name.'),
overwrite: z.boolean().optional().default(false),
},
},
async ({ name, app, overwrite }) => {
try {
const django = requireDjango();
const model = pascalCase(name);
const appName = app?.trim() || kebabCase(pluralize(model)).replace(/-/g, '_');
const appDir = resolveWorkspacePath(django.root, appName);
fs.mkdirSync(appDir, { recursive: true });
const files: string[] = [];
const modelFile = path.join(appDir, 'models.py');
const viewsFile = path.join(appDir, 'views.py');
const urlsFile = path.join(appDir, 'urls.py');
const adminFile = path.join(appDir, 'admin.py');
const appsFile = path.join(appDir, 'apps.py');
const initFile = path.join(appDir, '__init__.py');
for (const file of [modelFile, viewsFile, urlsFile, adminFile, appsFile]) {
assertWritable(file, overwrite ?? false);
}
if (!fs.existsSync(initFile)) {
fs.writeFileSync(initFile, '');
files.push(initFile);
}
fs.writeFileSync(
modelFile,
`from django.db import models
class ${model}(models.Model):
title = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return self.title
`,
);
files.push(modelFile);
fs.writeFileSync(
viewsFile,
`from django.http import JsonResponse
from django.views import View
from .models import ${model}
class ${model}ListView(View):
def get(self, request):
items = list(${model}.objects.values("id", "title", "created_at"))
return JsonResponse({"results": items})
`,
);
files.push(viewsFile);
const routeName = kebabCase(pluralize(model));
fs.writeFileSync(
urlsFile,
`from django.urls import path
from .views import ${model}ListView
urlpatterns = [
path("${routeName}/", ${model}ListView.as_view(), name="${routeName}-list"),
]
`,
);
files.push(urlsFile);
fs.writeFileSync(
adminFile,
`from django.contrib import admin
from .models import ${model}
admin.site.register(${model})
`,
);
files.push(adminFile);
fs.writeFileSync(
appsFile,
`from django.apps import AppConfig
class ${pascalCase(appName)}Config(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "${appName}"
`,
);
files.push(appsFile);
return jsonResult({
kind: 'django',
model,
app: appName,
route: `/${routeName}/`,
files,
nextSteps: [
`Add "${appName}" to INSTALLED_APPS.`,
`Include path("${appName}/", include("${appName}.urls")) in the project urls.py.`,
'Run python manage.py makemigrations && python manage.py migrate.',
],
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGenerateRailsResourceTool(server: McpServer): void {
server.registerTool(
'generate_rails_resource',
{
title: 'Generate Rails Resource',
description:
'Scaffold Rails model + controller stubs and append a resources route hint to config/routes.rb when present. ' +
'File-based (does not invoke `rails generate`).',
inputSchema: {
name: z.string().min(1).describe('Resource name, e.g. "Post" or "blog_post".'),
api: z.boolean().optional().default(true).describe('If true, generate an API-style controller under app/controllers/api.'),
overwrite: z.boolean().optional().default(false),
},
},
async ({ name, api, overwrite }) => {
try {
const detected = detectProject(config.workspaceRoot);
if (detected.backend?.kind !== 'rails') {
return errorResult('No Rails project detected. Expected a Gemfile with rails and/or bin/rails.');
}
const root = detected.backend.root;
const model = pascalCase(name);
const plural = pluralize(model);
const controllerName = `${plural}Controller`;
const table = kebabCase(plural).replace(/-/g, '_');
const modelFile = resolveWorkspacePath(root, path.join('app', 'models', `${kebabCase(model).replace(/-/g, '_')}.rb`));
const controllerRel = api
? path.join('app', 'controllers', 'api', `${kebabCase(plural).replace(/-/g, '_')}_controller.rb`)
: path.join('app', 'controllers', `${kebabCase(plural).replace(/-/g, '_')}_controller.rb`);
const controllerFile = resolveWorkspacePath(root, controllerRel);
assertWritable(modelFile, overwrite ?? false);
assertWritable(controllerFile, overwrite ?? false);
fs.mkdirSync(path.dirname(modelFile), { recursive: true });
fs.mkdirSync(path.dirname(controllerFile), { recursive: true });
const files: string[] = [];
fs.writeFileSync(
modelFile,
`class ${model} < ApplicationRecord
# self.table_name = "${table}"
end
`,
);
files.push(modelFile);
const parent = api ? 'ApplicationController' : 'ApplicationController';
fs.writeFileSync(
controllerFile,
api
? `module Api
class ${controllerName} < ${parent}
def index
render json: ${model}.all
end
def show
render json: ${model}.find(params[:id])
end
end
end
`
: `class ${controllerName} < ${parent}
def index
@${camelCase(plural)} = ${model}.all
end
def show
@${camelCase(model)} = ${model}.find(params[:id])
end
end
`,
);
files.push(controllerFile);
const routesFile = path.join(root, 'config', 'routes.rb');
let routeWired = false;
const routeLine = api
? ` namespace :api do\n resources :${table}, only: %i[index show]\n end`
: ` resources :${table}, only: %i[index show]`;
if (fs.existsSync(routesFile)) {
const text = fs.readFileSync(routesFile, 'utf8');
if (!text.includes(`resources :${table}`)) {
const wired = text.includes('Rails.application.routes.draw')
? text.replace(/Rails\.application\.routes\.draw do\s*\n/, (match) => `${match}${routeLine}\n`)
: `${text.trimEnd()}\n${routeLine}\n`;
fs.writeFileSync(routesFile, wired);
routeWired = true;
files.push(routesFile);
}
}
return jsonResult({
kind: 'rails',
model,
controller: controllerName,
api: Boolean(api),
route: api ? `/api/${table}` : `/${table}`,
routeWired,
files,
nextSteps: [
'Add a migration for the model (bin/rails g migration Create...).',
routeWired ? 'Review config/routes.rb for the new resources entry.' : 'Add a resources route in config/routes.rb.',
],
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGenerateSymfonyResourceTool(server: McpServer): void {
server.registerTool(
'generate_symfony_resource',
{
title: 'Generate Symfony Resource',
description:
'Scaffold a Symfony Entity + Repository + Controller stub under src/. File-based (does not invoke MakerBundle).',
inputSchema: {
name: z.string().min(1).describe('Resource/entity name, e.g. "Post" or "blog_post".'),
overwrite: z.boolean().optional().default(false),
},
},
async ({ name, overwrite }) => {
try {
const detected = detectProject(config.workspaceRoot);
if (detected.backend?.kind !== 'symfony') {
return errorResult('No Symfony project detected. Expected symfony/framework-bundle or bin/console.');
}
const root = detected.backend.root;
const entity = pascalCase(name);
const route = kebabCase(pluralize(entity));
const entityFile = resolveWorkspacePath(root, path.join('src', 'Entity', `${entity}.php`));
const repoFile = resolveWorkspacePath(root, path.join('src', 'Repository', `${entity}Repository.php`));
const controllerFile = resolveWorkspacePath(root, path.join('src', 'Controller', `${entity}Controller.php`));
for (const file of [entityFile, repoFile, controllerFile]) {
assertWritable(file, overwrite ?? false);
fs.mkdirSync(path.dirname(file), { recursive: true });
}
fs.writeFileSync(
entityFile,
`<?php
namespace App\\Entity;
use App\\Repository\\${entity}Repository;
use Doctrine\\ORM\\Mapping as ORM;
#[ORM\\Entity(repositoryClass: ${entity}Repository::class)]
class ${entity}
{
#[ORM\\Id]
#[ORM\\GeneratedValue]
#[ORM\\Column]
private ?int $id = null;
#[ORM\\Column(length: 255)]
private ?string $title = null;
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
}
`,
);
fs.writeFileSync(
repoFile,
`<?php
namespace App\\Repository;
use App\\Entity\\${entity};
use Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;
use Doctrine\\Persistence\\ManagerRegistry;
/**
* @extends ServiceEntityRepository<${entity}>
*/
class ${entity}Repository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ${entity}::class);
}
}
`,
);
fs.writeFileSync(
controllerFile,
`<?php
namespace App\\Controller;
use App\\Repository\\${entity}Repository;
use Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;
use Symfony\\Component\\HttpFoundation\\JsonResponse;
use Symfony\\Component\\Routing\\Attribute\\Route;
class ${entity}Controller extends AbstractController
{
#[Route('/${route}', name: 'app_${route}_index', methods: ['GET'])]
public function index(${entity}Repository $repository): JsonResponse
{
$items = array_map(
static fn (${entity} $item) => ['id' => $item->getId(), 'title' => $item->getTitle()],
$repository->findAll(),
);
return $this->json(['results' => $items]);
}
}
`,
);
return jsonResult({
kind: 'symfony',
entity,
route: `/${route}`,
files: [entityFile, repoFile, controllerFile],
nextSteps: [
'Run doctrine migrations if Doctrine is configured (php bin/console make:migration / doctrine:migrations:migrate).',
'Ensure Doctrine ORM and attributes are enabled for the Entity mapping.',
],
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
registerGenerateDjangoResourceTool,
registerGenerateRailsResourceTool,
registerGenerateSymfonyResourceTool,
} from './generateResources.js';
export function registerBackendResourceTools(server: McpServer): void {
registerGenerateDjangoResourceTool(server);
registerGenerateRailsResourceTool(server);
registerGenerateSymfonyResourceTool(server);
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { PNG } from 'pngjs';
import { comparePngBuffers } from '../visualDiff.js';
function solidPng(width: number, height: number, r: number, g: number, b: number): Buffer {
const png = new PNG({ width, height });
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const idx = (width * y + x) << 2;
png.data[idx] = r;
png.data[idx + 1] = g;
png.data[idx + 2] = b;
png.data[idx + 3] = 255;
}
}
return PNG.sync.write(png);
}
describe('comparePngBuffers', () => {
it('reports 0% mismatch for identical images', () => {
const a = solidPng(4, 4, 10, 20, 30);
const b = solidPng(4, 4, 10, 20, 30);
const result = comparePngBuffers(a, b);
expect(result.mismatchedPixels).toBe(0);
expect(result.mismatchPercent).toBe(0);
});
it('reports mismatch for different images', () => {
const a = solidPng(4, 4, 0, 0, 0);
const b = solidPng(4, 4, 255, 255, 255);
const result = comparePngBuffers(a, b);
expect(result.mismatchedPixels).toBeGreaterThan(0);
expect(result.mismatchPercent).toBeGreaterThan(0);
});
it('throws when dimensions differ', () => {
const a = solidPng(2, 2, 0, 0, 0);
const b = solidPng(3, 3, 0, 0, 0);
expect(() => comparePngBuffers(a, b)).toThrow(/dimensions differ/);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { writeBaseline, listBaselines } from '../../lib/baselines.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager } from './browserManager.js';
export function registerBrowserScreenshotBaselineTool(server: McpServer): void {
server.registerTool(
'browser_screenshot_baseline',
{
title: 'Save Screenshot Baseline',
description:
'Capture the current page (or a CSS selector) and save it as a PNG baseline under `.web-dev-mcp/baselines/{name}.png` for later visual diffs.',
inputSchema: {
name: z
.string()
.min(1)
.describe('Baseline name (no path separators), e.g. "home" or "login-form".'),
selector: z.string().optional().describe('Optional CSS selector; if omitted, captures the page.'),
fullPage: z
.boolean()
.optional()
.default(false)
.describe('Capture the full scrollable page when selector is omitted.'),
},
},
async ({ name, selector, fullPage }) => {
try {
const session = await browserManager.getSession();
let buffer: Buffer;
if (selector) {
const element = await session.page.locator(selector).first();
await element.waitFor({ state: 'visible', timeout: 5000 });
buffer = await element.screenshot({ type: 'png' });
} else {
buffer = await session.page.screenshot({ fullPage, type: 'png' });
}
const saved = writeBaseline(name, buffer);
return jsonResult({
name,
selector: selector ?? null,
fullPage: selector ? false : fullPage,
sizeBytes: buffer.length,
relativePath: saved.relativePath,
baselines: listBaselines(),
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+88
View File
@@ -0,0 +1,88 @@
import { chromium, type Browser, type BrowserContext, type ConsoleMessage, type Page } from 'playwright';
import { logger } from '../../lib/logger.js';
export interface BrowserSession {
browser: Browser;
context: BrowserContext;
page: Page;
consoleLogs: ConsoleLog[];
}
export interface ConsoleLog {
type: 'log' | 'error' | 'warn' | 'info' | 'debug';
text: string;
location?: string;
time: string;
}
class BrowserManager {
private session: BrowserSession | null = null;
async getSession(headless = true): Promise<BrowserSession> {
if (this.session) return this.session;
try {
const browser = await chromium.launch({ headless });
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
const consoleLogs: ConsoleLog[] = [];
page.on('console', (msg) => {
const location = msg.location();
consoleLogs.push({
type: msg.type() as ConsoleLog['type'],
text: msg.text(),
location: `${location.url}:${location.lineNumber}:${location.columnNumber}`,
time: new Date().toISOString(),
});
});
page.on('pageerror', (error) => {
consoleLogs.push({
type: 'error',
text: `Page error: ${error.message}`,
time: new Date().toISOString(),
});
});
page.on('requestfailed', (request) => {
consoleLogs.push({
type: 'error',
text: `Network request failed: ${request.method()} ${request.url()}`,
time: new Date().toISOString(),
});
});
this.session = { browser, context, page, consoleLogs };
return this.session;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Executable doesn't exist") || message.includes('browserType.launch')) {
throw new Error(
'Playwright Chromium browser is not installed. ' +
'Install it with: npx playwright install chromium',
);
}
throw error;
}
}
async close(): Promise<void> {
if (!this.session) return;
try {
await this.session.browser.close();
} catch (error) {
logger.error('error closing browser', { error: error instanceof Error ? error.message : error });
} finally {
this.session = null;
}
}
resetLogs(): void {
if (this.session) {
this.session.consoleLogs.length = 0;
}
}
}
export const browserManager = new BrowserManager();
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager, type ConsoleLog } from './browserManager.js';
export function registerBrowserConsoleLogsTool(server: McpServer): void {
server.registerTool(
'browser_get_console_logs',
{
title: 'Browser Console Logs',
description:
'Return console logs, JavaScript page errors, and failed network requests captured since the last browser_navigate call (or since the browser session started).',
inputSchema: {
level: z
.enum(['all', 'error', 'warn', 'log', 'info', 'debug'])
.optional()
.default('all')
.describe('Filter logs by type.'),
limit: z
.number()
.int()
.positive()
.optional()
.default(100)
.describe('Maximum number of logs to return.'),
},
},
async ({ level, limit }) => {
try {
const session = await browserManager.getSession();
let logs: ConsoleLog[] = session.consoleLogs.slice(-limit);
if (level !== 'all') {
logs = logs.filter((log) => log.type === level);
}
return jsonResult({
totalCaptured: session.consoleLogs.length,
returned: logs.length,
logs,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerBrowserNavigateTool } from './navigate.js';
import { registerBrowserScreenshotTool } from './screenshot.js';
import { registerBrowserConsoleLogsTool } from './consoleLogs.js';
import { registerBrowserInspectDomTool } from './inspectDom.js';
import { registerBrowserInteractTools } from './interact.js';
import { registerBrowserScreenshotBaselineTool } from './baseline.js';
import { registerBrowserVisualDiffTool } from './visualDiff.js';
export function registerBrowserTools(server: McpServer): void {
registerBrowserNavigateTool(server);
registerBrowserScreenshotTool(server);
registerBrowserScreenshotBaselineTool(server);
registerBrowserVisualDiffTool(server);
registerBrowserConsoleLogsTool(server);
registerBrowserInspectDomTool(server);
registerBrowserInteractTools(server);
}
export { browserManager } from './browserManager.js';
+52
View File
@@ -0,0 +1,52 @@
/// <reference lib="dom" />
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager } from './browserManager.js';
export function registerBrowserInspectDomTool(server: McpServer): void {
server.registerTool(
'browser_inspect_dom',
{
title: 'Browser Inspect DOM',
description:
'Query the DOM of the current page by CSS selector and return the outerHTML, inner text, and selected computed styles of the first matching element.',
inputSchema: {
selector: z.string().min(1).describe('CSS selector to query.'),
includeStyles: z
.array(z.string())
.optional()
.default(['color', 'backgroundColor', 'fontSize', 'display'])
.describe('Computed CSS properties to return.'),
},
},
async ({ selector, includeStyles }) => {
try {
const session = await browserManager.getSession();
const element = session.page.locator(selector).first();
await element.waitFor({ state: 'attached', timeout: 5000 });
const outerHTML = await element.evaluate((el) => el.outerHTML).catch(() => null);
const text = await element.innerText().catch(() => null);
const styles = await element.evaluate(
(el, props) => {
const computed = window.getComputedStyle(el);
return Object.fromEntries(props.map((p) => [p, computed.getPropertyValue(p)]));
},
includeStyles,
);
const count = await session.page.locator(selector).count();
return jsonResult({
selector,
matchCount: count,
outerHTML,
text,
styles,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager } from './browserManager.js';
export function registerBrowserInteractTools(server: McpServer): void {
server.registerTool(
'browser_click',
{
title: 'Browser Click',
description: 'Click the first element matching a CSS selector on the current page.',
inputSchema: {
selector: z.string().min(1).describe('CSS selector of the element to click.'),
},
},
async ({ selector }) => {
try {
const session = await browserManager.getSession();
await session.page.locator(selector).first().click();
return jsonResult({ clicked: true, selector });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
server.registerTool(
'browser_fill',
{
title: 'Browser Fill',
description: 'Fill an input/textarea element with the provided text.',
inputSchema: {
selector: z.string().min(1).describe('CSS selector of the input element.'),
value: z.string().describe('Text to type into the element.'),
clearFirst: z
.boolean()
.optional()
.default(true)
.describe('Clear the existing value before filling.'),
},
},
async ({ selector, value, clearFirst }) => {
try {
const session = await browserManager.getSession();
const locator = session.page.locator(selector).first();
if (clearFirst) await locator.fill(value);
else await locator.pressSequentially(value);
return jsonResult({ filled: true, selector, value });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
server.registerTool(
'browser_eval',
{
title: 'Browser Eval',
description:
'Evaluate a JavaScript function in the context of the current page and return the result. The function is stringified and executed in the browser, so it can access the DOM (document, window, etc.).',
inputSchema: {
script: z
.string()
.min(1)
.describe('JavaScript function body to evaluate. Must be a function expression returning a JSON-serializable value.'),
},
},
async ({ script }) => {
try {
const session = await browserManager.getSession();
const wrapped = `(async () => { ${script} })()`;
const result = await session.page.evaluate(wrapped);
return jsonResult({ result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager } from './browserManager.js';
export function registerBrowserNavigateTool(server: McpServer): void {
server.registerTool(
'browser_navigate',
{
title: 'Browser Navigate',
description:
'Open a URL in a headless/headed Playwright browser session. Reuses the same session across calls, so later tools (screenshot, inspect_dom, click, etc.) operate on the same page.',
inputSchema: {
url: z.string().url().describe('URL to navigate to, e.g. the local dev server URL from start_dev_server.'),
headless: z.boolean().optional().default(true).describe('Run browser headlessly.'),
waitUntil: z
.enum(['load', 'domcontentloaded', 'networkidle'])
.optional()
.default('load')
.describe('When to consider navigation complete.'),
resetLogs: z
.boolean()
.optional()
.default(true)
.describe('Clear previously captured console logs before navigating.'),
},
},
async ({ url, headless, waitUntil, resetLogs: shouldResetLogs }) => {
try {
const session = await browserManager.getSession(headless);
if (shouldResetLogs) browserManager.resetLogs();
const response = await session.page.goto(url, { waitUntil });
const title = await session.page.title().catch(() => '');
return jsonResult({
url,
title,
status: response?.status() ?? null,
finalUrl: response?.url() ?? url,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager } from './browserManager.js';
export function registerBrowserScreenshotTool(server: McpServer): void {
server.registerTool(
'browser_screenshot',
{
title: 'Browser Screenshot',
description:
'Capture a screenshot of the current page or a specific element. Returns the image as a base64 data URI (PNG).',
inputSchema: {
selector: z.string().optional().describe('CSS selector of the element to screenshot. If omitted, captures the full page.'),
fullPage: z
.boolean()
.optional()
.default(false)
.describe('Capture the full scrollable page (only used when selector is omitted).'),
},
},
async ({ selector, fullPage }) => {
try {
const session = await browserManager.getSession();
let buffer: Buffer;
if (selector) {
const element = await session.page.locator(selector).first();
await element.waitFor({ state: 'visible', timeout: 5000 });
buffer = await element.screenshot({ type: 'png' });
} else {
buffer = await session.page.screenshot({ fullPage, type: 'png' });
}
const base64 = buffer.toString('base64');
const dataUri = `data:image/png;base64,${base64}`;
return jsonResult({
selector: selector ?? null,
fullPage: selector ? false : fullPage,
sizeBytes: buffer.length,
dataUri,
truncatedDataUri: dataUri.length > config.maxToolOutputChars ? `${dataUri.slice(0, config.maxToolOutputChars)}...` : dataUri,
note: 'The full dataUri is returned above. If it is truncated, use the returned base64 value directly from your client.',
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+106
View File
@@ -0,0 +1,106 @@
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { readBaseline, resolveBaselinePath } from '../../lib/baselines.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { browserManager } from './browserManager.js';
function toDataUri(png: Buffer): string {
return `data:image/png;base64,${png.toString('base64')}`;
}
export function comparePngBuffers(
baselinePng: Buffer,
currentPng: Buffer,
threshold = 0.1,
): { width: number; height: number; mismatchedPixels: number; mismatchPercent: number; diffPng: Buffer } {
const baseline = PNG.sync.read(baselinePng);
const current = PNG.sync.read(currentPng);
if (baseline.width !== current.width || baseline.height !== current.height) {
throw new Error(
`Screenshot dimensions differ from baseline (${baseline.width}x${baseline.height} vs ${current.width}x${current.height}). ` +
'Recapture the baseline at the same viewport/selector size.',
);
}
const { width, height } = baseline;
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(baseline.data, current.data, diff.data, width, height, {
threshold,
});
const total = width * height;
const mismatchPercent = total === 0 ? 0 : (mismatchedPixels / total) * 100;
return {
width,
height,
mismatchedPixels,
mismatchPercent,
diffPng: PNG.sync.write(diff),
};
}
export function registerBrowserVisualDiffTool(server: McpServer): void {
server.registerTool(
'browser_visual_diff',
{
title: 'Visual Screenshot Diff',
description:
'Capture the current page/element and compare it to a previously saved baseline with pixelmatch. Returns mismatch stats and a diff PNG data URI.',
inputSchema: {
name: z.string().min(1).describe('Baseline name previously saved with browser_screenshot_baseline.'),
selector: z.string().optional().describe('Optional CSS selector to screenshot for comparison.'),
fullPage: z
.boolean()
.optional()
.default(false)
.describe('Capture the full scrollable page when selector is omitted.'),
threshold: z
.number()
.min(0)
.max(1)
.optional()
.default(0.1)
.describe('pixelmatch threshold (01). Lower is stricter.'),
},
},
async ({ name, selector, fullPage, threshold }) => {
try {
const baselinePng = readBaseline(name);
const session = await browserManager.getSession();
let currentPng: Buffer;
if (selector) {
const element = await session.page.locator(selector).first();
await element.waitFor({ state: 'visible', timeout: 5000 });
currentPng = await element.screenshot({ type: 'png' });
} else {
currentPng = await session.page.screenshot({ fullPage, type: 'png' });
}
const comparison = comparePngBuffers(baselinePng, currentPng, threshold ?? 0.1);
const diffDataUri = toDataUri(comparison.diffPng);
return jsonResult({
name,
baselinePath: resolveBaselinePath(name),
selector: selector ?? null,
width: comparison.width,
height: comparison.height,
mismatchedPixels: comparison.mismatchedPixels,
mismatchPercent: Number(comparison.mismatchPercent.toFixed(4)),
passed: comparison.mismatchedPixels === 0,
threshold: threshold ?? 0.1,
diffDataUri,
truncatedDiffDataUri:
diffDataUri.length > config.maxToolOutputChars
? `${diffDataUri.slice(0, config.maxToolOutputChars)}...`
: diffDataUri,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+42
View File
@@ -0,0 +1,42 @@
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-----
@@ -0,0 +1,22 @@
import { describe, it, expect, vi } from 'vitest';
import { createInMemoryClient } from '../../../test/mcpServer.js';
function firstText(result: unknown): string {
const r = result as { content?: Array<{ text?: string }> } | undefined;
return r?.content?.[0]?.text ?? '';
}
vi.mock('../requireCodeIgniter.js', () => ({
requireCodeIgniter: () => ({ root: '/tmp/ci4', kind: 'codeigniter' }),
}));
describe('generate_codeigniter_shield_auth confirm gate', () => {
it('rejects without confirm: true', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'generate_codeigniter_shield_auth',
arguments: {},
});
expect(firstText(result)).toContain('confirm: true');
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { createInMemoryClient } from '../../../test/mcpServer.js';
function firstText(result: unknown): string {
const r = result as { content?: Array<{ text?: string }> } | undefined;
return r?.content?.[0]?.text ?? '';
}
vi.mock('../requireCodeIgniter.js', () => ({
requireCodeIgniter: () => ({ root: '/tmp/ci4', adapter: 'codeigniter' }),
}));
vi.mock('../../../lib/runCommand.js', () => ({
runCommand: vi.fn(async () => ({
command: 'php',
args: ['spark', 'migrate:refresh', '--seed'],
cwd: '/tmp/ci4',
exitCode: 0,
timedOut: false,
failed: false,
output: 'success',
})),
}));
describe('spark tool confirm enforcement', () => {
it('rejects destructive spark commands without confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'spark',
arguments: { command: 'migrate:refresh', args: ['--seed'] },
});
const text = firstText(result);
expect(text).toContain('destructive');
expect(text).toContain('confirm: true');
});
it('allows destructive spark commands with confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'spark',
arguments: { command: 'migrate:refresh', args: ['--seed'], confirm: true },
});
const text = firstText(result);
expect(text).toContain('success');
});
it('rejects commands not on the allow-list', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'spark',
arguments: { command: 'serve' },
});
const text = firstText(result);
expect(text).toContain('not on the allow-list');
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { isAllowedSparkCommand, SPARK_DESTRUCTIVE } from '../sparkAllowList.js';
describe('spark allow-list', () => {
it('allows common scaffolding and maintenance commands', () => {
expect(isAllowedSparkCommand('make:controller')).toBe(true);
expect(isAllowedSparkCommand('make:model')).toBe(true);
expect(isAllowedSparkCommand('migrate')).toBe(true);
expect(isAllowedSparkCommand('routes')).toBe(true);
expect(isAllowedSparkCommand('cache:clear')).toBe(true);
});
it('rejects arbitrary commands', () => {
expect(isAllowedSparkCommand('migrate:refresh')).toBe(true); // allowed but destructive
expect(isAllowedSparkCommand('tinker')).toBe(false);
expect(isAllowedSparkCommand('serve')).toBe(false);
expect(isAllowedSparkCommand('shell')).toBe(false);
});
it('marks migrate:refresh as destructive', () => {
expect(SPARK_DESTRUCTIVE.has('migrate:refresh')).toBe(true);
expect(SPARK_DESTRUCTIVE.has('make:controller')).toBe(false);
});
});
@@ -0,0 +1,162 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { runCommand } from '../../lib/runCommand.js';
import { kebabCase, pascalCase } from '../../lib/strings.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireCodeIgniter } from './requireCodeIgniter.js';
function pluralize(word: string): string {
const lower = word.toLowerCase();
if (lower.endsWith('ies')) return word;
if (lower.endsWith('y') && !/[aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
if (/(s|x|z|ch|sh)$/i.test(word)) return `${word}es`;
if (lower.endsWith('s')) return word;
return `${word}s`;
}
export function registerGenerateCodeIgniterResourceTool(server: McpServer): void {
server.registerTool(
'generate_codeigniter_resource',
{
title: 'Generate CodeIgniter Resource',
description:
'One-shot scaffold of a full CodeIgniter 4 resource: model + migration + seeder + ResourceController, ' +
'then wire the route in app/Config/Routes.php. Supports an optional API version path prefix.',
inputSchema: {
name: z
.string()
.min(1)
.describe('Resource name, e.g. "Post" or "blog_post". Converted to PascalCase for the model/controller.'),
migrate: z
.boolean()
.optional()
.default(false)
.describe('If true, run `php spark migrate` after scaffolding. Requires a configured database.'),
apiVersion: z
.string()
.optional()
.describe('Optional API version prefix for the route, e.g. "v1" → resource at /v1/{name}.'),
resourceController: z
.boolean()
.optional()
.default(true)
.describe('If true (default), generate a ResourceController via spark --resource.'),
},
},
async ({ name, migrate, apiVersion, resourceController }) => {
try {
const ci = requireCodeIgniter();
const model = pascalCase(name);
const controller = `${model}Controller`;
const routeName = kebabCase(pluralize(model));
const versionPrefix = apiVersion?.replace(/^\/+|\/+$/g, '') || '';
const resourcePath = versionPrefix ? `${versionPrefix}/${routeName}` : routeName;
const steps: Array<Record<string, unknown>> = [];
const modelResult = await runCommand('php', ['spark', 'make:model', model, '--no-interaction'], {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({ step: 'make:model', exitCode: modelResult.exitCode, failed: modelResult.failed, output: modelResult.output });
const migrationResult = await runCommand(
'php',
['spark', 'make:migration', `create_${kebabCase(pluralize(model))}_table`, '--no-interaction'],
{ cwd: ci.root, timeoutMs: config.scaffoldCommandTimeoutMs },
);
steps.push({
step: 'make:migration',
exitCode: migrationResult.exitCode,
failed: migrationResult.failed,
output: migrationResult.output,
});
const seederResult = await runCommand('php', ['spark', 'make:seeder', `${model}Seeder`, '--no-interaction'], {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({
step: 'make:seeder',
exitCode: seederResult.exitCode,
failed: seederResult.failed,
output: seederResult.output,
});
const controllerArgs = ['spark', 'make:controller', controller, '--no-interaction'];
if (resourceController !== false) controllerArgs.splice(3, 0, '--resource');
const controllerResult = await runCommand('php', controllerArgs, {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({
step: 'make:controller',
exitCode: controllerResult.exitCode,
failed: controllerResult.failed,
output: controllerResult.output,
});
const routesFile = path.join(ci.root, 'app', 'Config', 'Routes.php');
const routeLine = `$routes->resource('${resourcePath}', ['controller' => '\\App\\Controllers\\${controller}']);`;
const routeWired = wireRoute(routesFile, routeLine);
steps.push({ step: 'wire-route', routesFile, routeLine, wired: routeWired });
let migrateResult: Awaited<ReturnType<typeof runCommand>> | null = null;
if (migrate) {
migrateResult = await runCommand('php', ['spark', 'migrate', '--no-interaction'], {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({
step: 'migrate',
exitCode: migrateResult.exitCode,
failed: migrateResult.failed,
output: migrateResult.output,
});
}
return jsonResult({
model,
controller,
apiVersion: versionPrefix || null,
route: `resource /${resourcePath}`,
resourceController: resourceController !== false,
routesFile,
routeWired,
steps,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
function wireRoute(routesFile: string, routeLine: string): boolean {
fs.mkdirSync(path.dirname(routesFile), { recursive: true });
if (!fs.existsSync(routesFile)) {
fs.writeFileSync(
routesFile,
`<?php\n\nuse CodeIgniter\\Router\\RouteCollection;\n\n/**\n * @var RouteCollection $routes\n */\n\n${routeLine}\n`,
);
return true;
}
const existing = fs.readFileSync(routesFile, 'utf8');
if (existing.includes(routeLine.trim())) return false;
let next = existing;
if (!/use\s+CodeIgniter\\Router\\RouteCollection\s*;/.test(next)) {
if (next.startsWith('<?php')) {
next = next.replace('<?php', "<?php\n\nuse CodeIgniter\\Router\\RouteCollection;");
} else {
next = `<?php\n\nuse CodeIgniter\\Router\\RouteCollection;\n\n${next}`;
}
}
fs.writeFileSync(routesFile, `${next.trimEnd()}\n${routeLine}\n`);
return true;
}
@@ -0,0 +1,77 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { hasComposerDependency, readComposerJson } from '../../lib/frameworks/composer.js';
import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireCodeIgniter } from './requireCodeIgniter.js';
export function registerGenerateCodeIgniterShieldAuthTool(server: McpServer): void {
server.registerTool(
'generate_codeigniter_shield_auth',
{
title: 'Scaffold CodeIgniter Shield Auth',
description:
'Install/setup CodeIgniter Shield auth helpers. Requires codeigniter4/shield. ' +
'Runs `php spark shield:setup` (and optionally composer require). Requires confirm: true.',
inputSchema: {
installPackage: z
.boolean()
.optional()
.default(false)
.describe('If true and Shield is missing, run composer require codeigniter4/shield first.'),
confirm: z.boolean().optional().describe('Required as true — Shield setup modifies project files.'),
},
},
async ({ installPackage, confirm }) => {
try {
if (confirm !== true) {
return errorResult('generate_codeigniter_shield_auth requires confirm: true.');
}
const ci = requireCodeIgniter();
const composer = readComposerJson(ci.root);
const steps: Array<Record<string, unknown>> = [];
let hasShield = hasComposerDependency(composer, 'codeigniter4/shield');
if (!hasShield && installPackage) {
const requireResult = await runCommand(
'composer',
['require', 'codeigniter4/shield', '--no-interaction'],
{ cwd: ci.root, timeoutMs: config.scaffoldCommandTimeoutMs },
);
steps.push({
step: 'composer-require',
exitCode: requireResult.exitCode,
failed: requireResult.failed,
output: requireResult.output,
});
if (requireResult.failed) {
return errorResult(`Failed to install codeigniter4/shield:\n${requireResult.output}`);
}
hasShield = true;
}
if (!hasShield) {
return errorResult(
'codeigniter4/shield is not installed. Pass installPackage: true (with confirm: true) ' +
'or run composer_require with package "codeigniter4/shield" first.',
);
}
const setup = await runCommand('php', ['spark', 'shield:setup', '--no-interaction'], {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({ step: 'shield:setup', exitCode: setup.exitCode, failed: setup.failed, output: setup.output });
return jsonResult({
package: 'codeigniter4/shield',
steps,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerSparkTool } from './spark.js';
import { registerGenerateCodeIgniterResourceTool } from './generateCodeIgniterResource.js';
import { registerCodeIgniterTestTool } from './runCodeIgniterTests.js';
import { registerGenerateCodeIgniterShieldAuthTool } from './generateShieldAuth.js';
import { registerSparkDiscoverTool } from './sparkDiscover.js';
export function registerCodeIgniterTools(server: McpServer): void {
registerSparkTool(server);
registerGenerateCodeIgniterResourceTool(server);
registerCodeIgniterTestTool(server);
registerGenerateCodeIgniterShieldAuthTool(server);
registerSparkDiscoverTool(server);
}
@@ -0,0 +1,14 @@
import { config } from '../../config.js';
import { detectProject } from '../../lib/frameworks/detect.js';
import { CodeIgniterAdapter } from '../../lib/frameworks/codeigniter.js';
export function requireCodeIgniter(): CodeIgniterAdapter {
const detected = detectProject(config.workspaceRoot);
if (detected.backend instanceof CodeIgniterAdapter) {
return detected.backend;
}
throw new Error(
'No CodeIgniter 4 project detected in WORKSPACE_ROOT. ' +
'Expected composer.json with codeigniter4/framework (or a spark file) at the workspace root.',
);
}
@@ -0,0 +1,84 @@
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { vendorBinExists } from '../../lib/frameworks/composer.js';
import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireCodeIgniter } from './requireCodeIgniter.js';
function vendorBin(root: string, bin: string): string {
const suffix = process.platform === 'win32' ? '.bat' : '';
return path.join(root, 'vendor', 'bin', `${bin}${suffix}`);
}
function parsePhpUnitSummary(output: string): Record<string, unknown> {
const ok = output.match(/OK\s*\((\d+)\s+tests?,\s*(\d+)\s+assertions?\)/i);
if (ok) {
return { passed: true, tests: Number(ok[1]), assertions: Number(ok[2]), failures: 0, errors: 0 };
}
const summary = output.match(
/Tests:\s*(\d+),\s*Assertions:\s*(\d+)(?:,\s*Errors:\s*(\d+))?(?:,\s*Failures:\s*(\d+))?(?:,\s*Skipped:\s*(\d+))?/i,
);
if (summary) {
return {
passed: !/FAILURES!|ERRORS!/i.test(output),
tests: Number(summary[1]),
assertions: Number(summary[2]),
errors: Number(summary[3] ?? 0),
failures: Number(summary[4] ?? 0),
skipped: Number(summary[5] ?? 0),
};
}
return { passed: null, note: 'Could not parse a pass/fail summary from the runner output.' };
}
export function registerCodeIgniterTestTool(server: McpServer): void {
server.registerTool(
'run_codeigniter_tests',
{
title: 'Run CodeIgniter Tests',
description:
"Run the CodeIgniter 4 project's PHPUnit-based test suite (CIUnitTestCase) via vendor/bin/phpunit, " +
'or `php spark test` as a fallback. Returns parsed pass/fail counts when possible.',
inputSchema: {
extraArgs: z
.array(z.string())
.optional()
.describe('Extra CLI args, e.g. ["--filter", "UserTest"].'),
},
},
async ({ extraArgs }) => {
try {
const ci = requireCodeIgniter();
const args = extraArgs ?? [];
let command: string;
let cmdArgs: string[];
if (vendorBinExists(ci.root, 'phpunit')) {
command = vendorBin(ci.root, 'phpunit');
cmdArgs = args;
} else if (vendorBinExists(ci.root, 'spark')) {
// CI4 does not ship a built-in `spark test` command out of the box,
// but projects sometimes add one. If phpunit is missing, try it.
command = 'php';
cmdArgs = ['spark', 'test', ...args];
} else {
return errorResult(
`No vendor/bin/phpunit found at ${ci.root}. Run composer install (or run_install) first.`,
);
}
const result = await runCommand(command, cmdArgs, {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({ runner: 'phpunit', summary: parsePhpUnitSummary(result.output), ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+69
View File
@@ -0,0 +1,69 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { processManager } from '../../lib/processManager.js';
import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { isAllowedSparkCommand, SPARK_ALLOWED, SPARK_DESTRUCTIVE } from './sparkAllowList.js';
import { requireCodeIgniter } from './requireCodeIgniter.js';
export function registerSparkTool(server: McpServer): void {
server.registerTool(
'spark',
{
title: 'Run Spark Command',
description:
'Run a whitelisted `php spark` subcommand in the detected CodeIgniter 4 project and return stdout/stderr. ' +
`Allowed commands: ${SPARK_ALLOWED.join(', ')}. ` +
'Destructive command (migrate:refresh) requires confirm: true.',
inputSchema: {
command: z
.string()
.min(1)
.describe('Spark subcommand, e.g. "make:controller" or "migrate".'),
args: z
.array(z.string())
.optional()
.default([])
.describe('Arguments forwarded after the subcommand, e.g. ["PostController", "--resource"].'),
confirm: z
.boolean()
.optional()
.describe('Required as true for destructive command (migrate:refresh).'),
},
},
async ({ command, args, confirm }) => {
try {
if (!isAllowedSparkCommand(command)) {
return errorResult(
`Spark command "${command}" is not on the allow-list. Allowed: ${SPARK_ALLOWED.join(', ')}.`,
);
}
if (SPARK_DESTRUCTIVE.has(command) && confirm !== true) {
return errorResult(
`Spark command "${command}" is destructive and requires confirm: true.`,
);
}
const ci = requireCodeIgniter();
const phpArgs = ['spark', command, ...(args ?? [])];
const result = await runCommand('php', phpArgs, {
cwd: ci.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({
sparkCommand: command,
sparkArgs: args ?? [],
exitCode: result.exitCode,
timedOut: result.timedOut,
failed: result.failed,
output: result.output,
cwd: result.cwd,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Spark subcommands the `spark` tool is willing to run.
* Anything not on this list is rejected so the agent cannot pass arbitrary
* spark commands (e.g. `spark make:migration` with a bad path, destructive
* migrations without confirm, etc.).
*/
export const SPARK_ALLOWED = [
'make:controller',
'make:model',
'make:entity',
'make:filter',
'make:command',
'make:validation',
'make:migration',
'make:seeder',
'make:test',
'make:config',
'migrate',
'migrate:rollback',
'migrate:refresh',
'migrate:status',
'db:seed',
'routes',
'cache:clear',
'optimize',
'list',
'about',
] as const;
export type SparkCommand = (typeof SPARK_ALLOWED)[number];
export const SPARK_DESTRUCTIVE = new Set<string>(['migrate:refresh']);
export function isAllowedSparkCommand(command: string): command is SparkCommand {
return (SPARK_ALLOWED as readonly string[]).includes(command);
}
+51
View File
@@ -0,0 +1,51 @@
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 { isAllowedSparkCommand, SPARK_ALLOWED } from './sparkAllowList.js';
import { requireCodeIgniter } from './requireCodeIgniter.js';
export function registerSparkDiscoverTool(server: McpServer): void {
server.registerTool(
'spark_discover',
{
title: 'Discover Spark Commands',
description:
'Run `php spark list` and return only commands that are on the server allow-list (intersected with SPARK_ALLOWED).',
inputSchema: {},
},
async () => {
try {
const ci = requireCodeIgniter();
const result = await runCommand('php', ['spark', 'list', '--no-interaction'], {
cwd: ci.root,
timeoutMs: config.defaultCommandTimeoutMs,
});
const discovered = new Set<string>();
for (const line of result.output.split(/\r?\n/)) {
const match = line.match(/^\s*([a-z][\w:-]*)\s+/i);
const command = match?.[1];
if (command && isAllowedSparkCommand(command)) {
discovered.add(command);
}
}
// Also include allow-listed commands even if spark list formatting hid them.
for (const allowed of SPARK_ALLOWED) {
if (result.output.includes(allowed)) discovered.add(allowed);
}
return jsonResult({
exitCode: result.exitCode,
failed: result.failed,
allowed: [...SPARK_ALLOWED],
discovered: [...discovered].sort(),
note: 'Only allow-listed spark commands are returned. Use the spark tool to run one.',
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { validateVolumeMount, isAllowListedImage, isLocallyBuiltImage } from '../dockerManager.js';
import { SandboxViolationError } from '../../../lib/sandbox.js';
describe('Docker guardrails', () => {
it('validates workspace-confined volume mounts', () => {
const mount = validateVolumeMount('src:/app/src');
expect(mount.target).toBe('/app/src');
expect(mount.mode).toBe('rw');
});
it('rejects Docker socket mounts', () => {
expect(() => validateVolumeMount('/var/run/docker.sock:/host/docker.sock')).toThrow(SandboxViolationError);
expect(() => validateVolumeMount('\\.\\pipe\\docker_engine:/host/docker.sock')).toThrow(SandboxViolationError);
});
it('rejects malformed mounts', () => {
expect(() => validateVolumeMount('/app')).toThrow();
expect(() => validateVolumeMount(':/app')).toThrow();
});
it('allows listed base images', () => {
expect(isAllowListedImage('node:20-alpine')).toBe(true);
expect(isAllowListedImage('nginx:latest')).toBe(true);
expect(isAllowListedImage('postgres:15')).toBe(true);
expect(isAllowListedImage('php:8.2')).toBe(true);
});
it('rejects unlisted images', () => {
expect(isAllowListedImage('evil/image')).toBe(false);
expect(isAllowListedImage('ubuntu:latest')).toBe(false);
});
it('treats registry-prefixed images as not locally built', () => {
expect(isLocallyBuiltImage('my-app')).toBe(true);
expect(isLocallyBuiltImage('web-dev-mcp')).toBe(true);
expect(isLocallyBuiltImage('registry.io/my-app')).toBe(false);
expect(isLocallyBuiltImage('user/repo')).toBe(false);
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createInMemoryClient } from '../../../test/mcpServer.js';
function firstText(result: unknown): string {
const r = result as { content?: Array<{ text?: string }> } | undefined;
return r?.content?.[0]?.text ?? '';
}
describe('docker generate + push confirm gates', () => {
let tmpDir: string;
let originalRoot: string;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-docker-gen-'));
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'app', dependencies: { react: '^18' } }));
const config = await import('../../../config.js');
originalRoot = config.config.workspaceRoot;
(config.config as { workspaceRoot: string }).workspaceRoot = tmpDir;
});
afterEach(async () => {
const config = await import('../../../config.js');
(config.config as { workspaceRoot: string }).workspaceRoot = originalRoot;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('generate_dockerfile writes a Dockerfile', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({ name: 'generate_dockerfile', arguments: {} });
const text = firstText(result);
expect(text).toContain('Dockerfile');
expect(fs.existsSync(path.join(tmpDir, 'Dockerfile'))).toBe(true);
});
it('generate_compose writes docker-compose.yml', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({ name: 'generate_compose', arguments: {} });
expect(firstText(result)).toContain('docker-compose.yml');
expect(fs.existsSync(path.join(tmpDir, 'docker-compose.yml'))).toBe(true);
});
it('docker_push_image requires confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'docker_push_image',
arguments: { image: 'my-app:latest' },
});
expect(firstText(result)).toContain('confirm: true');
});
it('generate_sail requires confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({ name: 'generate_sail', arguments: {} });
expect(firstText(result)).toContain('confirm: true');
});
});
+106
View File
@@ -0,0 +1,106 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { dockerManager } from './dockerManager.js';
export function registerDockerBuildImageTool(server: McpServer): void {
server.registerTool(
'docker_build_image',
{
title: 'Docker Build Image',
description:
'Build a Docker image from a Dockerfile in the workspace. Streams the build output back and returns the resulting image tag.',
inputSchema: {
tag: z.string().min(1).describe('Image tag, e.g. "my-app:latest".'),
dockerfile: z
.string()
.optional()
.default('Dockerfile')
.describe('Path to the Dockerfile, relative to the workspace root.'),
buildContext: z
.string()
.optional()
.default('.')
.describe('Build context directory, relative to the workspace root.'),
},
},
async ({ tag, dockerfile, buildContext }) => {
try {
await dockerManager.ensureReachable();
const dockerfilePath = resolveWorkspacePath(config.workspaceRoot, dockerfile);
const contextPath = resolveWorkspacePath(config.workspaceRoot, buildContext);
if (!fs.existsSync(dockerfilePath)) {
return errorResult(`Dockerfile not found: ${dockerfilePath}`);
}
const stream = await dockerManager.getDocker().buildImage(
{
context: contextPath,
src: [path.basename(dockerfilePath), ...listContextFiles(contextPath)],
},
{ t: tag, dockerfile: path.basename(dockerfilePath) },
);
const output: string[] = [];
await new Promise<void>((resolve, reject) => {
dockerManager
.getDocker()
.modem.followProgress(
stream,
(err, res) => {
if (err) {
reject(err);
return;
}
const last = res?.[res.length - 1];
if (last && 'error' in last && last.error) {
reject(new Error(String(last.error)));
return;
}
resolve();
},
(event) => {
const line = typeof event === 'string' ? event : JSON.stringify(event);
output.push(line);
if (output.length > config.maxBufferedLogLines) {
output.splice(0, output.length - config.maxBufferedLogLines);
}
},
);
});
return jsonResult({
tag,
dockerfile: dockerfilePath,
context: contextPath,
output: output.join('\n').slice(-config.maxToolOutputChars),
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
function listContextFiles(root: string): string[] {
const files: string[] = [];
const walk = (dir: string) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(root, full);
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') continue;
if (entry.isDirectory()) {
walk(full);
} else {
files.push(rel);
}
}
};
walk(root);
return files;
}
+85
View File
@@ -0,0 +1,85 @@
import { z } from 'zod';
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';
/**
* Docker Compose is invoked via the local `docker compose` or `docker-compose`
* CLI. The server does not parse compose files itself; it delegates to the
* official tool and streams the output back, just like workflow tools.
*/
export function registerDockerComposeTools(server: McpServer): void {
server.registerTool(
'docker_compose_up',
{
title: 'Docker Compose Up',
description:
'Bring up a docker-compose.yml stack in the workspace root. Detached by default so the server does not block.',
inputSchema: {
file: z
.string()
.optional()
.default('docker-compose.yml')
.describe('Compose file path, relative to the workspace root.'),
services: z
.array(z.string())
.optional()
.describe('Specific services to start. Omit to start all.'),
build: z
.boolean()
.optional()
.default(false)
.describe('Build images before starting.'),
},
},
async ({ file, services, build }) => {
try {
const args = ['compose', '-f', file, '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 });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
server.registerTool(
'docker_compose_down',
{
title: 'Docker Compose Down',
description:
'Shut down a docker-compose.yml stack in the workspace root.',
inputSchema: {
file: z
.string()
.optional()
.default('docker-compose.yml')
.describe('Compose file path, relative to the workspace root.'),
volumes: z
.boolean()
.optional()
.default(false)
.describe('Also remove named volumes declared in the volumes section.'),
},
},
async ({ file, volumes }) => {
try {
const args = ['compose', '-f', file, 'down'];
if (volumes) args.push('-v');
const result = await runCommand('docker', args, {
cwd: config.workspaceRoot,
timeoutMs: config.defaultCommandTimeoutMs,
});
return jsonResult({ file, ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { dockerManager } from './dockerManager.js';
export function registerDockerContainerLogsTool(server: McpServer): void {
server.registerTool(
'docker_get_container_logs',
{
title: 'Docker Get Container Logs',
description:
'Fetch logs from a running or stopped Docker container started by this server.',
inputSchema: {
containerId: z.string().min(1).describe('Container ID (short or long) returned by docker_run_container.'),
tail: z
.number()
.int()
.positive()
.optional()
.default(200)
.describe('Only return the last N lines.'),
follow: z
.boolean()
.optional()
.default(false)
.describe('Stream logs (not implemented; kept for API compatibility).'),
},
},
async ({ containerId, tail, follow }) => {
try {
await dockerManager.ensureReachable();
if (follow) {
return jsonResult({
containerId,
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({
stdout: true,
stderr: true,
tail,
});
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
return jsonResult({
containerId,
logs: raw.slice(-config.maxToolOutputChars),
bytes: raw.length,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+122
View File
@@ -0,0 +1,122 @@
import Docker from 'dockerode';
import { config } from '../../config.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { logger } from '../../lib/logger.js';
export interface ManagedContainer {
id: string;
image: string;
name: string;
startedAt: string;
ports: Record<number, number>;
}
class DockerManager {
private readonly docker: Docker;
private readonly containers = new Map<string, ManagedContainer>();
private daemonReachable: boolean | null = null;
constructor() {
this.docker = new Docker();
}
async ensureReachable(): Promise<void> {
if (this.daemonReachable === true) return;
try {
await this.docker.ping();
this.daemonReachable = true;
} catch (error) {
this.daemonReachable = false;
throw new Error(
'Docker daemon is not reachable. Make sure Docker is running and the current user can access it. ' +
`Original error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
getDocker(): Docker {
return this.docker;
}
track(containerId: string, image: string, name: string, ports: Record<number, number>): ManagedContainer {
if (this.containers.size >= config.maxConcurrentContainers) {
throw new Error(
`Refusing to start another container: already at the max of ${config.maxConcurrentContainers} concurrent containers.`,
);
}
const shortId = containerId.substring(0, 12);
const managed: ManagedContainer = {
id: shortId,
image,
name,
startedAt: new Date().toISOString(),
ports,
};
this.containers.set(shortId, managed);
return managed;
}
getTracked(id: string): ManagedContainer | undefined {
return this.containers.get(id) ?? this.containers.get(id.substring(0, 12));
}
listTracked(): ManagedContainer[] {
return Array.from(this.containers.values());
}
untrack(id: string): void {
this.containers.delete(id.substring(0, 12));
}
async cleanupAll(): Promise<void> {
for (const [id, managed] of this.containers.entries()) {
try {
const container = this.docker.getContainer(id);
await container.stop({ t: 5 }).catch(() => null);
await container.remove({ force: true }).catch(() => null);
} catch (error) {
logger.error(`failed to cleanup container ${managed.name} (${id})`, {
error: error instanceof Error ? error.message : error,
});
} finally {
this.containers.delete(id);
}
}
}
}
export const dockerManager = new DockerManager();
/**
* 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 absoluteSource = resolveWorkspacePath(config.workspaceRoot, source);
if (absoluteSource.toLowerCase().startsWith('\\\\.\\pipe\\docker_engine') || source.includes('/var/run/docker.sock')) {
throw new Error(`Refusing to mount the Docker socket: "${source}".`);
}
return { source: absoluteSource, target, mode };
}
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));
}
export function isLocallyBuiltImage(image: string): boolean {
// Heuristic: an image built by this server via docker_build_image won't have
// a registry prefix. We treat "my-app" or "web-dev-mcp" as local; anything
// with a registry host (contains '.' or '/') is not.
return !/[/.]/.test(image);
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { dockerManager } from './dockerManager.js';
export function registerDockerExecTool(server: McpServer): void {
server.registerTool(
'docker_exec',
{
title: 'Docker Exec',
description:
'Run a one-off command inside a running Docker container started by this server and return stdout/stderr.',
inputSchema: {
containerId: z.string().min(1).describe('Container ID (short or long) returned by docker_run_container.'),
command: z.array(z.string()).min(1).describe('Command and arguments to execute inside the container.'),
workingDir: z.string().optional().describe('Working directory inside the container.'),
env: z.record(z.string()).optional().describe('Additional environment variables for the exec.'),
},
},
async ({ containerId, command, workingDir, env }) => {
try {
await dockerManager.ensureReachable();
const docker = dockerManager.getDocker();
const exec = await docker.getContainer(containerId).exec({
Cmd: command,
WorkingDir: workingDir,
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ Detach: false, Tty: false });
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', resolve);
stream.on('error', reject);
});
const raw = Buffer.concat(chunks).toString('utf8');
const result = await exec.inspect();
return jsonResult({
containerId,
command,
exitCode: result.ExitCode ?? null,
output: raw.slice(-config.maxToolOutputChars),
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+259
View File
@@ -0,0 +1,259 @@
import fs from 'node:fs';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { detectProject, resolvePackage } from '../../lib/frameworks/detect.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { runCommand } from '../../lib/runCommand.js';
import { requireLaravel } from '../laravel/requireLaravel.js';
import { errorResult, jsonResult, packageSchema, toErrorMessage } from '../shared.js';
function nodeDockerfile(): string {
return `# Generated by web-dev-mcp
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
`;
}
function phpDockerfile(kind: 'laravel' | 'codeigniter'): string {
const publicDir = kind === 'laravel' ? 'public' : 'public';
return `# Generated by web-dev-mcp for ${kind}
FROM php:8.3-cli-alpine AS vendor
WORKDIR /app
COPY composer.json composer.lock* ./
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \\
&& composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist || composer install --no-interaction --prefer-dist
FROM php:8.3-apache
WORKDIR /var/www/html
RUN docker-php-ext-install pdo pdo_mysql
COPY --from=vendor /app/vendor ./vendor
COPY . .
ENV APACHE_DOCUMENT_ROOT=/var/www/html/${publicDir}
RUN sed -ri -e 's!/var/www/html!\\\${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \\
&& a2enmod rewrite
EXPOSE 80
`;
}
function nginxNodeCompose(): string {
return `# Generated by web-dev-mcp
services:
app:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
depends_on:
- redis
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
db_data:
`;
}
function phpCompose(): string {
return `# Generated by web-dev-mcp
services:
app:
build: .
ports:
- "8080:80"
environment:
APP_ENV: local
DB_HOST: db
DB_DATABASE: app
DB_USERNAME: app
DB_PASSWORD: app
REDIS_HOST: redis
depends_on:
- db
- redis
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: app
MYSQL_ROOT_PASSWORD: root
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
db_data:
`;
}
export function registerGenerateDockerfileTool(server: McpServer): void {
server.registerTool(
'generate_dockerfile',
{
title: 'Generate Dockerfile',
description:
'Write a stack-aware Dockerfile at the workspace (or package) root based on detected framework (Node multi-stage or PHP/Apache for Laravel/CodeIgniter).',
inputSchema: {
package: packageSchema,
overwrite: z.boolean().optional().default(false),
},
},
async ({ package: packageName, overwrite }) => {
try {
const root = packageName
? resolvePackage(config.workspaceRoot, packageName).pkg.absolutePath
: config.workspaceRoot;
const detected = detectProject(root);
const file = resolveWorkspacePath(root, 'Dockerfile');
if (!overwrite && fs.existsSync(file)) {
return errorResult(`"${file}" already exists. Pass overwrite: true to replace it.`);
}
let contents: string;
let stack: string;
if (
detected.backend?.kind === 'laravel' ||
detected.backend?.kind === 'codeigniter' ||
detected.backend?.kind === 'symfony'
) {
stack = detected.backend.kind;
contents = phpDockerfile(
detected.backend.kind === 'codeigniter' ? 'codeigniter' : 'laravel',
);
} else {
const front = detected.frontend?.kind;
stack =
front === 'react' || front === 'vue' || front === 'nuxt'
? `${front}/node`
: detected.backend?.kind === 'django' || detected.backend?.kind === 'rails'
? detected.backend.kind
: 'node';
contents = nodeDockerfile();
}
fs.writeFileSync(file, contents);
return jsonResult({ file, stack, package: packageName ?? null });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGenerateComposeTool(server: McpServer): void {
server.registerTool(
'generate_compose',
{
title: 'Generate docker-compose.yml',
description:
'Write a docker-compose.yml with app + db + redis presets inferred from the detected stack (Postgres for Node, MySQL for PHP).',
inputSchema: {
package: packageSchema,
overwrite: z.boolean().optional().default(false),
},
},
async ({ package: packageName, overwrite }) => {
try {
const root = packageName
? resolvePackage(config.workspaceRoot, packageName).pkg.absolutePath
: config.workspaceRoot;
const detected = detectProject(root);
const file = resolveWorkspacePath(root, 'docker-compose.yml');
if (!overwrite && fs.existsSync(file)) {
return errorResult(`"${file}" already exists. Pass overwrite: true to replace it.`);
}
const isPhp =
detected.backend?.kind === 'laravel' ||
detected.backend?.kind === 'codeigniter' ||
detected.backend?.kind === 'symfony';
const contents = isPhp ? phpCompose() : nginxNodeCompose();
fs.writeFileSync(file, contents);
return jsonResult({
file,
stack: isPhp
? detected.backend?.kind
: (detected.frontend?.kind ?? detected.backend?.kind ?? 'node'),
services: ['app', 'db', 'redis'],
package: packageName ?? null,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGenerateSailTool(server: McpServer): void {
server.registerTool(
'generate_sail',
{
title: 'Install Laravel Sail',
description:
'Run `php artisan sail:install` in a detected Laravel project. Requires confirm: true because it modifies docker-compose and related files.',
inputSchema: {
with: z
.array(z.string())
.optional()
.describe('Optional Sail services, e.g. ["mysql", "redis", "mailpit"].'),
confirm: z.boolean().optional().describe('Required as true to run sail:install.'),
},
},
async ({ with: services, confirm }) => {
try {
if (confirm !== true) {
return errorResult('generate_sail requires confirm: true.');
}
const laravel = requireLaravel();
const args = ['artisan', 'sail:install', '--no-interaction'];
if (services && services.length > 0) {
args.push(`--with=${services.join(',')}`);
}
const result = await runCommand('php', args, {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({
services: services ?? null,
exitCode: result.exitCode,
failed: result.failed,
output: result.output,
cwd: result.cwd,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerDockerBuildImageTool } from './buildImage.js';
import { registerDockerRunContainerTool } from './runContainer.js';
import { registerDockerListContainersTool } from './listContainers.js';
import { registerDockerContainerLogsTool } from './containerLogs.js';
import { registerDockerExecTool } from './exec.js';
import { registerDockerStopRemoveTools } from './stopRemove.js';
import { registerDockerComposeTools } from './compose.js';
import {
registerGenerateComposeTool,
registerGenerateDockerfileTool,
registerGenerateSailTool,
} from './generate.js';
import { registerDockerPushImageTool } from './pushImage.js';
export function registerDockerTools(server: McpServer): void {
registerDockerBuildImageTool(server);
registerDockerRunContainerTool(server);
registerDockerListContainersTool(server);
registerDockerContainerLogsTool(server);
registerDockerExecTool(server);
registerDockerStopRemoveTools(server);
registerDockerComposeTools(server);
registerGenerateDockerfileTool(server);
registerGenerateComposeTool(server);
registerGenerateSailTool(server);
registerDockerPushImageTool(server);
}
export { dockerManager } from './dockerManager.js';
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { dockerManager } from './dockerManager.js';
export function registerDockerListContainersTool(server: McpServer): void {
server.registerTool(
'docker_list_containers',
{
title: 'Docker List Containers',
description: 'List Docker containers started by this server with their current status and port mappings.',
inputSchema: {
includeDockerStatus: z
.boolean()
.optional()
.default(false)
.describe('Also query the Docker daemon for live status of each tracked container.'),
},
},
async ({ includeDockerStatus }) => {
try {
await dockerManager.ensureReachable();
const tracked = dockerManager.listTracked();
if (!includeDockerStatus) {
return jsonResult({ containers: tracked });
}
const docker = dockerManager.getDocker();
const enriched = await Promise.all(
tracked.map(async (c) => {
try {
const info = await docker.getContainer(c.id).inspect();
return {
...c,
state: info.State?.Status ?? 'unknown',
running: info.State?.Running ?? false,
exitCode: info.State?.ExitCode ?? null,
};
} catch {
return { ...c, state: 'unknown', running: false, exitCode: null };
}
}),
);
return jsonResult({ containers: enriched });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
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 { dockerManager } from './dockerManager.js';
export function registerDockerPushImageTool(server: McpServer): void {
server.registerTool(
'docker_push_image',
{
title: 'Docker Push Image',
description:
'Push a locally built or allow-listed Docker image to a registry (`docker push`). Requires confirm: true. ' +
'Credentials must already be available via the Docker credential store / environment — never pass secrets as tool args.',
inputSchema: {
image: z
.string()
.min(1)
.describe('Image reference to push, e.g. "ghcr.io/org/app:latest" or "my-app:1.0".'),
confirm: z.boolean().optional().describe('Required as true to push an image.'),
},
},
async ({ image, confirm }) => {
try {
if (confirm !== true) {
return errorResult('docker_push_image requires confirm: true.');
}
if (image.includes('--force') || /\s/.test(image)) {
return errorResult('Invalid image reference.');
}
await dockerManager.ensureReachable();
const result = await runCommand('docker', ['push', image], {
cwd: config.workspaceRoot,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({
image,
exitCode: result.exitCode,
failed: result.failed,
timedOut: result.timedOut,
output: result.output,
note: 'Auth must come from `docker login` / credential helpers / env — not from this tool.',
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { dockerManager, isAllowListedImage, isLocallyBuiltImage, validateVolumeMount } from './dockerManager.js';
export function registerDockerRunContainerTool(server: McpServer): void {
server.registerTool(
'docker_run_container',
{
title: 'Docker Run Container',
description:
'Run a Docker container from an image with port mappings, env vars, and workspace-confined volume mounts. ' +
'Locally built images are allowed; non-local images must be on the allow-list unless confirm: true is passed.',
inputSchema: {
image: z.string().min(1).describe('Docker image to run, e.g. "my-app:latest" or "nginx".'),
name: z.string().optional().describe('Optional container name.'),
ports: z
.record(z.number().int().positive())
.optional()
.describe('Port mapping { hostPort: containerPort }.'),
env: z.record(z.string()).optional().describe('Environment variables.'),
volumes: z
.array(z.string())
.optional()
.describe('Volume mounts in "source:target[:mode]" format. Source paths are confined to WORKSPACE_ROOT.'),
command: z.array(z.string()).optional().describe('Override the container command.'),
memory: z.string().optional().describe('Memory limit, e.g. "512m". Defaults to 512m.'),
cpus: z.string().optional().describe('CPU limit, e.g. "1.0". Defaults to 1.0.'),
confirm: z
.boolean()
.optional()
.describe('Required as true to run an image not on the allow-list.'),
},
},
async ({ image, name, ports, env, volumes, command, memory, cpus, confirm }) => {
try {
await dockerManager.ensureReachable();
if (!isLocallyBuiltImage(image) && !isAllowListedImage(image) && confirm !== true) {
return errorResult(
`Image "${image}" is not locally built and not on the allow-list (${config.dockerAllowedBaseImages.join(', ')}). ` +
'Pass confirm: true to run it anyway.',
);
}
const portBindings: Record<string, Array<{ HostPort: string }>> = {};
const exposedPorts: Record<string, {}> = {};
const portMap: Record<number, number> = {};
if (ports) {
for (const [hostPort, containerPort] of Object.entries(ports)) {
const key = `${containerPort}/tcp`;
portBindings[key] = [{ HostPort: hostPort }];
exposedPorts[key] = {};
portMap[Number(hostPort)] = containerPort;
}
}
const binds: string[] = [];
if (volumes) {
for (const spec of volumes) {
const mount = validateVolumeMount(spec);
binds.push(`${mount.source}:${mount.target}:${mount.mode}`);
}
}
const createOptions = {
Image: image,
name,
Cmd: command,
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: {
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
Binds: binds.length > 0 ? binds : undefined,
Memory: parseMemory(memory ?? config.defaultContainerMemory),
NanoCpus: parseCpus(cpus ?? config.defaultContainerCpus),
// Security guardrails:
Privileged: false,
NetworkMode: 'bridge',
PidMode: undefined,
IpcMode: undefined,
},
};
const docker = dockerManager.getDocker();
const container = await docker.createContainer(createOptions);
await container.start();
const info = await container.inspect();
const managed = dockerManager.track(
container.id,
image,
info.Name?.replace(/^\//, '') ?? container.id,
portMap,
);
return jsonResult({ ...managed, shortId: container.id.substring(0, 12) });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
function parseMemory(value: string): number {
const match = value.match(/^([0-9.]+)([kmgt]?b?)$/i);
if (!match || !match[1]) return 512 * 1024 * 1024;
const num = Number.parseFloat(match[1]);
const unit = match[2]?.toLowerCase() ?? '';
switch (unit) {
case 'k':
case 'kb':
return num * 1024;
case 'm':
case 'mb':
return num * 1024 * 1024;
case 'g':
case 'gb':
return num * 1024 * 1024 * 1024;
case 't':
case 'tb':
return num * 1024 * 1024 * 1024 * 1024;
default:
return num;
}
}
function parseCpus(value: string): number {
const num = Number.parseFloat(value);
return Number.isNaN(num) ? 1_000_000_000 : Math.round(num * 1_000_000_000);
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { dockerManager } from './dockerManager.js';
export function registerDockerStopRemoveTools(server: McpServer): void {
server.registerTool(
'docker_stop_container',
{
title: 'Docker Stop Container',
description: 'Stop a Docker container started by this server.',
inputSchema: {
containerId: z.string().min(1).describe('Container ID returned by docker_run_container.'),
timeout: z.number().int().positive().optional().default(10).describe('Seconds to wait before killing.'),
},
},
async ({ containerId, timeout }) => {
try {
await dockerManager.ensureReachable();
await dockerManager.getDocker().getContainer(containerId).stop({ t: timeout });
return jsonResult({ stopped: true, containerId });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
server.registerTool(
'docker_remove_container',
{
title: 'Docker Remove Container',
description: 'Remove a Docker container started by this server.',
inputSchema: {
containerId: z.string().min(1).describe('Container ID returned by docker_run_container.'),
force: z.boolean().optional().default(false).describe('Force removal even if running.'),
},
},
async ({ containerId, force }) => {
try {
await dockerManager.ensureReachable();
await dockerManager.getDocker().getContainer(containerId).remove({ force });
dockerManager.untrack(containerId);
return jsonResult({ removed: true, containerId });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+84
View File
@@ -0,0 +1,84 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { detectProject } from '../../lib/frameworks/detect.js';
import { readPackageJson } from '../../lib/frameworks/packageManager.js';
import { readComposerJson } from '../../lib/frameworks/composer.js';
import { errorResult, jsonResult, targetSchema, toErrorMessage } from '../shared.js';
export function registerGetPackageInfoTool(server: McpServer): void {
server.registerTool(
'get_package_info',
{
title: 'Get Package Info',
description:
'Return installed package metadata from package.json (React/Node projects) or composer.json (PHP projects): version, scripts, and key dependencies.',
inputSchema: {
target: targetSchema,
ecosystem: z
.enum(['npm', 'composer', 'auto'])
.optional()
.default('auto')
.describe('Which manifest to read. Auto detects from the resolved project.'),
},
},
async ({ target, ecosystem }) => {
try {
const detected = detectProject(config.workspaceRoot);
const adapter =
target === 'frontend'
? detected.frontend
: target === 'backend'
? detected.backend
: (detected.frontend ?? detected.backend ?? detected.primary);
if (!adapter) return errorResult('No project detected in WORKSPACE_ROOT.');
const resolvedEcosystem = ecosystem === 'auto' ? (adapter.packageManager === 'composer' ? 'composer' : 'npm') : ecosystem;
const root = adapter.root;
if (resolvedEcosystem === 'composer') {
const composerJson = readComposerJson(root);
if (!composerJson) return errorResult(`No composer.json found at ${root}`);
const lockPath = path.join(root, 'composer.lock');
let installed: Record<string, string> = {};
if (fs.existsSync(lockPath)) {
try {
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as {
packages?: Array<{ name: string; version: string }>;
};
installed = Object.fromEntries(
(lock.packages ?? []).slice(0, 50).map((p) => [p.name, p.version]),
);
} catch {
// ignore lock parse errors
}
}
return jsonResult({
ecosystem: 'composer',
root,
name: composerJson.name ?? null,
require: composerJson.require ?? {},
requireDev: composerJson['require-dev'] ?? {},
scripts: composerJson.scripts ?? {},
installedSample: installed,
});
}
const packageJson = readPackageJson(root);
if (!packageJson) return errorResult(`No package.json found at ${root}`);
return jsonResult({
ecosystem: 'npm',
root,
name: packageJson.name ?? null,
scripts: packageJson.scripts ?? {},
dependencies: packageJson.dependencies ?? {},
devDependencies: packageJson.devDependencies ?? {},
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerSearchPackageDocsTool } from './searchPackageDocs.js';
import { registerSearchMdnTool } from './searchMdn.js';
import { registerSearchLaravelDocsTool } from './searchLaravelDocs.js';
import { registerSearchCodeIgniterDocsTool } from './searchCodeIgniterDocs.js';
import { registerGetPackageInfoTool } from './getPackageInfo.js';
export function registerDocsTools(server: McpServer): void {
registerSearchPackageDocsTool(server);
registerSearchMdnTool(server);
registerSearchLaravelDocsTool(server);
registerSearchCodeIgniterDocsTool(server);
registerGetPackageInfoTool(server);
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { detectProject } from '../../lib/frameworks/detect.js';
import { readComposerJson } from '../../lib/frameworks/composer.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
function extractMajorMinor(versionConstraint: string | undefined): string {
if (!versionConstraint) return '4';
const match = versionConstraint.match(/(\d+)\.(\d+)/);
if (!match) return '4';
const [, major] = match;
return `${major}`;
}
export function registerSearchCodeIgniterDocsTool(server: McpServer): void {
server.registerTool(
'search_codeigniter_docs',
{
title: 'Search CodeIgniter Docs',
description:
'Return a deep link to the CodeIgniter 4 user guide for a topic, version-matched to the codeigniter4/framework constraint in composer.json.',
inputSchema: {
topic: z.string().min(1).describe('Documentation topic, e.g. "models" or "database".'),
version: z.string().optional().describe('Override the major version, e.g. "4". Auto-detected by default.'),
},
},
async ({ topic, version }) => {
try {
const detected = detectProject(config.workspaceRoot);
const ciRoot = detected.backend?.kind === 'codeigniter' ? detected.backend.root : config.workspaceRoot;
const composerJson = readComposerJson(ciRoot);
const constraint = composerJson?.require?.['codeigniter4/framework'];
const docsVersion = version ?? extractMajorMinor(constraint);
const slug = topic.toLowerCase().replace(/\s+/g, '_');
const url = `https://codeigniter4.github.io/userguide/${docsVersion}/${slug}.html`;
return jsonResult({
topic,
version: docsVersion,
composerConstraint: constraint ?? null,
url,
note: 'This is a best-effort documentation link. If the topic page does not exist, the user guide index may help.',
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { detectProject } from '../../lib/frameworks/detect.js';
import { readComposerJson } from '../../lib/frameworks/composer.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
function extractMajorMinor(versionConstraint: string | undefined): string {
if (!versionConstraint) return '11.x';
const match = versionConstraint.match(/(\d+)\.(\d+)/);
if (!match) return '11.x';
const [, major, minor] = match;
return `${major}.${minor}`;
}
export function registerSearchLaravelDocsTool(server: McpServer): void {
server.registerTool(
'search_laravel_docs',
{
title: 'Search Laravel Docs',
description:
'Return a deep link to the Laravel documentation for a topic, version-matched to the laravel/framework constraint in composer.json.',
inputSchema: {
topic: z.string().min(1).describe('Documentation topic, e.g. "eloquent" or "validation".'),
version: z.string().optional().describe('Override the version segment, e.g. "11.x". Auto-detected by default.'),
},
},
async ({ topic, version }) => {
try {
const detected = detectProject(config.workspaceRoot);
const laravelRoot = detected.backend?.kind === 'laravel' ? detected.backend.root : config.workspaceRoot;
const composerJson = readComposerJson(laravelRoot);
const constraint = composerJson?.require?.['laravel/framework'];
const docsVersion = version ?? extractMajorMinor(constraint) + '.x';
const slug = topic.toLowerCase().replace(/\s+/g, '-');
const url = `https://laravel.com/docs/${docsVersion}/${slug}`;
return jsonResult({
topic,
version: docsVersion,
composerConstraint: constraint ?? null,
url,
note: 'This is a best-effort documentation link. If the topic page does not exist, MDN/search engines may help refine the query.',
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
interface MdnSearchResult {
documents?: Array<{
mdn_url: string;
title: string;
summary: string;
locale: string;
}>;
}
export function registerSearchMdnTool(server: McpServer): void {
server.registerTool(
'search_mdn',
{
title: 'Search MDN',
description: 'Search MDN for a web platform API, CSS property, or HTML element and return the top results.',
inputSchema: {
query: z.string().min(1).describe('Search query, e.g. "Array.prototype.map" or "fetch API".'),
limit: z.number().int().positive().optional().default(5).describe('Maximum number of results to return.'),
},
},
async ({ query, limit }) => {
try {
const url = `https://developer.mozilla.org/api/v1/search?q=${encodeURIComponent(query)}&locale=en-US`;
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`MDN search returned HTTP ${response.status}`);
}
const data = (await response.json()) as MdnSearchResult;
const results =
data.documents?.slice(0, limit).map((doc) => ({
title: doc.title,
url: `https://developer.mozilla.org${doc.mdn_url}`,
summary: doc.summary,
})) ?? [];
return jsonResult({ query, results });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+112
View File
@@ -0,0 +1,112 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, textResult, toErrorMessage } from '../shared.js';
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}`);
}
return response.json();
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${url}`);
}
return response.text();
}
interface NpmPackageInfo {
'dist-tags': { latest: string };
versions: Record<string, { repository?: { url?: string }; homepage?: string; description?: string }>;
readme?: string;
}
async function fetchNpmDocs(name: string, version?: string): Promise<Record<string, unknown>> {
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
const info = (await fetchJson(registryUrl)) as NpmPackageInfo;
const resolvedVersion = version ?? info['dist-tags'].latest;
const versionData = info.versions[resolvedVersion];
let readme: string | null = null;
try {
readme = await fetchText(`https://unpkg.com/${name}@${resolvedVersion}/README.md`);
} catch {
try {
readme = await fetchText(`https://unpkg.com/${name}@${resolvedVersion}/readme.md`);
} catch {
readme = info.readme ?? null;
}
}
return {
ecosystem: 'npm',
package: name,
version: resolvedVersion,
description: versionData?.description ?? null,
homepage: versionData?.homepage ?? null,
repository: versionData?.repository?.url ?? null,
readme,
readmeSource: readme ? `https://unpkg.com/${name}@${resolvedVersion}/README.md` : null,
};
}
interface PackagistPackageInfo {
package: {
name: string;
description?: string;
repository?: string;
versions?: Record<string, { source?: { url?: string }; homepage?: string }>;
};
}
async function fetchPackagistDocs(name: string, version?: string): Promise<Record<string, unknown>> {
const url = `https://packagist.org/packages/${encodeURIComponent(name)}.json`;
const info = (await fetchJson(url)) as PackagistPackageInfo;
const pkg = info.package;
const versions = Object.keys(pkg.versions ?? {}).sort();
const resolvedVersion = version ?? versions[versions.length - 1];
return {
ecosystem: 'composer',
package: pkg.name,
version: resolvedVersion,
description: pkg.description ?? null,
repository: pkg.repository ?? null,
versions: versions.slice(-20),
note: 'README fetching for Packagist packages is not yet implemented; use the repository URL to read the README.',
};
}
export function registerSearchPackageDocsTool(server: McpServer): void {
server.registerTool(
'search_package_docs',
{
title: 'Search Package Docs',
description:
'Fetch README/docs for an npm or Composer/Packagist package. For npm packages, resolves the version and fetches the README from unpkg. For Composer packages, returns Packagist metadata including the repository URL.',
inputSchema: {
name: z.string().min(1).describe('Package name, e.g. "axios" or "laravel/framework".'),
ecosystem: z
.enum(['npm', 'composer', 'auto'])
.optional()
.default('auto')
.describe('Which package ecosystem to search. Auto detects from the name ("laravel/framework" → composer).'),
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;
if (resolvedEcosystem === 'composer') {
return jsonResult(await fetchPackagistDocs(name, version));
}
return jsonResult(await fetchNpmDocs(name, version));
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { summarizeNumstat } from '../gitTools.js';
describe('git_diff_summarize helpers', () => {
it('parses numstat lines including binary files', () => {
const files = summarizeNumstat(
['10\t2\tsrc/a.ts', '3\t0\tREADME.md', '-\t-\tlogo.png', ''].join('\n'),
);
expect(files).toHaveLength(3);
expect(files[0]).toEqual({ path: 'src/a.ts', insertions: 10, deletions: 2, binary: false });
expect(files[2]).toMatchObject({ path: 'logo.png', insertions: 0, deletions: 0, binary: true });
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { createInMemoryClient } from '../../../test/mcpServer.js';
function firstText(result: unknown): string {
const r = result as { content?: Array<{ text?: string }> } | undefined;
return r?.content?.[0]?.text ?? '';
}
vi.mock('../../../lib/runCommand.js', () => ({
runCommand: vi.fn(async (command: string, args: string[]) => ({
command,
args,
cwd: '/tmp',
exitCode: 0,
timedOut: false,
failed: false,
output: 'ok',
})),
}));
describe('git tool confirm gates', () => {
it('rejects git_commit without confirm: true', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'git_commit',
arguments: { message: 'test', paths: ['.'] },
});
expect(firstText(result)).toContain('confirm: true');
});
it('rejects git_branch create without confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'git_branch',
arguments: { action: 'create', name: 'feature/x' },
});
expect(firstText(result)).toContain('confirm: true');
});
});
+284
View File
@@ -0,0 +1,284 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { runCommand } from '../../lib/runCommand.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
async function git(args: string[]) {
return runCommand('git', args, {
cwd: config.workspaceRoot,
timeoutMs: config.defaultCommandTimeoutMs,
});
}
export function registerGitStatusTool(server: McpServer): void {
server.registerTool(
'git_status',
{
title: 'Git Status',
description:
'Return the current branch, ahead/behind counts vs upstream (when available), and porcelain status lines. Read-only.',
inputSchema: {},
},
async () => {
try {
const branchResult = await git(['rev-parse', '--abbrev-ref', 'HEAD']);
const porcelain = await git(['status', '--porcelain=v1']);
const aheadBehind = await git(['rev-list', '--left-right', '--count', '@{upstream}...HEAD']);
let ahead = null as number | null;
let behind = null as number | null;
if (!aheadBehind.failed && aheadBehind.output.trim()) {
const parts = aheadBehind.output.trim().split(/\s+/);
behind = Number.parseInt(parts[0] ?? '0', 10);
ahead = Number.parseInt(parts[1] ?? '0', 10);
}
return jsonResult({
branch: branchResult.output.trim() || null,
ahead,
behind,
porcelain: porcelain.output
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean),
raw: porcelain.output,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGitDiffTool(server: McpServer): void {
server.registerTool(
'git_diff',
{
title: 'Git Diff',
description:
'Show staged and/or unstaged diffs, optionally scoped to a workspace-relative path. Read-only; output is truncated.',
inputSchema: {
staged: z.boolean().optional().describe('If true, show only staged changes (`git diff --cached`).'),
path: z.string().optional().describe('Optional workspace-relative file or directory to diff.'),
},
},
async ({ staged, path: filePath }) => {
try {
const args = ['diff'];
if (staged) args.push('--cached');
if (filePath) {
const absolute = resolveWorkspacePath(config.workspaceRoot, filePath);
args.push('--', absolute);
}
const result = await git(args);
return jsonResult({
staged: Boolean(staged),
path: filePath ?? null,
exitCode: result.exitCode,
failed: result.failed,
output: result.output,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGitDiffSummarizeTool(server: McpServer): void {
server.registerTool(
'git_diff_summarize',
{
title: 'Git Diff Summarize',
description:
'Summarize staged and/or unstaged changes: per-file insertions/deletions plus totals. Read-only; uses `git diff --numstat` (no full patch).',
inputSchema: {
staged: z.boolean().optional().describe('If true, summarize only staged changes (`git diff --cached --numstat`).'),
path: z.string().optional().describe('Optional workspace-relative file or directory to summarize.'),
},
},
async ({ staged, path: filePath }) => {
try {
const args = ['diff', '--numstat'];
if (staged) args.push('--cached');
if (filePath) {
const absolute = resolveWorkspacePath(config.workspaceRoot, filePath);
args.push('--', absolute);
}
const result = await git(args);
const files = summarizeNumstat(result.output);
const insertions = files.reduce((sum, f) => sum + f.insertions, 0);
const deletions = files.reduce((sum, f) => sum + f.deletions, 0);
return jsonResult({
staged: Boolean(staged),
path: filePath ?? null,
fileCount: files.length,
insertions,
deletions,
files,
exitCode: result.exitCode,
failed: result.failed,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
/** Parse `git diff --numstat` lines into structured file stats. Exported for tests. */
export function summarizeNumstat(output: string): Array<{ path: string; insertions: number; deletions: number; binary: boolean }> {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [insRaw, delRaw, ...pathParts] = line.split(/\t/);
const filePath = pathParts.join('\t') || '';
const binary = insRaw === '-' || delRaw === '-';
return {
path: filePath,
insertions: binary ? 0 : Number.parseInt(insRaw ?? '0', 10) || 0,
deletions: binary ? 0 : Number.parseInt(delRaw ?? '0', 10) || 0,
binary,
};
})
.filter((row) => row.path.length > 0);
}
export function registerGitLogTool(server: McpServer): void {
server.registerTool(
'git_log',
{
title: 'Git Log',
description: 'Show recent commits (hash, author, date, subject). Read-only.',
inputSchema: {
n: z.number().int().min(1).max(100).optional().default(10).describe('Number of commits to return.'),
path: z.string().optional().describe('Optional workspace-relative path to limit history.'),
},
},
async ({ n, path: filePath }) => {
try {
const args = ['log', `-n`, String(n ?? 10), '--pretty=format:%H%x09%an%x09%ad%x09%s', '--date=iso'];
if (filePath) {
const absolute = resolveWorkspacePath(config.workspaceRoot, filePath);
args.push('--', absolute);
}
const result = await git(args);
const commits = result.output
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const [hash, author, date, ...subjectParts] = line.split('\t');
return {
hash: hash ?? '',
author: author ?? '',
date: date ?? '',
subject: subjectParts.join('\t'),
};
});
return jsonResult({ n: n ?? 10, path: filePath ?? null, commits });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGitBranchTool(server: McpServer): void {
server.registerTool(
'git_branch',
{
title: 'Git Branch',
description:
'List local branches, or create a new branch. Creating a branch requires confirm: true. Never force-deletes or force-pushes.',
inputSchema: {
action: z.enum(['list', 'create']).default('list').describe('list (default) or create a branch.'),
name: z.string().optional().describe('Branch name (required when action is create).'),
confirm: z.boolean().optional().describe('Required as true when creating a branch.'),
},
},
async ({ action, name, confirm }) => {
try {
if (action === 'list') {
const result = await git(['branch', '--list', '--format=%(refname:short)%09%(HEAD)']);
const branches = result.output
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
const [branchName, head] = line.split('\t');
return { name: branchName ?? '', current: head === '*' };
});
return jsonResult({ action: 'list', branches });
}
if (!name || !name.trim()) {
return errorResult('Branch name is required when action is "create".');
}
if (confirm !== true) {
return errorResult('Creating a branch requires confirm: true.');
}
if (!/^[A-Za-z0-9._/-]+$/.test(name) || name.includes('..')) {
return errorResult(`Invalid branch name "${name}".`);
}
const result = await git(['branch', name]);
return jsonResult({
action: 'create',
name,
exitCode: result.exitCode,
failed: result.failed,
output: result.output,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGitCommitTool(server: McpServer): void {
server.registerTool(
'git_commit',
{
title: 'Git Commit',
description:
'Stage workspace-relative paths (or all changes with paths: ["."]) and create a commit. Requires confirm: true. Does not amend, force, or push.',
inputSchema: {
message: z.string().min(1).describe('Commit message.'),
paths: z
.array(z.string())
.min(1)
.describe('Workspace-relative paths to stage before committing. Use ["."] for all changes.'),
confirm: z.boolean().optional().describe('Must be true to create a commit.'),
},
},
async ({ message, paths, confirm }) => {
try {
if (confirm !== true) {
return errorResult('git_commit requires confirm: true.');
}
const absolutePaths = paths.map((p) => resolveWorkspacePath(config.workspaceRoot, p));
const addResult = await git(['add', '--', ...absolutePaths]);
if (addResult.failed) {
return errorResult(`git add failed:\n${addResult.output}`);
}
const commitResult = await git(['commit', '-m', message]);
return jsonResult({
message,
paths,
staged: absolutePaths,
exitCode: commitResult.exitCode,
failed: commitResult.failed,
output: commitResult.output,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
registerGitBranchTool,
registerGitCommitTool,
registerGitDiffSummarizeTool,
registerGitDiffTool,
registerGitLogTool,
registerGitStatusTool,
} from './gitTools.js';
export function registerGitTools(server: McpServer): void {
registerGitStatusTool(server);
registerGitDiffTool(server);
registerGitDiffSummarizeTool(server);
registerGitLogTool(server);
registerGitBranchTool(server);
registerGitCommitTool(server);
}
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { createInMemoryClient } from '../../../test/mcpServer.js';
function firstText(result: unknown): string {
const r = result as { content?: Array<{ text?: string }> } | undefined;
return r?.content?.[0]?.text ?? '';
}
vi.mock('../requireLaravel.js', () => ({
requireLaravel: () => ({ root: '/tmp/laravel', adapter: 'laravel' }),
}));
vi.mock('../../../lib/runCommand.js', () => ({
runCommand: vi.fn(async () => ({
command: 'php',
args: ['artisan', 'migrate:fresh', '--seed'],
cwd: '/tmp/laravel',
exitCode: 0,
timedOut: false,
failed: false,
output: 'success',
})),
}));
describe('artisan tool confirm enforcement', () => {
it('rejects destructive artisan commands without confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'artisan',
arguments: { command: 'migrate:fresh', args: ['--seed'] },
});
const text = firstText(result);
expect(text).toContain('destructive');
expect(text).toContain('confirm: true');
});
it('allows destructive artisan commands with confirm', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'artisan',
arguments: { command: 'migrate:fresh', args: ['--seed'], confirm: true },
});
const text = firstText(result);
expect(text).toContain('success');
});
it('rejects commands not on the allow-list', async () => {
const client = await createInMemoryClient();
const result = await client.callTool({
name: 'artisan',
arguments: { command: 'tinker' },
});
const text = firstText(result);
expect(text).toContain('not on the allow-list');
});
});
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { isAllowedArtisanCommand, ARTISAN_DESTRUCTIVE, ARTISAN_BACKGROUND } from '../artisanAllowList.js';
describe('artisan allow-list', () => {
it('allows common scaffolding and maintenance commands', () => {
expect(isAllowedArtisanCommand('make:controller')).toBe(true);
expect(isAllowedArtisanCommand('migrate')).toBe(true);
expect(isAllowedArtisanCommand('route:list')).toBe(true);
expect(isAllowedArtisanCommand('cache:clear')).toBe(true);
});
it('rejects arbitrary commands', () => {
expect(isAllowedArtisanCommand('db:wipe')).toBe(true); // allowed but destructive
expect(isAllowedArtisanCommand('tinker')).toBe(false);
expect(isAllowedArtisanCommand('eval')).toBe(false);
expect(isAllowedArtisanCommand('shell')).toBe(false);
});
it('marks the right commands as destructive', () => {
expect(ARTISAN_DESTRUCTIVE.has('migrate:fresh')).toBe(true);
expect(ARTISAN_DESTRUCTIVE.has('migrate:refresh')).toBe(true);
expect(ARTISAN_DESTRUCTIVE.has('migrate:reset')).toBe(true);
expect(ARTISAN_DESTRUCTIVE.has('db:wipe')).toBe(true);
expect(ARTISAN_DESTRUCTIVE.has('make:controller')).toBe(false);
});
it('marks queue commands as background', () => {
expect(ARTISAN_BACKGROUND.has('queue:work')).toBe(true);
expect(ARTISAN_BACKGROUND.has('queue:listen')).toBe(true);
expect(ARTISAN_BACKGROUND.has('migrate')).toBe(false);
});
});
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { isAllowedArtisanCommand, ARTISAN_BACKGROUND, ARTISAN_DESTRUCTIVE } from '../artisanAllowList.js';
describe('artisan allow-list v1.2 expansions', () => {
it('allows queue batches/clear and reverb:start', () => {
expect(isAllowedArtisanCommand('queue:batches')).toBe(true);
expect(isAllowedArtisanCommand('queue:clear')).toBe(true);
expect(isAllowedArtisanCommand('reverb:start')).toBe(true);
});
it('treats queue:clear as destructive and reverb:start as background', () => {
expect(ARTISAN_DESTRUCTIVE.has('queue:clear')).toBe(true);
expect(ARTISAN_BACKGROUND.has('reverb:start')).toBe(true);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { processManager } from '../../lib/processManager.js';
import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import {
ARTISAN_ALLOWED,
ARTISAN_BACKGROUND,
ARTISAN_DESTRUCTIVE,
isAllowedArtisanCommand,
} from './artisanAllowList.js';
import { requireLaravel } from './requireLaravel.js';
export function registerArtisanTool(server: McpServer): void {
server.registerTool(
'artisan',
{
title: 'Run Artisan Command',
description:
'Run a whitelisted `php artisan` subcommand in the detected Laravel project and return stdout/stderr. ' +
`Allowed commands: ${ARTISAN_ALLOWED.join(', ')}. ` +
'Destructive commands (migrate:fresh, migrate:refresh, migrate:reset, db:wipe) require confirm: true. ' +
'Long-running commands (queue:work, queue:listen) are started as tracked background processes.',
inputSchema: {
command: z
.string()
.min(1)
.describe('Artisan subcommand, e.g. "make:controller" or "migrate".'),
args: z
.array(z.string())
.optional()
.default([])
.describe('Arguments forwarded after the subcommand, e.g. ["PostController", "--api"].'),
confirm: z
.boolean()
.optional()
.describe('Required as true for destructive commands (migrate:fresh/refresh/reset, db:wipe).'),
},
},
async ({ command, args, confirm }) => {
try {
if (!isAllowedArtisanCommand(command)) {
return errorResult(
`Artisan command "${command}" is not on the allow-list. Allowed: ${ARTISAN_ALLOWED.join(', ')}.`,
);
}
if (ARTISAN_DESTRUCTIVE.has(command) && confirm !== true) {
return errorResult(
`Artisan command "${command}" is destructive and requires confirm: true.`,
);
}
const laravel = requireLaravel();
const phpArgs = ['artisan', command, ...(args ?? [])];
if (ARTISAN_BACKGROUND.has(command)) {
const info = processManager.start({
label: `artisan:${command}`,
command: 'php',
args: phpArgs,
cwd: laravel.root,
});
return jsonResult({
background: true,
processId: info.id,
artisanCommand: command,
artisanArgs: args ?? [],
pid: info.pid,
status: info.status,
startedAt: info.startedAt,
cwd: info.cwd,
hint: 'Use get_process_logs / stop_process to manage this background artisan process.',
});
}
const result = await runCommand('php', phpArgs, {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({
background: false,
artisanCommand: command,
artisanArgs: args ?? [],
exitCode: result.exitCode,
timedOut: result.timedOut,
failed: result.failed,
output: result.output,
cwd: result.cwd,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Artisan subcommands the `artisan` tool is willing to run.
* Anything not on this list is rejected — the agent must not pass arbitrary
* artisan commands (e.g. `tinker` interactive, `db:wipe` without confirm, etc.).
*
* Destructive commands additionally require `confirm: true` in the tool call.
*/
export const ARTISAN_ALLOWED = [
'make:controller',
'make:model',
'make:migration',
'make:seeder',
'make:factory',
'make:request',
'make:resource',
'make:middleware',
'make:job',
'make:event',
'make:listener',
'make:mail',
'make:notification',
'make:policy',
'make:command',
'make:test',
'migrate',
'migrate:rollback',
'migrate:status',
'migrate:fresh',
'migrate:refresh',
'migrate:reset',
'db:seed',
'db:wipe',
'route:list',
'config:clear',
'config:cache',
'cache:clear',
'view:clear',
'route:clear',
'optimize:clear',
'queue:work',
'queue:listen',
'queue:failed',
'queue:retry',
'queue:batches',
'queue:clear',
'reverb:start',
'storage:link',
'key:generate',
'about',
'list',
] as const;
export type ArtisanCommand = (typeof ARTISAN_ALLOWED)[number];
/** Commands that mutate/destroy data and therefore require confirm: true. */
export const ARTISAN_DESTRUCTIVE = new Set<string>([
'migrate:fresh',
'migrate:refresh',
'migrate:reset',
'db:wipe',
'queue:clear',
]);
/** Long-running commands tracked via processManager instead of runCommand. */
export const ARTISAN_BACKGROUND = new Set<string>(['queue:work', 'queue:listen', 'reverb:start']);
export function isAllowedArtisanCommand(command: string): command is ArtisanCommand {
return (ARTISAN_ALLOWED as readonly string[]).includes(command);
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod';
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 { requirePhpBackend } from './requireLaravel.js';
/**
* Composer helpers shared by Laravel and CodeIgniter (both are Composer-based).
* Mirrors `add_dependency` but is explicitly for PHP packages.
*/
export function registerComposerTools(server: McpServer): void {
server.registerTool(
'composer_require',
{
title: 'Composer Require',
description:
'Add a Composer dependency to the detected PHP backend (Laravel or CodeIgniter). Equivalent to `composer require`.',
inputSchema: {
packages: z.array(z.string().min(1)).min(1).describe('Packagist package names, e.g. ["laravel/sanctum"].'),
dev: z.boolean().optional().default(false).describe('Install into require-dev (--dev).'),
},
},
async ({ packages, dev }) => {
try {
const backend = requirePhpBackend();
const args = ['require', ...(dev ? ['--dev'] : []), ...packages, '--no-interaction'];
const result = await runCommand('composer', args, {
cwd: backend.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({ adapter: backend.label, packages, dev, ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
server.registerTool(
'composer_remove',
{
title: 'Composer Remove',
description:
'Remove a Composer dependency from the detected PHP backend (Laravel or CodeIgniter). Requires confirm: true.',
inputSchema: {
packages: z.array(z.string().min(1)).min(1).describe('Packagist package names to remove.'),
confirm: z
.literal(true)
.describe('Must be exactly true; removing dependencies is a deliberate opt-in.'),
},
},
async ({ packages, confirm }) => {
if (!confirm) return errorResult('confirm: true is required to run composer_remove.');
try {
const backend = requirePhpBackend();
const result = await runCommand('composer', ['remove', ...packages, '--no-interaction'], {
cwd: backend.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({ adapter: backend.label, packages, ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
@@ -0,0 +1,98 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { hasComposerDependency, readComposerJson } from '../../lib/frameworks/composer.js';
import { runCommand } from '../../lib/runCommand.js';
import { pascalCase } from '../../lib/strings.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireLaravel } from './requireLaravel.js';
export function registerGenerateFilamentResourceTool(server: McpServer): void {
server.registerTool(
'generate_filament_resource',
{
title: 'Generate Filament Resource',
description:
'Generate a Filament admin resource via `php artisan make:filament-resource`. Requires filament/filament to be installed.',
inputSchema: {
name: z.string().min(1).describe('Resource/model name, e.g. "Post".'),
generate: z
.boolean()
.optional()
.default(true)
.describe('Pass --generate to Filament when true (default).'),
},
},
async ({ name, generate }) => {
try {
const laravel = requireLaravel();
const composer = readComposerJson(laravel.root);
if (!hasComposerDependency(composer, 'filament/filament')) {
return errorResult(
'filament/filament is not installed. Run composer_require with package "filament/filament" first ' +
'(or `composer require filament/filament`).',
);
}
const resource = pascalCase(name);
const args = ['artisan', 'make:filament-resource', resource, '--no-interaction'];
if (generate !== false) args.push('--generate');
const result = await runCommand('php', args, {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({
package: 'filament/filament',
resource,
exitCode: result.exitCode,
failed: result.failed,
output: result.output,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGenerateNovaResourceTool(server: McpServer): void {
server.registerTool(
'generate_nova_resource',
{
title: 'Generate Nova Resource',
description:
'Generate a Laravel Nova resource via `php artisan nova:resource`. Requires laravel/nova to be installed.',
inputSchema: {
name: z.string().min(1).describe('Resource name, e.g. "Post".'),
},
},
async ({ name }) => {
try {
const laravel = requireLaravel();
const composer = readComposerJson(laravel.root);
if (!hasComposerDependency(composer, 'laravel/nova')) {
return errorResult(
'laravel/nova is not installed. Install Nova (license required) then re-run this tool. ' +
'See https://nova.laravel.com/docs for installation.',
);
}
const resource = pascalCase(name);
const result = await runCommand('php', ['artisan', 'nova:resource', resource, '--no-interaction'], {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({
package: 'laravel/nova',
resource,
exitCode: result.exitCode,
failed: result.failed,
output: result.output,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
@@ -0,0 +1,143 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { camelCase, pascalCase } from '../../lib/strings.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireLaravel } from './requireLaravel.js';
const RELATION_TYPES = [
'belongsTo',
'hasOne',
'hasMany',
'belongsToMany',
'hasManyThrough',
'morphTo',
'morphMany',
'morphOne',
] as const;
function findModelFile(root: string, model: string): string | null {
const candidates = [
path.join(root, 'app', 'Models', `${model}.php`),
path.join(root, 'app', `${model}.php`),
];
return candidates.find((file) => fs.existsSync(file)) ?? null;
}
function relationMethodSource(
methodName: string,
relation: (typeof RELATION_TYPES)[number],
relatedModel: string,
): string {
const related = `\\App\\Models\\${relatedModel}::class`;
return `
public function ${methodName}()
{
return $this->${relation}(${related});
}
`;
}
function insertMethodBeforeClosingBrace(source: string, method: string): string {
const trimmed = method.trimEnd() + '\n';
if (source.includes(`function ${trimmed.match(/function\s+(\w+)/)?.[1] ?? ''}(`)) {
return source;
}
const lastBrace = source.lastIndexOf('}');
if (lastBrace === -1) {
throw new Error('Could not find closing class brace in model file.');
}
return `${source.slice(0, lastBrace)}${trimmed}${source.slice(lastBrace)}`;
}
export function registerGenerateEloquentRelationTool(server: McpServer): void {
server.registerTool(
'generate_eloquent_relation',
{
title: 'Generate Eloquent Relation',
description:
'Add an Eloquent relationship method to a model (belongsTo, hasMany, etc.), optionally also adding the inverse relation on the related model.',
inputSchema: {
model: z.string().min(1).describe('Model class name, e.g. "Post".'),
relation: z.enum(RELATION_TYPES).describe('Eloquent relation type.'),
related: z.string().min(1).describe('Related model class name, e.g. "User".'),
method: z.string().optional().describe('Method name on the source model. Defaults from relation type.'),
inverse: z
.boolean()
.optional()
.default(false)
.describe('If true, also add a sensible inverse method on the related model.'),
},
},
async ({ model, relation, related, method, inverse }) => {
try {
const laravel = requireLaravel();
const modelName = pascalCase(model);
const relatedName = pascalCase(related);
const modelFile = findModelFile(laravel.root, modelName);
if (!modelFile) {
return errorResult(`Model file not found for "${modelName}" under app/Models or app/.`);
}
const defaultMethod =
relation === 'belongsTo' || relation === 'hasOne' || relation === 'morphTo' || relation === 'morphOne'
? camelCase(relatedName)
: `${camelCase(relatedName)}s`;
const methodName = method?.trim() || defaultMethod;
const before = fs.readFileSync(modelFile, 'utf8');
const next = insertMethodBeforeClosingBrace(before, relationMethodSource(methodName, relation, relatedName));
fs.writeFileSync(modelFile, next);
const updated: string[] = [modelFile];
let inverseMethod: string | null = null;
let inverseFile: string | null = null;
if (inverse) {
const inverseMap: Partial<Record<(typeof RELATION_TYPES)[number], (typeof RELATION_TYPES)[number]>> = {
belongsTo: 'hasMany',
hasMany: 'belongsTo',
hasOne: 'belongsTo',
belongsToMany: 'belongsToMany',
morphMany: 'morphTo',
morphOne: 'morphTo',
};
const inverseRelation = inverseMap[relation];
if (inverseRelation) {
inverseFile = findModelFile(laravel.root, relatedName);
if (!inverseFile) {
return errorResult(
`Added ${methodName}() on ${modelName}, but related model "${relatedName}" was not found for the inverse.`,
);
}
inverseMethod =
inverseRelation === 'belongsTo' || inverseRelation === 'hasOne' || inverseRelation === 'morphTo'
? camelCase(modelName)
: `${camelCase(modelName)}s`;
const relatedSource = fs.readFileSync(inverseFile, 'utf8');
fs.writeFileSync(
inverseFile,
insertMethodBeforeClosingBrace(
relatedSource,
relationMethodSource(inverseMethod, inverseRelation, modelName),
),
);
updated.push(inverseFile);
}
}
return jsonResult({
model: modelName,
related: relatedName,
relation,
method: methodName,
inverseMethod,
files: updated,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
@@ -0,0 +1,128 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { runCommand } from '../../lib/runCommand.js';
import { kebabCase, pascalCase } from '../../lib/strings.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireLaravel } from './requireLaravel.js';
function pluralize(word: string): string {
const lower = word.toLowerCase();
if (lower.endsWith('ies')) return word;
if (lower.endsWith('y') && !/[aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
if (/(s|x|z|ch|sh)$/i.test(word)) return `${word}es`;
if (lower.endsWith('s')) return word;
return `${word}s`;
}
export function registerGenerateLaravelResourceTool(server: McpServer): void {
server.registerTool(
'generate_laravel_resource',
{
title: 'Generate Laravel Resource',
description:
'One-shot scaffold of a full Laravel resource: model + migration + factory + seeder + controller, ' +
'then wire an apiResource/resource route. Wraps the relevant `php artisan make:*` calls.',
inputSchema: {
name: z
.string()
.min(1)
.describe('Resource name, e.g. "Post" or "blog_post". Converted to PascalCase for the model/controller.'),
api: z
.boolean()
.optional()
.default(true)
.describe('If true (default), generate an API controller (--api) and register Route::apiResource in routes/api.php. If false, generate a web resource controller and register Route::resource in routes/web.php.'),
migrate: z
.boolean()
.optional()
.default(false)
.describe('If true, run `php artisan migrate` after scaffolding. Requires a configured database.'),
},
},
async ({ name, api, migrate }) => {
try {
const laravel = requireLaravel();
const model = pascalCase(name);
const controller = `${model}Controller`;
const routeName = kebabCase(pluralize(model));
const steps: Array<Record<string, unknown>> = [];
// Model + migration + factory + seeder in one go.
const modelResult = await runCommand(
'php',
['artisan', 'make:model', model, '-mfs', '--no-interaction'],
{ cwd: laravel.root, timeoutMs: config.scaffoldCommandTimeoutMs },
);
steps.push({ step: 'make:model -mfs', ...modelResult });
const controllerArgs = api
? ['artisan', 'make:controller', controller, '--api', '--model=' + model, '--no-interaction']
: ['artisan', 'make:controller', controller, '--resource', '--model=' + model, '--no-interaction'];
const controllerResult = await runCommand('php', controllerArgs, {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({ step: 'make:controller', ...controllerResult });
const routesFile = path.join(laravel.root, 'routes', api ? 'api.php' : 'web.php');
const routeLine = api
? `Route::apiResource('${routeName}', \\App\\Http\\Controllers\\${controller}::class);`
: `Route::resource('${routeName}', \\App\\Http\\Controllers\\${controller}::class);`;
const routeWired = wireRoute(routesFile, routeLine, api);
steps.push({ step: 'wire-route', routesFile, routeLine, wired: routeWired });
let migrateResult: Awaited<ReturnType<typeof runCommand>> | null = null;
if (migrate) {
migrateResult = await runCommand('php', ['artisan', 'migrate', '--no-interaction'], {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
steps.push({ step: 'migrate', ...migrateResult });
}
return jsonResult({
model,
controller,
route: `${api ? 'apiResource' : 'resource'} /${routeName}`,
routesFile,
routeWired,
steps,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
function wireRoute(routesFile: string, routeLine: string, api: boolean): boolean {
fs.mkdirSync(path.dirname(routesFile), { recursive: true });
if (!fs.existsSync(routesFile)) {
const header = api
? `<?php\n\nuse Illuminate\\Support\\Facades\\Route;\n\n`
: `<?php\n\nuse Illuminate\\Support\\Facades\\Route;\n\n`;
fs.writeFileSync(routesFile, header + routeLine + '\n');
return true;
}
const existing = fs.readFileSync(routesFile, 'utf8');
if (existing.includes(routeLine.trim())) return false;
// Ensure the Route facade is imported if the file already exists without it.
let next = existing;
if (!/use\s+Illuminate\\Support\\Facades\\Route\s*;/.test(next)) {
if (next.startsWith('<?php')) {
next = next.replace('<?php', "<?php\n\nuse Illuminate\\Support\\Facades\\Route;");
} else {
next = `<?php\n\nuse Illuminate\\Support\\Facades\\Route;\n\n${next}`;
}
}
fs.writeFileSync(routesFile, `${next.trimEnd()}\n${routeLine}\n`);
return true;
}
+21
View File
@@ -0,0 +1,21 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerArtisanTool } from './artisan.js';
import { registerGenerateLaravelResourceTool } from './generateLaravelResource.js';
import { registerComposerTools } from './composer.js';
import { registerPhpTestTools } from './runPhpTests.js';
import { registerTinkerEvalTool } from './tinkerEval.js';
import { registerGenerateEloquentRelationTool } from './generateEloquentRelation.js';
import { registerGenerateFilamentResourceTool, registerGenerateNovaResourceTool } from './generateAdminResources.js';
import { registerLaravelQueueStatusTool } from './queueStatus.js';
export function registerLaravelTools(server: McpServer): void {
registerArtisanTool(server);
registerGenerateLaravelResourceTool(server);
registerComposerTools(server);
registerPhpTestTools(server);
registerTinkerEvalTool(server);
registerGenerateEloquentRelationTool(server);
registerGenerateFilamentResourceTool(server);
registerGenerateNovaResourceTool(server);
registerLaravelQueueStatusTool(server);
}
+65
View File
@@ -0,0 +1,65 @@
import { z } from 'zod';
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 { requireLaravel } from './requireLaravel.js';
export function registerLaravelQueueStatusTool(server: McpServer): void {
server.registerTool(
'laravel_queue_status',
{
title: 'Laravel Queue Status',
description:
'Summarize Laravel queue health via `php artisan queue:failed` (and optionally `queue:batches` when available). Read-only.',
inputSchema: {
includeBatches: z
.boolean()
.optional()
.default(false)
.describe('If true, also run `php artisan queue:batches` when supported.'),
},
},
async ({ includeBatches }) => {
try {
const laravel = requireLaravel();
const failed = await runCommand('php', ['artisan', 'queue:failed', '--no-interaction'], {
cwd: laravel.root,
timeoutMs: config.defaultCommandTimeoutMs,
});
let batches: Awaited<ReturnType<typeof runCommand>> | null = null;
if (includeBatches) {
batches = await runCommand('php', ['artisan', 'queue:batches', '--no-interaction'], {
cwd: laravel.root,
timeoutMs: config.defaultCommandTimeoutMs,
});
}
const failedLines = failed.output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const failedCount = failedLines.filter((line) => /^\d+\s+/.test(line) || /\|/.test(line)).length;
return jsonResult({
failed: {
exitCode: failed.exitCode,
failed: failed.failed,
approxRows: failedCount,
output: failed.output,
},
batches: batches
? {
exitCode: batches.exitCode,
failed: batches.failed,
output: batches.output,
}
: null,
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+27
View File
@@ -0,0 +1,27 @@
import { config } from '../../config.js';
import { detectProject } from '../../lib/frameworks/detect.js';
import { LaravelAdapter } from '../../lib/frameworks/laravel.js';
import type { FrameworkAdapter } from '../../lib/frameworks/types.js';
export function requireLaravel(): LaravelAdapter {
const detected = detectProject(config.workspaceRoot);
if (detected.backend instanceof LaravelAdapter) {
return detected.backend;
}
throw new Error(
'No Laravel project detected in WORKSPACE_ROOT. ' +
'Expected composer.json with laravel/framework (or an artisan file) at the workspace root.',
);
}
/** Any Composer-based PHP backend (Laravel or CodeIgniter). */
export function requirePhpBackend(): FrameworkAdapter {
const detected = detectProject(config.workspaceRoot);
if (detected.backend && (detected.backend.kind === 'laravel' || detected.backend.kind === 'codeigniter')) {
return detected.backend;
}
throw new Error(
'No PHP backend (Laravel or CodeIgniter) detected in WORKSPACE_ROOT. ' +
'Expected a composer.json with laravel/framework or codeigniter4/framework.',
);
}
+121
View File
@@ -0,0 +1,121 @@
import path from 'node:path';
import fs from 'node:fs';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { config } from '../../config.js';
import { vendorBinExists } from '../../lib/frameworks/composer.js';
import { runCommand } from '../../lib/runCommand.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireLaravel } from './requireLaravel.js';
function vendorBin(root: string, bin: string): string {
const suffix = process.platform === 'win32' ? '.bat' : '';
return path.join(root, 'vendor', 'bin', `${bin}${suffix}`);
}
function parsePhpUnitSummary(output: string): Record<string, unknown> {
// PHPUnit: "OK (12 tests, 34 assertions)" or "FAILURES!\nTests: 12, Assertions: 30, Failures: 2."
const ok = output.match(/OK\s*\((\d+)\s+tests?,\s*(\d+)\s+assertions?\)/i);
if (ok) {
return { passed: true, tests: Number(ok[1]), assertions: Number(ok[2]), failures: 0, errors: 0 };
}
const summary = output.match(
/Tests:\s*(\d+),\s*Assertions:\s*(\d+)(?:,\s*Errors:\s*(\d+))?(?:,\s*Failures:\s*(\d+))?(?:,\s*Skipped:\s*(\d+))?/i,
);
if (summary) {
return {
passed: !/FAILURES!|ERRORS!/i.test(output),
tests: Number(summary[1]),
assertions: Number(summary[2]),
errors: Number(summary[3] ?? 0),
failures: Number(summary[4] ?? 0),
skipped: Number(summary[5] ?? 0),
};
}
// Pest often prints "Tests: 12 passed (34 assertions)" or similar.
const pest = output.match(/Tests:\s+(\d+)\s+passed/i);
if (pest) {
return { passed: !/failed|FAIL/i.test(output), tests: Number(pest[1]) };
}
return { passed: null, note: 'Could not parse a pass/fail summary from the runner output.' };
}
export function registerPhpTestTools(server: McpServer): void {
server.registerTool(
'run_phpunit',
{
title: 'Run PHPUnit',
description:
"Run the Laravel project's PHPUnit suite via vendor/bin/phpunit (or `php artisan test` as a fallback). Returns parsed pass/fail counts when possible.",
inputSchema: {
extraArgs: z
.array(z.string())
.optional()
.describe('Extra CLI args, e.g. ["--filter", "UserTest"].'),
},
},
async ({ extraArgs }) => {
try {
const laravel = requireLaravel();
const args = extraArgs ?? [];
let command: string;
let cmdArgs: string[];
if (vendorBinExists(laravel.root, 'phpunit')) {
command = vendorBin(laravel.root, 'phpunit');
cmdArgs = args;
} else if (fs.existsSync(path.join(laravel.root, 'artisan'))) {
command = 'php';
cmdArgs = ['artisan', 'test', '--without-tty', ...args];
} else {
return errorResult(
`No vendor/bin/phpunit found at ${laravel.root}. Run composer install (or run_install) first.`,
);
}
const result = await runCommand(command, cmdArgs, {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({ runner: 'phpunit', summary: parsePhpUnitSummary(result.output), ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
server.registerTool(
'run_pest',
{
title: 'Run Pest',
description:
"Run the Laravel project's Pest suite via vendor/bin/pest. Falls back to an error if Pest is not installed.",
inputSchema: {
extraArgs: z
.array(z.string())
.optional()
.describe('Extra CLI args, e.g. ["--filter", "it creates a post"].'),
},
},
async ({ extraArgs }) => {
try {
const laravel = requireLaravel();
if (!vendorBinExists(laravel.root, 'pest')) {
return errorResult(
`No vendor/bin/pest found at ${laravel.root}. Install pestphp/pest (composer_require) or use run_phpunit instead.`,
);
}
const result = await runCommand(vendorBin(laravel.root, 'pest'), extraArgs ?? [], {
cwd: laravel.root,
timeoutMs: config.scaffoldCommandTimeoutMs,
});
return jsonResult({ runner: 'pest', summary: parsePhpUnitSummary(result.output), ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+57
View File
@@ -0,0 +1,57 @@
import { z } from 'zod';
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 { requireLaravel } from './requireLaravel.js';
/**
* Heuristic: treat an expression as a write if it contains common Eloquent /
* DB mutation method names. Read-only inspections (find, get, all, count,
* toArray, …) do not require confirm.
*/
const WRITE_PATTERN =
/\b(save|create|update|delete|destroy|forceDelete|insert|upsert|truncate|updateOrCreate|firstOrCreate|push|attach|detach|sync|restore|increment|decrement)\s*\(/i;
export function registerTinkerEvalTool(server: McpServer): void {
server.registerTool(
'laravel_tinker_eval',
{
title: 'Laravel Tinker Eval',
description:
'Evaluate a short PHP expression/snippet via `php artisan tinker --execute` for quick data/model inspection. ' +
'Read-only by convention. Expressions that appear to mutate data (save/create/update/delete/…) require confirm: true.',
inputSchema: {
code: z
.string()
.min(1)
.max(4000)
.describe('PHP expression to evaluate, e.g. "\\\\App\\\\Models\\\\User::count()" or "config(\'app.name\')".'),
confirm: z
.boolean()
.optional()
.describe('Required as true when the expression appears to perform a write/mutation.'),
},
},
async ({ code, confirm }) => {
try {
if (WRITE_PATTERN.test(code) && confirm !== true) {
return errorResult(
'This expression looks like it mutates data (save/create/update/delete/…). ' +
'Re-run with confirm: true if you intentionally want to write.',
);
}
const laravel = requireLaravel();
// --execute runs the snippet non-interactively and prints the result.
const result = await runCommand('php', ['artisan', 'tinker', `--execute=${code}`], {
cwd: laravel.root,
timeoutMs: config.defaultCommandTimeoutMs,
});
return jsonResult({ code, writeDetected: WRITE_PATTERN.test(code), ...result });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+187
View File
@@ -0,0 +1,187 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
export function registerPrompts(server: McpServer): void {
server.registerPrompt(
'new-feature',
{
title: 'New Feature',
description:
'Guides the agent through implementing a new feature: detect the framework, scaffold/implement code, run tests, and verify in a browser.',
argsSchema: {
name: z
.string()
.min(1)
.describe('Short name of the feature, e.g. "user-profile" or "todo-list".'),
description: z
.string()
.optional()
.describe('A one-sentence description of what the feature should do.'),
},
},
async ({ name, description }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: [
`Implement a new feature called "${name}".${description ? ` ${description}` : ''}`,
'',
'Follow this workflow:',
'1. Call detect_framework to understand the workspace (backend, frontend, or both).',
'2. If needed, scaffold the relevant pieces (e.g. generate_component, generate_api_route, or generate_laravel_resource).',
'3. Implement the feature code, keeping existing conventions and not escaping the workspace root.',
'4. Run run_tests and/or run_lint to validate the change.',
'5. If a frontend exists, start_dev_server and browser_navigate/screenshot to verify the UI visually.',
'6. Return a concise summary of files changed, test results, and any remaining TODOs.',
].join('\n'),
},
},
],
}),
);
server.registerPrompt(
'new-laravel-resource',
{
title: 'New Laravel Resource',
description:
'Guides the agent through scaffolding a full Laravel resource, running migrations, writing a feature test, and verifying routes.',
argsSchema: {
name: z.string().min(1).describe('Resource name, e.g. "Post" or "Order".'),
},
},
async ({ name }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: [
`Create a new Laravel API resource for "${name}".`,
'',
'Workflow:',
`1. Call generate_laravel_resource with name "${name}" and api: true.`,
'2. Run artisan migrate (or artisan with command migrate and confirm: true) to create the table.',
'3. Fill in the migration fields and model $fillable based on the resource requirements.',
'4. Write a feature test with artisan make:test and run it via run_phpunit or run_pest.',
'5. Verify the routes with artisan route:list.',
'6. Optionally use laravel_tinker_eval to inspect a model instance.',
].join('\n'),
},
},
],
}),
);
server.registerPrompt(
'new-codeigniter-resource',
{
title: 'New CodeIgniter Resource',
description:
'Guides the agent through scaffolding a full CodeIgniter 4 resource, running migrations, and writing a test.',
argsSchema: {
name: z.string().min(1).describe('Resource name, e.g. "Post" or "Order".'),
},
},
async ({ name }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: [
`Create a new CodeIgniter 4 resource for "${name}".`,
'',
'Workflow:',
`1. Call generate_codeigniter_resource with name "${name}".`,
'2. Run spark migrate to create the table.',
'3. Fill in the migration fields and model properties as needed.',
'4. Write a test with spark make:test and run it via run_codeigniter_tests.',
'5. Verify the routes with spark routes.',
].join('\n'),
},
},
],
}),
);
server.registerPrompt(
'new-react-component',
{
title: 'New React Component',
description:
'Guides the agent through generating a React component, wiring it into a route/page, and verifying it in the browser.',
argsSchema: {
name: z.string().min(1).describe('Component name, e.g. "UserProfile".'),
route: z.string().optional().describe('Optional route path to wire the component to, e.g. "/users".'),
},
},
async ({ name, route }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: [
`Create a new React component called "${name}"${route ? ` and wire it to route "${route}"` : ''}.`,
'',
'Workflow:',
`1. Call generate_component (or generate_react_component) with name "${name}" and withTest: true.`,
route ? `2. Call add_react_route with path "${route}" and component "${name}".` : '2. (No route requested) Add the component to the appropriate existing page or parent component.',
'3. Run run_tests to verify the component test passes.',
'4. Start the dev server with start_dev_server, navigate with browser_navigate, and capture a browser_screenshot to verify the UI.',
].join('\n'),
},
},
],
}),
);
server.registerPrompt(
'visual-diff-ci',
{
title: 'Visual Diff CI',
description:
'Guides the agent through a CI-style visual regression check: baselines, live screenshots, and browser_visual_diff thresholds.',
argsSchema: {
url: z.string().min(1).describe('Page URL to check, e.g. http://127.0.0.1:5173/'),
baseline: z
.string()
.optional()
.describe('Baseline name under .web-dev-mcp/baselines/ (without .png). Defaults to "home".'),
thresholdPercent: z
.string()
.optional()
.describe('Max allowed mismatch percent as a string, e.g. "0.5". Defaults to "1".'),
},
},
async ({ url, baseline, thresholdPercent }) => {
const name = baseline?.trim() || 'home';
const threshold = thresholdPercent?.trim() || '1';
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: [
`Run a visual regression check for "${url}" against baseline "${name}" (fail if mismatch > ${threshold}%).`,
'',
'Workflow:',
'1. Call detect_framework (and start_dev_server if the URL is a local app that is not already running).',
`2. Call browser_navigate with url "${url}".`,
`3. If .web-dev-mcp/baselines/${name}.png is missing, call browser_screenshot_baseline with name "${name}" and treat this run as baseline creation (report that CI should re-run on the next change).`,
`4. Otherwise call browser_visual_diff with name "${name}" (and the same page still open).`,
`5. If mismatchPercent > ${threshold}, treat as FAILED: keep the diff PNG path in the summary and list likely UI regressions.`,
`6. If mismatchPercent <= ${threshold}, treat as PASSED.`,
'7. Return a concise CI-style summary: baseline name, mismatch %, pass/fail, and artifact paths.',
].join('\n'),
},
},
],
};
},
);
}
+143
View File
@@ -0,0 +1,143 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireReact } from './requireReact.js';
type RouterType = 'next-app' | 'next-pages' | 'react-router' | 'tanstack-router' | 'unknown';
export function registerAddReactRouteTool(server: McpServer): void {
server.registerTool(
'add_react_route',
{
title: 'Add React Route',
description:
'Add a route entry for the detected React router: Next.js App Router (creates a page file), Next.js Pages Router (creates a page file), react-router (appends a route to a routes file), or TanStack Router (appends a file-based route under src/routes).',
inputSchema: {
path: z.string().min(1).describe('URL path, e.g. "/users" or "/users/:id".'),
component: z
.string()
.min(1)
.describe('Component name to import, e.g. "UserList" or a relative path like "./pages/UserList".'),
file: z.string().optional().describe('Override the file to write/edit. Auto-detected by default.'),
},
},
async ({ path: routePath, component, file: fileOverride }) => {
try {
const react = requireReact();
const router = detectRouterType(react.root);
switch (router) {
case 'next-app':
case 'next-pages': {
const file =
fileOverride ??
path.join(
react.root,
router === 'next-app' ? 'app' : 'pages',
...routePath.split('/').filter(Boolean),
'page.tsx',
);
const created = writeNextPage(file, component, router === 'next-app');
return jsonResult({ router, file, created });
}
case 'react-router':
case 'tanstack-router': {
const file =
fileOverride ??
(router === 'react-router'
? path.join(react.root, 'src', 'routes.tsx')
: path.join(react.root, 'src', 'routes', `${routePath.replace(/[^a-z0-9]/gi, '-')}.tsx`));
const wired = appendRoute(file, routePath, component, router);
return jsonResult({ router, file, wired });
}
default:
return jsonResult({
router: 'unknown',
message:
'No known React router detected (Next.js app/pages, react-router, or TanStack Router). ' +
'Provide the file argument explicitly to add a route entry.',
});
}
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
function detectRouterType(root: string): RouterType {
if (fs.existsSync(path.join(root, 'app', 'layout.tsx')) || fs.existsSync(path.join(root, 'app', 'layout.jsx'))) {
return 'next-app';
}
if (fs.existsSync(path.join(root, 'pages', 'index.tsx')) || fs.existsSync(path.join(root, 'pages', 'index.jsx'))) {
return 'next-pages';
}
if (fs.existsSync(path.join(root, 'src', 'app', 'layout.tsx')) || fs.existsSync(path.join(root, 'src', 'app', 'layout.jsx'))) {
return 'next-app';
}
if (
fs.existsSync(path.join(root, 'src', 'pages', 'index.tsx')) ||
fs.existsSync(path.join(root, 'src', 'pages', 'index.jsx'))
) {
return 'next-pages';
}
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['@tanstack/react-router']) return 'tanstack-router';
if (deps['react-router-dom'] || deps['react-router']) return 'react-router';
return 'unknown';
}
function writeNextPage(file: string, component: string, appRouter: boolean): boolean {
fs.mkdirSync(path.dirname(file), { recursive: true });
if (fs.existsSync(file)) return false;
const importPath = component.startsWith('.') ? component : `./${component}`;
const contents = appRouter
? `import { ${component} } from '${importPath}';
export default function Page() {
return <${component} />;
}
`
: `import { ${component} } from '${importPath}';
export default function ${component}Page() {
return <${component} />;
}
`;
fs.writeFileSync(file, contents);
return true;
}
function appendRoute(file: string, routePath: string, component: string, router: RouterType): boolean {
fs.mkdirSync(path.dirname(file), { recursive: true });
const importPath = component.startsWith('.') ? component : `./${component}`;
const routeLine =
router === 'tanstack-router'
? `// TODO: route ${routePath} -> <${component} /> (TanStack Router uses file-based routing; this file was created).`
: `{ path: '${routePath}', element: <${component} /> },`;
if (!fs.existsSync(file)) {
fs.writeFileSync(
file,
`import { ${component} } from '${importPath}';
// Add this route to your router configuration:
${routeLine}
`,
);
return true;
}
const existing = fs.readFileSync(file, 'utf8');
if (existing.includes(routeLine.trim())) return false;
let next = existing;
if (!new RegExp(`import\\s+\\{[^}]*\\b${component}\\b[^}]*\\}\\s+from\\s+['"]${importPath.replace(/\./g, '\\.')}['"];`).test(next)) {
next = `import { ${component} } from '${importPath}';\n${next}`;
}
fs.writeFileSync(file, `${next.trimEnd()}\n${routeLine}\n`);
return true;
}
+123
View File
@@ -0,0 +1,123 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireReact } from './requireReact.js';
export function registerAnalyzeReactComponentTool(server: McpServer): void {
server.registerTool(
'analyze_react_component',
{
title: 'Analyze React Component',
description:
'Static scan of a React component file for common issues: missing `key` in lists, hook-rule violations, unused props, and Next.js App Router heuristics (hooks/events without "use client").',
inputSchema: {
file: z
.string()
.min(1)
.describe('Path to the component file, relative to the React project root.'),
},
},
async ({ file }) => {
try {
const react = requireReact();
const absolutePath = path.resolve(react.root, file);
if (!absolutePath.startsWith(react.root + path.sep)) {
return errorResult('Refusing to analyze a file outside the React project root.');
}
if (!fs.existsSync(absolutePath)) {
return errorResult(`File not found: ${file}`);
}
const source = fs.readFileSync(absolutePath, 'utf8');
const issues: Array<{ type: string; line: number; message: string }> = [];
const lines = source.split('\n');
const hasUseClient = /^\s*['"]use client['"]\s*;?/m.test(source);
const usesClientOnlyApis =
/\bon[A-Z][A-Za-z]+\s*=/.test(source) ||
/\buse(State|Effect|Reducer|Ref|LayoutEffect|Callback|Memo|Context)\s*\(/.test(source);
if (!hasUseClient && usesClientOnlyApis) {
issues.push({
type: 'missing-use-client',
line: 1,
message:
'File uses client-only APIs (hooks or event handlers) but is missing a "use client" directive. ' +
'Required for Next.js App Router Client Components.',
});
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const lineNumber = i + 1;
// Missing key in array maps (heuristic).
if (/(\w+\s*\.\s*map|React\.Children\.map)\s*\(/.test(line) && !/key\s*=/.test(line)) {
issues.push({
type: 'missing-key',
line: lineNumber,
message: 'Array.map without a key prop on the returned element.',
});
}
// Conditional or loop hook calls (simple heuristics).
if (/\buse[A-Z][A-Za-z0-9]*\s*\(/.test(line)) {
if (/\b(if|while|for|switch)\s*\(/.test(line)) {
issues.push({
type: 'rules-of-hooks',
line: lineNumber,
message: 'Hook call inside a conditional or loop block.',
});
}
}
// Functions / non-serializable values passed into imported components from a Server Component.
if (!hasUseClient && /<(?:[A-Z][\w.]*)\b[^>]*\{[^}]*=>/.test(line)) {
issues.push({
type: 'server-to-client-props',
line: lineNumber,
message:
'Looks like a function/arrow is passed as a prop from a Server Component. ' +
'Move interactive children into a Client Component ("use client").',
});
}
}
// Unused props: find destructured props and see if they are referenced.
const propDestructuring = source.match(/function\s+\w+\s*\(\s*\{\s*([^}]+)\s*\}\s*\)/);
if (propDestructuring && propDestructuring[1]) {
const rawProps = propDestructuring[1];
const props = rawProps
.split(',')
.map((p) => p.trim().split(/\s*:\s*/)[0]?.replace(/\?|:.*$/, '').trim() ?? '')
.filter(Boolean);
const destructuringIndex = propDestructuring.index ?? 0;
for (const prop of props) {
const propRegex = new RegExp(`\\b${prop}\\b`, 'g');
const matches = [...source.matchAll(propRegex)].length;
// The declaration itself counts as one match, plus the original destructuring pattern may add another.
if (matches <= 2) {
issues.push({
type: 'unused-prop',
line: source.substring(0, destructuringIndex).split('\n').length,
message: `Prop "${prop}" appears unused in the component body.`,
});
}
}
}
return jsonResult({
file,
projectRoot: react.root,
hasUseClient,
issues,
issueCount: issues.length,
note: 'This is a heuristic static scan, not a full ESLint run. Use run_lint for comprehensive checks.',
});
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+155
View File
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { pascalCase } from '../../lib/strings.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireReact } from './requireReact.js';
function resolveAppDir(root: string): string {
if (fs.existsSync(path.join(root, 'src', 'app'))) return path.join(root, 'src', 'app');
return path.join(root, 'app');
}
function routeSegments(routePath: string): string[] {
return routePath.split('/').filter(Boolean);
}
function assertNextAppRouter(root: string): void {
const appDir = resolveAppDir(root);
const hasLayout =
fs.existsSync(path.join(appDir, 'layout.tsx')) ||
fs.existsSync(path.join(appDir, 'layout.jsx')) ||
fs.existsSync(path.join(root, 'next.config.js')) ||
fs.existsSync(path.join(root, 'next.config.mjs')) ||
fs.existsSync(path.join(root, 'next.config.ts'));
if (!hasLayout && !fs.existsSync(appDir)) {
throw new Error(
'Next.js App Router not detected. Expected an app/ (or src/app/) directory or next.config.*.',
);
}
}
export function registerGenerateNextPageTool(server: McpServer): void {
server.registerTool(
'generate_next_page',
{
title: 'Generate Next.js Page',
description:
'Scaffold a Next.js App Router page.tsx under app/{path}/ (or src/app/). Optionally also creates loading.tsx and error.tsx.',
inputSchema: {
path: z.string().min(1).describe('URL path, e.g. "/users" or "/blog/[slug]".'),
title: z.string().optional().describe('Optional heading text; defaults to a PascalCase name from the path.'),
withLoading: z.boolean().optional().default(false).describe('Also create loading.tsx.'),
withError: z.boolean().optional().default(false).describe('Also create error.tsx.'),
overwrite: z.boolean().optional().default(false),
},
},
async ({ path: routePath, title, withLoading, withError, overwrite }) => {
try {
const react = requireReact();
assertNextAppRouter(react.root);
const typescript = fs.existsSync(path.join(react.root, 'tsconfig.json'));
const ext = typescript ? 'tsx' : 'jsx';
const appDir = resolveAppDir(react.root);
const dir = resolveWorkspacePath(appDir, path.join(...routeSegments(routePath)));
fs.mkdirSync(dir, { recursive: true });
const heading = title ?? pascalCase(routeSegments(routePath).join(' ') || 'Page');
const files: string[] = [];
const pageFile = path.join(dir, `page.${ext}`);
if (!overwrite && fs.existsSync(pageFile)) {
return errorResult(`"${pageFile}" already exists. Pass overwrite: true to replace it.`);
}
fs.writeFileSync(
pageFile,
`export default function ${heading.replace(/[^A-Za-z0-9]/g, '') || 'Page'}Page() {
return (
<main>
<h1>${heading}</h1>
</main>
);
}
`,
);
files.push(pageFile);
if (withLoading) {
const loadingFile = path.join(dir, `loading.${ext}`);
if (overwrite || !fs.existsSync(loadingFile)) {
fs.writeFileSync(loadingFile, `export default function Loading() {\n return <p>Loading…</p>;\n}\n`);
files.push(loadingFile);
}
}
if (withError) {
const errorFile = path.join(dir, `error.${ext}`);
if (overwrite || !fs.existsSync(errorFile)) {
fs.writeFileSync(
errorFile,
`'use client';\n\nexport default function Error({ error, reset }: { error: Error; reset: () => void }) {\n return (\n <div>\n <p>{error.message}</p>\n <button type="button" onClick={() => reset()}>\n Try again\n </button>\n </div>\n );\n}\n`,
);
files.push(errorFile);
}
}
return jsonResult({ router: 'app', path: routePath, files });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
export function registerGenerateNextLayoutTool(server: McpServer): void {
server.registerTool(
'generate_next_layout',
{
title: 'Generate Next.js Layout',
description: 'Scaffold a Next.js App Router layout.tsx under app/{path}/ (or src/app/).',
inputSchema: {
path: z
.string()
.optional()
.default('')
.describe('URL path segment for a nested layout, e.g. "/dashboard". Empty for the root layout.'),
overwrite: z.boolean().optional().default(false),
},
},
async ({ path: routePath, overwrite }) => {
try {
const react = requireReact();
assertNextAppRouter(react.root);
const typescript = fs.existsSync(path.join(react.root, 'tsconfig.json'));
const ext = typescript ? 'tsx' : 'jsx';
const appDir = resolveAppDir(react.root);
const segments = routeSegments(routePath ?? '');
const dir = segments.length === 0 ? appDir : resolveWorkspacePath(appDir, path.join(...segments));
fs.mkdirSync(dir, { recursive: true });
const layoutFile = path.join(dir, `layout.${ext}`);
if (!overwrite && fs.existsSync(layoutFile)) {
return errorResult(`"${layoutFile}" already exists. Pass overwrite: true to replace it.`);
}
const childrenType = typescript ? '{ children }: { children: React.ReactNode }' : '{ children }';
fs.writeFileSync(
layoutFile,
`export default function Layout(${childrenType}) {
return (
<section>
{children}
</section>
);
}
`,
);
return jsonResult({ router: 'app', path: routePath || '/', file: layoutFile });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { camelCase, pascalCase } from '../../lib/strings.js';
import { resolveWorkspacePath } from '../../lib/sandbox.js';
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
import { requireReact } from './requireReact.js';
export function registerGenerateReactHookTool(server: McpServer): void {
server.registerTool(
'generate_react_hook',
{
title: 'Generate React Hook',
description:
'Scaffold a custom React hook (useXyz.ts) and a matching test file. Detects TypeScript from tsconfig.json.',
inputSchema: {
name: z
.string()
.min(1)
.describe('Hook name without the "use" prefix, e.g. "Counter" or "fetchUser" (camelCase is fine).'),
directory: z
.string()
.optional()
.describe('Directory relative to the React project root. Defaults to "src/hooks".'),
withTest: z.boolean().optional().default(true),
overwrite: z.boolean().optional().default(false),
},
},
async ({ name, directory, withTest, overwrite }) => {
try {
const react = requireReact();
const typescript = fs.existsSync(path.join(react.root, 'tsconfig.json'));
const ext = typescript ? 'ts' : 'js';
const hookName = name.startsWith('use') ? camelCase(name) : `use${pascalCase(name)}`;
const dir = resolveWorkspacePath(react.root, directory ?? 'src/hooks');
const hookFile = path.join(dir, `${hookName}.${ext}`);
assertNotExists(hookFile, overwrite);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
hookFile,
`import { useState } from 'react';
export function ${hookName}() {
const [count, setCount] = useState(0);
return { count, increment: () => setCount((c) => c + 1) };
}
`,
);
const files = [hookFile];
if (withTest) {
const testFile = path.join(dir, `${hookName}.test.${ext}`);
assertNotExists(testFile, overwrite);
fs.writeFileSync(
testFile,
`import { describe, expect, it } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { ${hookName} } from './${hookName}';
describe('${hookName}', () => {
it('increments count', () => {
const { result } = renderHook(() => ${hookName}());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
});
`,
);
files.push(testFile);
}
return jsonResult({ name: hookName, files });
} catch (error) {
return errorResult(toErrorMessage(error));
}
},
);
}
function assertNotExists(file: string, overwrite: boolean): void {
if (!overwrite && fs.existsSync(file)) {
throw new Error(`"${file}" already exists. Pass overwrite: true to replace it.`);
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerGenerateReactHookTool } from './generateReactHook.js';
import { registerAddReactRouteTool } from './addReactRoute.js';
import { registerRunReactTestsTool } from './runReactTests.js';
import { registerAnalyzeReactComponentTool } from './analyzeComponent.js';
import { registerGenerateNextLayoutTool, registerGenerateNextPageTool } from './generateNextFiles.js';
export function registerReactTools(server: McpServer): void {
registerGenerateReactHookTool(server);
registerAddReactRouteTool(server);
registerRunReactTestsTool(server);
registerAnalyzeReactComponentTool(server);
registerGenerateNextPageTool(server);
registerGenerateNextLayoutTool(server);
}

Some files were not shown because too many files have changed in this diff Show More