* 2.24.1

- Updated dependencies
- MCP Server: better compatibility with Windows
- MCP Server: allow using a custom root for better flexibility
- Version Check: use package.json version instead of number of commits
- Prettier: reformatted some files
- Add SSH_AUTH_SOCK to dev passThroughEnv to solve SSH issues; handle execSync errors
- Use GIT_SSH to fix SSH issues on Windows
- Updated Stripe version
- Updated application version from 2.24.0 to 2.24.1 in package.json.
- Enhanced error handling in billing services to include error causes for better debugging.
This commit is contained in:
Giancarlo Buomprisco
2026-02-26 18:22:35 +08:00
committed by GitHub
parent f3ac595d06
commit ca585e09be
41 changed files with 2322 additions and 1803 deletions

View File

@@ -37,7 +37,7 @@
"@kit/email-templates": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@modelcontextprotocol/sdk": "1.26.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@types/node": "catalog:",
"postgres": "3.4.8",
"tsup": "catalog:",

View File

@@ -1,6 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { resolveProjectRoot } from './resolve-root';
import { registerComponentsTools } from './tools/components';
import {
registerDatabaseResources,
@@ -22,30 +23,30 @@ import { registerKitStatusTool } from './tools/status/index';
import { registerKitTranslationsTools } from './tools/translations/index';
async function main() {
// Create server instance
const server = new McpServer({
name: 'makerkit',
version: '1.0.0',
});
const transport = new StdioServerTransport();
const rootPath = resolveProjectRoot();
registerGetMigrationsTools(server);
registerKitStatusTool(server);
registerKitPrerequisitesTool(server);
registerKitEnvTools(server);
registerKitDevTools(server);
registerKitDbTools(server);
registerKitEmailsTools(server);
registerKitEmailTemplatesTools(server);
registerKitTranslationsTools(server);
registerDatabaseTools(server);
registerDatabaseResources(server);
registerComponentsTools(server);
registerScriptsTools(server);
registerRunChecksTool(server);
registerDepsUpgradeAdvisorTool(server);
registerPRDTools(server);
registerGetMigrationsTools(server, rootPath);
registerKitStatusTool(server, rootPath);
registerKitPrerequisitesTool(server, rootPath);
registerKitEnvTools(server, rootPath);
registerKitDevTools(server, rootPath);
registerKitDbTools(server, rootPath);
registerKitEmailsTools(server, rootPath);
registerKitEmailTemplatesTools(server, rootPath);
registerKitTranslationsTools(server, rootPath);
registerDatabaseTools(server, rootPath);
registerDatabaseResources(server, rootPath);
registerComponentsTools(server, rootPath);
registerScriptsTools(server, rootPath);
registerRunChecksTool(server, rootPath);
registerDepsUpgradeAdvisorTool(server, rootPath);
registerPRDTools(server, rootPath);
registerPromptsSystem(server);
await server.connect(transport);

View File

@@ -0,0 +1,291 @@
import { createServer } from 'node:net';
import { afterAll, describe, expect, it } from 'vitest';
import {
IS_WINDOWS,
crossExecFileSync,
execFileAsync,
findProcessesByName,
getPortProcess,
killProcess,
spawnDetached,
} from '../process-utils';
const pidsToCleanup: number[] = [];
afterAll(async () => {
for (const pid of pidsToCleanup) {
try {
await killProcess(pid);
} catch {
// already dead
}
}
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function isCommandAvailable(cmd: string): Promise<boolean> {
try {
await execFileAsync(cmd, ['--version']);
return true;
} catch {
return false;
}
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
// ---------------------------------------------------------------------------
// pnpm — the command that triggered this whole cross-platform fix.
// The MCP tools call pnpm for migrations, seeding, checks, and dev server.
// On Windows pnpm is a .cmd file that requires shell: true.
// ---------------------------------------------------------------------------
describe('pnpm commands', () => {
it('pnpm --version (used by kit_prerequisites)', async () => {
const result = await execFileAsync('pnpm', ['--version']);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
});
it('pnpm --version sync (used by migrations tool)', () => {
const result = crossExecFileSync('pnpm', ['--version'], {
encoding: 'utf8',
});
expect(String(result).trim()).toMatch(/^\d+\.\d+\.\d+/);
});
it('pnpm run with unknown script returns non-zero (used by run_checks)', async () => {
// run_checks calls: pnpm run <script>
// Verify the cross-platform wrapper surfaces the error correctly.
await expect(
execFileAsync('pnpm', ['run', '__nonexistent_script_xyz__']),
).rejects.toThrow();
});
it('pnpm ls --json (read-only, similar to deps-advisor outdated)', async () => {
const result = await execFileAsync('pnpm', ['ls', '--json', '--depth=0']);
// pnpm ls --json returns valid JSON
expect(() => JSON.parse(result.stdout)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// git — used by kit_status for branch, modified files, merge checks.
// ---------------------------------------------------------------------------
describe('git commands', () => {
it('git --version (used by kit_prerequisites)', async () => {
const result = await execFileAsync('git', ['--version']);
expect(result.stdout).toContain('git version');
});
it('git rev-parse --abbrev-ref HEAD (used by kit_status)', async () => {
const result = await execFileAsync('git', [
'rev-parse',
'--abbrev-ref',
'HEAD',
]);
expect(result.stdout.trim().length).toBeGreaterThan(0);
});
it('git status --porcelain (used by kit_status)', async () => {
const result = await execFileAsync('git', ['status', '--porcelain']);
// Can be empty (clean) or have entries — just shouldn't throw
expect(result.stderr).toBe('');
});
it('git log --oneline -1 (common git operation)', async () => {
const result = await execFileAsync('git', ['log', '--oneline', '-1']);
expect(result.stdout.trim().length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// node — used by kit_prerequisites.
// ---------------------------------------------------------------------------
describe('node commands', () => {
it('node --version (used by kit_prerequisites)', async () => {
const result = await execFileAsync('node', ['--version']);
expect(result.stdout.trim()).toMatch(/^v\d+\.\d+\.\d+$/);
});
it('node -e (used for spawning scripts)', async () => {
const result = await execFileAsync('node', ['-e', 'console.log("hello")']);
expect(result.stdout.trim()).toBe('hello');
});
});
// ---------------------------------------------------------------------------
// docker — used by kit_dev_start/stop for database and mailbox containers.
// Skipped if docker is not installed.
// ---------------------------------------------------------------------------
describe('docker commands', async () => {
const hasDocker = await isCommandAvailable('docker');
it.skipIf(!hasDocker)(
'docker --version (used by kit_prerequisites)',
async () => {
const result = await execFileAsync('docker', ['--version']);
expect(result.stdout).toContain('Docker');
},
);
it.skipIf(!hasDocker)(
'docker compose version (used before compose up/stop)',
async () => {
const result = await execFileAsync('docker', ['compose', 'version']);
expect(result.stdout).toContain('Docker Compose');
},
);
});
// ---------------------------------------------------------------------------
// Port operations — TCP socket check + getPortProcess.
// Used by kit_dev_status, kit_db_status, mailbox status to detect running
// services on specific ports (3000, 54333, 8025).
// ---------------------------------------------------------------------------
describe('port operations', () => {
it('getPortProcess finds listener on a bound port', async () => {
const server = createServer();
const port = await new Promise<number>((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
resolve(typeof addr === 'object' && addr ? addr.port : 0);
});
});
try {
const proc = await getPortProcess(port, process.cwd());
expect(proc).not.toBeNull();
expect(proc!.pid).toBeGreaterThan(0);
} finally {
server.close();
}
});
it('getPortProcess returns null for port with no listener', async () => {
// Use a high port unlikely to be in use
const proc = await getPortProcess(59998, process.cwd());
expect(proc).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Process lifecycle — spawnDetached + findProcessesByName + killProcess.
// The dev tools spawn pnpm and stripe as detached processes, find them by
// pattern (e.g. "stripe.*listen"), and kill them via PID/group.
// ---------------------------------------------------------------------------
describe('process lifecycle', () => {
it('spawn a long-running node process, find it, then kill it', async () => {
// Spawn a detached node process (mirrors how pnpm exec next dev works)
const marker = `__test_marker_${Date.now()}__`;
const child = spawnDetached('node', [
'-e',
`process.title = "${marker}"; setTimeout(() => {}, 60000)`,
]);
expect(child.pid).toBeGreaterThan(0);
pidsToCleanup.push(child.pid!);
await sleep(500);
// Verify it's alive (process.kill(pid, 0) is cross-platform)
expect(() => process.kill(child.pid!, 0)).not.toThrow();
// findProcessesByName should find it (mirrors "stripe.*listen" pattern)
const found = await findProcessesByName(marker, process.cwd());
expect(found.length).toBeGreaterThan(0);
// Kill it (mirrors kit_dev_stop stopping services)
await killProcess(child.pid!);
await sleep(300);
// Verify it's dead
expect(() => process.kill(child.pid!, 0)).toThrow();
});
it('spawn a pnpm process via spawnDetached', async () => {
// This is exactly how kit_dev_start spawns the Next.js dev server:
// spawnDetached('pnpm', ['exec', 'node', '-e', '...'])
const child = spawnDetached('pnpm', [
'exec',
'node',
'-e',
'setTimeout(() => {}, 60000)',
]);
expect(child.pid).toBeGreaterThan(0);
pidsToCleanup.push(child.pid!);
await sleep(500);
// Verify pnpm-spawned process is alive
expect(() => process.kill(child.pid!, 0)).not.toThrow();
// Clean up
await killProcess(child.pid!);
await sleep(300);
});
it('findProcessesByName returns empty for nonexistent pattern', async () => {
const procs = await findProcessesByName(
'zzz_no_such_process_12345',
process.cwd(),
);
expect(procs).toEqual([]);
});
it('killProcess does not throw for already-dead PID', async () => {
const child = spawnDetached('node', ['-e', 'process.exit(0)']);
pidsToCleanup.push(child.pid!);
await sleep(300);
// Process already exited — killProcess should not throw
await expect(killProcess(child.pid!)).resolves.toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Error handling — unknown commands should reject cleanly.
// ---------------------------------------------------------------------------
describe('error handling', () => {
it('execFileAsync rejects for unknown command', async () => {
await expect(
execFileAsync('__nonexistent_cmd_xyz__', ['--help']),
).rejects.toThrow();
});
it('crossExecFileSync throws for unknown command', () => {
expect(() =>
crossExecFileSync('__nonexistent_cmd_xyz__', ['--help']),
).toThrow();
});
});
// ---------------------------------------------------------------------------
// Platform constant
// ---------------------------------------------------------------------------
describe('IS_WINDOWS', () => {
it('matches process.platform', () => {
expect(IS_WINDOWS).toBe(process.platform === 'win32');
});
});

View File

@@ -0,0 +1,263 @@
import {
type ExecFileOptions,
type ExecFileSyncOptions,
type SpawnOptions,
execFile,
execFileSync,
spawn,
} from 'node:child_process';
import { platform } from 'node:os';
import { promisify } from 'node:util';
const rawExecFileAsync = promisify(execFile);
export const IS_WINDOWS = platform() === 'win32';
/**
* Cross-platform execFile for `.cmd` / `.bat` commands (pnpm, npm, etc.).
*
* On Windows, `.cmd`/`.bat` files cannot be executed without a shell
* (see https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows).
* Adds `shell: true` + `windowsHide: true` on Windows to resolve them
* without opening a visible console window.
*
* For native executables (git, node, docker) prefer `execFileSync` / `execFile`
* directly — they don't need a shell.
*/
export function execFileAsync(
command: string,
args: string[],
options: ExecFileOptions = {},
) {
return rawExecFileAsync(command, args, withShell(options));
}
/**
* Cross-platform execFileSync.
*/
export function crossExecFileSync(
command: string,
args: string[],
options: ExecFileSyncOptions = {},
) {
return execFileSync(command, args, withShell(options));
}
/**
* Spawn a long-running detached process.
*
* - **Unix**: uses `detached: true` so the child becomes a process-group
* leader (allows group-kill via negative PID).
* - **Windows**: omits `detached` to avoid opening a visible console window.
* The child stays alive as long as the MCP-server process does.
*/
export function spawnDetached(
command: string,
args: string[],
options: SpawnOptions = {},
) {
const child = spawn(command, args, {
...options,
...(IS_WINDOWS ? { shell: true, windowsHide: true } : {}),
detached: !IS_WINDOWS,
stdio: 'ignore',
});
child.unref();
return child;
}
/**
* Kill a process (and its tree on Windows).
*
* - **Unix**: kills the process group via `process.kill(-pid)`, falling back
* to the individual process.
* - **Windows**: uses `taskkill /T /F /PID` which terminates the whole tree.
*/
export async function killProcess(
pid: number,
signal: string = 'SIGTERM',
): Promise<void> {
if (IS_WINDOWS) {
try {
await rawExecFileAsync('taskkill', ['/T', '/F', '/PID', String(pid)], {
windowsHide: true,
});
} catch {
// Process may already be dead.
}
return;
}
try {
process.kill(-pid, signal);
} catch {
try {
process.kill(pid, signal);
} catch {
// Process may already be dead.
}
}
}
/**
* Find which process is listening on a TCP port.
*
* - **Unix**: parses `lsof` output.
* - **Windows**: parses `netstat -ano` output.
*/
export async function getPortProcess(
port: number,
cwd: string,
): Promise<{ pid: number; command: string } | null> {
try {
if (IS_WINDOWS) {
return await getPortProcessWindows(port, cwd);
}
return await getPortProcessUnix(port, cwd);
} catch {
return null;
}
}
/**
* Find running processes whose command line matches a pattern.
*
* - **Unix**: uses `pgrep -fl`.
* - **Windows**: uses PowerShell `Get-CimInstance Win32_Process`.
*/
export async function findProcessesByName(
pattern: string,
cwd: string,
): Promise<Array<{ pid: number; command: string }>> {
try {
if (IS_WINDOWS) {
return await findProcessesByNameWindows(pattern, cwd);
}
return await findProcessesByNameUnix(pattern, cwd);
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function withShell<T extends SpawnOptions | ExecFileOptions>(options: T): T {
if (IS_WINDOWS) {
return { ...options, shell: true, windowsHide: true };
}
return options;
}
async function getPortProcessUnix(port: number, cwd: string) {
const result = await rawExecFileAsync(
'lsof',
['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'],
{ cwd },
);
const lines = result.stdout.split('\n').map((l) => l.trim());
const pidLine = lines.find((l) => l.startsWith('p'));
const commandLine = lines.find((l) => l.startsWith('c'));
if (!pidLine || !commandLine) return null;
const pid = Number(pidLine.slice(1));
if (!Number.isFinite(pid)) return null;
return { pid, command: commandLine.slice(1) };
}
async function getPortProcessWindows(port: number, cwd: string) {
const result = await rawExecFileAsync('netstat', ['-ano'], {
cwd,
windowsHide: true,
});
const portStr = `:${port}`;
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed.includes('LISTENING')) continue;
if (!trimmed.includes(portStr)) continue;
// Netstat format: TCP 0.0.0.0:PORT 0.0.0.0:0 LISTENING PID
const parts = trimmed.split(/\s+/);
const pid = Number(parts[parts.length - 1]);
if (!Number.isFinite(pid) || pid === 0) continue;
// Verify the port column actually matches (avoid partial matches)
const localAddr = parts[1] ?? '';
if (localAddr.endsWith(portStr)) {
return { pid, command: 'unknown' };
}
}
return null;
}
async function findProcessesByNameUnix(pattern: string, cwd: string) {
const result = await rawExecFileAsync('pgrep', ['-fl', pattern], { cwd });
return result.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(' ');
if (spaceIdx <= 0) return null;
const pid = Number(line.slice(0, spaceIdx));
const command = line.slice(spaceIdx + 1).trim();
if (!Number.isFinite(pid)) return null;
return { pid, command };
})
.filter((p): p is { pid: number; command: string } => p !== null);
}
async function findProcessesByNameWindows(pattern: string, cwd: string) {
// Convert simple regex-like pattern to a PowerShell -match pattern.
// The patterns used in the codebase (e.g. "stripe.*listen") are already
// valid PowerShell regex.
const psCommand = [
`Get-CimInstance Win32_Process`,
`| Where-Object { $_.CommandLine -match '${pattern.replace(/'/g, "''")}' }`,
`| ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }`,
].join(' ');
const result = await rawExecFileAsync(
'powershell',
['-NoProfile', '-Command', psCommand],
{ cwd, windowsHide: true },
);
return result.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const trimmed = line.trim();
const spaceIdx = trimmed.indexOf(' ');
if (spaceIdx <= 0) return null;
const pid = Number(trimmed.slice(0, spaceIdx));
const command = trimmed.slice(spaceIdx + 1).trim();
if (!Number.isFinite(pid)) return null;
return { pid, command };
})
.filter((p): p is { pid: number; command: string } => p !== null);
}

View File

@@ -0,0 +1,18 @@
import { findWorkspaceRoot } from './tools/env/public-api';
export function resolveProjectRoot(argv: string[] = process.argv): string {
// 1. CLI flag: --root /path
const rootIdx = argv.indexOf('--root');
if (rootIdx !== -1 && argv[rootIdx + 1]) {
return argv[rootIdx + 1];
}
// 2. Env var
if (process.env.MAKERKIT_PROJECT_ROOT) {
return process.env.MAKERKIT_PROJECT_ROOT;
}
// 3. Auto-discovery (traverse up from cwd looking for pnpm-workspace.yaml)
return findWorkspaceRoot(process.cwd());
}

View File

@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MigrationsTool } from '../migrations';
const { crossExecFileSyncMock } = vi.hoisted(() => ({
crossExecFileSyncMock: vi.fn(() => Buffer.from('ok')),
}));
vi.mock('../../lib/process-utils', () => ({
crossExecFileSync: crossExecFileSyncMock,
}));
describe('MigrationsTool', () => {
beforeEach(() => {
crossExecFileSyncMock.mockClear();
});
afterEach(() => {
// Reset to default
MigrationsTool.setRootPath(process.cwd());
});
it('uses crossExecFileSync args for CreateMigration with safe name', () => {
MigrationsTool.CreateMigration('add_users_table');
expect(crossExecFileSyncMock).toHaveBeenCalledWith(
'pnpm',
['--filter', 'web', 'supabase', 'migrations', 'new', 'add_users_table'],
{ cwd: process.cwd() },
);
});
it('rejects unsafe migration names', () => {
expect(() => MigrationsTool.CreateMigration('foo && rm -rf /')).toThrow(
'Migration name must contain only letters, numbers, hyphens, or underscores',
);
expect(crossExecFileSyncMock).not.toHaveBeenCalled();
});
it('uses crossExecFileSync args for Diff', () => {
MigrationsTool.Diff();
expect(crossExecFileSyncMock).toHaveBeenCalledWith(
'pnpm',
['--filter', 'web', 'supabase', 'db', 'diff'],
{ cwd: process.cwd() },
);
});
it('uses custom rootPath after setRootPath', () => {
MigrationsTool.setRootPath('/custom/project');
MigrationsTool.CreateMigration('test_migration');
expect(crossExecFileSyncMock).toHaveBeenCalledWith(
'pnpm',
['--filter', 'web', 'supabase', 'migrations', 'new', 'test_migration'],
{ cwd: '/custom/project' },
);
});
it('uses custom rootPath for Diff after setRootPath', () => {
MigrationsTool.setRootPath('/custom/project');
MigrationsTool.Diff();
expect(crossExecFileSyncMock).toHaveBeenCalledWith(
'pnpm',
['--filter', 'web', 'supabase', 'db', 'diff'],
{ cwd: '/custom/project' },
);
});
});

View File

@@ -12,9 +12,15 @@ interface ComponentInfo {
}
export class ComponentsTool {
private static _rootPath = process.cwd();
static setRootPath(path: string) {
this._rootPath = path;
}
static async getComponents(): Promise<ComponentInfo[]> {
const packageJsonPath = join(
process.cwd(),
this._rootPath,
'packages',
'ui',
'package.json',
@@ -179,7 +185,7 @@ export class ComponentsTool {
static async getComponentContent(componentName: string): Promise<string> {
const packageJsonPath = join(
process.cwd(),
this._rootPath,
'packages',
'ui',
'package.json',
@@ -193,7 +199,7 @@ export class ComponentsTool {
throw new Error(`Component "${componentName}" not found in exports`);
}
const fullPath = join(process.cwd(), 'packages', 'ui', filePath);
const fullPath = join(this._rootPath, 'packages', 'ui', filePath);
return readFile(fullPath, 'utf8');
}
@@ -337,7 +343,11 @@ export class ComponentsTool {
}
}
export function registerComponentsTools(server: McpServer) {
export function registerComponentsTools(server: McpServer, rootPath?: string) {
if (rootPath) {
ComponentsTool.setRootPath(rootPath);
}
createGetComponentsTool(server);
createGetComponentContentTool(server);
createComponentsSearchTool(server);

View File

@@ -1116,7 +1116,11 @@ export class DatabaseTool {
}
}
export function registerDatabaseTools(server: McpServer) {
export function registerDatabaseTools(server: McpServer, rootPath?: string) {
if (rootPath) {
DatabaseTool.ROOT_PATH = rootPath;
}
createGetSchemaFilesTool(server);
createGetSchemaContentTool(server);
createGetSchemasByTopicTool(server);
@@ -1126,7 +1130,10 @@ export function registerDatabaseTools(server: McpServer) {
createSearchFunctionsTool(server);
}
export function registerDatabaseResources(server: McpServer) {
export function registerDatabaseResources(
server: McpServer,
_rootPath?: string,
) {
createDatabaseSummaryTool(server);
createDatabaseTablesListTool(server);
createGetTableInfoTool(server);

View File

@@ -1,10 +1,9 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { access, readFile, readdir } from 'node:fs/promises';
import { Socket } from 'node:net';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { execFileAsync } from '../../lib/process-utils';
import { type KitDbServiceDeps, createKitDbService } from './kit-db.service';
import {
KitDbMigrateInputSchema,
@@ -17,15 +16,13 @@ import {
KitDbStatusOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
type TextContent = {
type: 'text';
text: string;
};
export function registerKitDbTools(server: McpServer) {
const service = createKitDbService(createKitDbDeps());
export function registerKitDbTools(server: McpServer, rootPath?: string) {
const service = createKitDbService(createKitDbDeps(rootPath));
server.registerTool(
'kit_db_status',

View File

@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { execFileAsync } from '../../lib/process-utils';
import {
type DepsUpgradeAdvisorDeps,
createDepsUpgradeAdvisorService,
@@ -11,12 +10,13 @@ import {
DepsUpgradeAdvisorOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
export function registerDepsUpgradeAdvisorTool(server: McpServer) {
export function registerDepsUpgradeAdvisorTool(
server: McpServer,
rootPath?: string,
) {
return registerDepsUpgradeAdvisorToolWithDeps(
server,
createDepsUpgradeAdvisorDeps(),
createDepsUpgradeAdvisorDeps(rootPath),
);
}
@@ -63,9 +63,9 @@ export function registerDepsUpgradeAdvisorToolWithDeps(
);
}
function createDepsUpgradeAdvisorDeps(): DepsUpgradeAdvisorDeps {
const rootPath = process.cwd();
function createDepsUpgradeAdvisorDeps(
rootPath = process.cwd(),
): DepsUpgradeAdvisorDeps {
return {
async executeCommand(command, args) {
try {

View File

@@ -1,10 +1,15 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile, spawn } from 'node:child_process';
import { access, readFile } from 'node:fs/promises';
import { Socket } from 'node:net';
import { join } from 'node:path';
import { promisify } from 'node:util';
import {
execFileAsync,
findProcessesByName,
getPortProcess,
killProcess,
spawnDetached,
} from '../../lib/process-utils';
import {
DEFAULT_PORT_CONFIG,
type KitDevServiceDeps,
@@ -21,10 +26,8 @@ import {
KitMailboxStatusOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
export function registerKitDevTools(server: McpServer) {
const service = createKitDevService(createKitDevDeps());
export function registerKitDevTools(server: McpServer, rootPath?: string) {
const service = createKitDevService(createKitDevDeps(rootPath));
server.registerTool(
'kit_dev_start',
@@ -235,13 +238,7 @@ export function createKitDevDeps(rootPath = process.cwd()): KitDevServiceDeps {
};
},
async spawnDetached(command: string, args: string[]) {
const child = spawn(command, args, {
cwd: rootPath,
detached: true,
stdio: 'ignore',
});
child.unref();
const child = spawnDetached(command, args, { cwd: rootPath });
if (!child.pid) {
throw new Error(`Failed to spawn ${command}`);
@@ -264,42 +261,7 @@ export function createKitDevDeps(rootPath = process.cwd()): KitDevServiceDeps {
return response.json();
},
async getPortProcess(port: number) {
try {
const result = await execFileAsync(
'lsof',
['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'],
{
cwd: rootPath,
},
);
const pidLine = result.stdout
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('p'));
const commandLine = result.stdout
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('c'));
if (!pidLine || !commandLine) {
return null;
}
const pid = Number(pidLine.slice(1));
if (!Number.isFinite(pid)) {
return null;
}
return {
pid,
command: commandLine.slice(1),
};
} catch {
return null;
}
return getPortProcess(port, rootPath);
},
async isProcessRunning(pid: number) {
try {
@@ -310,44 +272,10 @@ export function createKitDevDeps(rootPath = process.cwd()): KitDevServiceDeps {
}
},
async findProcessesByName(pattern: string) {
try {
const result = await execFileAsync('pgrep', ['-fl', pattern], {
cwd: rootPath,
});
return result.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const spaceIdx = line.indexOf(' ');
if (spaceIdx <= 0) {
return null;
}
const pid = Number(line.slice(0, spaceIdx));
const command = line.slice(spaceIdx + 1).trim();
if (!Number.isFinite(pid)) {
return null;
}
return { pid, command };
})
.filter((p): p is { pid: number; command: string } => p !== null);
} catch {
return [];
}
return findProcessesByName(pattern, rootPath);
},
async killProcess(pid: number, signal = 'SIGTERM') {
try {
// Kill the entire process group (negative PID) since services
// are spawned detached and become process group leaders.
process.kill(-pid, signal);
} catch {
// Fall back to killing the individual process if group kill fails.
process.kill(pid, signal);
}
return killProcess(pid, signal);
},
async sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -17,8 +17,11 @@ type TextContent = {
text: string;
};
export function registerKitEmailTemplatesTools(server: McpServer) {
const service = createKitEmailsService(createKitEmailsDeps());
export function registerKitEmailTemplatesTools(
server: McpServer,
rootPath?: string,
) {
const service = createKitEmailsService(createKitEmailsDeps(rootPath));
server.registerTool(
'kit_email_templates_list',

View File

@@ -0,0 +1,54 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findWorkspaceRoot } from '../scanner';
describe('findWorkspaceRoot', () => {
let tmp: string;
beforeEach(() => {
tmp = join(tmpdir(), `fwr-test-${Date.now()}`);
mkdirSync(tmp, { recursive: true });
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('returns directory containing pnpm-workspace.yaml', () => {
writeFileSync(join(tmp, 'pnpm-workspace.yaml'), '');
expect(findWorkspaceRoot(tmp)).toBe(tmp);
});
it('walks up to find workspace root from nested dir', () => {
const nested = join(tmp, 'packages', 'foo', 'src');
mkdirSync(nested, { recursive: true });
writeFileSync(join(tmp, 'pnpm-workspace.yaml'), '');
expect(findWorkspaceRoot(nested)).toBe(tmp);
});
it('returns startPath when no workspace file found within depth', () => {
const deep = join(tmp, 'a', 'b', 'c', 'd', 'e', 'f', 'g');
mkdirSync(deep, { recursive: true });
writeFileSync(join(tmp, 'pnpm-workspace.yaml'), '');
// 7 levels deep, limit is 6 — should NOT find it
expect(findWorkspaceRoot(deep)).toBe(deep);
});
it('returns startPath when no workspace file exists at all', () => {
expect(findWorkspaceRoot(tmp)).toBe(tmp);
});
it('finds root at exact depth boundary (5 levels up)', () => {
const nested = join(tmp, 'a', 'b', 'c', 'd', 'e');
mkdirSync(nested, { recursive: true });
writeFileSync(join(tmp, 'pnpm-workspace.yaml'), '');
expect(findWorkspaceRoot(nested)).toBe(tmp);
});
});

View File

@@ -23,8 +23,8 @@ type TextContent = {
text: string;
};
export function registerKitEnvTools(server: McpServer) {
const service = createKitEnvService(createKitEnvDeps());
export function registerKitEnvTools(server: McpServer, rootPath?: string) {
const service = createKitEnvService(createKitEnvDeps(rootPath));
server.registerTool(
'kit_env_schema',

View File

@@ -1,8 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { Socket } from 'node:net';
import { promisify } from 'node:util';
import { execFileAsync } from '../../lib/process-utils';
import {
type KitMailboxDeps,
createKitMailboxService,
@@ -16,15 +15,13 @@ import {
KitEmailsSetReadStatusOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
type TextContent = {
type: 'text';
text: string;
};
export function registerKitEmailsTools(server: McpServer) {
const service = createKitMailboxService(createKitMailboxDeps());
export function registerKitEmailsTools(server: McpServer, rootPath?: string) {
const service = createKitMailboxService(createKitMailboxDeps(rootPath));
server.registerTool(
'kit_emails_list',

View File

@@ -1,33 +1,60 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execSync } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod/v3';
import { crossExecFileSync } from '../lib/process-utils';
export class MigrationsTool {
private static _rootPath = process.cwd();
static setRootPath(path: string) {
this._rootPath = path;
}
private static get MIGRATIONS_DIR() {
return join(this._rootPath, 'apps', 'web', 'supabase', 'migrations');
}
static GetMigrations() {
return readdir(
join(process.cwd(), 'apps', 'web', 'supabase', 'migrations'),
);
return readdir(this.MIGRATIONS_DIR);
}
static getMigrationContent(path: string) {
return readFile(
join(process.cwd(), 'apps', 'web', 'supabase', 'migrations', path),
'utf8',
);
return readFile(join(this.MIGRATIONS_DIR, path), 'utf8');
}
static CreateMigration(name: string) {
return execSync(`pnpm --filter web supabase migrations new ${name}`);
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error(
'Migration name must contain only letters, numbers, hyphens, or underscores',
);
}
return crossExecFileSync(
'pnpm',
['--filter', 'web', 'supabase', 'migrations', 'new', name],
{ cwd: this._rootPath },
);
}
static Diff() {
return execSync(`pnpm --filter web supabase db diff`);
return crossExecFileSync(
'pnpm',
['--filter', 'web', 'supabase', 'db', 'diff'],
{ cwd: this._rootPath },
);
}
}
export function registerGetMigrationsTools(server: McpServer) {
export function registerGetMigrationsTools(
server: McpServer,
rootPath?: string,
) {
if (rootPath) {
MigrationsTool.setRootPath(rootPath);
}
createGetMigrationsTool(server);
createGetMigrationContentTool(server);
createCreateMigrationTool(server);
@@ -89,7 +116,7 @@ function createGetMigrationContentTool(server: McpServer) {
'get_migration_content',
{
description:
'📜 Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
'Get migration file content (HISTORICAL) - For current state use get_schema_content instead',
inputSchema: {
state: z.object({
path: z.string(),
@@ -103,7 +130,7 @@ function createGetMigrationContentTool(server: McpServer) {
content: [
{
type: 'text',
text: `📜 MIGRATION FILE: ${state.path} (HISTORICAL)\n\nNote: This shows historical changes. For current database state, use get_schema_content instead.\n\n${content}`,
text: `MIGRATION FILE: ${state.path} (HISTORICAL)\n\nNote: This shows historical changes. For current database state, use get_schema_content instead.\n\n${content}`,
},
],
};
@@ -116,7 +143,7 @@ function createGetMigrationsTool(server: McpServer) {
'get_migrations',
{
description:
'📜 Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
'Get migration files (HISTORICAL CHANGES) - Use schema files for current state instead',
},
async () => {
const migrations = await MigrationsTool.GetMigrations();
@@ -125,7 +152,7 @@ function createGetMigrationsTool(server: McpServer) {
content: [
{
type: 'text',
text: `📜 MIGRATION FILES (HISTORICAL CHANGES)\n\nNote: For current database state, use get_schema_files instead. Migrations show historical changes.\n\n${migrations.join('\n')}`,
text: `MIGRATION FILES (HISTORICAL CHANGES)\n\nNote: For current database state, use get_schema_files instead. Migrations show historical changes.\n\n${migrations.join('\n')}`,
},
],
};

View File

@@ -967,7 +967,11 @@ export class PRDManager {
}
// MCP Server Tool Registration
export function registerPRDTools(server: McpServer) {
export function registerPRDTools(server: McpServer, rootPath?: string) {
if (rootPath) {
PRDManager.setRootPath(rootPath);
}
createListPRDsTool(server);
createGetPRDTool(server);
createCreatePRDTool(server);

View File

@@ -1,9 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { access, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { execFileAsync } from '../../lib/process-utils';
import {
type KitPrerequisitesDeps,
createKitPrerequisitesService,
@@ -13,9 +12,10 @@ import {
KitPrerequisitesOutputSchema,
} from './schema';
const execFileAsync = promisify(execFile);
export function registerKitPrerequisitesTool(server: McpServer) {
export function registerKitPrerequisitesTool(
server: McpServer,
rootPath?: string,
) {
return server.registerTool(
'kit_prerequisites',
{
@@ -28,7 +28,7 @@ export function registerKitPrerequisitesTool(server: McpServer) {
try {
const service = createKitPrerequisitesService(
createKitPrerequisitesDeps(),
createKitPrerequisitesDeps(rootPath),
);
const result = await service.check(parsedInput);
@@ -57,9 +57,9 @@ export function registerKitPrerequisitesTool(server: McpServer) {
);
}
function createKitPrerequisitesDeps(): KitPrerequisitesDeps {
const rootPath = process.cwd();
function createKitPrerequisitesDeps(
rootPath = process.cwd(),
): KitPrerequisitesDeps {
return {
async getVariantFamily() {
const variant = await resolveVariant(rootPath);

View File

@@ -1,19 +1,16 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { execFileAsync } from '../../lib/process-utils';
import {
type RunChecksDeps,
createRunChecksService,
} from './run-checks.service';
import { RunChecksInputSchema, RunChecksOutputSchema } from './schema';
const execFileAsync = promisify(execFile);
export function registerRunChecksTool(server: McpServer) {
const service = createRunChecksService(createRunChecksDeps());
export function registerRunChecksTool(server: McpServer, rootPath?: string) {
const service = createRunChecksService(createRunChecksDeps(rootPath));
return server.registerTool(
'run_checks',

View File

@@ -21,8 +21,14 @@ interface ScriptInfo {
}
export class ScriptsTool {
private static _rootPath = process.cwd();
static setRootPath(path: string) {
this._rootPath = path;
}
static async getScripts(): Promise<ScriptInfo[]> {
const packageJsonPath = join(process.cwd(), 'package.json');
const packageJsonPath = join(this._rootPath, 'package.json');
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
const scripts: ScriptInfo[] = [];
@@ -41,7 +47,7 @@ export class ScriptsTool {
}
static async getScriptDetails(scriptName: string): Promise<ScriptInfo> {
const packageJsonPath = join(process.cwd(), 'package.json');
const packageJsonPath = join(this._rootPath, 'package.json');
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
const command = packageJson.scripts[scriptName];
@@ -234,7 +240,11 @@ export class ScriptsTool {
}
}
export function registerScriptsTools(server: McpServer) {
export function registerScriptsTools(server: McpServer, rootPath?: string) {
if (rootPath) {
ScriptsTool.setRootPath(rootPath);
}
createGetScriptsTool(server);
createGetScriptDetailsTool(server);
createGetHealthcheckScriptsTool(server);

View File

@@ -1,19 +1,16 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { execFile } from 'node:child_process';
import { access, readFile, stat } from 'node:fs/promises';
import { Socket } from 'node:net';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { execFileAsync } from '../../lib/process-utils';
import {
type KitStatusDeps,
createKitStatusService,
} from './kit-status.service';
import { KitStatusInputSchema, KitStatusOutputSchema } from './schema';
const execFileAsync = promisify(execFile);
export function registerKitStatusTool(server: McpServer) {
export function registerKitStatusTool(server: McpServer, rootPath?: string) {
return server.registerTool(
'kit_status',
{
@@ -25,7 +22,7 @@ export function registerKitStatusTool(server: McpServer) {
const parsedInput = KitStatusInputSchema.parse(input);
try {
const service = createKitStatusService(createKitStatusDeps());
const service = createKitStatusService(createKitStatusDeps(rootPath));
const status = await service.getStatus(parsedInput);
return {
@@ -54,9 +51,7 @@ export function registerKitStatusTool(server: McpServer) {
);
}
function createKitStatusDeps(): KitStatusDeps {
const rootPath = process.cwd();
function createKitStatusDeps(rootPath = process.cwd()): KitStatusDeps {
return {
rootPath,
async readJsonFile(path: string): Promise<unknown> {

View File

@@ -27,8 +27,13 @@ type TextContent = {
text: string;
};
export function registerKitTranslationsTools(server: McpServer) {
const service = createKitTranslationsService(createKitTranslationsDeps());
export function registerKitTranslationsTools(
server: McpServer,
rootPath?: string,
) {
const service = createKitTranslationsService(
createKitTranslationsDeps(rootPath),
);
server.registerTool(
'kit_translations_list',